What was built, in what order, and why it was ordered that way. Written as the build happened.
Goal: a headless simulation that runs 200 deterministic days with no UI attached.
Scaffolded Vite + React 19 + TypeScript, then switched the package manager and runtime to bun
partway through (bun runs the TypeScript tools directly, so tools/balance.ts needs no build
step).
The first real artefact was src/sim/types.ts — the complete type contract for every entity,
action and event in the game. Everything else, including all the parallel work below, was written
against it. Then rng.ts (seedable, serialisable sfc32), bus.ts (typed event bus) and
balance.ts (every tuning number in one place).
Exit criterion met: 200 headless days, deterministic across runs, zero DOM dependencies.
With the contract fixed, all content could be authored concurrently. Nine agents wrote sibling files, then a tenth reconciled cross-references and typechecked the tree.
| File | Count |
|---|---|
techniques.ts |
48 across 8 modalities, incl. 8 philosophy-exclusive |
events-life.ts + events-practice.ts |
64 |
arcs.ts |
42 arc beats |
traits.ts |
22 |
upgrades.ts |
26 |
trainings.ts |
24 |
milestones.ts |
30 |
campaign.ts |
5 accreditation stages |
programs.ts / philosophies.ts |
6 / 3 |
names.ts / testimonials.ts |
~120 names, 70 backstories, 48 testimonials |
The integration pass caught six dangling program→event references and wired trait-specific events,
and it also caught a real infrastructure bug: tsconfig.json had baseUrl set, which
TypeScript 7 removed, so tsc was aborting on config parse and typechecking nothing at all.
Every "clean" typecheck up to that point had been meaningless.
quality.ts, session.ts, generators.ts, eventsys.ts, scheduler.ts, engine.ts, save.ts.
The design decision that shaped the most code: SessionResult.reasons carries the full
explanation of every session, so the reflect card never has to re-derive anything and the
"no hidden punishments" commitment is structural rather than a UI convention.
tools/autoplay.ts (a headless reasonable player) plus tools/balance.ts (runs it thousands of
times and reports the curves).
The first run failed the game. 6 runs × 60 days reported:
- Reputation, community trust, morale and practice level all pinned at maximum by day 60
- 86% of sessions graded "Excellent", 0% breakthrough, 0% poor
- Zero dropouts, zero burnouts, zero departures across every run
- Cash compounding to $75k with nothing to spend it on
This is v1's failure reproduced exactly, three times faster. Four root causes, all found by reading the report:
- Quality could not exceed 0.907. Compression above the knee was linear, so the 0.92 breakthrough threshold was mathematically unreachable — while every session simultaneously reached the ceiling.
- Aggregate bonuses were unbounded. The 26-item upgrade tree was worth up to +0.5 raw quality on its own, which pinned every session at the cap regardless of play.
- The same bug in morale. Aggregate office
moraleDriftwas worth +5.2/night against a −3.1/night reversion, so every team pinned at 100 and the poaching/retention game never fired. - Linear meter gains. Reputation and trust gained linearly and decayed at a constant rate, so both ran to 100 and stopped being meters.
The fixes were structural rather than numeric:
- Compression now asymptotes toward the practice-level ceiling used as an asymptote rather than a hard clamp — a clamp made every mature session score the identical number.
- Additive modifiers are summed and clamped as a group; office and trait contributions each asymptote separately.
- Morale reverts toward a baseline every night.
- Reputation and trust gains scale by
(1 − v/max)^nwhile losses land at full strength. - One normal variance sample per session, drawn at session start and stored on it so the preview and the result agree.
- Strain accrues from carrying too many hours, not from a tired evening, so burnout is visible before it lands.
- Most overhead moved from per-client to per-session, so a large caseload no longer quietly bankrupts a healthy practice.
Iterated against the harness until the three difficulties read as three genuinely different games. Final curves are in BALANCE.md. Measured after the Phase 6 and 7 fixes: Cozy never collapses and 39/40 runs finish accredited; Standard also holds at 0/40 collapses but spreads widely (p10 owns 4 upgrades, p90 owns all 26); Challenge collapses 14/40 with a median final balance of $4,723 and a p10 that ends in the red.
Design tokens (theme.css), shared primitives and the procedural portrait system were written
first as the shared vocabulary, then eight agents built panels and moments against them:
HUD and day flow, schedule, clients, staff and hiring, finances and upgrades, programs/policies/
campaign/wall, the session decision overlay, the reflect card, events, celebrations, title and
onboarding, philosophy and the end screen.
- The living office (
src/scene/): a PixiJS v8 dollhouse cross-section, drawn entirely procedurally — no image assets, no network requests. Lazy-loaded behind a boundary that degrades to nothing, so the game stays fully playable without WebGL. - Audio (
src/audio/): every sound synthesised with the Web Audio API, in an F-major pentatonic scale so nothing clashes across a long session, with a voice limiter and a generated convolution reverb. No audio files. - Tests (
src/sim/*.test.ts): RNG determinism, the quality formula's invariants, session resolution, the day loop, scheduling, save migrations, and content integrity.
Wired everything through src/App.tsx, then actually played it. Six real bugs came out of
half an hour of play plus the test suite, and every one of them was invisible to typechecking:
- A 60-year-old was referred for "Child Behavioral." Client age was drawn independently of
the presenting condition. Worse, the session card then rated Sand Tray a "Strong fit" for a
57-year-old with PTSD, because Play Therapy techniques legitimately list trauma in
goodFor. Ages now follow the condition, and bothspecializationFitandtechniqueFitaccount for whether a school is built for the person actually in the chair. progressDeltawas computed before arc beats applied, so the reflect card could report +4 while the client sheet moved −2. It now reports the true total, and a beat that moves progress says so in the reasons list. This one matters more than its size: the whole no-hidden-punishments contract rests on the card and the sheet agreeing.- Events repeated within days of each other — the same dilemma three mornings running, and
twice on one day. Found by
tools/playtest.ts, which narrates a single run; the statistical harness had smoothed it away completely. Events now carry a per-scope cooldown. - A flat per-session event chance would have fired ~5 modals a day at late-game session volume — the opposite of cozy. Capped per day.
- Two events the engine raises by name had never been authored, so the Act 1→2 hinge and
the post-burnout conversation were both dead — and the first-hire nudge silently retried
every single day of Act 1, because a no-op raise never lands in
firedOnce. compress()could exceed the practice ceiling by 0.01 when the cap sat exactly on the knee, andcapacity()returned NaN for a save missingpracticeLevel.
Also caught during setup: tsconfig.json had baseUrl, which TypeScript 7 removed, so tsc
was aborting on config parse and checking nothing. Every green typecheck before that fix was
meaningless — worth knowing if you upgrade TypeScript under an older config.
The build was "done" before this phase. Everything here came from a person actually sitting with it, and none of it was visible to typechecking or to the balance harness.
The day started running while the tour was up. You cannot read a coach-mark against a moving
schedule. The clock now waits while tutorialStep >= 0, and finishing or skipping the tour hands
it back — so nobody is left staring at a paused day wondering what they broke.
Panels opened underneath the HUD strip, hiding their own headers. Both now hang off a single
--hud-h token so the bar and the panels cannot disagree.
Tooltips were clipped, and then ran off the page. Two separate faults. The HUD sets
overflow-hidden and establishes a stacking context via backdrop-filter, so a tooltip inside
it was both cut off and trapped below the scene — fixed by portaling to document.body, the only
reliable escape from an ancestor's clip, transform, filter or z-index. Then a tooltip on a control
near the right edge hung off the viewport, which needed real placement logic: flip to the opposite
side when the preferred one has no room, then clamp along the cross axis. That is fiddly enough
to be worth extracting (src/ui/anchor.ts) and testing exhaustively — the sweep across every
anchor position immediately caught that the clamp's Math.max has to come after the Math.min,
or an oversized tooltip gets pushed off the right edge instead of pinning left.
"I selected a therapy option and now the game is stuck." The most interesting bug of the build, and the one I did not fully solve.
The symptom was diagnostic: pause/play has no impact on time. tick() refuses to advance while
any event is pending, and that check ignores paused — so a freeze means a pending event with
nothing on screen to resolve it. App.tsx decided what to mount using !!p.techniqueCards while
the modals picked their subject using .length > 0, so an empty array would mount an overlay that
renders nothing and suppress the event modal.
But a stress harness driving 120 seeds × 45 days through the exact browser tick pattern found zero stalls, which means the engine does not currently produce that empty array — so that probably was not the reported freeze. Rather than keep guessing, the failure mode was made non-fatal:
- The predicates moved into
src/sim/pending.tsas a stated liveness contract, used by both the mounting decision and the modals, so they cannot disagree. - A watchdog detects blocked-but-nothing-on-screen, logs what was pending, and resumes the clock.
- An error boundary catches render failures, which present identically to a freeze. The sim lives outside React, so its state is intact — the boundary offers retry, roll back to autosave, or export the run.
src/sim/stall.test.tsguards the whole class: tick sizes, auto-pause on and off, a fully-booked day, and "a resolved decision always hands the clock back".
The honest summary is that the bug is now recoverable and diagnosable rather than definitively identified. If it recurs, the console names the pending event or the boundary shows the stack.
Phase 7's lesson was that three tools each see one class of bug and never each other's. Two of the three gaps were still open, so this phase built instruments rather than features.
Replay turned out to be an audit of everything that was not an action. The premise is trivial —
the sim is deterministic and GameAction is serialisable, so record the dispatches and re-run them
— and it immediately found two places where the run was not, in fact, a function of its actions.
Log and toast ids came off module-level counters, so two same-seed games in one process differed in
exactly the way that defeats a whole-state diff. And three UI surfaces wrote state.flags directly
with a no-op dispatch to force a publish, which had worked invisibly for the whole build: a
recorded run where somebody dismissed the quarter review no longer reproduced. Neither was
detectable before there was something that cared.
The one genuinely subtle bug was in the recorder itself, and it was found by review rather than by
running it: Recorder kept the run's starting legacy by reference, and legacy is meta-
progression the end screen mutates after a run finishes. So retiring, spending a legacy perk, then
exporting the run rewrote the recorded starting conditions retroactively — and the CLI reported a
divergence on day 1, blaming the player's log in the exact moment the tool exists to serve them.
Deep-copying the origin is a one-line fix; noticing that the tool could lie about the thing it was
built to prove is the part worth remembering.
One design note that reads as pedantry and is not: ticks are run-length encoded, not summed.
TICK 1 twice is not TICK 2 — tick() reads thresholds off state.minute, so a coarser step
can activate two sessions in one pass and draw their variance in schedule order rather than clock
order. Recording {action, n} keeps the sequence identical and still takes a 12,288-action browser
run down to 384 entries.
The adversarial player measured a floor nobody had seen. --skill 0 never did the genuinely
wrong thing — it would not work an exhausted therapist or process trauma on an unstable client — so
"poor" sessions were 0.0% in every sweep ever run. A policy that plays like an overwhelmed beginner
rather than a random button-masher puts them at ~7% and mixed at ~65%, and finally exercises
departures and burnouts at scale.
It also caught three things the competent bot had been hiding, all documented rather than tuned away because they are design questions and not bugs: bad practice is not punished financially (adversarial Cozy banks $393k against the reasonable player's $130k, purely on session volume), cures track session count more than session quality, and burnout has an upside, because a sabbatical hands a therapist back with more capacity than they left with. The first is the one that undercuts the design, and it is still open.
Three small UI gaps, one of which turned out to be the same shape as the freeze from Phase 7.
Panels and the day cards had been fighting for the middle of the screen. Dismissing the card
when a panel opens was the tempting fix and the wrong one: the morning brief's own footer says
"Open the schedule", so hiding the brief in order to show the schedule would have made the game
argue with itself. The card docks into the column beside the panel instead, and below a readable
width it fades back rather than being unmounted, so it returns exactly where it was. The
arithmetic went into src/ui/dock.ts, pure and unit-tested beside anchor.ts, and the panel
publishes its own measured width rather than anyone keeping a table of panel sizes — a table would
be wrong the first time somebody changed a shell.
The shortcut card could cover a decision that was holding the clock. Opening it was already
suppressed while a modal was up, but the reverse order was reachable: leave it open at 4×, have an
event fire a second later, and a list of keyboard shortcuts is painted over the thing tick() is
waiting on. That is Phase 7's freeze wearing a different hat — the clock stops, pause does not
help, and the explanation is behind the card. src/ui/modals.ts is now the single answer to "who
owns the centre of the screen", which App.tsx mounts from and the keyboard layer suppresses from,
so the two cannot drift apart. Same shape of fix as src/sim/pending.ts, for the same reason.
And the first tests that drive a browser. Six Playwright specs; the full-day one crosses every
integration seam the unit tests cannot reach, and four of Phase 7's five player-found bugs now have
a regression test. The pair that matters most asserts the liveness contract from both ends: the
clock stops for a decision, starts again when it is answered, and — the assertion that took a
second pass to get right — taking the pause away mid-decision does not move it. A decision
auto-pauses as well as blocking, so paused alone cannot tell you which one is holding the clock,
and that ambiguity is precisely why the reported freeze was so hard to read.
Two things learned the hard way. The suite needs its own Vite config with HMR off, because a
source file saved mid-run destroys the execution context and the failure looks like a game bug.
And getBoundingClientRect cannot see a clip — the tooltip test hit-tests with
elementFromPoint, which is the difference between catching the Phase 7 clipping bug and writing a
test that would have passed while it was live.
The pacing assertions had been in the harness for about an hour when they turned up a live defect, and it is a good example of a bug that only a moment-shaped test can see: 120 of 120 reasonable 200-day runs were re-raising an event inside its own cooldown, and every statistical table in docs/BALANCE.md was perfectly happy about it.
pickEvent checked the cooldown; raiseEvent only ever set it. So random draws were spaced
and scripted raises were not, which is backwards — the scripted ones are the beats that matter.
The visible symptom was the cosiest possible thing going wrong: a client asks whether she should
stop therapy, you spend a wrenching minute deciding, and the same conversation reopens the next
morning. Two clients' insurance authorisation running out on the same Tuesday produced two
identical practice-wide letters in the same brief.
The obvious fix was a trap, and the harness's own notes said so. Making raiseEvent return
undefined on a live cooldown fixes every count in the report and quietly deletes narrative beats,
because arc beats and followUp chains both reach the event system through state.queuedEvents
and no caller on that path reads the return value. beat_asks_to_bring_someone would have gone
on promising a conversation that never arrived — the exact failure this codebase already has a
scar from. So the rule became hold, not refuse: a raise that cannot land today is re-queued for
the day the window lifts, and only a caller who explicitly says it promised nothing
(onRepeat: 'skip') gets to drop one. EVENT_MAX_DEFERRALS bounds the wait, because at the limit
"deferred" and "deleted" are the same word.
Two of the offenders were not event-system bugs at all. The insurance renegotiation was raised
per client but authored scope: 'practice' — a practice-wide letter that never names the client,
triggered by one client's paperwork. It is fixed by not passing a clientId and by treating the
practice as its own subject. The burnout aftermath call is the morning after a sabbatical and means
nothing detached from it, so it skips rather than waits. Both were fixed where they lived.
And then the narrated run said it was not fixed. The sweep reported same-subject repeats down
from 6,543 to 8. bun run playtest reported "I think I'm done." twice in one morning and again the
next — three different clients, all legitimate, and unreadable as anything but a bug. The morning
queue drains before the player answers anything, so both modals went up together. One more rule —
one conversation per morning, whoever it is about — took days carrying a duplicated modal title
from 14 to 1 over 12 × 200 days. Which is the third time in this build that the statistical harness
and the narrated run disagreed, and the third time the narrated run was right about how it feels.
The cost is in the ledger, and it is not small. That insurance event was firing about fifty times a run, and three of its four choices hand out cash or practice-wide morale. Closing it took seven points off average morale everywhere and pushed Challenge from 14/40 collapses to 17/40. It was an aggregate faucet scaling with the client list — the same shape as the three aggregate-bonus bugs before it, wearing an event's clothes — so the numbers were left where they fell rather than compensated for. That decision is recorded in docs/BALANCE.md with the before/after table, because the next person to read the curves deserves to know which of them moved for a pacing fix.
SessionType had been fully typed since Phase 0. Three certifications carried
mods.unlockSessionType, generateClient could produce every kind, and resolveSession already
multiplied progress and revenue per type. Nothing ever passed sessionType to generateClient,
so none of it had ever run. The Group Room cost $3,800 and changed nothing a player could see.
The referral path was an afternoon. The reason it had not been done was the other half.
ScheduledSession held one clientId, so a "group" client was an ordinary client booking an
ordinary slot at 0.55× revenue for 0.78× progress — strictly worse than an individual on every
axis. Wiring the referral in without touching the schedule would have shipped a $3,800 purchase
whose only effect is to make your practice worse, which is worse than shipping nothing, because
the player pays to find out. So a group session had to hold several people: memberIds on the
session, one SessionResult per member, and a seam (sessionMembers, sessionIncludes,
detachClientFromSchedule) so that every existing single-client path stays byte-identical.
Couples and family were already right and were left alone. A couple is one case with one arc
and one bill — that is what partnerHandles and the 1.5× rate always meant. What they were
missing was a cost: 1.5× the fee for 1.12× progress made the certification a pure upgrade whose
only question was "why not sooner". The cost that fits the fiction is the alliance — two people
have to trust you — and because rapport gates progress through the whole Trust chapter, it lands
as "slow to get going, then faster", which is what couples work actually is.
The measurement nearly went out wrong twice.
The first sweep showed Standard accreditation falling 32/40 → 29/40 and Challenge collapses
17/40 → 10/40, and it would have been easy to write that up as the price of session types. It was
not. Rewriting the scheduler's energy forecast to use the real per-session cost — which is
obviously more correct — also made it subtract the cost of the hour being considered, which the
old flat estimate never did. That one-session-stricter reserve was doing all of it. It is now
SCHEDULER_ENERGY_ESTIMATE, deliberately left defending the reserve one session late, with the
measured cost of fixing it recorded in docs/BALANCE.md. A correct change is still the wrong change
when it rides in on somebody else's.
The second was quieter. A reason line that explains a 22% haircut on every group session is
exactly the kind of number the reasons array exists to carry — and it was being truncated off the
end of it, because slice(0, 9) kept the quality breakdown and dropped everything appended after
it. Which meant regressions and the trust gate had always been at risk of vanishing from the
card on a session with a busy breakdown. The array is now two lists: what happened to this hour
first, why it went that way second.
One latent bug fell out of the noise. A client's chapter is derived from their progress, and
applyEffect moved progress without re-deriving it — so an event could leave somebody sitting in
the Work chapter at 78%, drawing the wrong arc beats and being offered the wrong techniques. It
had been there the whole time; it only surfaced because a shifted rng stream made an invariant
test finally land on it. Fixed at the source, one line, in eventsys.ts.
And bun run playtest could not see any of it. The narrated run — the tool that exists to
catch what statistics smooth away — never bought an upgrade, so it could never reach a
certification. It buys them now, cheapest first, and prints a group as one room rather than six
sessions. The first group in the seed-2024 run lands on day 113 with two people in it and is
running rooms of six by day 138, with the roster turning over as people finish. That is the check
that mattered, and it is not one the harness could have made.
The room shipped in the sim and in the panels and never reached the office. office.ts indexed
today's sessions on s.clientId, which is only the seat a session is filed under, so every
member past the first was invisible to the scene: they never walked in, never sat down, and a room
of five drew as one person in an armchair. The rule CLAUDE.md had just gained — any new
schedule.filter(x => x.clientId === ...) is a bug waiting for the first group booking — turned
out to be describing code that was already written.
The interesting part was where to put six people in 186 units. A therapy room is that wide and already has a therapist's armchair at 66, a side table at ~104, a client's armchair at 138 and a floor lamp at 168. A single row of six would have been a queue at a bus stop. Two shallow arcs read as a circle instead — a near row on the boards and a far row six units higher, a shade smaller, interleaved in x so that the z-order carries the depth. Actors sort on x alone, so the far arc also takes a fixed z-bias; without it a person at the back of the circle can draw in front of the person beside them.
The ring is a twelve-unit half-step grid, chosen because it lands exactly on the two armchairs the room already owns. So a group is the 1:1 hour with chairs pulled up rather than a different room, the therapist sits in the circle at the same spacing as everyone else, and the fill order is such that a room of four is a room of three with one more chair in it. It closes at 150, which keeps the lamp and the corner plant outside the circle, and centres on 108 — the little table with the tissues on it, which is where the Group Room's own blurb says they should be.
Two chair layers, not one. The first pass drew the borrowed chairs in a single Graphics after
the room's furniture, and the far chairs then painted over the armchair and the side table — a
chair at the back reading as nearer than one at the front. They are two layers now, one either side
of propsG. The chairs are the plain wooden ones from the waiting room in mismatched dye lots,
because five matching armchairs would read as a set the practice does not own.
And a thing nobody asked for that turned out to matter. Six members leave the waiting room on the same intent pass at the same speed, so they crossed the building as a single blob and popped apart on arrival. A ±10% per-actor walking pace — seeded off their id, so it is stable — is the difference between a group arriving and a group marching.
Four surfaces got a craft pass at once — the SVG portraits, the Pixi drawing toolkit, the scene
composition, and the UI chrome — on the rule that each owned a disjoint set of files and none of
them changed a signature the others called. The palette was frozen for the duration, because
PAL in sprites.ts and the @theme block in theme.css are the same eighteen colours written
down twice and a drift between them is invisible until something sits next to something else.
The portraits were the worst thing in the game and the easiest to miss. At 46px on a client
card they read fine. Rendered at 220px they were a passport photo taken from a foot away: the head
filled the entire clip circle, there was no neck, the shoulders were a sliver at the bottom, the
twelve hairstyles had been authored against a head that no longer existed and sat on the skull like
swim caps, the ears were two detached lumps, and every single face was the same face with a
different colour — no nose, identical eyes, identical mouth, identical blush. It is now a bust: a
smaller head on five face shapes, hair re-authored in a back layer and a front layer so long styles
fall behind the shoulders, and per-seed variation in eye shape and spacing, brow weight and tilt,
nose, mouth, lips and blush — all of it hashed out of the eight integers PortraitSeed already
carries, because the sim owns that type and a craft pass does not get to extend it.
Two real bugs fell out of looking closely. The <defs> id concatenated seed digits with no
separator, so (skin 1, hair 12) and (skin 11, hair 2) minted the same string and two different
people shared one clipPath and one gradient. And the Plant motif — the reward for finishing a
client — had a stem long enough that a fully bloomed flower's top petals were clipped off by the
viewBox. Both had been true for the whole build.
The scene was mostly missing, not mostly wrong. On any wide viewport the building fills the
bottom 40% of the frame and the rest was a flat blue-grey wash with five invisible cloud ellipses
in it, the roof was one dark triangle, and the "skyline" was the same PAL.night at two alphas —
which is not depth, it is two grey blocks. There is now a horizon that sits behind the roofline, a
sun and moon that hand over the arc at dusk, four backdrop bands separated in warmth as well as in
value, a hill town whose windows come on when ours do, a front garden with a path and a gate and a
street lamp, a roof with courses and an eave and a gutter, cladding, and a painted name board.
The bug that was there the whole time, in front of everyone. Four amber hairlines fanned
across the entire building from the top-left corner of the design box. They were not new — they
came from drawWallClock, which opened a subpath with arc(), and Pixi's arc continues the
current path rather than starting one. A path that has just been stroked has its cursor back at
the origin, so the bezel highlight on a wall clock was drawing a line to itself from the corner of
the world. The same latent mistake was sitting in the session door's progress ring, where it was
a spoke through the middle of the dial and read as decoration.
The lesson is not "check your arcs". It is that this defect was only ever visible in a screenshot of the whole frame. The typechecker cannot see it, no unit test asserts on a canvas, and the e2e suite deliberately blocks the office module because a software rasteriser on a CI runner starves the page. Every other instrument in this project was working perfectly while a hairline ran the width of the building.
Which is also why bun run shots now exists. The README embeds four pictures of the game, and
every art pass silently makes them false; regenerating them is one seeded command rather than an
afternoon of cropping, which is the only reason it will actually get done.
Two refinements that turned out to share a root: a number the player can see, computed separately from the thing it describes.
The practice had no reference view of itself. stats.history has recorded a DaySnapshot every
evening since Phase 0 — reputation, community trust, practice level, caseload, quality, morale,
cash — and it had three readers, all of them partial. The Quarter Review draws six of those series
over a 28-day window and then you dismiss it. The finances panel draws the money-flavoured four.
The end screen reads it once, after it is over. So the two meters the game asks you to steward
over months, reputation and community trust, existed on screen only as today's value in the HUD
strip — and both move a fraction of a point a day, which means the current value is the one number
about them that carries no information.
src/ui/panels/PracticePanel.tsx is a rail door rather than a modal, deliberately: tick() refuses
to advance time while anything is pending, so a screen you browse must never be a screen that
stops the clock. It reads the whole history, marks the level-ups on every chart as dashed
hairlines — derived from the snapshots' own practiceLevel, so the ladder above the charts and the
lines below cannot disagree — and adds no sim state whatsoever.
Two things in the drawing went out wrong first and are worth recording.
Pinning reputation and trust to a fixed 0–100 axis was the honest instinct and the wrong picture. A run that climbed 10 to 41 came out as a dead flat line along the floor of the box, which is the one reading that is definitely false. The fix is to draw to what happened, padded and clamped inside the metric's bounds, and print the observed low and high underneath — shape and scale arrive together, and nothing is implied that did not occur.
The chart stretches horizontally to whatever width the panel has (preserveAspectRatio="none",
which is what lets two hundred days fill the box). Lines survive that with a non-scaling stroke.
A circle does not — the head of the series came out an ellipse. It rides above the svg as a
positioned element instead. Neither of these is visible to tsc, to a unit test, or to the e2e
suite; both took looking at it.
And a therapist was being sold a Saturday they had already spent. generateTherapist grants the
tier-1 cards of a therapist's own modality — that is what "an EMDR therapist" means — but set
certifications: []. Training eligibility filters on certifications. So the list offered a new
EMDR hire EMDR Part One: Preparation for $900 and a day of cleared caseload, and the engine's
if (!t.techniques.includes(g)) meant it granted nothing at all. A trap purchase, in the game whose
whole design commitment is no hidden punishments.
certificationsFor() derives the record from the cards rather than storing it twice: a course
counts as sat when every card it grants is already in hand. All-or-nothing, so a mid-career hire —
who arrives with one of the two tier-2 cards — still sees the tier-2 course, correctly, because it
still has something to teach them. Save v9 back-fills existing runs, including the hire board. Two
smaller leaks in the same path went with it: the completion toast counted the brochure's grants
rather than the cards that actually landed, and the course card promised every grant even when the
therapist already worked that way. Both now report what the fee buys. src/sim/training.test.ts
holds the invariant — no therapist is ever offered a course whose every card they already hold.
The harness had been buying the dead course too, and that is the part worth reading.
maybeTrain in tools/autoplay.ts filters on certifications exactly the way the panel does. So
for the whole build, every measured run sent every therapist on a one-day $700 course that granted
nothing, first, before anything real — and every curve in docs/BALANCE.md was taken against a game
where training was a cheap skill trickle rather than the expensive, days-away decision the design
describes. Fixing the trap moved the numbers: Cozy untouched, Standard a little tighter, and
Challenge at skill 0.6 going from 7/60 collapses to 15/60. Grade distribution and quality drift
barely moved; this is an economy change, not a quality one. Written up in docs/BALANCE.md →
What the certification back-fill moved.
One modelling error in the bot came out with it and is recorded separately on purpose: it priced a
course's fee and never its days, which was harmless while it only ever bought the cheapest
one-day option and stopped being harmless the moment it could reach a three-day one. Charging the
lost days recovered 3 of the 15 collapses. The other 12 are the change. Tuning the instrument
until the number comes back is how a harness stops being evidence, so the correction and the
result are reported as two things rather than one.
The shared lesson is the one from Phase 12, in a smaller key: a number the player sees has to be computed from the thing that happened, not alongside it. The certification list and the technique list were two records of one fact, and they drifted the moment one of them was written by hand. That the drift was invisible for the whole build — through a typechecker, 275 unit tests, a browser suite and every balance sweep — is because none of those instruments can see a purchase that is merely pointless. It took reading the eligibility filter next to the generator.
The harness is the product. Every serious balance problem in this build was found by reading a report, not by playing. None of them would have surfaced in an hour of hand-play, and all of them were the kind that only bite at hour six — which is exactly the failure mode v2 exists to fix.
Aggregate bonuses are the recurring bug. It appeared three separate times — quality, morale, reputation — always the same shape: a per-item bonus that is obviously fine multiplied by a content list that grows. Any new "sum a modifier across owned things" needs an aggregate clamp written at the same time.
The type contract paid for itself immediately. Twenty-odd agents wrote against
src/sim/types.ts concurrently with essentially no integration friction, because the contract was
finished and detailed before any of them started.
Every tool has exactly one blind spot, and they do not overlap. The typechecker cannot see a 60-year-old referred for "Child Behavioral". The balance harness cannot see the same dilemma firing three mornings running. The narrated playtest cannot see a tooltip running off the right edge. The browser tests cannot see a curve going soft over two hundred days. And not one of them — including the browser tests, which block the office module on purpose — can see a stray hairline drawn the full width of the building; that took a screenshot of the whole frame and somebody looking at it. Every class of bug in this project was found by exactly one instrument and never by the others — so "it typechecks and the curves are fine" is not evidence of much on its own.
The corollary is that building an instrument is a way of finding bugs, not just of preventing them. The pacing assertions found a live defect within an hour of existing. Replay found two places where the run was not a function of its actions. Neither was a regression; both had been true for the whole build, and neither was visible until something was looking.
The tempting fix is usually the one that deletes something quietly. Twice now the obvious
one-liner — make raiseEvent return early on cooldown; let a group session shrink to one member —
would have passed every test and taken away something the player was promised. Both times the
right shape was hold, not refuse: defer the beat, dissolve the room. When a fix makes a number
go to zero, check whether it fixed the cause or removed the evidence.
Design commitments need structural enforcement, not discipline. "No hidden punishments" only
held because SessionResult.reasons carries the full explanation and progressDelta reports the
total change. The moment those were computed separately from what the sim applied, the card
started lying — quietly, and only sometimes.