{"version":1,"files":[{"path":"blog/a-look-inside-this-site.md","text":"---\ntitle: A look inside this site\ndate: 2026-11-04\nsummary: A shell, a filesystem, plugins, programs and a CTF range, all running in your browser with no backend.\ntags: [site, react, webgl]\n---\n\nThis site has two halves. The homepage is a portfolio. The workspace at `/terminal` is a small shell that runs entirely in your browser, with its own filesystem, programs, plugins and a CTF range. GitHub Pages serves the files, and there is no backend behind them.\n\nThe terminal idea came from [Sat Naing's terminal portfolio](https://github.com/satnaing/terminal-portfolio). This one kept a single rule as it grew: whatever appears in the terminal has to compute something real. A fake hacking screen would be easy to make and would prove nothing.\n\n![The workspace, with a post open beside the terminal](images/workspace.webp)\n\n## Every action is a command\n\nTyping `snake`, clicking Snake in the sidebar and clicking a link in the welcome text all run the same command definition. A click is a command typed on your behalf, so it lands in the terminal like any other.\n\nPrograms hold the terminal the way a foreground process does. That caused a real bug: anything sent while a demo was running queued up behind it, so clicking the next demo seemed to do nothing. Now a click interrupts whatever is running. One demo swaps straight into another, and anything else ends the demo first. Ctrl+C works too.\n\n## A filesystem made at build time\n\nEverything in `src/content/`, and every post, is packed into a virtual filesystem rooted at `~`. The usual ls, cd, cat and preview commands all work on it, so this post is also a file: `~/blog/a-look-inside-this-site.md`.\n\nIt is real enough to pipe. `cat ~/blog/*.md | grep -i snake | wc -l` expands the wildcard, feeds each stage the text the one before it printed, and leaves errors on the terminal instead of sending them down the pipe. The text tools are all there: grep, wc, sort, uniq, head, tail, find and strings.\n\n```terminal\n# Count the lines in these posts that mention Snake\ncat ~/blog/*.md | grep -i snake | wc -l\n```\n\nBlocks like that one are written as a `terminal` code fence in the Markdown. Beside a terminal, Run sends the commands straight to it. In the full-screen reader, the page first moves over beside a terminal, and they run when it gets there.\n\nAddresses map back onto those files. [/blog/a-look-inside-this-site.md](/blog/a-look-inside-this-site.md) opens in a full-screen reader, and adding `?layout=split` opens it beside the terminal.\n\nWhat opens depends on the file type. Markdown gets the reader, source code gets line numbers, audio gets a player with a live spectrogram, and HTML runs in a sandboxed frame. Plugins and programs open on settings pages of their own.\n\nA document looks the same beside the terminal and full screen, because both use one renderer. The small squares above each title switch between five reading themes.\n\n## Plugins and programs\n\nTwo packaged formats do most of the work.\n\nA `.plg` is a bundle of HTML, CSS and a script. The workspace mounts it as a dock widget or a background layer, hands it a small API, and calls its cleanup function when it is switched off. Everything under Effects is a plugin. `tv` bends the whole page through an SVG displacement filter, and `sparks` throws the letters you type off the caret. They all live in `~/plugins/`, where you can read them.\n\n```terminal\n# See the page on an old television; run it again to switch it off\ntv\n```\n\nThe four background effects also sit in the top bar of every page. They run on one layer above everything, so they carry on as you move between the homepage, the terminal and the reader.\n\nA `.exe` runs in a Web Worker. It can stream output, ask for input and hold the terminal until it exits, which is how the programs in `~/programs/` behave.\n\n## Demos\n\nEverything under Watch is computed frame by frame:\n\n- `donut` lights a torus and z-buffers it into text.\n- `life` is Conway's Game of Life, and you can draw on it.\n- `snake` plays itself and fills the board. [How it works](/blog/a-snake-that-cannot-lose.md).\n- `snowflake` is a zoom with no bottom. [How it works](/blog/an-endless-dive-into-a-snowflake.md).\n- `fire` is the PSX Doom fire, with one line of arithmetic per cell.\n- `matrix` is digital rain, and `matrix your name` writes your name into it.\n\n## A small composer\n\n`compose` opens a studio that writes music from a seed and synthesises it with plain arithmetic. The one exception is the piano style, which plays recordings of a real grand: the Salamander set, 1.3 MB, fetched only when that style is chosen. It knows lo-fi, kawaii bass, solo piano, guofeng, hardstyle and a few others, leans major and sunny unless told otherwise, and has eight drum kits. Songs run verse, lift, chorus and bridge like songs do, and each seed picks its own guests from what suits the style: piano, strings, guitar, a flute. Live mode streams blocks of about 46 ms from a worker straight to the audio thread, so a change to the style, key, tempo or anything else is heard in under a fifth of a second. Render makes a song of a set length, with a proper ending, as a WAV you can download. [How it works](/blog/music-from-a-seed.md).\n\n```terminal\n# Same settings, same song, in any browser\ncompose kawaii seed=7 tempo=150 key=F seconds=30\n```\n\n## A CTF range\n\n`ctf` opens a board of seven challenges, one per category: web, crypto, reverse, pwn, forensics, misc and recon. Each one takes a different form inside the workspace, such as a page, a program, a plugin or an audio file, and each is solved with a real technique. Flags are checked by comparing SHA-256 digests, and your progress stays in your browser.\n\n```terminal\nctf\n```\n\n## The companions\n\nThe voxel characters on the homepage began as Tripo models. A pipeline of Python and Blender scripts then reshapes the face, projects a reference render onto the mesh and regrades the textures. Each one ships as a GLB of about 2 MB. In the browser, three.js gives them an idle animation, turns them toward your pointer and runs a sharpening pass that keeps the voxel edges crisp. If your system asks for reduced motion, they hold still.\n\n## Under the hood\n\nThe site uses React, TypeScript, Vite, styled-components and three.js. Seven themes run through the terminal, previews and demos. Vitest covers the shell, the packaged formats and the CTF checks, and it plays full games of Snake on three board sizes.\n\nType `help` to see what else is in here.\n"},{"path":"blog/a-snake-that-cannot-lose.md","text":"---\ntitle: A snake that can't lose\ndate: 2026-11-04\nsummary: A Hamiltonian cycle makes Snake safe but slow. Rebuilding the cycle before every dash keeps it safe and cuts a full game to under a third of the moves.\ntags: [algorithms, graphs, site]\n---\n\nThe `snake` demo plays by itself and fills the board. Making it never lose is the easy part. The hard part is stopping it from wandering.\n\n## Two bad snakes\n\nA greedy snake takes the shortest path to the food. It plays well for a while, then boxes itself in.\n\nA safe snake follows a Hamiltonian cycle, a loop through every cell of the board. Its body always lies along the loop, so the cell ahead is always free. It can't lose, but the food is half a lap away on average. On a 26×18 board a full game takes about 54,000 moves, and most of them are spent walking past food.\n\nThe solver here sits between the two. It takes a short path whenever a safe loop will still exist afterwards.\n\n## Building a loop\n\nSplit the board into 2×2 blocks and grow a random spanning tree over the blocks. Walking around the outside of that tree visits every cell once and returns to the start, which is why both sides of the board must be even.\n\n![Left: a spanning tree of blocks with the loop around it. Right: the one-way streets every such loop follows.](images/snake-loop-and-streets.webp)\n\nEach side between two blocks is either open, where the tree crosses it, or shut. Those sides decide the whole loop.\n\n## Asking a better question\n\nThe obvious check is whether a dash rejoins the loop the snake is on. Testing dashes against existing loops turned out to be too strict: mid-game, only 11 to 17 percent of moves were dashes.\n\nA better question is whether any loop at all fits the body as it will be on arrival, and that can be answered directly. Each step of the body says something about one block side. A step that crosses a side needs it open, and a step that runs along a side needs it shut. If two steps disagree, or the open sides close into a ring, no loop fits. Otherwise union-find joins the remaining blocks through random sides, which completes the tree. That tree is a new loop with the body already on it.\n\nSo every dash is tested by simulating the arrival and building the next loop in advance. If none fits, the snake takes one step along its current loop and tries again.\n\n## One-way streets\n\nA plain breadth-first search still struggles, because most shortest paths can't lie on any loop. The way out is a property these loops share. Walked clockwise, all of them run the same one-way streets: even rows go east, odd rows go west, even columns go north and odd columns go south. In 200 random loops on a 26×18 board, none of the 93,600 steps broke that rule.\n\nSearching only along the streets throws out impossible paths before they are tried. The price is small. Across every pair of cells on that board, the street route is 1.04 steps longer than the Manhattan distance on average, and exactly as short 55 percent of the time.\n\nThe search also knows about time. A body cell counts as free if the tail will have left by the time the head arrives. Without that, a long snake treats its own tail as a wall that won't be there.\n\n## Results\n\nAverages over 12 full games on a 26×18 board:\n\n| Strategy | Moves |\n| :--- | ---: |\n| Walking one fixed loop | 53,955 |\n| This solver | 15,712 |\n| Manhattan distance to each food, ignoring the body | 5,597 |\n\nThe last row is a floor no snake can reach, since it pretends the body isn't there. The solver takes 3.4 times fewer moves than the fixed loop. About half of its moves are dashes, and the rest are spent waiting on the loop until a dash becomes safe.\n\n| Board filled | Moves per food | Manhattan distance | Moves on a dash |\n| :--- | ---: | ---: | ---: |\n| 0–25% | 25.7 | 14.7 | 80% |\n| 25–50% | 46.9 | 13.8 | 46% |\n| 50–75% | 44.0 | 12.2 | 39% |\n| 75–100% | 19.0 | 7.8 | 55% |\n\nThe middle of the game is the hardest. The body is long enough to block most dashes, and the free space is still large enough that the loop takes a while to come round to the food.\n\n## How sure is \"can't lose\"?\n\nThe argument is short. The snake only moves along a loop that holds its whole body, or along a dash whose end has already been shown to fit one. The test suite plays 18 full games on three board sizes and checks every move, and the solver throws if the head ever enters the body. The 58 games behind these benchmarks all finished as well. That is strong evidence, but it isn't a formal proof.\n\nThere is one limit. The snake grows by one cell per food. Growing by two or more caused collisions late in the game, so the guarantee depends on that.\n\n## Try it\n\nPress H to see the current loop, watch the dashed line for the planned dash, and click anywhere to drop food. Space starts a new game.\n\n```terminal\nsnake\n```\n\n![The snake with its safe loop showing](images/snake-demo.webp)\n"},{"path":"blog/an-endless-dive-into-a-snowflake.md","text":"---\ntitle: An endless dive into a snowflake\ndate: 2026-11-04\nsummary: A physical growth model makes the crystal, a small tree of copies makes it self-similar, and a zoom of three lands exactly where it started.\ntags: [graphics, webgl, fractals]\n---\n\nThe `snowflake` demo zooms into an ice crystal and never reaches the bottom. None of it is prerecorded. The crystal grows in your browser when the demo starts, so every run is different.\n\n![A frame from the dive](images/snowflake-dive.webp)\n\n## Growing the crystal\n\nThe crystal comes from [Clifford Reiter's local cellular model](https://doi.org/10.1016/j.chaos.2004.06.071) of snow growth, published in 2005. Water sits on a hexagonal lattice. Cells touching the ice are receptive: they keep whatever reaches them and gain a little from the air. Every other cell lets its vapour diffuse to its neighbours. When a receptive cell reaches 1 it freezes, and its neighbours become receptive.\n\nTwo numbers shape the result: how much vapour the air holds, and how fast it deposits. Fixed values give one kind of shape from centre to tip, but real flakes fall through changing air. So each crystal here gets one to three cold bands at random distances from the centre. Inside a band the background vapour drops from about 0.42 to about 0.25, and the deposit rate rises about fourfold. That gives every crystal its own rings.\n\n## Simulating a twelfth\n\nThe disc has a radius of 400 lattice cells, which comes to 517,411 cells. A snowflake repeats six times around its centre and mirrors within each sixth, so only the wedge `q ≥ r ≥ 0` in axial coordinates needs simulating. That is 48,680 cells. A neighbour table, built once, folds any neighbour outside the wedge back into it by rotating and reflecting, so the simulation never sees an edge.\n\nA shader unfolds the wedge again to light the crystal, and the result is baked into a mipmapped sprite. Growth carries on while you watch, with a fresh bake every five frames.\n\n## One image can't zoom forever\n\nZooming into a sprite runs out of pixels quickly. So each crystal is treated as a tree. It holds seven copies of itself at a third of its size: one at the hub, turned by 30°, and one on each of its six tips.\n\nFor every pixel, the fragment shader walks eight levels down that tree. At each level it draws the sprite at that scale, then moves into the child that holds the pixel. Near the centre, that child is the hub. Otherwise the pixel is folded into the nearest sixth and moved to that tip. The work per pixel stays the same however deep the camera seems to be.\n\n## Why it never runs out of precision\n\nA deep Mandelbrot zoom needs ever more precise numbers, because the camera is a real place in one fixed coordinate system. This dive has no such place. The camera only moves through a single phase, from 0 to 1. At phase 1 everything is three times larger and turned by 30°, which is exactly how the hub child looks from its parent. So the phase wraps to 0 and the picture stays the same. The \"magnified 10^n\" in the status line is only a counter, and the crystal geometry sees just the fractional part.\n\nThe wrap is only invisible if nothing depends on which level is being drawn. Every fade, tint and blur here is a function of how large a crystal looks on screen, never of its depth. When measured across a wrap, the frames differed by 0.68 out of 255, which is less than one step of colour.\n\nAt the resting speed the view triples every 8.7 seconds, or grows tenfold about every 18 seconds. Holding the pointer down cuts a loop to 2 seconds.\n\n## Try it\n\nHold the pointer to dive faster, and press space to grow a new crystal. It needs WebGL2.\n\n```terminal\nsnowflake\n```\n"},{"path":"blog/images/snake-demo.webp","url":"/content/blog/images/snake-demo.webp"},{"path":"blog/images/snake-loop-and-streets.webp","url":"/content/blog/images/snake-loop-and-streets.webp"},{"path":"blog/images/snowflake-dive.webp","url":"/content/blog/images/snowflake-dive.webp"},{"path":"blog/images/workspace.webp","url":"/content/blog/images/workspace.webp"},{"path":"blog/music-from-a-seed.md","text":"---\ntitle: Music from a seed\ndate: 2026-11-04\nsummary: The song player on this site plays nothing recorded in advance. A seed picks a form, chords, a tune and a line-up, and your browser works out every sample as it plays.\ntags: [music, audio, site]\n---\n\nThe music button in the top bar opens a player with a random song on it. There is no audio file behind it. A number, the seed, decides what gets written, and your browser synthesises the result while you listen. The same settings give the same song on any machine, and a new seed gives one nobody has heard.\n\n```terminal\n# Same settings, same song, in any browser\ncompose kawaii seed=88 tempo=150 key=F seconds=30\n```\n\n## The shape of a song\n\nEach style knows how long its sections run. A song opens with an intro, then goes round a verse, a lift and a chorus, and every third time a bridge takes the verse's place. A very short render skips straight to the chorus, because a thirty-second song made of verse would never get anywhere.\n\nWhat changes between sections is who is playing. A verse has a light hand on the drums and leaves the big chords out. A lift speeds its chords up from quarters to sixteenths, and then the drums stop short of the drop.\n\n## Writing a tune\n\nRandom notes are not a tune, and a walk through the scale is not much better. A [comparison of generators](https://mlanthology.org/mlj/2023/yin2023mlj-deep/) found the strongest deep-learning model only level with a Markov method, so this one has no neural network. It puts together four older ideas, and all of it runs in a few milliseconds in your browser.\n\n**A template comes first**, the approach of [TeleMelody](https://arxiv.org/abs/2109.09617): which bars share a rhythm, where a note pushes ahead of the beat, and where each phrase comes to rest. Eight bars ask a question and then answer it, so the first half stops on an open note and the second comes home.\n\n**Pitches come from a probability model.** Temperley's melody model multiplies a range profile, a proximity profile and a key profile: tunes stay in a register, move by small intervals and favour the notes of the key. On top of that sit [Narmour's implications](https://en.wikipedia.org/wiki/Implication-Realization): small steps tend to carry on, and big leaps tend to turn back.\n\n**Constraints are built in, not checked afterwards.** A plain Markov walk cannot promise to end on the right note. [Pachet's Markov constraints](https://www.ijcai.org/Abstract/11/113) fix that by renormalising the probabilities backwards from the end of the line, so each choice already knows which futures are possible. There is no search and no dead end, and the walk keeps the model's proportions.\n\n**Then generate and test.** Each stretch is written sixteen times and the best is kept, judged by what makes tunes stick. [An earworm study](https://www.eurekalert.org/news-releases/521894) found common contours with a few unusual intervals, and Hooktheory's song metrics suggest around 69% chord tones.\n\nChoruses sit higher and follow the chords more strictly than verses, which is Temperley's \"loose verse, tight chorus\". The first pass is played plain, so there is something to recognise. After that a phrase picks up what a player would add on the way back round: a grace note, a step between two notes a third apart, a turn, a stutter, a run up to whatever comes next.\n\n## Parts that listen to the tune\n\nThe tune is written first and the other parts fit around it. A chord tone a semitone from the tune takes the tune's note instead of rubbing against it. Bells and vocal chops go only where the tune leaves a gap. The bass walks into the next chord, and later choruses sing in thirds. Chorus chords run for eight bars and end on a real cadence rather than going round a four-bar loop.\n\n## Every sound has a job\n\nKawaii future bass shows why this takes more than one good synth patch. Its parts are built differently because they do different jobs.\n\n- **Chords** are detuned saws with a quieter layer an octave up, voiced wide with sevenths and ninths. The obvious way to get the stabbing rhythm is to play the chord again and again, and it sounds wrong. The real thing holds the chord for the bar and cuts it up with a tempo-synced gate on its level and filter, with a small bend up on every hit.\n- **Bass** is a sine with a square an octave above it, on the chords' own rhythm but only every other hit. The gaps are what make it bounce.\n- **Plucks** are FM bells and a toy piano, high up and short, and they only appear where the tune leaves a gap.\n- **Leads** are a choice of five: a soft filtered square, a thick saw stack, a chip pulse, a bell and a sung vowel.\n\nThe harmony borrows from J-pop too. Some lifts walk down by semitones from the second chord before landing on the fifth, and some songs lift the last chorus by a whole step.\n\n## Guests\n\nEach seed also picks a line-up from what suits its style, so two songs in one style need not sound like the same band. Lo-fi might get a guitar and a flute, guofeng an erhu, and hardstyle a piano that sits out the drops.\n\nNearly everything is arithmetic. The piano is the one exception, because a synthesised piano sounds like a guitar. It plays recordings of a real grand, the [Salamander](https://sfzinstruments.github.io/pianos/salamander) set by Alexander Holm under CC BY 3.0, cut down to 21 notes and 1.3 MB and only fetched when a song needs it.\n\n## Playing it live\n\nLive mode has no end. A worker writes blocks of about 46 ms and hands them straight to the audio thread, a few blocks ahead, so the main thread only ever sends changes. Move a slider and the running voices carry on under a new score, which is why a change is heard in under a fifth of a second instead of restarting the song.\n\nRender uses the same engine, gives the song a proper ending, and hands you a WAV.\n"}]}