CHAPTER 02 OF 06

The spec

Write it before the first prompt, with numbers in it.

A coding agent will never tell you that you left something out. It fills the gap with something plausible and keeps going, and plausible is the part that hurts. A wrong guess that looks wrong gets caught in the first five minutes. A wrong guess that looks reasonable gets built on.

They also compound. A guess about how timing works decides where state has to live, which decides what the export path can do, and by session four you are debugging a decision nobody ever made and nobody can find. The fix is not a better prompt. The fix is a document that turns every one of those guesses into a lookup.

8BEETY's spec ran to about 630 lines before any meaningful code existed. That sounds like a lot until you notice it is mostly numbers you were going to have to decide anyway. You are choosing when to decide them, not whether.

How specific it got

Specific enough to be boring, which is the correct amount.

A formula instead of an adjective. The spec did not say "make the pitches sound authentic." It said NES pitch round-trips through the hardware timer math, 1789773 / (16 * (t + 1)). That is not a suggestion an agent can interpret. It is an equation it can implement and a test it can check.

A bug, kept on purpose. On real NES hardware, timers below 8 go silent. The spec says so, and says to preserve it rather than fix it. This is the tell for a spec that has actually been thought about: it does not only describe the behavior you want, it names the broken-looking behavior you want kept. Without that line, an agent "fixes" it in the first week, correctly, by its own lights, and the output stops sounding like an NES.

A data shape, in field names. FrameScript is per-channel parallel typed arrays, one entry per frame: period, volume, duty, pan, and trig. Not "an array of note events," which is what anyone would have reached for. This is the load-bearing decision in the whole document, because every milestone after the first is downstream of it, and a mistake here gets paid for over and over.

Non-goals, stated flatly. No accounts. No server-side anything. No full tracker or macro editor. No Nintendo or Sega logos, characters, or trade dress anywhere in the UI. Every one of those is an argument you have already won, settled once in writing instead of relitigated at 11pm in session six when adding a login sounds briefly reasonable.

The two rules that make a spec load-bearing

Most specs die the same way. Somebody writes one, the code drifts, nobody updates the file, and three weeks later it is fiction that your agent still reads and still believes. Two rules keep that from happening, and both belong at the top of the file where nobody can miss them.

One: the spec wins. When the code and the spec disagree, the spec is right until somebody changes the spec first. Code that contradicts it is a bug, even when it is good code, even when it is the code you just wrote. That rule costs nothing to state and everything to keep, and the first time you have to keep it will be a time when keeping it is inconvenient.

Two: deviations get written back. When you deliberately depart from the spec, and you will, you put it in the deviations log with a date and a reason, then you go and fix the section it contradicts. An undocumented deviation is a bug with a good story attached.

What those two rules buy you is arguments you only have to have once. When a question came up mid-build on 8BEETY, the answer was read SPEC.md §7.3, not a negotiation.

The artifact

SPEC.md
# SPEC.md

Source of truth. When this file and the code disagree, this file is right
until someone changes this file first.

Last updated: [YYYY-MM-DD]

## 1. Product statement

[One paragraph. What the thing is, who runs it, what comes out of it.
Written so a stranger could repeat it back to you correctly. No marketing.]

Example (8BEETY): a browser tool that turns any MIDI file into music that
sounds like it came out of an NES, a Game Boy, a Super Nintendo, or a Sega
Genesis. Drop in a MIDI, set a tempo, assign tracks to chip channels, export
a WAV or a 9:16 video. No login, no server, hosted as a static site.

**Guiding principle:** [the one sentence that settles architecture
arguments. The useful form is a correction: name the obvious wrong mental
model, then the right one.]

Example (8BEETY): this is not a synthesizer that receives notes, it is a
compiler that produces a register script, plus a player that renders that
script. Everything downstream of the compiler is deterministic, which is
what makes offline export and testing easy.

## 2. Non-goals

[What this will never do, stated flatly. No "for now" unless you mean it.
Every non-goal here is an argument you have already won.]

Example (8BEETY): no user accounts. No server-side anything. No full tracker
or macro editor. No cycle-accurate emulation (musical models of the chips,
not emulators). No Nintendo or Sega logos, characters, or trade dress
anywhere in the UI.

## 3. Core rules

[The architectural invariants. These are the rules where breaking one means
a rewrite rather than a fix. Keep the list short enough to remember, and
copy it into CLAUDE.md so the agent reads it every session.]

Example (8BEETY):

- All timing is 60 fps frames. Parameter changes happen only on frame
  boundaries. Never schedule audio events in seconds from the main thread.
- The compiler is a pure function: `compile(song, project, profile)` returns
  a FrameScript, deterministically, and never reads global state.
- The audio worklet imports nothing from the rest of the app.
- TypeScript strict, no `any`. The dependency list is closed; adding to it
  is an explicit decision, not a side effect of a task.

## 4. Domain model

[The real numbers and formulas. Rates, ranges, limits, units, rounding
rules, what happens at the edges. This is the section that turns "make it
sound right" into something a machine can implement.]

Example (8BEETY): NES pitch round-trips through the real hardware timer
math, `1789773 / (16 * (t + 1))`, so high notes pick up the chip's genuine
detune instead of a clean pitch nobody's console ever produced. Timers below
8 go silent on the real hardware, and that is preserved rather than fixed.
The Game Boy uses `131072 / (2048 - x)`.

## 5. Data model

[The load-bearing decision. Exact shapes, exact field names, exact types,
and a diagram of what turns into what. Every milestone after the first is
downstream of this section, so a mistake here gets paid for repeatedly.]

```
MIDI file --import--> Song (normalized notes and tempo)
Song + Project (the user's arrangement) --compile--> FrameScript
FrameScript --ApuCore--> audio samples (realtime worklet OR offline render)
```

Example (8BEETY): FrameScript is per-channel parallel typed arrays, one
entry per frame: `period` (the pitch register), `volume`, `duty` (duty
index, wave preset, FM patch, or sample index depending on the chip), `pan`,
and `trig` (the note-on marker). A three-minute song is roughly 10,800
frames across a handful of channels, small enough to hand to the worklet in
one message, and identical for playback, WAV export, and video export.

## 6. Behavior

[What the system does with that data. The rules, the edge cases, and the
priority order for when two things want the same resource.]

Example (8BEETY): polyphony is resolved the 8-bit way, with top and bottom
note extraction, arp (chord tones cycled at 1 to 3 frames per step), and
split (one chord spread across several channels). A GM drum map routes kick,
snare, toms, hats, and crash onto the noise channel, with priority
resolution when two of them land on the same frame.

## 7. UI

[Screens, density, and what is on one screen versus behind a click. Name
the feel in words a stranger could actually apply to a layout.]

Example (8BEETY): dense, utilitarian, dark. One screen: a header with chip
select, BPM, transport, and export; a chip rack of drag-target channel
cards; a track list with per-track piano rolls; a bar ruler with click-seek
and drag-loop.

## 8. Export, persistence, sharing

[Formats in and out, where state lives between sessions, and exactly what a
shared link contains.]

Example (8BEETY): WAV export renders offline through the exact same audio
core as playback, which is why the two match. Video export is a 9:16
720x1280 MP4 of the visualization. Project files are zod-validated JSON with
the MIDI embedded as base64, and share links compress the whole project into
the URL fragment with lz-string, so there is still no server.

## 9. Acceptance criteria

[Per milestone. Each one has to be settled by running a command or by a
named comparison someone else could repeat without asking you what you
meant. "Works well" is not a criterion. "Sounds good" is not a criterion.]

- M[n]: [the criterion, with the command or the comparison named]
- Standing gate for every milestone: [e.g. `npm test` green and
  `npm run build` green, both actually run, output pasted]

Example (8BEETY): the NES fixture is compared by ear against a FamiTracker
export of the same pattern, and separately, chunked realtime rendering and
whole-file offline rendering must produce sample-exact identical output,
which is a runnable proof that the player is deterministic.

## 10. Deviations log

[When you deliberately depart from this spec, write it here with a date and
a reason, then go fix the section above. An undocumented deviation is a bug
with a good story attached.]

- [YYYY-MM-DD] [what changed, why, and which section above was updated]

The standalone version, with adaptation notes and worked 8BEETY examples in every section, lives at SPEC.md (Source of Truth).

One test for whether a section is finished: put a number in it. If you cannot, you do not know what you want yet, and neither will the agent, which will pick a number for you and never mention that it did.