The Making of Coven Rush

Chapter 6

The Code

Rules, render and UI layers, the art worker, the tuning file, and the safety nets that let many builders work at once.

Under the forest, the party and the music, Coven Rush is about 45,000 lines of TypeScript and JavaScript. The same few dozen builder sessions wrote it in four days, often a dozen at once, each on its own branch. Code written that fast, by that many hands, turns into a tangle unless a few rules hold it in shape. This chapter is about those rules: how the code is laid out as it stands on the morning of 2026-10-07, why it's laid out that way, and how a night of tidying changed a great deal of it without changing the game by a single pixel.

One rule from day one

The first decision about the code was made before there was any code. On 2026-10-03 Ed settled the engine: build the prototype in Three.js in the browser, and port it to Godot once he's happy with it. That one sentence shaped everything after it. If the game is going to move to another engine, the part worth keeping is the game itself: its state and its rules. So those live apart from anything that draws. The plan for the very first playable build, the fly-around prototype #4, put it in so many words: "Rules (plain TypeScript, no Three.js, so they port to Godot)". DESIGN.md still opens its Engine section with the same rule, and the builders' guide in the repository repeats it to every builder who starts work:

the game's state and rules live in src/rules/ with no Three.js in them, and src/render/ draws them.the builders' guide, from Ed's ruling of 2026-10-03

Four days and some 390 pull requests later, the rule still holds exactly. On today's prototype branch not one file in src/rules/ imports Three.js. None imports anything from src/render/, src/ui/, src/platform/ or src/app/ either. The dependencies all point one way: everything else reads the rules, and the rules read nothing but themselves and the config files. You can check it the hard way. The benchmark bundles the rules with esbuild and runs them in plain Node, with no browser and no Three.js, all the way to wave 30.

The layers

The source has five folders under src/, plus the art generators in art/ and the data in config/:

folderwhat it ismoduleslinestest files
src/rules/the game: map, forest, witch, creatures, combat, party, legends, music plan; pure, seeded, fixed-step7713,09364 (8,672 lines)
src/render/drawing it in Three.js: the View, sprites and shaders, ground, sky, the rig, the art workers7913,34725
src/platform/the browser's edges: input, audio (music engine and sound effects), the playtest log, frame stats223,4304
src/ui/the HTML over the game: the character creator, the spell scroll, the start card, touch controls121,8784
src/app/new tonight: the link's switches, the HUD, debug knobs and sound, moved out of main.ts44030
art/the sprite generators, shared with the Witch Art Lab5713,330(checked by art/check.mjs)

(Lines counted on the prototype branch at #390's merge, tests left out of the second count. Many lines in this codebase are long, with a comment trailing the code, so these figures are a rough measure.)

Here is how they fit together, and which way the data flows:

config/ tuning.json + its schema combat, movement, travel, buffs… style.json, music-style.json art/ generators plain JS, also the Art Lab's draw + bake to pixels art workers (up to 6) artBuild.runJob, OffscreenCanvas → packed atlas pixels IndexedDB cache key: ART_HASH + style + set platform/input.ts keys, mouse, gamepad, ui/touch.ts (or the bot) src/rules/ stepGame → fixedStep × n STEP 1/60 s, seeded rng no Three.js, no DOM, no clock (the part that ports to Godot) Game state witches, creatures, party, combat, camera, this frame's events src/render/ View (Three.js) atlases, sprites, rig, ground, sky, ley lines, leash src/ui/ + src/app/hud creator, spell scroll, clock, debug overlay (HTML) platform/audio/ (Web Audio) sfxCues ← the rules' events musicEngine ← musicCue (rules' plan) src/main.ts: the frame loop that reads input, steps the rules, plays the sound and draws, every frame style (and the flags it carries) tuning Controls changes textures read events, musicCue

The arrows matter more than the boxes. The rules take in two things, the player's Controls and the numbers in config/, and give out one thing, the game state. Everything else reads that state. The renderer draws it. The sound effects listen for the events the rules leave in it. The music engine asks the rules' own conductor what to play. The HTML overlays show the clock and the panels. None of them can change the game. If the renderer were thrown away tomorrow and rewritten in Godot, the rules would neither notice nor care.

The rules: a seeded, fixed-step simulation

Two promises make the rules portable, testable and comparable. The builders' guide's glossary states both under "Sim step":

the rules advance only in fixed STEPs (1/60 s, rules/game.ts), driven only by inputs and the seed … Rules code never reads the wall clock or Math.random.the builders' guide, glossary

The randomness is a small seeded generator, mulberry32, in rules/random.ts. It's the same function the art generator uses, so the game and the Art Lab cut the same forest from the same seed. Every map, forest, coast, creature, wave order and tune follows from the seed and nothing else. The fixed step came with the combat foundations on 2026-10-04 #44: "The sim runs in fixed 1/60 s steps, driven only by inputs and the seed, and drawing is interpolated between steps. A determinism test checks that the same inputs give the same state." That test is still in rules.test.ts: "is deterministic: the same seed and inputs give the same state". The bots have tests like it.

Read src/rules/ today and you'll find one honest exception: the forest's background prefetch reads performance.now(). It uses the clock only to decide how much building to do in this frame's time budget, never what gets built, because each chunk of forest is made from the seed alone. Whatever order the chunks come in, it's the same forest.

One step of the game is fixedStep in rules/game.ts. In order, it runs the two clocks (the world slows in a sleeping legend's circle while she keeps her own speed), the legend buffs, the party spell's hold, her movement and blink, the camera, the waves, the legends, her knockout, growth, the fights or the afterparty, the creatures near her, the travellers, the sigil button, the 💌s, berries, the speakers' boot, the dancefloor, the party witches, the beach witches and, last, everyone easing apart. It's a long list, and the order is part of the game. That's why every refactor tonight kept it exactly.

Presses get special care. A frame on a fast display may run no steps at all, and a slow one may run several. So a one-shot press (the sigil, a rise, a dodge, a zoom notch) is held over in g.pending until a step comes, then given to that one step only. Events work the other way round. Each frame's combat, knockout, quest, wave and leash events are gathered across all the steps it ran, so a frame that ran three steps loses none of them. The leash's events were the last to join that list #213: until 2026-10-06, a frame that ran several steps kept only the last step's, so a sigil put down "could simply not show".

The frame loop

main.ts's frame() is the heartbeat. Here is what happens between one display refresh and the next:

requestAnimationFrame(frame): dt = now − last input.read() → Controls (bot.decide in a bot game) stepGame: acc += min(dt, MAX_STEP 0.1 s) acc capped at 0.1 + STEP: a hitch loses time, never runs away while acc ≥ STEP: acc −= STEP remember(g); fixedStep(g, controls) ≤ 7× alpha = acc / STEP (0 to 1) musicCue(game) → sound.update (audio) interpolated(game, view.render( time − (1 − alpha) · STEP · timeScale)) aim reticle, applyShake (the sub-pixel glide) One frame, on the clock real time S0S1S2S3S4S5 fixed steps, 1/60 s apart display frames (uneven) acc left alpha = 50/60 ≈ 0.83 drawn at S2 S3 The picture is one step behind real time: positions mixed from the step before (S2) to the latest (S3) by alpha, then put back. 💌s and shots: carried back along their velocity.

A few details make this loop smooth on any display. Before each step, remember() notes where every witch and creature stood, and where the camera was. Creatures go into one reused Float64Array, so this makes no garbage. After the steps, interpolated() moves everything to the moment between the last two steps that alpha points at. It runs the draw, then puts every value back, so the rules never see a drawn position. A creature that jumped further than any charge could cover in one step has teleported, so it isn't blended. 💌s and shots come and go within a step, so instead of being remembered they're carried back along their own velocity. That fix came from Ed's playtest note "even though the framerate appears to be high in the stats, it feels low when you are playing" #216.

The same pull request settled where the witch lands on screen. The camera snaps to whole art pixels, then the canvas shifts by whatever is left, so the art stays on its grid while the view glides. Before the fix, that leftover shift followed the camera, and the witch shivered by about an art pixel every frame. Now the shift follows her (view.glide = "witch"), and she holds still to within a screen pixel. At 60 Hz in the treetops her frame-to-frame wobble fell from 1.91 px to 0.23 px.

Two limits keep a bad frame from making things worse. A frame longer than MAX_STEP (0.1 s) loses the rest of its time, and the accumulator is capped too, so a hitch never makes the next frame run dozens of catch-up steps. Behind the start screen the loop draws only one frame every 0.3 s and leaves the CPU to the art workers. Tools drive the loop by hand through window.witch.frame and frameLive, which step a fixed 1/60 s. That is how the bench, the smoke test's boost leg and the feel tracer get the same frames twice.

Render, UI and the platform

src/render/ is the largest folder, at 79 modules. Its entry point is View (render/view.ts), which owns the Three.js scene and, each frame, culls, places the camera, rebuilds the scenery near her, draws the creatures and runs the post-processing. Since the first housekeeping round #131, much of that work lives in render/view/ as plain functions that take the view: culling.ts, pops.ts, scenery.ts, creatures.ts, home.ts, lights.ts. The pattern was simple to copy, and tonight render/leash.ts got the same treatment #384. Around the View sit the sprite shaders with the pixel wind (sprites.ts), the rolling ground and its height texture (ground.ts, height.ts), the live rig that strings creatures together from baked parts (render/rig/), the ley lines, the sky and the smoke. All of it reads the game; none of it writes to it.

src/ui/ is the HTML: the bedroom's character creator (the largest UI file, at 766 lines), the spell scroll, the start card, the decisions panel and the touch controls. Since tonight #391 it builds every element one way, with h(tag, props, ...kids) and button() in ui/dom.ts. src/platform/ holds what touches the browser's own machinery. That means input (every binding in one table, KEYS and PAD, read into the rules' Controls type), the whole audio folder, the playtest log in localStorage, the stall log and frame stats, and the freeze for debug screenshots. src/app/ is new tonight: the parts of main.ts that were wiring rather than loop.

The art pipeline

Every sprite in the game is drawn by code. The generators in art/ are plain JavaScript: creatures from genome records, trees grown from plant genomes, the witch, props, scenes, the dancefloor, sigils and effects. They are the same files the Witch Art Lab inlines into its single page, so a change in the lab is a change in the game. Most build small 3D models in code and bake each to two images: an albedo, and a normal map that the game lights at night. Under the bold style, the art-pixel treatment is baked in as well (art/stylise.js).

Drawing all that is heavy. When the loading was first measured on 2026-10-04 #23, the 30 area types' trees were 98% of the work, "about 60 s each" in the cloud sandbox. So the drawing moved off the page. render/artBuild.ts draws a set and packs it into atlas pixels with no Three.js and no page, so it runs in a Web Worker on an OffscreenCanvas. render/assets.ts starts one worker per core but the page's own, up to six. It asks for sets as the witch nears them, nearest first, with what the view needs right now jumping the queue. A set that arrives after the start fades in over 0.8 s, so nothing pops. Back on the page, render/atlas.ts turns the pixels into a pair of textures, albedo and normal, with the sway mask packed into the normal map's alpha. A whole set then draws as one instanced batch. If workers or OffscreenCanvas are missing, or a worker fails, the sets are drawn on the page instead, one a frame.

Drawn sets are kept between visits in IndexedDB (render/artCache.ts). The key is built from ART_HASH, the style's hash, the set's name, and its seed and scale where they matter. vite.config.ts computes ART_HASH at build time as a SHA-1 of the art code (the files at the top of art/), artBuild.ts, the map and random modules and config/area-types.json. So a new build that changes none of the art starts at once, and a build that does change it draws afresh. Every flag that changes the art (?style=, ?flora=, ?props=) travels to the workers inside the style, so it's part of the key too. The cache is safe to miss: with storage blocked, full or failing, every call quietly answers nothing. The live rig's parts are baked the same way, one atlas page per species and level, keyed by the genome's hash, and the least used are let go past 48.

Config: the numbers Ed edits

Every number Ed might want to change lives in config/, not in the code. config/tuning.json is the big one. It now holds about 1,150 knobs, each with a _ note in plain words: speeds, camera, density, glow, pixel size, waves, legends, bloom. When the housekeeping round gave it a schema on 2026-10-05 #124, it had 673. That schema is a small subset of JSON Schema written in plain TypeScript (rules/schema.ts, no new dependency). tuningSchema.test.ts fails on an unknown, missing, mistyped or out-of-range knob. The reference, config/KNOBS.md, is generated from the schema and the notes, and a test fails if it's stale. It lists no values, so changing a number never needs the docs redone.

The other configs get the same check #129: combat.json, movement.json, travel.json, area-types.json and legend-buffs.json. rules/configSchemas.ts names each one's records and string choices. A compile-time assertion fails the typecheck if a list of choices drifts from the type the code reads. configs.test.ts checks that every species, attack, area type and buff they name is real. config/style.json is the art's style, saved from the Art Lab. config/music-style.json is the music as data. The changelog became one small file per change in config/changelog/ #77, after the single list had been "the most-edited file (54 of the last 150 commits) and the main source of merge conflicts".

At load, app/linkParams.ts builds the run's tuning from the file and the link's switches (?px=, ?hills=, ?shape= and the rest). The rules read that tuning, and the renderer reads the same object for its own knobs.

The music: plan in the rules, sound in the platform

The music follows the boundary too. DESIGN.md puts it in one sentence: "The rules say what should play … the engine decides when it's handed to the audio thread." The rules side is pure and seeded like the rest. rules/musicPlan.ts is the conductor: which section plays in each 4-bar block, by wave, the boot, a siege nearby or a knockout. rules/musicScore.ts generates the notes from the run's seed and config/music-style.json. rules/music.ts mixes by place: louder near a playing soundsystem, muffled in a legend's circle, fading by the sea. The platform side, platform/audio/musicEngine.ts, synthesises every note in Web Audio and keeps game time and audio time in step. It schedules 0.6 s ahead, and up to 2 s after a stall. It eases back into step at most 5% faster or slower, and holds late sixteenths rather than dropping them, so the music never waits for a slow frame.

The sound effects listen rather than being called. platform/audio/sfxCues.ts reads, each frame, the events the rules left (a 💌 landing, a hit, a soundsystem lost, a knockout) and what changed since the last frame (a creature turning enraged or happy). Its header says it plainly: "The rules know nothing of it." Because of that, the same rules drive the game, the headless bots and the benchmark, and none of them needs a sound card.

The safety nets

With a dozen builders pushing at once, "does it still work?" needed answering quickly, many times a day. The checks grew with the game.

checkwhat it catcheswhen
npm test (Vitest)the rules module by module, the configs and schemas, the changelog, the on-screen cues' geometry, the music zones, the Making Of's links. 283 tests on 2026-10-05 #77, 401 that evening #131, 693 by the bench's arrival #338, 756 tonight #393CI, every push and PR
npm run typechecktsc --noEmit over src/, config/ and the Vite config; since tonight, also a creature field left out of its fixed shape #393CI
smoke:quickheadless Chromium loads the build at 1280×720, flies the ground and the treetops; fails on any page error, a blank picture or a witch that won't move #26CI, second job
music checktools/music-lab/check.cjs renders every section offline: script errors, fetches, silence, NaN, clippingCI
game unchanged (bench:quick)the rules' state to wave 10 on two seeds and three scenes pixel by pixel, on the PR's base and head, compared #344its own workflow, every PR into the prototype
full smokeboth modes on laptop and phone layouts, touch, pops at every zoom step, Ed's window sizes, a 10 s boost's frame budget, four party wavesby builders before a FINAL
art/check.mjsevery generator draws: 566 sprite checks, from creatures' size steps and silhouettes to the sway masks and the treehouse's anchorsbefore any art push
tools/genome/compare.mjsevery creature sprite pixel for pixel against an old checkoutgenome or builder changes
sfx check and compareevery effect rendered offline; two renders compared byte for bytebefore audio pushes
playthrough bot15 game minutes of the real rules headless: exceptions, NaNs, creatures off the map or stuckafter a rules change, or overnight

The full smoke test is honest about its own limits. In the cloud, the software renderer takes one to two seconds a frame. Under that, the frame-time budgets (issue #310) and the pop checks (issue #311) fail on the prototype itself, for reasons that have nothing to do with any one change. Rather than let a red run mean nothing, tools/smoke/smoke.cjs keeps a KNOWN list at its top: six entries today, each tied to its issue. They're reported apart as "known: N" and don't fail the run, and one that starts passing says so, so it can come off the list. The builders' guide says: "Keep the list small and honest, so a red full smoke means something new."

The order of checks is a rule too. "All the builders should push first and run checks afterwards," Ed said on 2026-10-04, as the builders' guide records. Fast checks, push, a playable link for him, then the slow checks. A playable build is never held back waiting for the smoke run.

The bench: proof that nothing changed

The newest net was built for tonight. Ed's request for housekeeping came on 2026-10-05:

When all the non-art builders are done, this might be a good time to do any housekeeping / refactoring / updating etc that you would like to do to the codebase.Ed, 2026-10-05, quoted in issue #122

The rule for that round was "No behaviour change", which raises a question: how do you prove a refactor changed nothing? The prototype builder's plan put "benchmark first". The harness landed on 2026-10-06 #338. npm run bench runs the rules headless on seeds 123 and 165272 to wave 30. It times every step and takes a fingerprint of the whole game state every 300 steps. Then it drives the built game frame by frame at a fixed 1/60 s through seven scenes, with a settled screenshot of each. bench:compare checks the state part by part and the pictures pixel by pixel, writing a diff-<scene>.png for any that differ.

Getting two runs of one build to match exactly took some work. The party spell's sparkles had used Math.random, so they were seeded; that was the only change to the game itself. The full bench takes 45 minutes to two hours in the cloud. So a quick mode followed the same evening #344: rules to wave 10, three scenes at 960×540 (the boot, a wave fight in a debug arena, the bedroom), about five minutes. A workflow runs it on every PR's base and head. Green means the same game.

One fix came in the middle of the night #383. The gate went red on any edit to the tuning file, even a note. The rules carry the whole tuning file inside g.buffs, notes and all, in file order. The fingerprint now hashes a canonical form: keys sorted and the tuning's _ notes dropped, while Maps, Sets and arrays keep their order, "because that order is the game's own state". The proof ran four ways. A second run of the same build matched. An edited note, with every group of the tuning file in reverse order, matched too. One value changed by one (15 to 16) differed, and only in buffs.

Tonight's tidy-up

From the evening of 2026-10-06 into the early hours of 2026-10-07, Ed's overnight programme set the builders to tidy rather than build. Phase 1 was moves with no change in behaviour. Phase 2 was speed. Phase 3 was documents. Each piece was one theme in one PR, gated on "game unchanged". (Times below are UTC, when each merged.) In the files the PRs touched, the largest modules were split, duplicated names were given one home, and repeated helpers were merged into one.

  1. Audio tidy #376. One SfxKit.loopNoise() replaced thirteen hand-built looping noise sources, stale comments were put right, and DESIGN.md gained the music's scheduling model. Of 61 sound effects rendered before and after, 31 were byte-identical. The other 30 differed by less than one sample step, the same floor two runs of the old code show.
  2. The bench fingerprint made canonical #383.
  3. Shared art helpers into art/core.js #370. Seven copies of one hash function, four of a glow ball and three of a weighted pick each became one. All 5,174 exported assets (10,348 PNGs) came out byte-identical.
  4. rules/combat.ts split by concern #386 into combat/data.ts, targeting.ts, hits.ts and legend.ts, with every public name re-exported so no importer changed.
  5. The style bible #382, docs/STYLE.md: the grid, a 58-colour master palette, outlines, scale against the witch and the readability ladder, each rule with how to check it.
  6. main.ts split, part 1 #380. The link's switches, the HUD, the debug knobs and the sound moved into src/app/, each called where its code used to run, so storage reads, DOM appends and listeners still happen in the same order.
  7. One source of truth for state names #387. The creature states had been spelled out in four places and the legend states in two. Now CREATURE_STATES and LEGEND_STATES in creatures.ts are the one home, and everything else names their types.
  8. render/leash.ts split #384 into glyphs, stack, projection, bubbles and combat, moved by script (this. became lv.).
  9. art/check.mjs covers every generator #392. A map of every art module against the check found seven generators nothing drew at all: the beach finds and footprints, the bedroom, the 14 newer brooms, her hat alone, rune stones with sigils, the party patch and the legends' sprite sets.
  10. The UI's one pattern #391, h() and button(). It was proved with DOM dumps: every element's tag, sorted attributes, computed style and text, 932 lines for the creator with every tab open, identical before and after.
  11. The sigil button out of game.ts #390. The whole E press, inline in fixedStep (hat, relic, leash, rune, quest), moved verbatim and in the same order into rules/sigilButton.ts.
  12. One shape for every creature #393, phase 2's first.
modulebeforeaftertodayPR
src/rules/combat.ts1,048384 (+ 4 files, 710)384#386
src/render/leash.ts1,335504 (+ 5 files, 898)508#384
src/main.tsabout 790506 (+ 4 files, about 400)514#380
src/rules/game.ts−53 (+ sigilButton.ts, 74)770#390
src/render/view.ts1,483798 (+ 6 files)1,180#131, 2026-10-05
src/ui/ (creator, start card, panels)−58 net#391
art helpers, 15 files+153 / −169#370

The view.ts row is a warning about how this works. It was split once, on 2026-10-05, from 1,483 lines to 798. Then a day and a half of new features grew it back to 1,180. Splitting it again by layer (view/camera.ts, frameUniforms.ts, witch.ts, hud.ts, ahead.ts), to about 844, is open as #374. Also still open at the time of writing: main.ts part 2, which takes the link's game and view switches and the page keys into src/app/, to about 447 lines #395; the tuning file in nine sections #371; a dead-code sweep that found only six unused names in the whole of src/ #388; and a README for every tools directory, generated from the scripts' own headers #389.

Every one of those merged slices posted the same proof. For the rules: seeds 123 and 165272 to wave 10, the same creature counts, and the state "same". For the pictures: boot, wave-fight and bedroom, "0 pixels differ". The leash.ts split's table is typical:

match: same state, same picturestools/bench/compare.cjs, on #384

Phase 2: one shape for every creature

The most striking result of the night came from a profiler. tools/bench/alloc.mjs, new in #393, runs V8's sampling heap profiler over the bench's fixed run. At wave 20, with 1,545 creatures, the rules were allocating about 1 MB a step, roughly 64 MB a second. Over a third of it was charged to a function that allocates nothing itself. The cause was the creatures' shape. Optional fields had been added to them in different orders as play went on, so V8 saw 35 different hidden classes. Every read of c.x or c.level went through a slow lookup that boxed each number it read. The fix was a single place: makeCreature now sets every optional field, in one fixed order, to the undefined it read as before. Measured, that's one hidden class among 1,086 creatures. A field added to Creature but not to the list now fails the typecheck.

beforeafter
allocated a step, wave 201,075 KB446 KB
rules step, wave 204.35 ms2.92 ms
wave 30, seed 123, median / p993.27 / 6.11 ms2.80 / 5.92 ms
wave 30, seed 165272, median / p994.00 / 7.07 ms3.06 / 5.37 ms
state, both seedssame

That's a faster game, a third of the garbage gone, and still exactly the same game. The canonical fingerprint is what made the last row possible: it sorts every object's keys, so a new field order doesn't count as a change.

Where the size is now

The biggest modules on the prototype branch this morning, by line count:

rules render art ui platform, main 04008001,100 render/view.tsart/witch.jsart/check.mjsart/creatures3d.jsrules/game.tsui/creator.tsrender/ground.tsrules/map.tsart/sigils.jsart/areas.jsrender/height.tsrender/sprites.tsrules/states.tsrules/forest.tsmain.tsrender/leash.tsaudio/musicEngine.ts 1,180941911805770766661611594583582571566528514508480

The biggest file of all isn't game code. It's the rules' oldest test file, rules.test.ts, at 1,792 lines. Splitting it by topic was on the housekeeping plan from the start and is still to come. No game module is over 1,200 lines any more, and the combat rules are now spread over five files of a few hundred lines each.

None of this was for show. Each split leaves code a builder can read in one sitting, and gives the next round of work fewer collisions between branches. Above all, it keeps the port in sight. When Coven Rush moves to Godot, the 13,000 lines of src/rules/ carry over as they are, and the bench's fingerprint will be waiting to say whether the port plays the same game.