CHAPTER 06 OF 06

Make it yours

What to change, what it costs, and which files it lands in.

Everything up to here has been somebody else's decisions, laid out so you can see why they were made. This chapter is where you make one.

The honest framing first. A fork you can describe in one sentence is a good first change, and a fork that needs a paragraph is two forks you have not separated yet. That is the same test the milestone plan used, applied to somebody else's codebase instead of your own, and it holds up better here because you did not write any of this and you have no idea yet which parts are load-bearing.

The difficulty ratings below are not about how clever a change is. They are about how many layers it touches. A change that is conceptually dull and lands in four files across three subsystems is harder than a change that is conceptually strange and lands in one. This is generally true of software and it is emphatically true when an agent is doing the typing, because every extra layer is another place for it to make a reasonable-looking decision you never got asked about.

The fork table

Change Difficulty Where it lands What it teaches
New instrument presets Easy src/engine/instruments.ts How much of a sound is data rather than code
A different console shell in the video export Easy src/viz/ Where the drawing lives, and why it never touches audio
A new export format Medium src/audio/render.ts That there is one render path, and you are adding a destination to it
A different input format, for example MusicXML Medium src/engine/midi-import.ts That the normalizer is a wall, and what a wall is worth
A fifth chip Hard Spec first, then src/engine/pitch.ts, then src/audio/apu-worklet.ts, then src/engine/chip-profiles.ts Whether the data shape was actually right
A different domain entirely Not a fork The method transfers, none of the code does That the five artifacts were never about music

New instrument presets

Instruments in 8BEETY are FamiTracker-style per-frame macro tables, volume and arpeggio and pitch and duty, plus a small tweak surface. Adding one is mostly adding numbers to src/engine/instruments.ts, which is why it is the cheapest change in the repo and the best one to learn the loop on.

Write in the spec, before you touch it: which chips the preset is legal on, its macro tables as literal frame-by-frame numbers, and the existing preset it is supposed to sit next to without duplicating. Skip that and the agent will produce something that sounds fine on the NES and clips on the Genesis, or a macro longer than any note in your fixture, so the release stage never runs and you never hear it. Neither of those looks like a failure. Both of them sound like "yeah, that's alright," which is the noise a project makes on its way to being mediocre.

A different console shell in the video export

src/viz/ holds the lane renderer, the drawn console shells, and the video export. A new shell is drawing plus layout constants, and nothing in there can reach the audio engine, which is what makes it a safe place to experiment.

Write in the spec: the exact geometry inside the 9:16 720x1280 frame, where the playthrough panel sits, where the song title goes, and the non-goal restated in full, because this is the row where it matters most. No real logos, no characters, no trade dress. Skip that and the agent draws something recognizable, because recognizable is exactly what "make it look like a Sega Genesis" means to a model, and now the legal problem is inside a video that somebody posted. The repo already has layout invariant tests for the drawn consoles. Write yours before the drawing, and the first thing you see is a failure that tells you something.

A new export format

Read src/audio/render.ts first. It is an offline render through the same ApuCore the browser plays through, followed by a hand-written encoder. So a new format is a new encoder on the end of an existing render. It is not a new render.

Write in the spec: the exact byte layout, the sample rate, the channel count, and one test file you can decode with a tool that is not your own code. Skip that and hand an agent "add MP3 export," and there is a real chance you get a second render path, because building one is a perfectly sensible reading of the request. It will work. It will also drift from the worklet at some point you cannot identify, and from then on your exports and your playback are two different products. The rule that prevents this is already sitting in CLAUDE.md: everything goes through the same ApuCore, and if you are about to write a second render path, stop and ask.

A different input format

src/engine/midi-import.ts normalizes whatever a MIDI file happens to contain into a Song. That is a wall, and nothing downstream of it has ever seen a .mid. Adding MusicXML means writing a second importer that produces the same Song, and if the wall is doing its job, nothing else in the repo changes at all.

Write in the spec: the mapping from MusicXML's vocabulary onto Song fields, and specifically what happens to everything it carries that Song has no room for. Dynamics, articulations, repeats. "Discarded" is a completely valid answer as long as it is written down. Skip that and the agent widens Song to fit the new format, politely, one optional field at a time, and six tasks later compile() has branches for data only one importer can produce. The wall stops being a wall, and you will not be able to point at the commit where that happened.

A fifth chip

This is the row worth reading even if you never do it, because it is the row that tells you whether the architecture was real or just tidy.

Start with what happened the last time. The SNES model is eight sampled voices with an echo bus. The Genesis is five lanes of four-operator FM. Neither of those is a square wave with a different timer in front of it; to anyone doing the DSP they are a different kind of sound production from the NES and the Game Boy in every way that matters. And when they went in, the store, the worklet transport, the visualizer, and both exporters needed almost no changes.

The reason is FrameScript. Five per-channel arrays, one entry per frame: period, volume, duty, pan, trig. period is an NES timer on one chip, a Game Boy period on another, a packed YM2612 fnum and block on the third, an SPC pitch register on the fourth. duty is a duty index, or a wave preset, or an FM patch, or a sample index. The numbers mean four completely different things across four chips. The shape never moves. So every part of the system whose job is to carry numbers around rather than interpret them, which is src/store.ts, the message that goes to the worklet, the lane drawing, the WAV encoder, and the video recorder, never had to learn that a fifth kind of sound existed.

That is why the order in the table is spec, then pitch, then worklet, then profile. The order is doing work.

The spec section comes first because a chip is a pile of exact numbers and there is no version of this where you invent them as you go. Channel count and channel kinds. The register math as an actual formula. The volume range. What quantization the hardware imposed, and on which clock. And the list of its authentic-sounding defects that you are keeping, named as kept, with the reason. Leave that last part out and your agent will fix them, correctly, by its own lights, exactly the way it would have "fixed" the NES timer floor.

src/engine/pitch.ts comes second because pitch conversion is a pure function over integers, which makes it the cheapest thing in the entire change to test. Round-trip every note the profile advertises. Assert that the drift is present rather than asserting it is absent. Assert the edge cases the real hardware had. All of that goes green before you have written a single sample of DSP, and from that point on, pitch is off the suspect list for everything that follows.

The worklet DSP comes third, in src/audio/apu-worklet.ts, under the rule that file has always lived by: it imports nothing from the rest of the app, so it loads as an AudioWorklet in the browser and imports cleanly into Vitest under Node. That rule is what lets a brand new synthesis model be testable on the day it is written instead of the week you get round to wiring up a browser harness.

The chip profile comes last, in src/engine/chip-profiles.ts, because it declares which lanes exist and what ranges they accept, and it is the piece that makes the new chip show up in the app. Last means the switch that turns it on only gets flipped once everything behind it is green.

Now the part where skipping the spec costs you. The independent review passes on the bigger features, the 16-bit chips and the console-art video export, caught two octaves silently dropped on the SNES, a double-gain bug that clipped all 16-bit audio, WAV exports that lost a hard-panned channel entirely, and an FM release envelope that could never sound. Look at what each of those is underneath: a range, a gain stage, a channel count, an envelope rate. Every one of them is a number that a spec section would have pinned, and every one of them demoed fine. A fifth chip added without a spec section is four more bugs of that exact shape, and the only reason anybody knows about these four is that somebody who had not written the code was told to verify by running things rather than by reading them.

One more thing, and it is the good news hiding in the hard row. If a fifth chip is genuinely hard for this architecture rather than merely laborious, you find out while writing the spec, at the moment you try to express its channels in those five arrays and one of them will not fit. That is the cheapest possible place to discover it. "The data shape does not accommodate this" is a decision you make on a page in an afternoon. The same discovery made halfway through the DSP is a rewrite, and you will make it on a Thursday with three files open and no good options.

A different domain entirely

Not a fork, so it does not get a file. It gets the rest of the chapter.

Taking the method somewhere else

Chapter 4 was blunt about what does not transfer: the chip DSP. A few thousand lines about four specific pieces of 1980s silicon, and none of it will help you build anything else. Fine. That was always the part that took the longest and taught the least.

The five artifacts are the part that moves, and none of them know anything about music. So here is the whole method mapped onto something with no audio in it at all: a staff scheduling tool. Shifts, availability, labor rules, a rota somebody prints out and sticks on a wall. One person, one agent, same loop.

What plays the role of the exact formulas. For 8BEETY it was 1789773 / (16 * (t + 1)) and a timer floor kept on purpose. For the scheduler it is the labor rules, written as arithmetic rather than as intentions. The numbers below are illustrative, and yours come from your jurisdiction rather than from this page. Overtime is every minute past 40 in a Monday-to-Sunday week, paid at 1.5x base. Minimum rest between shifts is 11 hours, and a roster that breaks it is invalid, not merely flagged. Nobody works more than six consecutive days. And you need the equivalent of the NES timer floor, the rule that looks like a bug and is kept anyway: a shift starting 22:00 Tuesday and ending 06:00 Wednesday counts entirely toward Tuesday, because that is how the payroll system your customers already use counts it. Write that one down with its reason attached, or the first agent that goes near the weekly totals will correct it, and every export you have ever sent will change by eight hours.

What the pure core is. buildRoster(staff, demand, rules) returns a roster. No database call, no clock read, no random tie-breaking, no ambient notion of "today". Every input arrives as an argument, including which week it is for. That single signature is the difference between a suite that runs a hundred awkward weeks in milliseconds and a suite that needs a seeded database and a faked clock before it can assert anything at all. When you catch yourself wanting the current time inside that function, that is a clock trying to become global state, and the answer is a parameter.

What the deterministic data contract is. 8BEETY quantized everything to 60 fps frames and put five typed arrays behind it. The scheduler quantizes to 15-minute slots and puts one entry per slot per employee: assigned role, pay band, and a flag for whatever makes that slot unusual, such as overtime, a rest violation, or a requirement nobody filled. Fixed shape, one week at a time, small enough to pass around whole. The payoff is exactly 8BEETY's payoff: the on-screen grid, the printable rota, the payroll export, and the cost projection all become four readers of one array, instead of four subsystems that each recompute the truth and start disagreeing by a decimal in month three.

What the second variant proves. Do for the scheduler what the fifth chip did for 8BEETY, and do it before you build the first ruleset rather than after. Take the second one, a different jurisdiction, a union agreement, a night-shift premium, and check that it fits the slot entry without reshaping it. If it needs a field you do not have, you have found that out on a page. If it needs a quantum finer than 15 minutes, you have found out something far more expensive, in week one, for the price of an afternoon.

What a milestone ships. The three properties need no translation. M0 is scaffold and the two green commands, nothing else. M1 imports a staff list and puts availability on screen. M2 is buildRoster with one ruleset and no editing UI at all, and what it ships is the first week you can actually look at. M3 draws overtime and rest violations onto that grid. None of those needs an "and" to describe, and every one of them changes something a person can see, which is the scheduling version of changing what comes out of the speakers.

The chip DSP does not transfer, and neither does the labor law. What transfers is that both of them got written down as numbers before any code existed, by somebody who then spent their evenings reading diffs instead of guessing.

The loop, one more time

1. SPEC.md          what to build, with numbers in it          (write first)
2. CLAUDE.md        standing rules and verification gates      (write second)
3. Milestone plan   small, testable, shippable slices
4. Build            one task, test-first, in a fresh context
5. Review           independent, against the spec, verified by running
6. Green gates      full suite and production build, output pasted
7. Repeat           one milestone at a time, until it is finished

That is the guide. Seven lines, and the size of the app stops mattering, because the only thing you ever have to hold in your head is the size of one milestone.