← All docs Games for Group · Learnings

Learnings — bugs that taught us something

Every bug that was surprising gets an entry here: what broke, why it really broke, and — the important part — why our testing didn't catch it. A bug that was just a typo doesn't belong here. A bug that fooled us does.

Newest first. Written for a reader who wasn't there.

Companion files. decisions.html records choices we made and why. The docs/architecture/*.html pages explain how the app works today. This file is the scar tissue: it exists so we stop re-learning the same lesson.

The pattern worth internalising: almost every entry below is the same shape — a check that looked green was structurally incapable of failing. Web can't fail a UI-thread bug. Dev can't fail a production-domain bug. When something "passes", ask what it would take for that check to fail. If the answer is "nothing", it proved nothing.

2026-10-29 The offline bundle check said Story Builder was missing when it was there all along

Symptom: the LAN guest freshness test failed with twelve games "missing". After a fresh bun run export:lan-guest, one was still missing: Story Builder.

Root cause: two faults on top of each other. Eleven games really were missing, because this machine's export was weeks old. Story Builder was not: it was in the bundle, but the test finds a game by its tagline key as a whole quoted string, and Story Builder built its key from a template (`${COPY}.tagline`). The minifier keeps that as a constant plus a piece, so the whole key never appears. Every other game writes it out.

Why it escaped: the stale export hid it. While eleven games were missing, a twelfth looked like more of the same. My first guess was "still stale" until the fresh export left exactly one.

The rule now: a game writes its tagline key whole, as a plain string, in meta. When the freshness test names a game after a fresh export, look at how that game writes its key before you blame the bundle.

2026-10-27 A friend let in at the door watched it shut in their face, then open again a second later

Symptom: in a played recording, a guest knocked and peeked round the half-open door. The host tapped Let in. The door swung shut with nobody at it, and about a second later it was flung open and the guest walked in. It looked new to Karim.

Root cause: two pieces, each right, with a gap between them. The door is drawn from the knock queue, and Let in takes the knock off the queue at once. But since 2026-10-12 a friend who has been let in is outside the picture until their own phone says where they are going (an entrance is for somebody joining, decided on their own phone), and only then does the door open for them. Between the two, the door had nobody at it, so it shut. The door also opened one draw after they left the doorway, so even with no gap it would have started to shut for a frame. Not a recent change: the gap has been there since 2026-10-12. My first guess, the door's layer change of 2026-10-17, only changed where the door is drawn.

Why it escaped: the knock and the arrival are tested and watched one at a time, and each is correct. Only a second phone knocking at a room someone watches shows the hand-over between them, and the gap is as long as the guest's phone takes to load the room (up to three seconds when there is no live channel).

The rule now: let in is not in. Whoever has been let in and not walked in yet stands at the door, in front of any knocks (ui/scene/SceneStage atDoorOf), and does not step forward again since they were already there. The door opens in the same draw they leave it (SceneThings useDoorOpen). When one state hands over to another, draw the hand-over.

2026-10-17 On two iPhones, a sideways drag anywhere in a room went home, and a stroke on Quick Draw dragged the whole game

Symptom: Karim and Aafreen played on their own phones. Exploring the room, moving his mascot, or drawing a stroke on Quick Draw's canvas took him back to Home. While drawing, the game also slid up and down under his finger.

Root cause: two platform defaults nobody had switched off. (1) On iOS 26 the screen stack's swipe back starts from anywhere on the screen, not only from the left edge (the screens library leaves it on unless told otherwise), and nothing in the room told the stack that going back was not wanted. (2) An iPhone pulls any vertical page up and down on a drag, even when everything already fits and there is nothing to scroll. Quick Draw sizes itself to fit the screen exactly, but sits in the play screens' page like every game, so a stroke could pull the page.

Why it escaped: neither can happen on the web, where everything is checked: a browser has no swipe inside the page and its pages do not stretch when they fit. The simulator would show both, but only with a finger dragged sideways over a room, which no check does. The edge-only swipe of older iPhones was rare enough that nobody met it.

The rule now: a room is left by its own door (modes/useStayInRoom, the decision of 2026-10-17); any new screen a room can show needs nothing, a new kind of room calls the hook. A play page that fits does not move (usePlayScroll switches the stretch off for a page with nothing to scroll). Not settled the same day: the living room's door is missing on both phones, light and dark, while the web draws it, and the door code is in their build. The lamp's warm edge on the door frame, drawn by another layer, shows on the phone where the frame should be, so only the door's own layer is not drawn. It could not be made to happen on the simulator (iOS 26.5): the door draws in the development build, with production code served to it, in a full release build made the way TestFlight's is, and with Reduce Motion on. So it is not the room's logic, not how the code is packed and not a release-only setting; what is left is something about the real phones (their iOS version, or the device itself) or the real room's data, which the preview fakes. The door's layer was the one layer of the scene built as a box of no size, drawn outside its own box; it is now the stage's size like every other layer. That is the likely fix, and it is proved only when the door shows on a phone.

2026-10-13 Whoever set off across a scene was drawn somewhere else for one frame, and only on the web

Symptom: in pictures taken frame by frame, somebody starting a hop or a ride in the room was drawn for one frame somewhere they had never been (down near where they were going, or off the foot of the screen), and then the trip played from where they stood. In every scene, the pool included. Measured with a probe that reads the mascot's own box on every frame: a tap on the pool's slide, the redraw it set off finished 232 ms later, and the next frame drew the rider 900 px under their place (their box was moved down by 896.8 px where 3 px up had been set). On a hop in the backyard the same frame was 164 px under where they stood. The frame after was on the path.

Root cause: two clocks. A trip is started in the middle of the redraw a tap sets off, and on the web the animation library stamps its start with the clock on the wall at that moment. It is then stepped with the browser's frame times, and a browser stamps a frame with the time it fell due. When the redraw took longer than a frame, the first frame after it was due before the trip started: for that one frame, the time since the start is less than nought. The library does not hold a timed move to its start (runtime = now - startTime, then the easing of it), and a bezier asked about a time before nought runs on backwards the way it was heading, steeply for a curve that sets off fast. A phone's own build starts and steps a move on one clock, on the UI thread, and has no such frame.

Why it escaped: no gate can see a frame. A builder's stills are taken after things have stopped, and a strip of frames taken every few hundred milliseconds steps over a frame that lasts sixteen. It needs a slow redraw, so it is plainest in a development build and a full room, and it never happens where motion is checked by the rule in the project's card (a recording on the simulator). The first guess was wrong: I expected the new resting place and the distance from it to reach the screen in two different frames, which is the fault the rule "where a thing rests is ordinary layout" guards against. The probe showed both land in the same task, before anything is drawn. Only reading the box frame by frame showed that the bad frame was the first step of the move itself.

The rule now: in a scene, a curve handed to a timed move is made with ui/scene/curve, which on the web holds a bezier to its two ends, so a frame from before the start draws the start. It changes nothing in a phone's own build. The scene's trips, the zip line's trolley and the backyard's things use it. (On the web that curve is a plain function, which a timed move in an animated style takes as it is. The library's "easing is not supported on the web" downgrade to a straight line, the reason curves are spelled as beziers in this app, is in its entering and leaving animations, which it drives through CSS; a scene uses none.) The same first frame is waiting in every other timed move in the app that uses a bezier and is started while a page is busy (about a hundred and thirty of them): most are small moves where a wrong frame is a twitch, not a mascot under the floor, and none was changed here. When one is seen, that is what it is. And check motion on the web by reading where the thing is drawn on every frame, never from pictures taken at intervals.

2026-10-12 Two phones settled one spot two ways for good, and 56 cases that shuffled every message passed

Symptom: none on any screen; caught in code review of the rules that agree who is where in the room (ui/scene/places.ts), before anything mounted them. Six people in a room. "f" moves to the rug and "c" leaves. Which of the two a phone heard first decided who got the rug, and two phones that heard them in different orders never agreed again.

Root cause: a phone made claims for other people. It made up a place for somebody who had never moved (where it last drew them when a friend left, where it thought a newcomer landed), out of what it happened to have heard by then. Another phone had heard something else by then and made up another, and a made-up place outranked a real move, so the difference was permanent. A second fault of the same family was found in the same review: what a phone remembered depended on which states of the roster it happened to see, so a phone that watched somebody leave and return forgot their move, a phone whose roster skipped the gap still held it at number seven, and the second ignored them for the rest of the night when they came back counting from one.

Why it escaped: the tests were written failure-modes-first, and "the same messages in another order end in another room" was one of the modes. Its cases shuffled moves and snapshots, and all 56 cases passed. They never shuffled the roster. Who is in the room comes from the database and moves come down the live channel: two channels, so every phone hears the two woven together differently, and the tests modelled one channel and held the other still. The property under test was order, and the one input whose order was never varied was the one that broke it.

The rule now: an order test shuffles every input channel, woven together, never the messages of one over a fixed state of the others: list the channels a thing hears from before writing the shuffle, and if one of them is "the roster", "the settings" or "the clock", it is in the shuffle too. And in anything phones agree on by rule: a phone only ever makes a claim for its own person. What it holds for anybody else is what that person said, and nothing it worked out for them. Whoever has said nothing is placed by a rule read off shared facts when the room is drawn, never by a claim somebody else's phone stored. The cases are in places.test.ts (failure modes 15 to 18); the design they protect is in decisions, 2026-10-12.

2026-10-08 Charades sent every guesser the prompt being acted, as the last line of the room's memory

Symptom: none on any screen. While somebody acted, every other phone in the room was sent the list of prompts the room had already been dealt. Its last entry is always the prompt on the table, and a Charades prompt's id is its own words (counting-sheep), so there was nothing to look up: the answer was in what the phone received. A host's own typed list went out the same way, whole, to every guest, and in Reverse a phone that was only watching was sent the prompt itself. Found by reading every game's view during the survey for moving the answer lists off the phone, then confirmed by running the rules: the guesser's view held active: null and usedIds: ["counting-sheep"].

Root cause: the view was one line. It returned the whole state with the live prompt set to null, and the actor got the state untouched. That is a list of one thing to hide. The room's memory and the host's list are the same secret under two other names, and they went out with everything else. Reverse asked "is this the guesser?" and sent the prompt to everybody who was not, which includes a phone with no seat at all.

Why it escaped: the tests asked one field (active is null for a guesser) and were right. No screen draws the memory or the list, so nothing could show it.

Why it came back: this is the Undercover fault of 2026-10-01 (below), unchanged: a view written as what to hide, a memory whose last entry is "now", the host's list riding along. That entry ends with the rule that catches it, and the rule was applied to Undercover alone. Charades is older than the rule and was never read against it. Trivia, a week later, was the same story and its entry says so in the same words. A rule learned from one game was filed as that game's fix, and three games had the fault.

The rule now: a rule that comes out of one game's leak is run against every game that hides something, the same day, and the entry names the games that were read. On 2026-10-08 every game's view was read for this (the table is in decisions, "Answer lists leave the phone"), and Charades was the one still written as a list of things to hide. Its view now says who is sent the prompt (the seats acting it, nobody else), never sends the host's list, and sends the memory as its length with every name blanked, to the actors too. Its rules.test.ts walks a whole match in both ways to play, in a room and on one phone, with the host's list and with ours, builds the view of every seat, a watcher and a phone with no player at every beat, and searches all of it for every prompt under every spelling. One test plants seven leaks to prove the search can fail.

The miss worth keeping: the first fix cut the memory out of the view whole, with a comment that said no screen reads it. One does. The line a mascot says at each reveal is picked on every phone from how long the memory is and from its first entry, so with an empty memory every phone said line 1 of its case at every reveal, in all three modes. Every phone said the same thing, so nothing looked broken; it only never changed. A review caught it. The search for readers had looked in the screens and not in the plain file beside them that picks the line. The view now keeps the length and blanks the names, the line's starting point comes from who hosts the room, and a test holds both ends: every viewer picks the line the referee's own state picks, and the line moves on from turn to turn. Before a field is blanked, find everything that reads it, in every kind of file, and ask what each reader needs: often it is the count and not the names.

2026-10-08 Trivia sent every coming question to every phone

Symptom: none on any screen. In a room where the clocks run the quiz, every phone was sent all ten questions as the match began: each one's words and its four options, with only the answer masked. Anyone reading what their own phone received (a browser's own tools are enough) could read the whole quiz ahead of the table, and look each answer up. Found by reading the view, during the backend cost study that also produced Karim's rule: "No one should be able to know anything using any tools."

Root cause: the view treated a question as having one secret, its answer, and masked that one field on a copy of the question. The words and the options are secrets too until the question is asked. The mode where a host runs the room, written later, did blank them, for that mode only: the older branch beside it was never read again. And a question has one more spelling. The built-in questions ship inside the app, so a question's id is a key to its words and its answer both, and the id rode in two places: on each question, and in the room's memory of what it has played, which holds a match's ten from the start.

Why it escaped: the test asked whether the answer was hidden. That is a question about one field, and it passes for any view that gets that field right, whatever else rides along beside it. No test asked what a phone was sent. Nothing on a screen could show it either: the board only ever draws the question being asked. The rule that would have caught it was already written down (adding a game: "a secret has more than one spelling"), with Undercover as its worked example, but Trivia is older than the rule and was never read against it.

The three misses worth keeping: the first fix blanked the words and the options and kept the id, and its new test passed. It was the project's own page that said an id which looks a thing up in the app is the same secret. The test was made to look for the id before the code was changed again. The second fix read "not asked yet" as "after the current one", in the code and in the test alike. While a host runs the room the current question is not asked either until the host shows it, and it was still sent with its id and its category. A review caught it: the test started looking one question too late, so it walked straight past the one that mattered. The third: a match that stops with a question unasked. A host has the question hidden, a player leaves, too few are left and the match is over; "over" was read as "everything may be shown now", and the question nobody had been asked was sent whole, with its answer. The walk missed it because it visited every phase and never the ways a match can end: it only ever reached the end by playing to it. The code and the test now each say where the unasked questions start, in their own words (the test keeps its own count of what was put on screens, and never reads it off the state it is checking), a match is stopped short at every step of a question on the way, and from that point on every question is the one blank.

The rule now: test what a seat is sent, the whole view, not one field. Play the match from its first step to its last in every mode, build every viewer's view at every step (a watcher with no seat included), and search the whole text of it for each secret in every spelling: the words, the options, the id. And a thing that is not on screen yet is sent as one blank, never as a copy with some fields emptied: a copy carries whatever the thing is given later. Trivia's unasked questions are one constant now (UNASKED), and their ids are blanked in the room's memory too. And when a test hides things "after" some point, write down where that point is, and walk the moment it moves: every step on the way, and every way the thing can end.

Still open: the question being asked now is sent with its id, because a screen has to draw it, and the app still holds the built-in questions with their answers. So the current answer can be looked up while the clock runs. That closes when the questions leave the app and live on the server, which is its own job.

2026-10-08 A tick that could cancel itself passed every test: the test's own scheduler told it that it was running

Symptom: none yet, and that is the point. A match now remembers its scheduled wake-up and cancels it when a move makes it pointless. A wake-up that books the next one (a number caller, a turn clock running out) finds the row remembering a job, and that job is itself. Convex lets a cancelled job finish and drops what it scheduled. Had the tick cancelled itself, every repeating timer in every game would have stopped after one tick, in production only.

Root cause: the first version had one guard: cancel a job only if it still reads as waiting. That is safe only if a scheduled mutation, while it runs, reads its own job as "in progress". Convex documents that state for actions. For mutations it happens to be so today, in the backend's source. Correctness was resting on a detail nobody promised.

Why it escaped: the test ran real ticks through convex-test, whose fake scheduler marks a job "in progress" itself before it runs it. So the guard always saw what it needed, in the test, by the test's own doing. The test that was written to catch a tick cancelling itself could not fail on the one thing it was about. A review caught it by asking what the check would need in order to fail.

The rule now: a job is told who it is rather than left to work it out from a state somebody else sets: the tick and the flag say so in the call, and never cancel the job of their own kind (convex/lib/matchTimers.ts, rule 2). And when a test double supplies the very fact the code depends on, take the double out of that fact: the new tests run a job's own function by hand, at a chosen moment, while its job still reads as waiting, and hold that nothing is cancelled.

2026-10-08 A party's seats stopped fading: who is here was watched by the room's status, and a party is "playing" all night

Symptom: caught in review, never shipped. To stop paying for a list nobody was looking at, the who-is-here list was watched only while the room's status was "lobby". A party runs as "playing" from its first game to its awards, and between its games it draws the same living room as the lobby, seats and all. So a guest whose phone had died during a party game sat there at full strength on "next up", the very screen where the host has Remove.

Root cause: the change was keyed on something that coincided with the truth instead of on the truth. "The room is in its lobby" and "the seats are on screen" are the same thing in a room that plays one game at a time, and that was the only kind of room looked at.

Why it escaped: the facts the change was built on said the seat is drawn in the lobby, and they were checked by searching for who reads the flag (one line, in the seat), not for who mounts the seat (three screens). The browser pass played a lobby, a game and a winner screen with three phones and measured exactly what it expected. Nobody started a party. No gate can see it: the two shells of a party both typecheck, and a seat that does not fade is not an error.

The rule now: key a saving on the thing that is true, and let the thing itself say so. The living room is counted while its screen is the one in front (ui/room/SeatsOnScreen.tsx), and the shell watches for exactly that long, so no screen has to remember and no status has to coincide. Before narrowing where something is watched, list every screen that draws it, by who mounts the drawing component, in every kind of room: one game, a party, a tournament.

2026-10-08 A message that meant two things: the send before the bell could start the seekers early and get another player refused

Symptom: Hide and Seek in a room, read in a code review and never seen on a phone. A phone sends its player's tapped spot about four seconds before the hiding ends. If that player was the last one not in, the seekers set off at once, up to four seconds early. A phone sending a breath behind it had not heard yet, and was refused: "That move won't work here", for a tap its player had made in time, and they stayed in their old spot.

Root cause: the send reused "Hide here". That message says two things: where the player is, and that they are in. The phone only meant the first. The mechanism was copied from Doodle Twist, where a drawing sent by itself and a drawing sent by its player are the same thing, because its rules wait for the bell either way. Here the second meaning ends the beat.

Why it escaped: the rules were right and fully tested: "Hide here" from the last hider should start the seekers. The fault was in what a screen chose to say with it, and a screen has no tests. It also needs two phones and the last four seconds of a clock, which one browser on one phone never plays.

The rule now: before a screen sends an existing message by itself, list everything that message means in the rules, and send it only if every one of them is meant. Otherwise the rules get a message of their own that means the one thing (here a "tuck": a place, and never that the player is in), with a test that it cannot do the rest. And when copying a mechanism from another game, ask what the rules do with the message at the far end, not only how the near end sends it.

2026-10-08 A secret leaked through where the screen was left: on one phone the seeker was handed the place scrolled to the last hider's spot

Symptom: Hide and Seek on one shared phone. A place is longer than the screen (the train at every table, every place from nine seats), so a hider scrolls to their spot. The last hider scrolled to the guard's van, tapped it and pressed "Hide here", and the seeker was handed a phone already showing the van end of the train. Measured in a browser at phone size: three players on the train, the seeker was handed the place 413 points down when the last hider hid at the far end and 0 when they hid at the near end. Nine players at the school: 514 and 0. Found by reading, in a code review, not by play.

Root cause: it was the pass-the-phone screen that wiped the board between hiders. It takes the board off the screen, so the next hider's is built new. Nothing wiped it after the LAST hider, because the looking is played in the open and no pass-the-phone screen comes up for it. The board stayed, and with it everything a hand had done to it. Where a view is scrolled is the screen's own memory, not the game's, so no rule had ever been asked about it.

Why it escaped: every test of this game's secrets asks what a phone is SENT, and the seeker was sent nothing they should not have: the views were right at every beat. On one phone everything is on the phone already, and the secret was in what the last person left on the glass. The earlier hiders were safe by accident (the pass-the-phone screen), which made the board look safe. A picture of the seeker's first screen shows a place with nobody in it, which is correct. And the tables it was first played at had places that fit the screen. The first idea for a fix was wrong too: a call that scrolls the place back to the top would have covered the one scroll and nothing else, and the strip of stops and the row of heads scroll sideways as well.

The rule now: on one phone, when the eyes change, the screen is built again from nothing. The board names whose eyes it is for (each hider in turn while they hide, then the whole table: eyesOf in hide-and-seek/Board.tsx), and everything a person can leave behind lives under that name: where the place is scrolled, the strip, the spot they chose, the look being shown. A new name is a new board. It is the game's own answer, never a hope that the shell's pass-the-phone screen happens to stand in between. For any game with a secret on one phone ask two questions, not one: what is the phone sent, and what did the last person leave on it (where it is scrolled, what is selected, what is typed, what is still moving). Check it by measuring the same number after two different secrets: after the fix the seeker is handed 0 in all four cases above. It matters more since the ruling of the same day that hiders see each other (decisions): the last hider's screen now shows where everyone is.

2026-10-06 After a pause the clock bar was wrong by the length of the pause, and stood still on a phone that joined during it

Symptom: an online room, the host pauses in the middle of a turn and starts again. The slim clock bar kept draining while the game stood still, so afterwards it was ahead by the whole pause: empty if the pause was longer than the time left. A phone that joined while the game was paused showed a bar that never moved again. The server's time was right all along. Found in Next Letter, one of the games built that night, and then measured in Word Search, which shipped weeks earlier: a 27 second pause on a two minute round left the bar at 64% with 87% of the time really left, on the host's phone and a guest's, and at 88% for good on the phone that joined during the pause.

Root cause: a pause does not move a deadline. The match keeps its own time, which is real time less every pause, and a screen reads that time through a small reader it is handed (useMatchNow). The reader is made from the pause as it stands. The bar set its countdown going once per deadline and kept the reader it had at that moment. So a bar that started before the pause went on reading "never paused", and a bar that started during the pause went on reading "paused" for ever. The ring clocks (Charades, Quick Draw) already list the reader among the things that set them going again.

Why it escaped: the bar's list of what restarts it had its lint check switched off with a comment, the one tool that names a missing reader. A game nobody pauses shows nothing wrong, and a short pause is a small jump nobody measures. The pause was tried on the ring clocks when it was built. The same family as 2026-10-01 below (Tambola's caller): reading the match's time wrongly is silent until somebody pauses.

The rule now: the reader is the same function for as long as the pause holds and another one the moment it changes, and that is written into it rather than left to the compiler (ui/matchClock). A countdown lists the reader among the things that restart it, or reads the newest one each tick; no countdown switches that check off. A shared clock is played through a pause on three phones before it ships: the one on the clock, one waiting, and one that joins during the pause, and the bar is measured against the time really left. After the fix: 73% drawn against 72% left on all three in Next Letter, 91.0% against 91.3% in Word Search.

Found by the review that followed: the same check was switched off, with the same comment, on the five seconds Tambola shows a claim for (tambola/useFreshClaim). Read in the code, not seen on a phone: a pause inside those five seconds let them run out on real time. It now lists the reader and holds while the game is stopped, like the bar.

2026-10-06 A phone that only watches had to scroll to see the clock: the Watching pill stood in the board's own column

Symptom: a phone that joins after the game has started only watches. On it, a board that fills the phone's height was 57 points too tall for the screen, so its foot, where the clock and the buttons are, was under the fold. Seen in Story Builder, built that night, then measured in Charades, which shipped weeks earlier: a watcher's page 817 points tall in a 760 point window, a player's 760. Quick Draw and Undercover size themselves the same way and were not measured.

Root cause: two pieces of one screen, each right alone. A board that fills the window takes the window's height and subtracts the play header and the page's padding (PLAY_FRAME). The play shell, for a watcher only, stacked a "Watching" pill and a gap over the board: 37 points and 20. Nobody owned the sum, and the board cannot know what the shell put above it.

Why it escaped: it is only on a watcher's phone, and a watcher exists only when somebody joins after the start. Every room anyone tests starts with everyone seated. Each of these boards was built and checked from a player's seat. On a board that does not fill the window the pill just sits it a little lower, which looks fine.

The rule now: nothing of the shell's sits over the board. The header's height is the one thing above a board that every board has made room for, so what a viewer is told before the board is the header's to say: "Watching" is now the quiet line under the game's name (ui/WatchingMark), in a room and on the hotspot alike. A board that fills the window is looked at from a watcher's phone too before it is called done.

2026-10-06 Next Letter passed every check while two players finished in three minutes: lives each is still the size of the table

A short note, because the lesson is already here: see 2026-08-17 below, "Twelve review agents approved a game that nobody would play twice". This is the same miss in a quieter form.

Symptom: Next Letter's first draft, built overnight, passed its rules tests and every other check. Its builder's report said a two-player game ended in three minutes. Nobody had played it.

Root cause: every player started with the same number of lives, whatever the table. A game ends when everyone but the winner has lost all of theirs. So a game lasted as long as it took to lose that many lives once for every player but one: short for two, long for eight. The size of the table was setting the length.

Why it escaped: no check asks how long a game lasts. Rules tests prove what is allowed and what happens next, and all of that was right. And the 2026-08-17 rule names lives as a fair way to meter a game, so fixed lives looked as if it had passed. Only reading the report's line about length caught it.

The rule now: a meter counted per player is still the room's size. Meter the whole table and share it out. Next Letter's host picks a length, the app fixes how many lives the table loses in a game (7 on Short, 13 on Normal, 20 on Long), and each player's lives are worked out from that: the table's lives shared between everyone but the winner, rounded up, never fewer than one, with no most. Two players get 7, 13 or 20 each, eight get 1, 2 or 3. And the length test of 2026-08-17 is done at the smallest table and at the largest, not at one size. The call is in decisions.html, 2026-10-06.

A miss on the way: the first fix shared the table's lives out and then kept each player's between 2 and 9. That limit was the same fault again at both ends of the table. Two players on Long got 9 where the length asked for 20, and eight on Short got 2 where it asked for 1. The limit is gone.

2026-10-06 Sea Battle's ships were drawn cut off after a shuffle

Symptom: Karim and his wife, each on an iPhone, on the screen where you hide your fleet. After a Shuffle some ships showed only their first square: a round end, one window, then a flat cut at the square's edge. Reproduced on the iPhone simulator on the first Shuffle.

Root cause: a ship is one drawing. Standing up, the same drawing is given a quarter turn; lying across, it was given no turn at all. On an iPhone the drawing library only ever SETS a turn. When the turn is taken away it does nothing, so the old one stays. A ship that a shuffle (or a tap) turned from standing to lying kept its quarter turn inside a box that was now wide and one square tall, and the box cut it off. Standing to lying was always wrong; lying to standing was always right, which is why only some ships broke.

The miss: the first reading was that the ship's box and its animation shared one view, and iOS had handed the view back its old size (the rule from 2026-09-13). The picture did not fit that: a stale size would have drawn a small whole ship, not a full-size one cut off. Saying "no turn" out loud fixed it with nothing else changed, on the same fleet. The box is a plain view now all the same, because that is the shape the rule asks for, but it was not the cause.

Why it escaped, and would have: the rules were right, so every test was right. The browser clears a turn that is taken away, so it never shows there (Android's code clears it too; not tried). And a fresh game draws every ship for the first time, which is always right: it takes a ship turning back to show it.

The rule now: a drawing that is sometimes turned always says its turn, "none" included (rotate(0), scale(1 1)), and never drops the turn. The only two other drawings that dropped one, the speech bubble's tail (ui/SpeechBubble.tsx) and the words on a mascot's sign (ui/mascot/items.tsx), say theirs now too; neither had shown the fault.

2026-10-05 A build flag kept the tune out of the guest page, until an ordinary build had been made first

Symptom: the awards tune was to stay out of the hotspot guest page (a web build copied into every phone app). The file was named in the code only when the build was not the guest one: the guest build has a flag of its own, and the bundler drops what a flag rules out. Three builds were made one after another, an ordinary one, the guest one and a third. The guest build carried the tune, all megabyte and a half of it, and nothing said so.

Root cause: the bundler keeps each file it has compiled, and it did not count the flag as part of what that file was compiled from. The ordinary build compiled the tune's file first, with the tune in. The guest build found that copy and used it. Made again with an empty cache, the same guest build left the tune out. So whether the flag worked depended on what had been built on that machine before.

Why it escaped, and would have: every check on the code was green, because the code was right. The first guest build ever made on a machine, or by a clean build server, would also have been right. It was caught only because the finished folder was listed after each build, and only because the ordinary build happened to run first. The first hypothesis was wrong too: the flag was assumed not to have reached the build at all; the bundle showed it had been read, and the wrong branch kept.

The rule now: a file is kept out of a build by taking it out of the finished folder and looking in that folder afterwards (scripts/trim-guest-music.mjs, and the guest bundle's own test), never by a build flag alone. How far it reaches, as a review then measured: the two builds here were made the same way but for the flag, which is why one reused the other's files. The repo's own site export is made with other options, and a guest export made straight after it keeps its flag. The order that goes wrong with the repo's commands is the other one: a plain web export made after the guest export believes it is a guest page (the hotspot mode's guest check reads the same flag, modes/lan/webGuest.ts). No script here makes that plain export, and the mode is paused; its resume checklist says so.

2026-10-05 With the mascots talking, a countdown was read out every second, and a loading line cut off the turn line

Symptom: a third reviewer played the sound trial in real online rooms of two to six phones, which none of our runs had. Two of the faults it found in the voice: "First move in 0:58, 0:57, 0:56…" was spoken again every second for the whole minute, and "The final in 8…1" the same (25 lines in 25 seconds, on every phone). And the first "Ana's turn" of a tournament match was cut a third of a second in by "Getting the board ready…", which itself lasted a few hundredths of a second.

Root cause: a bubble's words were said whenever they changed, and a count is words that change every second. And the loading mascot is one more mascot with a bubble, so it spoke, and a new line takes the mouth from the one being said.

Why it escaped: the lesson from the review before this one was "write down everything that was asked for across a whole run, and read the list". It was applied, to the runs we had: one phone, a same-device game. Neither counting bubble exists outside a tournament, so no run ever had one on screen. And a built page fetches a board in a few thousandths of a second, while the loading mascot waits 0.3 s before it shows, so it never appeared either. The log was complete and the screens were missing. A list of everything that happened is only as wide as what was run.

The rule now: a line is one line however the numbers in it tick, and a line that only fills a wait is not said (decisions, 2026-10-05). For checking: a moment that a one-phone game never reaches is put on screen on purpose before the voice is called done. The made-up tournament (the dev-only preview) shows the count, and holding one file back for half a second makes the loading mascot appear. Run that way, the count is asked for once in ten ticks and "Ana's turn" is said whole while the loading line is only drawn.

2026-10-05 What an independent review found in the sound trial that every check of ours had passed

Symptom: the sound trial went to a second reviewer with typecheck, tests, the level tests and its builder's own headless runs all green. It came back with ten faults. Four are worth keeping, because each one got past a check that looked like it covered it.

What was wrongWhy our checks passed itThe rule now
The lobby tune followed a player home and never stopped. Open a room from a link, tap, press Back: home opens on top, the room stays alive underneath, and its tune plays on. A screen counted as a place while it was MOUNTED. The director is a pure function and its tests hand it the places, so they could not know a screen was behind another. Every run of ours left a room by a route that unmounted it; the one path that keeps it alive is Back with nothing to go back to. "Where the player is" comes from the navigator (which screen is in front), never from what is mounted. A stack keeps what is under it alive.
Seven recordings were 1 to 5 dB quieter than the rest (the turn bell, the sound heard most, at half the level of the tap beside it). The test was named "the recordings play at one level" and held a CEILING. A quiet file is under any ceiling. The level had been set before two channels were folded into one, which moves the peak. A level is one number: hold it from both sides. Measure the file that ships, in the form it is played, as the last step.
No test held the default set's notes, though the write-up said one did. The reviewer changed a note four ways and 152 tests passed. The test checked the shape of what playing a note builds, and one note's data. "Moved, not rewritten" was true and unprotected. A promise that something has NOT changed is held by a record of the old thing, made by running the old thing, and compared whole.
The Chess stage said two lines nobody needed: the mover's own turn line as their piece set off, and the next player's turn line for one frame before the mover's "Check!". Every unit test looks at one line at a time. A line shown for one frame cannot be seen, and with the voice on its letters start invisible. It was found only by writing down everything the engine was asked for during a whole game, with times. For anything that happens over time, log what was ASKED for across a whole run and read the list. A sound said for 3 ms is still a sound asked for.

Two misses of the builder's own, the same night. The headless driver starts a small server on a fixed port and then drives "the build". Another session already had a server on that port, so the driver quietly drove that build, an older one: the chess sheet on screen had options the branch no longer has, which is the only reason it was noticed. The driver now refuses to run unless its own server started. And the recorded tunes could not be heard playing in the app's browser pane at all: a page in a hidden pane is paused by the browser itself, whatever the page does. Both are the same lesson as the wrong-backend builds of 2026-10-04 below: check which thing you are looking at before believing what it shows.

And two from the reviewer's second look. The fix for "a phone's sheet sits outside the silent zones" came with a test, and the test held the carrying, not that the sheet does it: the reviewer took the carrying out of the sheet and everything still passed. A test of a mechanism is not a test that the mechanism is used: the real sheet is now drawn at phone width in a test, with the sheet library stood in for by the one thing that matters about it (it draws what it is handed somewhere else). And the music credits said each tune's trailing silence had been cut. The step that was run shortens a silence TO a second and a half, it does not remove it, and nobody measured the files afterwards: every tune rests a second or two before it comes round. A sentence about what was done to a file is checked on the file.

2026-10-05 A player left between two games, and the stayer's sheet still offered "Next round"

Symptom: in a best of three, the other player walked out while the match waited for its next game. For about three seconds the stayer's sheet kept "Next round" (a tap answered "That move won't work here."), its tally turned to "Rev 1 · Someone 0", and the line about how game one ended read "a player resigned".

Root cause: the room's word arrived on time and was not read. Two things, one at each end. The rules: a table whose player has walked out is over, but it went on calling itself "between rounds" (betweenRounds looked only at the last round and how many were left), so the sheet stayed open. The screen: "the room has called this match" meant "it ended with no result of the game's own". A walk-out HAS a result of the game's own (the game stops the match short and lists whoever stayed), so a leaver's match was never "called", and the foot kept its button. The two odd lines were one fault: the leaver is gone from the room's list in the same step, so every name looked up there came back empty. "A player resigned" was the true line about game one ("Mia resigned") with Mia's name gone.

Why it escaped: the walk-out was tested from the start of a match and in the middle of a round, never in the wait between two. All eight two-player games had it. The last fix was built for "called for another reason" and tried it only as a line in a pure test, where the reason was handed in.

The rule now: a table whose seats ran out is not between rounds (engine table.ts), so the sheet closes with the match in every game and every kind of room. What a sheet offers asks whether the match is OVER, for any reason (matchIsOver in tournament/flow.ts), never how it ended. A board that outlives its match keeps its players' names from the match's own record (boardRoster), and a sheet keeps the names it knew (ui/knownNames.ts). A state a screen can be in is reached by play in a test, in every game that shares the rule: tournamentReady.test.ts plays one game of each of the eight and then has either player leave.

2026-10-05 The fix for a dead button took the button away by the phone's own clock

Symptom: on a phone whose clock was forty seconds fast, the sheet between two games said "Nobody started it. The score decides." from the moment it opened, with no button, while the other phone's "Next round" still worked. The player behind could not start game two and was put out.

Root cause: the fix for the button that outlived the wait decided "the wait is out" from the seconds the phone itself counted. Whether the wait is out is a fact about the room. A phone only has a reading of it, and a reading is as wrong as the phone's clock.

Why it escaped: it was tried on one machine, where every browser shares one clock, and its test fed the rule a number of seconds without asking whose seconds they were. The reviewer set a phone's clock forward on a built site.

The rule now: what a screen OFFERS or takes away rests on the room's word, never on a phone's clock. The button stays until the room has called the match, and the sheet says the wait ran out only when the room's own record of how the match ended says so (ui/roundFoot.ts, calledOnScore in tournament/flow.ts). A phone's reading is only ever SAID, and held to what the thing can be: the wait reads one to thirty seconds, or "Start it now" where the phone reads nothing left. A rule that takes a time is tested with the phone's clock right, ahead and behind. Still true of every countdown in the app: a turn clock, the first-move minute and the wait are read against the phone's own clock, so a phone that is off shows them off by as much. Nothing is decided by those readings (the room calls time), but the numbers are wrong on such a phone. Two of them are held to their own size, so they are never absurd: a first move reads between one second and a minute (an hour-slow phone once read "First move in 60:59"), and the wait between one and thirty seconds. Held is not right: a slow phone that reloads halfway through a wait reads twenty-nine seconds with nine left, and a turn clock is not held at all. A right reading on a wrong clock needs the room's own time on the phone, which nothing sends today.

2026-10-05 A match won two games to none could still be lost

Symptom: in a tournament's best of three, at two games to none, the sheet said "One round left, and it can't change that." and offered "Play the rest". The player behind tapped it. The leader made no move for a minute, and the match went to the player behind.

Root cause: two rules, each right alone. A decided series is its players' call (made for friends at one table, where the last rounds are still fun). A clock that runs out loses the match (made for a room that is waiting). In a tournament both applied to the same game: the dead game was a real game on a real clock, so losing it on time lost the match it could not change.

Why it escaped: every test of a decided series stopped at the choice, and every test of the clock ran in a game that mattered. Nobody had played the dead game, in a test or by hand. It is also on main: the first-move minute only made it easier to reach. A reviewer found it by tapping the button nobody taps.

The rule now: in a tournament a decided match is over at once, so the dead game does not exist (decisions, 2026-10-05). When a rule is borrowed into a second setting, ask what ELSE is true there that was not true where it was made: here, a clock and a waiting room. And when something is offered that "cannot change anything", play it once and see.

2026-10-05 The round sheet kept its button for three seconds after the wait ran out

Symptom: the wait between two games reached zero, the match was called on its score, and the sheet stayed up with "Next round" on it until the result screen replaced it. A tap answered "That move won't work here."

Root cause: the sheet was told about the wait only while the match was being played. Once the room called it, the wait was gone from what the sheet knew, so it drew its ordinary foot over a board that had stopped between two games.

Why it escaped: the countdown was checked while it counted, and the result screen once it was up. The three seconds between them belong to neither.

The rule now: a countdown is checked at zero and just after it, not only while it runs. The foot's three states are one small function with a test (ui/roundFoot.ts). (Its first cut read "the wait is out" off the phone's own clock, which was a fault of its own: "The fix for a dead button took the button away by the phone's own clock", above.)

2026-10-05 On a phone browser, Tab walked out of an open sheet, and the sheet was a "slider"

Symptom: with a sheet open at phone width, Tab went to the lobby's buttons behind it, Escape did nothing, and the accessibility tree listed the lobby first and then "Bottom Sheet, slider".

Root cause: the two presentations of the shared dialog are different machines. A big screen's is the browser build's own modal, which traps focus. A phone's is the sheet library, which draws into the page beside the screen and names its parts for a phone's screen reader ("adjustable").

Why it escaped: keyboards are tried on a wide window, where the dialog was right. Nobody pressed Tab at 375 pixels.

The rule now: the shared sheet keeps focus on the web (ui/sheetFocus.ts). A thing with two presentations is checked in both for whatever one of them gets from its platform for free.

2026-10-05 A test helper named __d broke the board it was testing

Symptom: the host's board showed "Something went wrong. Give it another go." as a tournament match began, during a check by hand in the browser.

Root cause: the check had hung a small helper on the page as window.__d. That is the name the dev bundler defines every module with. The board's code is loaded late, so the first board to load called the helper instead and threw.

Why it escaped: everything already loaded kept working, so the lobby and its sheets looked fine for minutes.

The rule now: a helper put on a page under test gets a long name of its own, never two underscores and a letter. A crash seen while driving a page by script is first suspected of the script.

2026-10-04 A note that starts mid-breath plays the wrong pitch for a moment in a browser; and a test build believed to talk to the practice backend talked to the real one

Symptom 1: the made-by-code sounds measured louder in a real browser than in our own tests: a turn chime, a knock and the mascot's babble each had a click at the start of some notes.

Root cause: a note's pitch was set as a timed instruction ("be 880 from this instant"). A browser works in small blocks of about three thousandths of a second. A note that starts in the middle of a block plays at the browser's default pitch (the A above middle C) until that block ends, and only then takes the instruction. So each such note began with up to three thousandths of a second of the wrong pitch. The listening page the sounds were first played on had the same fault.

Why it escaped: the tests measure sound with a small recorder we wrote ourselves, and it obeyed the instruction from the first instant, as the standard reads. It agreed with our code because the same person's understanding wrote both. The fault only showed when the same sounds were rendered once in a real browser and the two sets of numbers were laid side by side.

The rule now: a note is given its pitch twice, as its starting value and as the timed instruction (ui/audio/blocks.ts). And a measuring tool we build ourselves is checked once against the real thing before its numbers are believed: it cannot disagree with us on what we both misread.

Symptom 2 (a miss, no harm done): the builder of the sound trial exported a release-style web build to click through, with the practice backend's address loaded into the shell first, and reported that the build "never touches production". The build named the production backend. Only the one-phone screens, the home and Profile were opened, and those only read; nothing was written.

Why it escaped: the belief came from the command that was typed, not from the thing it made. The build named the production backend although the practice address was in the shell, and again with a local override file (.env.production.local) beside it. What changed the outcome was the bundler's cache: it sits in the Mac's temporary folder, every checkout on the machine shares it, and it keeps a module as it was built, address included. A build that reuses a module first built for production names production, whatever it is told. Given a cache of its own, the same export named the practice backend. Nothing was checked on the bundle until the site-assembly step, which refuses a practice backend, passed when it was expected to refuse.

The rule now: a build made for a look is made by a script that refuses three times. Before: it starts only if the address it was handed is the practice one (the two backends' names are read from scripts/build-site.mjs and scripts/deploy-site.sh, never typed again). During: it hands the build that address in the environment and in the override file, and gives it a bundler cache of its own. After: it reads the files that were built and deletes the build if they name the production backend or the production relay, or do not name the practice one. The last step is the one that counts: it is "verify the artifact" again, and the site build already does the same for the opposite mistake. A check that must reach nothing outside drives the build in a headless browser that refuses every request not for this computer; the one-phone screens need nothing else.

2026-10-04 "A whole minute every time" was true only for a player who had moved

Symptom: in a best of three, a player was out of a match she was leading nine seconds into game two. Her friend had moved, waited until nine seconds of her first-move minute were left, resigned and tapped Next round. She opened the next game on "First move in 0:09".

Root cause: the minute was built on the turn clock, which gives time back only to a player whose OWN move ends their turn. That is right for a turn clock: nobody may keep a round open by tapping. But a first-move turn can end under a player without their moving (the other player resigns, the round is settled), and then what was left of that minute was all they had for their next first move.

Why it escaped: every test handed the minute on after its holder's own move, or from a clock nobody had spent. "Whole again every time" was written in the comment and never asked of a turn that ended any other way. A reviewer found it by playing the unkind line with three browsers.

The rule now: a limit on ONE turn is refilled when the turn is TAKEN, not when the last one was finished (taken, perTurn in packages/engine/src/playClock.ts). When a rule says "every time", list every way the thing can end and test each: here a move, a resignation by either player, the room settling the round, the wait between games, and a walk of four thousand random steps that checks the minute is whole each time a first move is handed over.

2026-10-04 A phone's sheet could not see what the board's frame knew

Symptom: between two games of a tournament match, a big screen said "Start it within 28 seconds, or the score decides." A phone said nothing, while the thirty seconds counted all the same.

Root cause: the wait is kept in a small frame round the board. A big screen's dialog is drawn inside that frame. A phone's bottom sheet is drawn at the top of the app, outside it, and from there the frame answers "no wait". The line asked the frame from inside the sheet.

Why it escaped: it was built and looked at on a big screen, and both sizes pass every test: nothing renders a sheet in the tests. (It was on main before this branch.)

The rule now: whatever a sheet needs from the screen it stands over is read by the component that opens it, and handed in (RoundDialog hands WaitLine its endsAt). Anything inside the shared Modal may be drawn outside its caller's frames. Look at a sheet at phone width, not only as a dialog.

2026-10-04 "The coin lands at the same moment on every phone" held for Tic-Tac-Toe and not for Chess, and for one frame it held for nobody

Symptom: in a drawn Chess match the people watching read "The coin picked me!" about a third of a second before the coin lay still for its two players. And at the very start, for a single frame, a watcher's screen showed the winner's line before it went back to "It's a coin toss!".

Root cause: three things. (1) The coin's timetable was counted from the moment a phone heard, but the two players' coin screen only opened once their board had finished showing its own ending. Tic-Tac-Toe holds a finished board for less than the timetable allows, so its coin was on time. Chess holds one a little longer, so its coin was tossed late and landed late. (2) The watchers were told at the very instant the coin was meant to be down, with nothing in hand for the moment a landed coin takes to lie still. (3) A watcher's hold on the result was started in an effect, which runs after the render that first sees the result is already on screen: one frame with the answer in it.

Why it escaped: the rule was tested as arithmetic on one game's numbers, and looked at with the one game the previews use. It showed only when three screens were timed side by side in a game with a longer hold. Even then the gap was small enough to pass for the recording's own coarseness (it looked five times a second). Reading the code is what showed the first two causes, and the single frame only showed once every change of the page was logged with its time.

The rule now: a moment that several screens share is owned by the shared timetable, and nothing a single game does may move it: the drawn board gives way to the coin when the toss is due (coinDue in modes/online/tournament/flow.ts). Whoever must not know first is told AFTER the moment, by a beat (COIN_TOLD_MS), never at it. And a hold that hides something starts in the render that first sees it, never in an effect after it (useHolds in watch/useWatching.ts). Time a shared moment in the game with the longest hold, and log every change of the page, not five looks a second.

2026-10-04 "The tournament opens on three minutes" was true of the question and false on the screen

Symptom: Chess's tournament sheet was changed to open on three minutes each. The rules said three, the tests said three, and the sheet opened on five and started every match on five.

Root cause: the tournament sheet draws the game's clock question from the SAME answers the room's ordinary start sheet holds. A question's "opens on" only fills an answer nobody has given yet, and the room had already filled it with the ordinary sheet's own five. While both sheets opened on five, nothing showed that the tournament's value was never read.

Why it escaped: the tests checked the question (its default is three) and the server (given three, it plays three). Nothing checked what the sheet HOLDS when it opens, which is state in a screen, and the two defaults had always been the same number. A first guess was wrong too: that the room remembered an older answer. It did not. Playing it at phone width found it in one tap.

The rule now: an answer belongs to whoever asks it. The tournament's clock length is part of the tournament's own pick (TournamentPick.clock, withOwnClock in modes/online/tournament/StartSheet.tsx) and is laid over the room's answers when it is sent (tournamentAnswers). When one question is asked in two places with two defaults, give it two answers. And a default is proved on the screen that shows it, not on the spec that names it.

2026-10-04 Ninety-eight lines of the brand's voice were rewritten to a rule the owner never saw

Symptom: in one day, four passes replaced 98 mascot lines in Checkers, Dots and Boxes, Sea Battle, Chess, Reversi, Pairs, the tournament and the party's awards, so that no line spoke to the other player: "Thanks for that." became "A little souvenir.", "Bow down." became "Feeling regal.", "Too easy." became "Phew. Made it.". A test was written to hold the rule. Karim then read old beside new, all 98: "Mascot line changes are all looking bad. The previous version was good. The previous ones sound and feel like the mascot is talking. The ones we currently have feel like some kind of commentary is running, so I prefer the previous version." Every old line went back.

Root cause: a reviewer's taste finding was treated as a fault to fix rather than a question to ask. A review found "a few lines that poke at the other player". Nobody asked Karim whether that was wrong. It was fixed, and written into the taste doc as a rule with his name and the date on it ("Lines never poke at a person"), which he had not said. From then on it read as his: the next game built that morning was written to it, each later review measured the banks against it and found more, and each pass was the fix for the review before. Two more rules grew out of it the same way ("a bank says one idea once", "no line in two banks").

Why it escaped: tone cannot be tested, every gate was green, and the one person who could judge had not read the lines. The test made it worse: it held the rule, so a green run looked like the rule being kept, when the question was whether the rule was his. Three earlier entries on this page are about that test not judging tone well enough; none asks whether there was anything to judge. And the new lines went into main marked "awaiting his review", in two merges.

The rule now: a change to what the mascot says is shown to Karim, old beside new, BEFORE it lands, and a reviewer's finding about tone or wording is a question for him, never a fix. The taste doc carries his name only on what he said, as its own first paragraph asks. The line test judges nothing: it pins every line, so a change shows in a diff and can be put in front of him.

Found while putting them back: the 98 were not the first. Two earlier fixes that morning had already changed seventeen lines for the same reason, and he had read neither version of those. Sixteen went back as well, and the pairs are listed for him in decisions (the same day). The seventeenth had since become a line he has read and kept, so it stays until he chooses.

2026-10-04 A safety net that deletes was only ever tested on broken things, so nobody saw what it did to healthy ones

Symptom: a rules bug on a timeout in the fifth game of a one-phone party would have forgotten the whole night and its awards, with "Couldn't pick that one up. Starting fresh." And an ordinary fault of the screen in a match that had just come back, board already drawn, deleted the match instead of showing "Try again".

Root cause: two nets forget a save that cannot be played on. Each asked "is this the save's doing?", and the answers were "always" (the clock's, for every match for its whole life, resumed or not) and "until somebody moves" (the screen's). And what they forgot was the match AND its party.

Why it escaped: every test fed the nets a DAMAGED save and passed when the ending was calm. More forgetting made those tests greener. No test asked what a net costs when the fault is not the save's: a healthy match, a party with four games recorded. A review that played healthy saves through the same faults found it.

The rule now: a guard that destroys something says when it stands down (here: the save has been drawn once and has ticked once), and is tested on healthy things as well as broken ones: what does it delete when it is wrong? And it deletes the smallest thing that fixes it: one match, never the night around it.

2026-10-04 A rule written to be kind to the friend who came last took away their one nice award; and a comment said a person had read 1,700 lines nobody had

Symptom: "the winner's second is left off after the wooden spoon" also left off "Joined at the hip" when the winner shared it with the friend who came last, so that friend was on stage for the spoon only. Two friends level on the most wins were nobody's "winner", so one of them still went up right after the spoon. And the test for mascot lines said every line "has been read by somebody", while the list had been written out from the banks in bulk and was waiting for Karim.

Root cause: the rule looked at who it was taking from (the winner) and not at who else was holding the same award. "The winner" was read off an award that has no holder when two are level. And the comment described what the list was FOR as if it had happened.

Why it escaped: the fuzz for the spoon rule counted the winner's trophies after the spoon, which the cut made zero, and had no way to see an award that was no longer there. A comment and a doc are read by no gate: only a reviewer who set the test's header beside the taste doc ("awaiting his review") saw that they disagreed.

The rule now: when a rule removes something to protect somebody, ask what that same somebody was holding in it. Say who a rule is about from the facts (the places), never from an award that may not exist. A promise about something that is NOT there needs a case written out by hand. And a header says what has been done, by whom: "pinned, so a change shows in the diff" is true; "read by a person" was a plan.

2026-10-04 "Everyone gets one before anyone gets a second" was kept by who held what, and broken by the order the room watched

Symptom: four friends, three games: Ana was called up twice before Cy, Ben or Di was called once. In 54 of every 100 nights with a second award, somebody's second was handed over before a friend's first. One night in five had the same award name on stage three times or more.

Root cause: the stage was FILLED in the right order (one each, then seconds) and then SHOWN in another: sorted by the kind of award, which put a winner's two awards side by side. The rule was kept in the data and broken on the screen. The sameness came from giving each friend their single best line: on most nights that is the same line for everybody.

Why it escaped: the fuzz asked who holds how many, which is a question about a set, and the promise has the word "before" in it, which is a question about an order. I had even written the order down as a thing the rule did NOT cover, and called it deliberate. And a pinned fixture going from five different names to four copies of one was read as "the rule changed", not as "the show got duller".

The rule now: a promise with "before", "first" or "then" in it is tested on the list in the order a person sees it, never on who ends up holding what. When a fixture changes, read the new line as the room would hear it before accepting it.

2026-10-04 A damaged save that looked like a match was kept, and then threw on the first thing a screen asked of it

Symptom: a finished party whose played game had no places came back, and the awards threw. Junk in the list of games passed over threw on the "next up" line. Pairs with its turn gone threw on the tap on Resume. Of 267 opening states with one field taken out, 16 made the shell's own reads throw.

Root cause: the check asked whether a save was match-shaped (a game, seats, a seed, a count) and made three of the reads the way back in makes. The other reads were left to the screens.

Why it escaped: the tests broke the SHAPE of a save (no players, no state, a party that is not a party) and never something inside a game's own state or inside one played game, because the list of broken saves was written from the same idea of "what a save is" as the check. A reviewer took one field out of every game's opening state, which nobody had typed a case for. My own miss while fixing it: one line in a shared screen file passed the type check and 1,600 tests and broke the bundle outright (a way of declaring a class field that the app's bundler refuses). Only loading the page showed it: the local route answered with an error page.

The rule now: a guard runs the caller's own functions, not a description of what they need. The test for it is derived from the real games (every game, every field taken out in turn), with the other half beside it (every healthy start comes back exactly), so it can neither under-forget nor over-forget. And a change to a screen is loaded in the browser before it is called done, however green the gates are.

2026-10-04 A save was "proven good" by a tap, and the check kept its own list of what the screen reads

Symptom: a one-phone save whose game state had lost a field could still reach a crash screen. A review took 2,001 damaged saves through the way back in: 187 were kept and then threw. Tic-Tac-Toe without its series could not draw the header (the score line reads it). Word Search without its finds came back on "Pass the phone", and the clock that "I'm Ana, let's go!" started threw on its first tick, from a timer, where nobody catches it.

Root cause: three things. The check on a save listed the screen's reads by hand, and the list was one read short. The safety net round the screen stood down "once somebody has moved", and in the games that open on "Pass the phone" the tap that takes it IS a move, so the net was gone before the board was ever drawn. And the check tried only a clock that was already counting, never the one a tap starts.

Why it escaped: the test for it asked the same hand-kept list of questions as the check did, so the two agreed. It took the opening state of each game with one field out, and the faults were in the next state along (after the phone is taken) and in a timer no test advanced.

The rule now: a check that must cover "everything the screen reads" calls the screen's own selector, and the screen is held to reading through it (a test reads the screen's source for a read of the rules made on the side). A save is proven by being DRAWN, not by being tapped past. A throw from a timer has no screen to land on, so the timer catches its own. And a test of "what happens next" plays the next thing: the tap, the state it leaves, and the real timer on a clock the test moves.

2026-10-04 Three checks proved less than their names said

Symptom: the line test's "the check can fail" asserted that a list with one more line in it was not equal to the list without it: true of any two lists, whatever the code did. Its net of patterns was described as catching the shapes a poke takes, and caught three of thirty-two poking lines it had not seen. The "no answer carries a seed" test found a seed only in its exact spelling (in capitals, or in two halves, it passed), read only .ts files (a query in a .js file returning every seed passed), and eleven of the questions it asked answered nothing at all, in every run.

Root cause: each check was written against the fault that was in front of its author, and then named for the whole class.

Why it escaped: all three were green, and a green check is read as its title. A search of an empty answer finds nothing, which looks exactly like a search of a full one that finds nothing.

The rule now: a check says in its own header what it does NOT find. "It can fail" is shown by running the real comparison on a planted fault, never by an assertion about the test's own data. A search is run where there is something to search (each question asked where it has an answer). A list of "everything" is read from the thing itself (every file kind the platform loads, every function by name), and what is on no list fails. And a pattern check that catches one line in ten carries that number in the test, so nobody mistakes it for the judge.

2026-10-04 A word list passed for a judge of tone

Symptom: "Checkmate! See that?" and "They were just sitting there." stayed in Chess, and "Fleet? What fleet?" in Sea Battle, after the pass that took the poking lines out. A reviewer made up thirty-two lines: twenty-eight poking ones passed the test ("Nice try.", "Better luck next time.", "They never saw it.") and two harmless brags failed ("Stop me!", "Try me."). And the lines that replaced the pokes repeated their neighbours: "Box spotted!" beside "Spotted it!".

Root cause: the test was a list of the words yesterday's bad lines used. It knew those lines and nothing about the next one.

Why it escaped: its own proof that "the check can fail" was the lines it had been built from, so it passed its own exam. Nobody tried a line it had never seen. And "is this bank saying one idea twice" was never a question the test asked at all.

The rule now: when the thing to be judged needs a person (tone, taste, whether two lines are the same joke), the test's job is to make sure a person LOOKS: a reviewed list that every new or changed line must be added to, where it shows up as a diff. Patterns stay as a net and say so. A check is proved with cases somebody else made up.

2026-10-04 The awards fuzz held the stage to the wrong promise, so it guarded the fault instead of finding it

Symptom: four friends, two games, nobody level: the winner went up twice and the friend who came second was never called, with room left on the stage. Two team games where the sides swapped wins ended on a stage of two. Three friends who walked in during the last game made two players "a group of five", and one of them went up three times.

Root cause: a true-of-you award ("Had a moment") could go up once per night, whoever it was true of, so the stage ran out of things to say while friends were still waiting; and the group a "most" is measured among counted everyone in the room, played or not.

Why it escaped: the fuzz had a rule of its own, "the stage repeats an award", that demanded the very limit that caused the fault, and it measured a shared award against everyone in the room exactly as the code did. Two copies of one mistake agree. It also never made the nights that break it: no team results, nobody walking in mid-game, 223 nights in all. An independent review found it by writing the promise down first ("nobody twice while someone has none") and then playing 280,000 nights. My first fix was wrong too: it kept "prefer a name nobody has used", which handed a friend the weaker line because of who was picked before them.

The rule now: a fuzz checks the PROMISE as a player would say it, never the mechanism the code uses to keep it; and it proves it plays the hard nights (the awards fuzz counts its own shared awards, full stages, called-off games and friends who never played, and fails if they go missing). Break the rule on purpose once and watch the fuzz fail before trusting it.

2026-10-04 A player could work out the rival's hidden fleet from their own: the seed was small enough to try every one

Symptom: none on screen, and nothing wrong in anything the server sent. A review script took what one Sea Battle player's phone is sent (their own fleet and the seat order), tried every possible seed, and laid out the rival's fleet: about 75 seconds on one core. The same search reads the whole Pairs deck from the first cards turned over. An earlier review had left it as "still open" under Undercover.

Root cause: every hidden thing in a match is drawn from its seed, and a player is shown some of those draws. The seed was a 32-bit number, and the generator kept 32 bits of state, so "which seeds would have dealt me this" has a short list of answers and a laptop can write it out. Hiding the seed hid nothing, because it could be recomputed.

Why it escaped: every secrecy test asked "is the secret in what this phone is sent?", and it was not. No test asked "can this phone compute what it is not sent?". The seed was treated as a secret because it was never sent, and its size was never looked at: it came from the day the dice only had to be fair. Two wrong turns on the way to the fix are worth keeping. A wide seed alone would have changed nothing, because the old generator cuts whatever it is given down to 32 bits. And the list of "questions a phone can ask" was first written from memory: reading it from the functions themselves turned up one the list had missed.

The rule now: for anything dealt in secret, ask what a player can work out, not only what they are sent. A secret drawn from a seed is only as hidden as the seed is hard to find, so a match's seed is 256 bits from a strong generator and its stream is one that gives nothing away however much of it is shown (decisions, 2026-10-04). The tests hold the form, not a search: every way a match begins gives it a wide seed of its own, every bit of the seed changes the draws, and no answer to any question a phone can ask carries a seed, for every game, with the list of questions read from the backend's own functions (convex/engine.seed.test.ts). An old number seed still draws what it drew, pinned before the change, because a match in progress recomputes its next draw from it.

2026-10-04 A saved game the phone could not read left "Same Device" on its loading screen for good

Symptom: open Same Device and the loading mascot never leaves. No resume card, no "Start fresh", and closing the app changes nothing: only clearing the app's data brings the mode back. Three saves did it: a saved party that was not party-shaped, a match on a clock whose saved state its own game could not read, and a save cut short on its way to the disk. A fourth, a saved match with no players, got as far as the resume card and threw there.

Root cause: the screen waits for the store to say "I have read what was saved". The store says so only after its last step, and its last step was ours: put the saved party and its match in one story, and stop a clock that was running. Both ask questions of the saved data (is this game over? which game is the party on?), and on data they could not read they threw. The storage library catches that throw and stops there, so the store never says it has finished, nothing is logged, and nothing times out. A save that is not valid JSON fails one step earlier and ends in the same place.

Why it escaped: every test wrote a save through the store and read it back through the store. Both ends were the same code, so the save was always one the code could read: the check could not fail. The one test with an odd save covered an older version number, which is forgotten before the last step runs. And the "has it finished" flag belongs to a library, so nobody had asked what would leave it false forever. The first fix considered was a try around the last step. It would have left the cut-short save and the storage that refuses exactly as broken, because both fail before that step is reached.

The rule now: what comes off a disk is read, never trusted, and reading it always finishes. The storage itself never throws; every piece of a save is checked before it is used, with the questions the screens will ask of it asked there first; and the step between "the data arrived" and "ready" cannot throw. Whatever cannot be read is forgotten and the table is kept (decisions, 2026-10-04). The tests break real saves by hand, one thing at a time, and check for each that the loading flag settles (apps/app/src/modes/local/unreadableSave.test.ts). Of any loading flag, ask: what would leave this false for good?

2026-10-04 A played party found what the gates could not: a clock running behind "Pass the phone", awards that were true and read wrong, and a photo nobody had looked at past six

Symptom: (1) Reload a one-phone Charades during its 13 seconds to decide, tap Resume, and the clock ran out behind "Pass the phone"; the 60 seconds of acting began with nobody looking. (2) "One-hit wonder: Won game 3. Then 5th, 4th." about games played before the win; "Plot twist: 2nd and 6th. Then 2nd and 2nd."; one friend with three of the stage's four awards. (3) Twenty friends in the group photo were initials with no names; eight on a phone had tags running into each other; twelve on a laptop stood their back row behind the front one. (4) A game's own award never showed its own words in a test. (5) Two hundred thousand bytes of "settings" passed a four-kilobyte limit.

Root cause: (1) Two rules that were each right met at a seam neither owned: Resume starts a stopped clock; a hidden-view game opens on the hand-off after a reload. (2) Every award was TRUE of the night as the engine measured it, and the sentence promised something else: an order, a visible change, a spread. (3) The layout overlapped rows on purpose (a back row "half a mascot higher") and sized tags from the mascot, not from the room between neighbours. (4) A plain test has no i18next, and the words fall back to "Chess star" when a key is not found, so the fallback was all the test ever saw. (5) The limit counted the letters of the JSON text, where a block of bytes is two.

Why it escaped: (1) the away rule was checked moment by moment on every game, and no check played Resume and the hand-off together. (2) The award fuzz recounts every NUMBER an award carries and never reads the line as a person would. It proved "the average improved" and could not see that the places printed did not. (3) The geometry test asked that everyone had a spot on the stage and that no two shared one. It never asked whether a tag could be read or was under somebody's feet, and the builder's own screenshots were at six. (4) A fallback that always answers makes the check for a missing answer pass. (5) The test for the limit sent a long string, the one kind of value the count was right about.

The rule now: a check has to read what the player reads. The award fuzz holds every line to what it prints (a later place better than every earlier one; nobody over two; a shared win said). The photo's test is about tags: no two touch, none under the next row, an award under every name, on eight windows from 2 to 30 friends. The words test starts i18next with every game's own copy off its folder and refuses the fallback. Resume and the hand-off are played together, through the store, on every game that has both. And when a helper falls back quietly, the test for it must be able to tell the fallback from the real thing.

2026-10-04 Three faults a second look found in work that had passed every gate: a clock eaten before the battle, a phone asked for on the wrong turn, and a colour handed to the wrong player

Symptom: (1) In a tournament Sea Battle, the opener who said Ready last began the battle on what was left of the time they had spent hiding ships, and could lose on time at the first shot. (2) Reload a one-phone Sea Battle mid-battle and it said "Pass the phone to Ben, only Ben sees what's next" on Ana's turn, with nothing private next; Undercover did the same at its public reveal. (3) After a guest left a finished game, a player who stayed could be drawn in the leaver's colour on the result and its picture.

Root cause: each was the platform guessing at something only one place knows. (1) The clock read "whose turn" off a stretch that was nobody's turn: both players hiding at once, with one of them named as the holder so that somebody's clock would run. (2) The one-phone shell remembers who took the phone last in the screen's own memory, and assumed a turn resting on a seat means that seat must take the phone. (3) The result re-dealt the match's colours from the seats it had, guessing where a leaver stood in the room's list.

Why it escaped: (1) the clock's tests were written against a player keeping a round open by tapping, and they passed: nothing asked what a clock is worth when the phase changes under it. (2) No test reloads. Every hand-off test started from a screen that remembered. (3) The colour tests used tables whose seats followed the room's order, because that is the order a test writes a list in; the fault needed the host to pick seats in another order.

The rule now: when the platform needs a fact about a game, the game says it, and when a fact is only known at one moment, it is written down at that moment. A game says when nobody is on turn because both are at it (allAtOnce) and when the phone must be taken in private (needsHandOff); a match writes down the colours it was played in as it starts. Checks: every two-player game is walked through a tournament start, a shuffled-table run of six thousand results uses the server's own seat builder, and a test asks both hand-off games "who is asked for the phone with no memory" at every beat. A test that would have caught (3) needed its data in an order no person types: shuffle the inputs of an order-dependent rule.

2026-10-04 Pairs: a quick player left the screen seconds behind, and the first fix made it worse

Symptom: tap cards the instant a tap is allowed and the screen falls behind the game: up to 2.9 seconds and five moves in a 20-second run, 6.7 seconds after fifteen known pairs raced through in five. 25 of 28 taps landed on cards the player had not yet seen settle. The winner screen could arrive before the last pair had been shown.

Root cause: the board asked only the RULES whether a tap counted. The rules are a move ahead of the screen the instant a card is tapped, and the board shows one move at a time, so every tap that the rules allowed but the screen had not caught up with put one more move in the queue.

The miss (ours): the entry below fixed one symptom of this (a miss that flashed by) by giving the miss a floor on the screen. That made a miss cost 1.67 seconds on screen against 1.4 in the rules, so the queue grew faster. It treated "the screen is behind" as a fact to live with instead of the fault.

Why it escaped: the rules' tests are right, and the state was correct at every step. The presenter's own tests prove nothing is dropped, which is true: it was all shown, late. A careful play-through never queues anything. Only a script tapping faster than a person, with the screen measured against the state, showed it.

The rule now: a tap counts only on a screen that has kept up. The board asks the rules AND the screen (games/memory/moves.ts, keptUp, takesTap): a tap is taken while the screen shows the rules' own table, or is one move short of it and that move is a card just turned or two cards turning back. Anything else is ignored, silently. Moves are short (a card turns in a fifth of a second), a pair's pop plays on after its move so the next card never waits for it, and a miss is on screen for exactly the rules' own stretch. Measured again the same way: at most one move and about a quarter of a second behind, and the last pair is on screen two seconds before the winner screen. A test now plays whole games as fast as taps are taken and holds that bound (moves.test.ts). The floor in the entry below is gone.

2026-10-04 The LAN bundle check passed with six games missing: it looked for a word, and the word was somewhere else

Symptom: the test that says "the frozen guest bundle holds every game" was green while Checkers, Dots and Boxes, Sea Battle, Chess, Reversi and Pairs were not in the bundle at all.

Root cause: it searched the bundle for each game's id as a quoted word. An id is often an ordinary word, and the bundle holds those words for other reasons: the catalog names games that are coming, and a word list holds "memory".

Why it escaped: the check could only fail for an id that appears nowhere else, and nobody asked what it would take for it to fail for this game.

The rule now: look for a thing by a string only that thing carries. The test searches for each game's own tagline key (games.<id>.tagline), checks that every game has one of that form, and proves the search rejects a near miss (modes/lan/guestBundle.test.ts). It is red on a machine whose bundle is stale, which is the point.

2026-10-04 Pairs: a miss the rules held up for a second and a half flashed by on a screen that was behind

Symptom: tap a run of known pairs quickly and then miss. The two wrong cards, which the rules keep up for 1.4 seconds for everyone to learn, were on screen for a fraction of that, sometimes for no time at all, before they turned back.

Root cause: the stretch was measured in the rules' time, and the screen was showing the past. A board plays one move at a time and queues the rest, so after six quick taps it is a second or two behind the state. The miss joined the back of that queue, and the state that turns the cards back arrived 1.4 seconds after the miss was MADE, not after it was SHOWN. The later the miss was drawn, the less of its stretch was left.

Why it escaped: the rules' tests check the deadline and pass, and they are right: the state is correct. A slow, careful play-through shows the full stretch, because nothing is queued. It only showed when a whole game was clicked through fast in the browser and a picture was taken in the middle.

The rule now: a stretch the rules hold for people to SEE something needs its own floor on the screen. The miss keeps the board busy for most of a second after its cards have turned (games/memory/anim.ts, MISS_HOLD_MS), so the move that turns them back waits its turn in the queue; on a screen that is keeping up the floor is shorter than the rules' own stretch and costs nothing. Any game that shows something for a time written in the state (a reveal, an answer, a card) asks the same question: what does a screen that is behind show?

2026-10-04 The fix that stops a one-phone clock left Word Search on an empty board, and Chess stuck between two games

Symptom: Word Search on one phone, on "Pass the phone to Ana": hide the tab or switch apps, come back, tap "I'm Ana, let's go!". A "The game is paused." toast, then a Paused card over an empty board; after "Keep going" there was still no grid, no clock and nothing to start one. Only End game got out. Chess with a clock and two games did the same between the games: "Next game" only toasted, and the card that starts the match again sat under the locked round sheet. Quick Draw's choosing clock never started, and Charades never left its between-turns screen.

Root cause: the away-pause added the same day (the entry below) stopped any game that CAN have a clock, whether or not one was counting. A stopped match refuses every action, the hand-off sends the game's start action exactly once, and the round sheet is locked, so a match stopped on either screen had lost its only way on.

Why it escaped: the fix was checked on Chess only, mid-game, where a clock is always counting and the paused card is always on top. Its tests used Chess too. Nothing played it on a game that starts its turn from the hand-off, on a round sheet, or on a racing timer, and a shared shell behaves differently on each.

The rule now: going away stops only a clock that is counting: a clock the game has, a deadline armed, nobody home yet (modes/local/away.ts, checked on every game the registry offers on one phone at every moment its own rules reach, awayGames.test.ts). A stopped match always has its way back on top: the paused card over the board, the hand-off's own button (taking the phone starts the clock, then tells the game), and the round sheet waits behind the card. A change to a shared shell is played on at least one hand-off game, one round-sheet game and one racing-timer game before it ships, not on the game it was written for.

2026-10-04 A saved Chess game lost on time behind "Pick up where you left off?", before its board was drawn

Symptom: one phone, a chess clock with a few seconds left, the page reloaded. Ten seconds on the resume question, Resume, and the screen said "Ben wins! Won on time". The same clock kept running with the phone in a pocket.

Root cause: the one-phone store armed its timer the moment the saved match was read back, against a deadline written in real time, and nothing stopped a clock when nobody was looking. Every clocked game on one phone had this; Chess was the first where the clock IS the result, so it was the first where it showed.

Why it escaped: the rules' tests hand the reducer a time and never let any pass, and the builder's own play never reloaded with a clock running. The builder had even written the fact down ("a one-phone clock keeps running when the phone is put away") as a note for review, not as a fault. Two smaller ones rode with it the same way: a move sent after the flag was played and paid its bonus (a test said so and called it deliberate), and a king shook again when a check was answered, because two counters were added into one number and the sum changed.

The rule now: on one shared phone a clock runs only while its match is on screen (modes/local/away.ts): put away, hidden, left or closed stops it, and a tap starts it. A note that begins "it keeps running when" is a bug report; write the test. Where time IS the result (a chess clock, the tournament's own clock), a deadline is checked by whoever receives the action, never left to whichever timer fires first. A turn clock is the other kind, and takes a late move as the player's own: the one rule for both is in decisions.html (2026-10-04, "A move that arrives after the clock ran out"). And two reasons for one animation get two counters.

2026-10-04 People watching a tournament match read who the coin picked four seconds before the coin landed for its two players

Symptom: after two draws a match is decided by a coin. Its two players watched the coin fly; everyone watching them already read "The coin picked me!" over the winner, saw the tick on the tab and their fan point arrive, about four seconds earlier.

Root cause: one event, two screens, each timed from its own start. The watchers' screen said the result the moment the programme settled the match. The players' coin started when ITS screen opened, which is after their board has shown its ending (a beat of a second and a half), then waits, then flies. Each screen was right by itself; nothing made them agree.

Why it escaped: every test and every preview looks at one phone. The coin's screen was checked on a player's phone and the watching screen on a watcher's, both correct alone, and a secret that leaks only BETWEEN two phones is invisible to both. It was found by a reviewer reading the two timings side by side, not by anything that ran. (The first fix had a second leak the same way: the fan point is now marked on the server the instant the match settles, so the number would have hopped early. It is held back with the rest.)

The rule now: a moment the whole room shares is timed from the one thing every phone shares: when it heard. The coin is down COIN_LANDS_MS after that on every screen (coinTimes in modes/online/tournament/flow.ts), and a watcher's copy of the match is held back as undecided until then (heldForCoin), so no piece of the screen can say it early. When a result is shown with suspense on one screen, ask what every OTHER screen shows during that suspense.

2026-10-04 Anyone in a room, a watcher included, could end an Undercover or Charades match by sending an action called "toString"

Symptom: none a player would meet by tapping. A crafted action named toString was accepted from any member of the room and the stored match became the text "[object Undefined]": the game was gone for everyone and could not be played on.

Root cause: both games found the handler for an action by looking its name up on a plain table of handlers. A plain object answers to names nobody put there (toString, constructor, valueOf), so the lookup returned a function that was not a handler, the reducer called it, and whatever it returned was stored as the match.

Why it escaped: this is the THIRD time the same fault was fixed. Spy Grid carried a comment explaining it, Quick Draw had the check, and the shared action parser got it earlier the same day, each time for the game in front of the person fixing it. The one check that covered several games ran only over two-player games inside a tournament. A fault that every game can have was being guarded one game at a time, by whoever remembered.

The rule now: a fault any game can have gets ONE test over every game that ships. games/strangerActions.test.ts sends every shipped game the names every object inherits, from a seat and from a watcher, at the start and one move in, and expects a refusal and an untouched match. It finds each game's first move by asking the game (games/probeMoves.ts), so a new game is covered the day it is registered. The server's game registry is also a table with no parent, so a game id called constructor is simply no game.

2026-10-04 One player shuffling ships could hold a whole tournament round open

Symptom: in a Sea Battle tournament match a player who kept tapping Shuffle never ran out of time, so their match never ended and the next round never started for anyone.

Root cause: the clock gave time back "when you move", and the code read that as "on every action by the player whose clock is running". In Tic-Tac-Toe an action is a turn, so the two were the same thing. In a game where a turn is many actions they are not, and a player could earn the time back faster than they spent it. The game's own limit on hiding the fleet was switched off in tournament matches on purpose (the platform's clock is the one clock), so nothing else stopped it.

Why it escaped: the clock's tests were all played with Tic-Tac-Toe, the one game where the wrong rule and the right rule give the same answer. The games that tell them apart arrived the same night, each with its own tests of its own clock, none of which runs in a tournament.

The rule now: time comes back when the turn passes on, never for an action that leaves it where it was. And a platform rule is tested with the game that is hardest on it, not the simplest one: games/clockFarming.test.ts plays Sea Battle, Checkers and Dots and Boxes against the server's own clock functions and fails if any action that keeps the turn ever gives time back.

2026-10-04 "Play again" in another mode played the old mode, in every table game and both shells

Symptom: finish Tic-Tac-Toe, Connect Four or Checkers in Classic, tap Play again, pick another mode, Start: Classic again. And the other way round: switch the mode with the chip mid-match, and the play-again sheet opened showing the old mode while Start played the new one.

Root cause: two answers to one question. A table remembers the mode it was last on and hands it to its next match, and setup gave that memory the last word over the sheet. The memory predates the sheet: when it was written, Rematch asked nothing, so nothing could disagree with it.

Why it escaped: the engine test fed setup a mode that was not a mode at all, so the memory "winning" looked right. The play-again tests changed a number (seconds, rounds), never the one answer the game also remembers. Both shells call the same reader, so they agreed with each other and with the fault. An independent reviewer found it by playing again with a different pick.

The rule now: when a later feature gives the player a way to answer something the code already remembers, decide which is newer and test the two disagreeing. The sheet wins when it was answered (carryAfterSheet); the memory stands only for a replay with no sheet. The check runs on every registered game whose sheet has an answer that can change in play, derived, in the engine, the one-phone session and Convex.

2026-10-04 Three run-outs of the clock inside one multi-jump forfeited the round in a player's first missed turn

Symptom: Checkers' rule is "let the clock run out three turns in a row and the round is the other player's". A player who walked away during a triple jump lost the round ninety seconds later, inside one turn. Sea Battle had the same fault whenever the clock's own shots kept hitting.

Root cause: the count went up on every run-out, and a turn can hold several: a jump that goes on keeps the turn, and so does a hit.

Why it escaped: the test for the rule ran the clock out on plain single moves, where a run-out and a turn are the same thing. Seventy thousand fuzzed matches found no rules fault because the fuzzer checked that states were legal, not that a sentence in the rules was true.

The rule now: a rule written in TURNS is tested on a turn with more than one move in it. Both games ask whether the clock already moved for this player in the turn still on, read off the last move, and count once. Dots and Boxes has no such rule: its clock only ever draws a line.

2026-10-04 Pause was offered where no clock runs, and on one phone a fast tap drew a line for the next player

Symptom: Checkers, Dots and Boxes and Sea Battle offered Pause on one shared phone, where they have no clock at all, and online when the host left the clock Off. Separately, in Dots and Boxes on one phone, a second tap within a fifth of a second of a line that passed the turn was drawn for the NEXT player, while the paper still wore the last player's colour.

Root cause: the shells offered Pause on a game-wide flag, and these were the first games whose clock is a choice. The tap: the rules pass the turn the instant a line is accepted, and on one phone "me" is whoever the rules say is up, so the check "is it my turn" was true for the next player before the screen had shown the turn changing.

Why it escaped: both are true only on one phone or with one setting, and each game was built and proved online with its default clock. No test asked the shells anything: the flag was read in three files, each correct for every game that existed when it was written.

The rule now: a fact about the MATCH is read off the match, through one helper all shells share (hasClock), and a test sets up every registered game in every shell with every choice on its sheet. And the rule this file already had, again: anything decided from me asks "and on one phone?" (takesTap in the game's moves.ts).

2026-10-04 The winner of a finished game became "Player" when they left the room, and the shared picture kept it

Symptom: a guest wins, the result screen says so, the guest leaves. Every phone still in the room now shows the winner as "Player" in the first colour, and a picture shared from there says "Player wins!" for good.

Root cause: the result ranks seats by id, and the screen turned an id into a name by looking it up in the room's live member list. Leaving deletes the member's row. The match itself kept only the ids.

Why it escaped: every test and every proof finished a game and looked at the result with everyone still in the room. On one phone the players are part of the saved match, so that mode never showed it. An independent reviewer found it by asking what the screen reads and who can delete it.

The rule now: a screen about something that is OVER reads what was true when it ended, from the thing that ended, never from a list that keeps changing. The match keeps its seats' names and colours from its own start (convex/lib/matchSeats.ts), and a test finishes a game, has the guest leave, and checks the result still names and colours them. When a screen joins two things by id, ask who can delete the other side while the screen is up.

2026-10-04 A share in a phone's browser sometimes downloaded instead, and the mascot said "Saved" either way

Symptom: on a phone's browser a quick tap on Share saved a file to downloads instead of opening the share sheet; inside Instagram's browser the tap did nothing visible and the mascot said "Saved to your downloads".

Root cause: a browser only lets a share begin inside the tap that asked for it. The file was still being drawn (1.4 seconds for four players, 4.6 for thirty), the tap waited for it, and by then the browser no longer counted it as a tap, so the share was refused and the code fell back to a download. A download started by a script reports nothing, so the code announced it whether or not it happened.

Why it escaped: the proofs pressed the button after the file was ready, in a desktop browser, where a download always works. Nothing pressed early, and nothing ran inside another app's browser.

The rule now: a button whose action needs something that is still being made is not pressable until it exists; never queue the press. And a closing line may only say what the platform confirmed: if nothing reports back, say where to look or how to do it by hand, not that it is done. The first miss here was assuming the fallback was harmless because "the file still reaches them".

2026-10-04 Either player of a tournament match could wipe a round they were losing by sending an action called "constructor"

Symptom: none on any screen. A review sent a table game the action {type: 'constructor', mode: 'giveaway'} and got a fresh board back, mid-round, from a player who is not allowed to switch the mode. In a tournament match either of the two players could do it. The action {type: '__proto__'} crashed with a raw fault instead of a refusal.

Root cause: two look-ups on plain objects. The shared action parser found its parser with parsers[type], and every plain object inherits a member called constructor, so that name "parsed". The table helper then asked whether the action was one of its own three with type in TABLE_CALLS, which is also true for inherited names. The permission check treated it as "not a mode switch", which in a tournament match is allowed for both players, and the code after it assumed that whatever was left was a mode switch and restarted the round.

Why it escaped: every test sent the three real actions and a made-up word. Nobody sent a name that exists on every object. The check and the action were each right on their own; the hole was the last branch saying "everything else is a mode switch" instead of naming it. First guess, wrong: the report said the tournament setting itself let players switch modes. It did not. The plain mode action was already refused, and the test for that passed.

The rule now: a name that arrives off the wire is looked up among an object's own members only, never with a bare index or in. A chain of "if this, if that" over an action ends by naming the last case and refusing anything else. The derived check that every two-player game can be played in a tournament now sends each game the mode action and three inherited names, as both players and as a watcher, and expects a refusal each time.

2026-10-04 Every phone was sent the whole tournament twice each time a match ended, and the size test looked at round two

Symptom: none in play. A review measured what a phone is sent: for thirty players over seven set rounds the bracket grew from 39 KB to 115 KB and was re-sent twice per match end, about 17 MB per phone over one tournament. A fan could also back a player after the last move, and a host's skip landing a moment after a party game ended recorded a fully played game as skipped.

Root cause: one design choice behind all three. A match that ended wrote its result onto its fixture row, and the programme read it there a moment later and wrote the fixture again. Every phone reads the fixtures, so each match end was two sends. Between those two writes the fixture said "finished", which the pick rule and the skip both read as "nothing is on". And the bracket itself named each match four times and spelt a 32-character player id a dozen times a match.

Why it escaped: the one size check played a single round and asserted "under 100 KB", which is true at round two and says nothing about round seven. Nothing counted how often the answer was sent. The in-between moment lasts a few milliseconds, and every test let the scheduled step run before looking.

The rule now: a size check measures the last round at the full room size, with a budget close to the real number. A value written by one step and read by the next must not sit on a row the screens are subscribed to. And a test of anything that happens in two steps looks at the moment between them, with the second step held back.

2026-10-04 A tournament round took two and a half seconds to pair, and an award told a player something that was not true

Symptom: none in any test. An independent review played the tournament and party rules out thousands of ways and found twelve faults in code whose 132 tests were green. The two that matter most: with thirty players and every round asked for, one step of a set-rounds tournament took 2.5 seconds, long enough for the server to cut it off and retry it for ever. And a player could be handed "top half of every game" after finishing joint last every time, or "the same place every time" after winning a duel.

Root cause: the pairing was a search that gave up after a fixed number of steps and started again allowing one more rematch, so a full room in a late round paid for the search dozens of times over. The awards measured a place as a fraction of the field, which calls three players level on second "the upper third", and three of them read only games with three or more finishers, so their sentence was about a night the player did not have.

Why it escaped: the tests played tournaments of up to twelve players for a handful of rounds and never looked at the clock, so the slow case was never run and could not have failed if it had been. The award tests each built a night to show one award being given, and asserted that it was. None asked the opposite question: of every award handed out on any night, is the sentence true? A test that only checks the cases its author thought of agrees with the author.

The rule now: rules that pair, rank or hand out anything ship with a played-out test at the full room size, and with a check that recounts every claim from nothing and compares, on nights made by chance. Both are in the suite (programme/fuzz.*.test.ts, pairing.test.ts), bounded to about a second. A step with a search in it gets a time guard at the largest size. And a number that will be read out in a sentence is checked as that sentence, over the whole night, not over the games that were convenient to read.

2026-10-04 The tournament's first bracket query handed every phone the number its draws are made from

Symptom: none on screen. The first version of the bracket query for an online tournament returned the tournament's whole state, so each phone could work out the rounds and the table with the shared rules. That state holds the seed. Every draw still to come is made from it: the next round's pairs, who gets the free pass, and the coin that settles a match drawn twice.

Root cause: the shared rules are written to run anywhere, and on one phone they must hold the seed. Sending "the state" to an online screen felt like the same thing. It is not: online, a player holding the seed can read the coin before deciding whether to play for a draw.

Why it escaped: every test passed, because every test asked whether the bracket was right, and it was. Nothing asked what else was in the answer. It was caught before anything shipped, on a second read of the query's output to check whose writes wake it. A match's own seed was already kept on the server for exactly this reason, with a comment saying so. The new query did not go and read it.

The rule now: a state that holds a seed is never sent whole. The server works out whatever needs the seed and sends the answer (programmes.view sends the rounds, the table, the podium and where each player stands, with the seed blanked), and a test reads the served answer and checks the stored seed is nowhere in it. When a new query returns a blob, read the blob for what a player could do with it, not only for whether the screen can draw from it.

2026-10-04 The saved picture had every word cut in half, and the screen showed nothing wrong

Symptom: the first shared picture saved from the browser was the right size, with the right colours, mascots and shapes, and every line of words sat half a line too low, sliced through the middle by its own box: the headline, the names, the brand label. The preview on screen was perfect.

Root cause: the browser's file is painted by html2canvas, which finds where a font's baseline is by putting a 1px image beside a letter in a hidden box under <body> and reading how far down the image sits. The app's base styles (Tailwind's reset) make every image a block, so the probe's image dropped onto a line of its own and every baseline came back one line too low.

Why it escaped: nothing on screen is painted that way. The preview is the live page, so the typecheck, the tests, the preview and a screenshot of the picture were all right; only the file itself was wrong, and only opening the file showed it. The first hypothesis was also wrong: it looked like a missing font, and the fonts were fine.

The rule now: a thing that only exists after it is made is checked by opening the thing (the same rule as the build artifacts, CLAUDE.md rule 8). The picture's proof is the saved PNG, opened and looked at, from the browser and from the phone, never the preview. The fix lives beside the capture (ui/sharePicture/pictureFile.web.ts): one style rule, present only while a picture is being made, puts the probe's image back on the letter's line.

2026-10-04 A checker showed on the square it was going to for one frame, then slid there from where it started

Symptom: in Checkers, tapping a landing square made the piece blink onto that square for a single frame, jump back to where it stood, and then slide across. Too quick to name by eye, enough to feel wrong.

Root cause: the piece follows the board rule: where it rests is ordinary layout, and the animation only carries its distance from there. The distance was worked out inside the animated style as "where I am, minus my resting square", and the resting square was read off the render. When a hop starts, the layout effect sets the travel going at once, but the animated style is still the OLD one, holding the old resting square, until the library swaps it a moment later. For that moment it computed "start minus start", which is nothing, on a piece whose layout had already moved to the destination.

The misses first: a plain start offset declared in the same render (before the animated style, then after it) changed nothing, because the stale animated style still wrote over it. The fix was to stop the animated style reading the render at all.

Why it escaped: one frame. A screenshot never lands on it, the rules tests know nothing about frames, and the first sampler looked every 40ms. It showed only when every frame's position was written down and read back (requestAnimationFrame, the piece's box each frame). Ludo and Snakes & Ladders work out a token's distance the same way and may carry the same frame; they have not been measured.

The rule now: an animated style reads shared values only. Anything it needs from the render (a resting square, a size) is put in a shared value in the same layout effect that starts the travel, so the two can never be a frame apart. And motion is checked by reading every frame back, not by looking.

2026-10-03 Android killed the app when the back arrow left the one-phone room

Symptom: on Android, tapping the back arrow in the one-phone room closed the whole app ("GG keeps stopping"), every time. The log said a view was being added to a parent while it "already has a parent". iPhone and the web were fine.

Root cause: two things meeting. (1) Android only gives a plain box its own native view tree when it has to. A box that just draws a background hands its children to the box above it; the moment it gets a shift, a fade or a layer order it takes them back. The back arrow's face shifts down 2px while pressed, so its icon changed parents on press and again on release. (2) When a screen leaves, the navigation library (react-native-screens) marks every view on it as "leaving" so it can still be drawn while it slides out, and Android keeps a "leaving" view attached to its old parent. So a view that changes parents while its screen is leaving cannot be added to the new one, and Android throws on the main thread. The back arrow does both in one tap: it leaves the screen and releases the press. It is a known, unfixed issue upstream (react-native-screens #4677, closed "not planned"); the workaround they give is the one used here.

The fix: every face that changes while pressed is pinned with collapsable={false}, so it always owns its children and nothing changes parents: the header buttons (HeaderCircle), the room's stand keys and name plate, the "?" on a game card, the account chip.

The guesses, measured: the brief suspected an animation or drawing torn down mid-flight, a worklet, or a view removed twice. None of them. The release log only had view numbers; the debug build prints the whole view tree with the error, and the three numbers were the back arrow's icon, its face and its button. That named the component in one run.

Why it escaped: nothing was wrong in the script, so typecheck, tests and the web had nothing to see, and iPhone does not keep leaving views attached. It needs Android, a pressed face that changes, and a screen leaving in the same instant. Not checked on a device: leaving an ONLINE room (hosting needs a sign-in). Its way out is a menu and a confirm sheet, whose buttons do not change parents, and the faces above are pinned there too.

The rule now: a box whose look changes with a press or a state (a shift, a fade, a scale, a layer order) and that has children gets collapsable={false}, or carries the change on the pressable itself. Read an Android mounting crash from the DEBUG build: it prints the view tree. The rule is on the app card.

2026-10-03 Names under the room's mascots were empty pills, on Android and on iPhone

Symptom: under each seated mascot the name tag was a small white pill with no name in it. Reported on Android; the iPhone simulator showed the same once it was looked at.

Root cause: the tag was centred by putting it in a box with no width and centring it there. The web lets words spill out of such a box. A phone measures words against the box they are in, so they were given no room and drew nothing; only the pill's padding was left. "Add a friend" was cut short for the same reason: its words were measured against the seat's width.

Why it escaped: the room was built and reviewed on the web, where it is right, and the layout tests check where a seat is, not what its tag draws. "iPhone is fine" was assumed, not looked at.

The rule now: never centre words in a box with no width. Give the box real room, centred on the thing it labels. A new screen is looked at on a phone before it is called done, both phones.

2026-10-03 The second player was pushed off the screen under two-player boards (Android)

Symptom: under Tic-Tac-Toe and Connect Four the row shows both players and a speech bubble. On Android the second player was off the right edge.

Root cause: the speaker's group grows to fill the row, and the bubble inside it was told to grow too. Android sized the group as the whole row before sharing the row out, so the next seat had nowhere to be. iPhone and the web sized it from its content. The bubble never needed to grow: it sits beside its speaker.

Why it escaped: same script, different answer per platform, and no test lays a row out.

The rule now: inside a row that scrolls sideways, only ONE level grows. Something that grows inside something that grows is measured differently on Android.

2026-10-03 The app kept a processor busy after it was sent to the background (Android)

Symptom: with the app in the background for 30 seconds, it was still using 12 to 20% of one processor, for as long as it stayed there.

Root cause: a mascot that is awake fidgets on its own clock, and the home's scenes run little scripts. Both stop when their SCREEN is hidden, but nothing told them the whole APP was hidden, and Android keeps a backgrounded app's script running. Measured from each screen: Home 12 to 20%, Profile and Host 12 to 16% (one awake mascot each), Games 4%, the room 0% (everyone seated is asleep). After the fix: Home 0 to 4%, Profile and Host 0%.

The guess that was wrong: the animation library's frame loop. It already pauses with the app.

Why it escaped: nothing is visible; only a processor reading shows it.

The rule now: "is anyone looking" means this screen AND this app. The mascot asks useAppActive() itself, and the Crew pages' visibility store counts it as part of being in front. Not measured: a board in the middle of a game.

2026-10-03 Every room link threw its built page away: the page was built before anyone had a code

Symptom: opening any room link (/room/ZZZZ) on gamesforgroup.com logged React error #418: the page the build wrote and the first draw in the browser disagreed, so React discarded the built page and drew the screen again. The room then worked, so nobody saw more than a flicker. It is the way every guest comes in.

Root cause: a room link has no file of its own. The build writes ONE page for all of them (the host hands every /room/* address the same file), and at that moment the code in the address is the literal placeholder [code]. That is not a valid code, so the page that got built was the "That code doesn't look right" screen. In the browser the first draw read the real code, found it well-formed and drew the waiting screen. Two different screens, one error. The address is a thing only the device knows, exactly like the window width and the theme. A second cost came with it: on a slow phone every guest was shown "That code doesn't look right" until the script arrived.

The guesses, measured: the brief listed the code, a device value (width, theme, the remembered name), sign-in state and the page title. A check that loads the built site headlessly settled it in one run: a well-formed code threw in light and in dark, at phone and desktop width, while a badly formed code (/room/nope-not-a-code) was clean, because that is the one address whose screen equals the built one. So it was the code and nothing else. Every other way in was already clean: the home, the games list, a game page, host, same-device, profile, contact, privacy, terms and the not-found page.

Why it escaped: Metro dev never hydrates, the unit tests never load a built file, and the page recovers by itself, so the only trace is a line in the production console. The 2026-09-14 rule named the theme and the width as device values and did not name the address. It was noticed only as a by-catch of the theme-flash work, by someone reading that console.

The rule now: a route's own param is a device value. A page built once for many addresses draws the same neutral screen first (the room's waiting screen, an empty ground until the mascot is due) and lets the address decide one render later, through useHydrated in ui/hydration.ts. And the property is checked on the thing that ships: bun run check:hydration opens every way into the built site in a headless browser, as a light desktop visitor and a dark phone visitor, and fails on any page that throws. bun run deploy runs it before uploading. Not proven here: a room that exists, entered as a guest. The built site talks to the production backend and opening a room needs a host to sign in. The first draw never has the room's data either way, so it is the same first draw as the missing room that was tested.

2026-10-03 The site opened light and then switched to dark for every dark-mode visitor

Symptom: Karim, on gamesforgroup.com with his system in dark: "It initially loaded in light mode and then switched to dark." Every dark-mode visit to the home, the games list and a game page did it.

Root cause: two things, both left by the 2026-09-14 fix below. That fix stopped React throwing the page away by making every screen answer "light" on the browser's first pass, the same as the build. It cured the error and left the flash: the built page IS a light page wherever a colour is chosen in script, and it stays that way until the bundle has downloaded and run. The home chose almost everything in script: its ground, every card, every line of text, the mascots' antenna stalks, each game card's background. Measured on the built home, about 250 values in the page differed between the two looks. Second, a player who picked a look with the switch had it applied only after the app started, from storage that answers late.

The wrong first guess: that the theme tokens were the problem (a class put on the page by script after it loaded). They were fine: the stylesheet already follows the system setting with no script, and the page's ground was already painted right. The flash was everything the stylesheet does not paint.

Why it escaped: Metro in development never draws HTML first, so there is no built page to be wrong. The built site in the light look is right. And the 2026-09-14 check was "no error in the console", which the light-first page passes. Only a built site, a dark system and a slow enough script show it.

The rule now: on a page the build draws, a value that differs by look is never chosen in script. The page carries both answers and the browser picks, before any script runs: useThemePick and useThemeColor in ui/hydration.ts (on the web they hand back one CSS value holding the light and the dark answer; native gets the plain value), and dark: classes for showing one of two things (the sun and the moon). The two switches those values read, the page's ground and the remembered choice are set in the document head (device/appearanceBoot.ts), ahead of the stylesheet. build-site.mjs fails if a built page loses that head, or has the light ground written into it. The test for a new screen: open the built page with scripts off, in dark. What still shows the other look: the speech bubble's outline and the Profile switch (both are drawn only after the script runs), and the desktop layout, which still starts as the phone column.

Found on the way: a room link (/room/CODE) still throws the 2026-09-14 hydration error on the live site: the built shell is drawn for no code and the browser draws it for a real one.

2026-10-02 Tambola kept reading numbers out with the sound button off in both windows: the voice came from a third window nobody was looking at

Symptom: Karim plays a local room in Chrome (host) and Safari (guest). Both read the numbers out. He turns the sound off in one, then in both, and a voice keeps calling numbers. He cannot tell which browser it is.

Root cause: neither. With the switch off a screen asks the speech engine for nothing; measured in the browser by counting every request to speak, before any change. The voice came from another window on the same Mac: the browser pane a builder agent tests in. It was hidden, signed in, sitting in a Tambola match with its own sound switched on and the call speed at three seconds, and a hidden window speaks as loudly as a visible one (reproduced: a second, hidden window with sound on read three numbers out while the window with the switch off stayed silent). That this pane was the one Karim heard is the likely story, not a proven one: nothing records which window made a sound. One real fault sat beside it: switching the sound off did not stop the line already being said, so the voice ran on for up to three seconds after the tap, which reads as "the switch did nothing".

Wrong first hypotheses: (1) the voice has its own read-aloud switch and ignores the sound button. It did, until 2026-09-09; today there is one switch and the voice reads it. (2) Each window keeps its own copy of the switch and one was stale. True, and harmless here: a window's own copy is the one its own voice obeys, so a stale window shows "on" and speaks, never "off" and speaks. (3) Calls pile up in the speech engine. They cannot: every line cancels the one before it.

Why it escaped: the builder checks its own window, and its own window was correct. A sound has no address: nothing on screen says which window is speaking, and the pane is not on screen at all. The test matching the report ("is the switch honoured?") passes on every window, so the search stayed inside the code while the cause was outside it.

The rule now: the sound switch is followed the moment it flips: off cancels the line being said, and nothing reaches the speech engine while it is off (games/tambola/callerVoice.ts, tested with a fake engine). And for whoever tests with sound: a builder that turns sound on in its browser pane turns it off again and leaves no match running there. A hidden window with a live match is a voice in Karim's room. When a sound "ignores" a switch, first count the windows that could be making it.

2026-10-02 Online, "Play again" always opened on the game's defaults, and started the rematch with them

Symptom: in an online room the host finishes a match played with their own options (45-second turns, Reverse), taps Play again, and the sheet shows the game's defaults. Tapping Start plays the defaults. On one shared phone the same sheet opened on the last match's answers, as intended.

Root cause: two changes, each right alone. The room carries the host's lobby picks so guests can watch them (the "mirror"), and starting a match cleared it, so a later lobby opens fresh. Months later the play-again sheet was built to open on "the host's last picks", and online it read that same mirror: a field the server had emptied the moment the match began. It could only ever read nothing. The comment above the reader said the room "has been carrying them all along". Found in review on 2026-10-02. It was already on main (the sheet arrived on 2026-09-18).

Why it escaped: every check passed by agreeing with the bug. The sheet falls back to the defaults when it is handed nothing, so nothing crashed and nothing looked empty. The rematch tests call the server with settings typed into the test, so they proved the server honours answers and never asked where the screen gets them. The same-device shell reads the match's own settings and worked, so the feature was seen working, on the other shell. A game with no options has no sheet. And the first hypothesis in review was half the story: the debounce on the mirror means it can also be one edit behind what was really started, so keeping it was not enough.

The rule now: the room remembers what the match is PLAYED with, written by the server from the settings it handed the game, in the one place both a start and a rematch pass through. It is cleared on the two ways back to the lobby (New game, and ending a match midway). The content never goes on the room, only whether the match used the host's own. convex/engine.playAgain.test.ts compares the room with the match row for every step. More generally: when a screen reads a value another step clears, follow the value through its whole life before trusting the comment that says it is there. A fallback that looks like a normal screen hides a reader that never gets fed.

2026-10-01 Tambola's caller stopped announcing claims online once the host had paused for five seconds in total

Symptom: in an online room, after the host's breaks added up to five seconds, the caller never again showed who claimed a prize and never said "X claimed". On Full House it vanished at once instead of holding the moment. The tickets on the same phone still celebrated the claim, so the board and its own caller disagreed.

Root cause: a claim is stamped in match time, which is real time less every break. Screens read match time from a provider (MatchClock), and with no provider they quietly get real time. A game's overlay is written inside the play shell but DRAWN at the outlet, further up the screen, and a component reads its providers from where it is drawn. Online, the provider sat around the board only, below the outlet. So the caller measured a match-time stamp against real time, and the claim looked older by the length of every break. Same-device and LAN wrapped the whole frame, outlet included, and were right.

Why it escaped: the fallback is silent and correct until somebody pauses, so an unpaused game shows nothing wrong. Typecheck cannot see which provider a node lands under. The two shells that were right were the ones tested by hand, and nobody opened them beside the online one. The hook's own comment said "measured on the match clock", which was true of the code and false of where it ran.

The rule now: a provider the board reads goes around the whole play frame, header and overlay outlet included, in every shell. Online now does this in the room screen, the same place the other two do. Anything published to an outlet lives where the OUTLET is, not where it was written, so check the outlet's parents when an overlay needs something the board has. The time sum is a pure function with a test that reads a claim after a long break on both clocks (useFreshClaim.test.ts).

2026-10-01 Undercover's Players sheet said who won while the loser was still in shades

Symptom: on the vote that decides a game, the reveal holds a drumroll: the one voted out stands in shades and their side is not shown yet. During that second, anyone who opened the Players sheet saw every winner's total already up by one. That names the winning side, and with it the side of the one in shades.

Root cause: the rules settled the game at the tap that turned the tally over. The outcome and the added wins were in the state from the first frame of the reveal, and playerView passed both through. The board held them back for the drumroll. The Players sheet is the shell's, it reads the game's liveScores, and it knows nothing about a drumroll.

Why it escaped: every check looked at the board. The view tests asked about the sides, the words and the pair, and those were sealed. Nobody asked what ELSE draws from the same state at that moment. A score is a second spelling of "who won", and it is drawn by a screen the game does not own. The first idea for a fix was also the weaker one: blank the two fields in the view. That keeps two versions of the truth, and the path where a seat leaves mid-reveal would have needed the same care again.

The rule now: a result is not written into the state until the room is allowed to see it. Undercover pays the win as the room taps past the reveal, never when the tally turns over, so there is nothing to hide and nothing a shell can show early. The board works out its own button label ("How it ended") from the two public counts. When a game withholds something for a beat, list every reader of the state, the shells included (the Players sheet, the header, the winner screen), not only the board. The test takes every seat's real view of a deciding reveal and asks for an unchanged score and no outcome (rules.test.ts, "the vote that decides it").

2026-10-01 Trivia Clash showed the next question's answer for one frame after the host had peeked

Symptom: on one phone, a host who had tapped "Show me the answer" and then moved on saw the right option of the NEXT question hop, and sometimes flash green, as it arrived. The phone lies in the middle of the table.

Root cause: the peek was a true-or-false flag, put back to false by an effect that ran when the question changed. An effect runs after the screen has drawn, so the new question was drawn once with the old flag still true. That one draw started the right answer's delayed bounce, and nothing cancelled it.

Why it escaped: one frame is invisible in a screenshot and in a tap-through at normal speed. The bounce outlived the frame, which is the only reason anyone saw it.

The rule now: state that belongs to something is stored WITH that thing's name and read back by comparing, never reset afterwards. The peek stores which question it was asked for. Undercover's unconfirmed pick stores its round the same way (it used to carry over into the next round after a tie). And an animation started on a condition is cancelled when the condition goes false.

2026-10-01 Undercover's table was only right at the sizes the test happened to list

Symptom: with four players on a laptop a little shorter than the tested one, the vote cards spilled off their mat and the mat lay on the far player's name. On a small phone seven and eight sat on each other's word bubbles, and a 320 wide phone gave twenty players dots.

Root cause: the seat-size search checked seats against seats and stopped at its smallest size whether or not anything still overlapped. The vote mat was never part of the search. The board's own least height was worked out from a guess at the headline's height, and the headline is taller on a narrow phone.

Why it escaped: the layout test was thorough, at five hand-picked stage sizes. It passed at exactly those and said nothing about the sizes in between, or about narrower ones. The first attempt at the fix repeated the mistake: it sent four players to the long table on most laptops before the search was made to include the mat.

The rule now: a search for "the biggest size that works" checks everything that has to work, and returns "none" when nothing does, so the caller can do something else (the long table). A least size is set on the thing itself (the stage), never added up from guesses about what stands around it. The layout test sweeps heights and includes the narrowest phone, a small one, and a tablet beside the chat. A seat's tap target is part of the layout and is tested with it.

2026-10-01 Undercover sent every phone the id of the word pair on the table

Symptom: nothing on screen. A player who opened the browser's developer tools could read, in the state their phone was sent, the id of the pair in play (coffee-tea), look it up in the word list that ships inside the app, see both words, and compare them with their own card. That is their side, and from it everybody's. A host's own typed list travelled the same way, whole, to every guest.

Root cause: playerView was written as a list of things to hide: the pair, the other cards, the sides, the sealed votes. Everything else in the state went out untouched, and two of those leftovers were the same secret under another name: the room's memory of pairs already played (its last entry is always the pair on the table) and the host's list. The board even read the memory, to pick which funny line to say.

Why it escaped: every test asked the view about the fields it knew were secret (is pair null, are the sides empty) and each answer was right. No test asked whether the secret was anywhere else in what a phone receives. A secret has more than one spelling: an id, an index, a list it can be looked up in, a memory whose last entry is "now". The first guess here was also too narrow: the report named the pair id, and the host's list turned up only when every field was read.

The rule now: a hidden-info game tests the view by READING ALL OF IT. Walk a whole match, take every seat's real playerView at every beat, and search every value in it for the secret in each of its spellings (Undercover's rules.test.ts, "what a phone is sent, beat by beat"; one test there plants leaks to prove the search can fail). In the rules file, anything the referee keeps for itself is cut from every view in one place, in every beat including the last screen. Anything the board needs that was derived from a secret gets its own plain public value instead (the lines now start from a number the match picks at setup).

Still open, and not fixable in a view: the word list is inside the app, and it says which word of each pair is the civilians'. A player can look their own word up in it and learn their side with no help from the state at all. And the match's random seed is small enough that the public shuffles (seat order, speaking order) could in principle be searched back to it. Both need a decision above this game: where a secret list lives, and how the seed is made.

2026-10-01 Undercover gave Mr White away through the layout, a second before the verdict

Symptom: during the reveal's drumroll (the one voted out in shades, side still hidden), a table that had seen a few reveals could tell Mr White apart: his mascot stood smaller and higher, and the button under the thumb was missing. On an ordinary reveal the button was there at once, and its label ("How it ended" against "Next round") told the room the game was decided.

Root cause: Mr White's vote-out goes to a different screen (his guess) from everyone else's (the reveal). The two were built separately, each sized for what lands under it, so they differed from the first frame, while the drumroll was meant to be identical.

Why it escaped: the data was sealed correctly (the side really was hidden), so every test of playerView passed. Each screen, looked at alone, was right. The leak only exists when you compare two screens, which no gate does.

The rule now: a secret moment that two outcomes share is ONE component (UnmaskFrame) that alone decides when anything outcome-specific may appear. Size, position, reserved space, buttons and labels all count as data: ask "could someone who has seen both outcomes tell them apart before the reveal?". The same review found turns and line picks named by seat index or seat order in four games (a leave replays a line or swaps it mid-read); they now use the round and the seat id, or a public value that never changes with the roster.

2026-10-01 Quick Draw's twenty reveal lines always showed the first one

Symptom: the drawer's mascot was meant to walk through twenty funny lines per outcome, a different one each turn. A review found every reveal would say line one.

Root cause: the pick counted the room's drawn-word list, but every phone's view of the state (playerView) empties that list, so the count was always zero. The pick now counts round and seat, which every phone is sent.

Why it escaped: the unit test built a state by hand with the list filled in, a state no board ever receives. It passed by agreeing with the same wrong assumption.

The rule now: a Board-side selector is tested on the state a phone actually gets: build it through the game's own playerView, or leave out exactly what it leaves out. The same review caught a second seam: a turn named by seat index restarts when an earlier seat leaves mid-turn. A drawer's turn is round + drawer.

2026-09-29 The dice boards lost their outline on the iPhone for the length of every ride

Symptom: moving the Snakes & Ladders and Ludo panels onto the Crew card (a 3px outline in light), a recording on the simulator showed the outline vanish down both sides while the camera zoomed in on a ladder or a capture, and come back when it pulled out. The web kept it the whole time.

Root cause: the panel clips so the camera can push the board in, and iOS clips a view's children at its OUTER bounds — the border's outside edge — while the browser clips inside the border. The zoomed board therefore painted over the panel's own edge on the phone only. The old 2px pale line hid the same fault; a bold outline made it visible.

Why it escaped: every still picture of a board is taken at rest, where nothing overflows, and the web build clips the other way. Only a recording of a ride, on the simulator, can show it.

The rule now: the two dice boards share one panel (ui/board/CameraPanel) made of two views: the card outside (edge and lift, no clipping) and a borderless clip inside it, whose bounds already sit inside the edge. Never clip on the view that draws the border when something moves inside it — and check anything that moves inside a clip on the simulator, mid-motion.

2026-09-28 A rematch played different content on one phone than online — and each was wrong in its own way

Symptom: found in code review, not play. On one phone, a host who played Charades on their own list and then took "Built-in" on the play-again sheet got their own list again; a spent Trivia pack replayed the same questions. Online the second case was right, but a host taking "Built-in" for a game whose list survives a match (Charades, Undercover, the word hunt) was ignored and got the old list back.

Root cause: three things, stacked. (1) The host's CHOICE — ours or theirs — never reached either engine; each guessed it from what happened to be in the pack store, and each had its own fallback when the store was empty: one phone always fell back to the finished match's list, online fell back to it for reusable games. (2) On one phone the sheet cleared the store and started in the same breath, and the rematch screen read the store from its LAST render, so it even handed the old list over as if it were new — the set-up screen had already solved this with a packRef, and the rematch screen never copied the solution. (3) While looking, the options step turned out to clear the pack on Start for EVERY game — added for Charades' "Built-in" — which also threw away a door game's freshly made pack whenever that game had options.

Why it escaped: each engine's rematch had its own fallback and its own tests, and every test passed — because no test compared the two engines against each other, and none put the host's choice in at all (there was nowhere to put it). A rule that lives in two places and is checked in two places is two rules.

The rule now: the sheet TELLS the engines the source, and one pure helper in the engine package (rematchPack) is the whole decision, called by the couch session and by Convex alike: built-in plays no pack; own plays the list the sheet carries, or — only for a reusable pack — the finished one. Tests cover the helper, the couch path and the online mutation with the same cases. When two engines must agree, give them one function to call, never two that "mirror" each other.

2026-09-27 "This game's already over." in round 1 of 3, and three tests that insisted on it

Symptom: two phones race for the last word of a Word Search round. The loser's drag reads "This game's already over." — with two rounds still to play. The same line answered a late tap in a Connect Four or Tic-Tac-Toe series.

Root cause: the engine's shared table asks a game's over before a move, and over is the round's — every table game passes its round-settled check there. A settled round threw GAME_OVER whether or not the series was done.

Why it escaped: three games' tests pinned it. Each one checks that result() is still null — the game is not over — and on the very next line expects GAME_OVER. A code-only assertion says what was thrown, not whether it was true, and nobody reads the code as a sentence a player will see. It surfaced in a copy review, by someone asking of each refusal "and what does the loser of that race read?"

The rule now: a refusal's code is words on a screen; test it as if you were reading them. The table names ROUND_OVER ("That round's over.") while rounds remain and keeps GAME_OVER for a series that is done.

2026-09-20 A game's refusal from setup never left the server, so online alone kept reading the generic line

Symptom: with the shells fixed to say which game refused, Trivia Clash's "Host mode needs at least 3 players." showed on the hotspot host's phone, and online — the one mode where host-run trivia is actually chosen — still read "Something went sideways. Give it another go." The lobby's Start had the game's id, the rules named the reason, and the sentence still never arrived.

Root cause: setup is a rules call like any other and it refuses like one, but it was the one rules call convex/engine.ts ran uncaught. A GameError out of a mutation is a plain Error, and Convex hands the client an opaque server failure — the code goes, the reason goes, and the toast falls all the way through to the neutral line. The dispatch path had been wrapping its throws into a typed ConvexError for ages; setup, called from insertGameState and so from both Start and Rematch, had never been given the same door.

Why it escaped: it is only lossy where a wire sits between the rules and the screen. The hotspot host runs setup in-process and its Start catches the throw with the reason intact, so the mode you can drive without a backend looked right. The reducer test proved the reason was thrown, which was never in doubt; nothing asked what a player reads after it crosses a wire.

The miss worth recording: the first draft of this entry said one phone was fine too. It was not — the same-device Start had no catch at all, so a refusal from setup there would have escaped to whatever happened to be above it. It was only latent (Trivia always runs host-led on one phone, and no other game's setup refuses with a reason), which is exactly why the claim went unchallenged until a second reader checked it against the code. It catches now, with the game's id, like the other two.

The deeper miss: expectGameError could only see the code, so a refusal that lost its reason still passed the test written for it. Every game's reason assertions had been silently vacuous. It now takes the reason as an optional third argument — or null for "names none", because a test that only says what a refusal should name cannot catch one that names a reason it should not — and the games that name one assert it.

The rule now: a refusal is only delivered where it is read, so every rules call that crosses a transport gets asserted at the far end, not at the throw. convex/engine.refusal.test.ts starts a two-seat room in host mode and reads the code, the reason and its values off the rejection the client actually receives.

2026-09-18 Mr White's six words were tappable by nobody at all

Symptom: the first play-through of Undercover on one shared phone. The room votes Mr White out, the screen does its game-show reveal, offers him six words to pick the civilians' word from — and not one of the six can be tapped. No error, no toast, nothing. The match simply cannot go on.

Root cause: the rule said "only Mr White may guess", which is exactly right in a room where he is holding his own phone. On ONE shared phone there is no such thing as his own phone: the device sits on the table and the shell attributes every tap on it to one fixed seat — the last person the deal handed it to. That seat is almost never Mr White, so the rule and the only pair of hands on the device could never agree. The vote had already been written the other way (on a shared phone the holder taps the room's one vote); the guess had not.

Why it escaped: every gate was green. Typecheck cannot see it. The rules tests proved the rule they had been told to prove — that only Mr White may guess — and were themselves written from the same wrong idea, so they agreed with the bug rather than catching it. And the rule is correct online, which is where the thinking had been done. Nothing but playing a real game to the moment Mr White is caught could have failed.

The rule now: CLAUDE.md already says it — "anything shown to one viewer and hidden from another asks: and on one phone?" This is that question asked of a MOVE rather than of a screen, and the sharper form is: every rule written as "only this seat may do it" must answer what happens when that seat is not the one holding the device. On a shared phone the answer is nearly always "the holder does it for them", and the reducer and the Board must read that answer from the same selector — which is now what they do.

2026-09-17 Every game's refusal copy was dead: the shells never said which game refused

Symptom: a Word Search player dragging letters that spell nothing read "That move won't work here." — the shared line for any bad move — when the game had written "Not one of the words." for exactly that moment. Same for Trivia's "Host mode needs at least 3 players.", which had never been on a screen. Spy Grid's four start refusals showed only as the quiet hint under a disabled START, never as the answer to a refused tap.

Root cause: the words for a refusal are looked up as games.<id>.errors.<reason>, which needs two things: the reason the rules named (it travelled) and the game's id (it did not). toastError(error, gameId) has taken the id since the games started naming their refusals, and not one caller in the app ever passed it — every shell wrote toastError(error), so every refusal fell through to the shared line whatever the reducer had said. The LAN guest was worse: its error frame carried the reducer's developer note instead, so "the dragged cells match no hidden word" was what a browser guest actually read.

Why it escaped: the argument is optional, so the compiler had nothing to object to; the copy existed and was valid, so i18n:check and lint:copy were happy; the reducers threw the right reasons, so every rules.test.ts passed. Each half of the feature was correct on its own and nothing in the repo asked whether they were joined up. The only way to see it is to refuse a move on a running screen and read the toast — which is a thing a person does, not a gate.

The rule now: a wired-up feature is not finished until something fails when the wire is cut. errorMessages.test.ts now asserts both directions against the real copy — the game's line when the game is named, the shared line when it is not — so the exact regression has a test that goes red. And the LAN error frame carries a code, a reason and the sentence's values, never a sentence: the host phone has no idea what language the guest is reading.

The miss worth recording: the first instinct was to add the argument at the three dispatch sites named in the report. That leaves the same hole everywhere else a reducer can refuse — the Players sheet's Remove, a guest's own Leave, a rematch re-running setup, and the lobby's Start, which is where Trivia's refusal actually lives (before any board exists to ask which game is playing). Grep for the call, not for the screen.

2026-09-17 The people being cheered never got the cheers

Symptom: Charades grew live-stream reactions while somebody is acting. On two browsers side by side, the guesser tapped an emoji, watched it float up their own screen — and nothing whatsoever happened on the screen of the person they were cheering.

Root cause: the shared kit is told when to HOLD the room's live connection. The board told it "hold it while this viewer may send" — which is true only of the watchers, so the actor's phone never joined the room at all and had nothing to receive on. Reactions are for the watchers to SEND and for everyone to SEE, and those are two different questions: the connection is held by every screen the acting is on, and only the row of emojis is per-viewer.

Why it escaped: one screen looks completely correct on its own. The sender sees their own emoji float, because a reaction is shown locally the moment it is tapped rather than after a round trip — so the single screenshot, the single browser and every automated check showed a working feature. The only thing that could ever have failed was two browsers open at once, and the proof in the end was counting emoji on the OTHER window and reading the relay's log (one socket where there should have been two).

The rule now: for anything shared between people, the test is a count on the OTHER screen, never a picture of the sender's. And when a shared kit asks when to hold a connection, answer "while this screen is in the moment", never "while this person may act" — the two only look the same until somebody is on the receiving end.

2026-09-17 Chain Reaction struck a player out while their orbs were still crossing the board

Symptom: tap the cell that sets a cascade off and, for about a second, the screen said three different things at once. The seat strip wrote OUT beside a player whose orbs were plainly still on the board. The totals beside every name were the mid-explosion ones, taken from the replay. And the card under the board had already moved on to the next player's name, while the explosion that decided the turn was still travelling. A toast saying "Ben is out" arrived a second before Ben's last orb was taken.

Root cause: the rules settle a whole cascade the instant a cell is tapped, so by the time anything is drawn the orbs have moved, seats are out and the turn belongs to someone else. The board replayed the explosion wave by wave, but only the GRID was drawn from the replay. Alive-or-out came from the settled state, the totals came from the replay frame, the turn card was the shell's own and read the settled state too, and the room's toast fired on the state arriving. Four readers, three different moments, one screen.

Why it escaped: every one of those readers is correct on its own and correct again the moment the cascade ends, which is when a screenshot is taken. Tests only ever ask the reducer what is true, and "what is true" is exactly the thing the board is deliberately not showing yet. No gate can compare two parts of one frame, and the window is one second long.

The rule now: the same one Snakes & Ladders was given, applied wider — a screen draws itself from ONE moment, and "the screen" includes everything around the board: the standings, the turn line, and the room's own news. Chain Reaction now takes its whole picture from the shared presented state, and because the shell's turn card cannot see that moment, the game draws its own turn line instead (ownsTurnStatus). The toast was the piece with no way to wait, so games can now say how long the room should wait for their board to catch up (announceDelayMs) — the news lands in the same frame the standings say OUT and the orbs are gone.

The second half of it: the cells were the other tell. Every cell was disabled off-turn and re-edged in the current player's colour, so all fifty-four borders flickered on every single move and the grid read as greyed-out — broken, not "not your turn". Cells now look the same at all times and an off-turn tap simply does nothing, which is the call Karim already made for Quick Draw's drawing tools two days earlier. A rule about one game's toolbar was a rule about every repeated control on a board.

2026-09-15 The reaction row was a tall oval on the iPhone and a neat pill on the web

Symptom: on the iPhone simulator the emoji row opened as a white oval about half the canvas tall, the eight emojis squeezed along its top edge and the first and last cut off by its curve. The web showed a one-line pill.

Root cause: the row is a sideways scroll area, and on iPhone and Android a scroll area grows to fill whatever height its parent allows by default. Measured on the device, the row was 6,336pt tall inside the catalog's long page. The web's scroll area does not grow, so it never showed. Telling the scroll area not to grow fixes it.

Why it escaped: it was checked in the browser only, and the web ignores that default. A static screenshot of the web row looked finished.

The rule now: every sideways scroll area gets an explicit "do not grow", and any row that sits inside a taller box is looked at on the simulator before it is called done. The row also fits its buttons to the width it has, so all eight show at 375px.

2026-09-15 Every Quick Draw line vanished for a third of a second the moment it was finished

Symptom: in Karim's simulator recording, each stroke disappeared when the finger lifted and came back later: about 320 to 370ms online (the whole round trip), and still a flash on one phone. Undo, Clear and Fill also waited for the server.

Root cause: the canvas wiped its own in-progress line on lift, then asked the game to add the stroke. The stroke only came back when new state was drawn: after the server answered online, and a render later even on one phone, because the wipe happens on the UI thread and the committed stroke arrives through React. Nothing ever held the line in between. On top of that, every state update (a guess, a hint, the server's echo) arrived as fresh objects, so every line on the canvas was rebuilt. One wrong first read: the drawer's line is not streamed live to guessers. They only ever get finished strokes, so a guesser's canvas just grows.

Why it escaped: a screenshot always shows the settled stroke, and tests check state, never the frames between lift and redraw. The gap only shows in a frame-by-frame look at a recording.

The rule now: anything a player just made stays on their screen until the state they render already contains it. The drawing canvas holds finished lines and lets each go only once the strokes it is handed have grown by it, or input comes back on (refused). The drawer's own marks paint at once through the game's optimisticView, and the stroke list keeps its identity when its content has not changed.

2026-09-15 The paint bucket filled on the web and painted nothing on the iPhone

Symptom: the same fill, over the same marks, gave a region in the browser and in the unit tests, and an empty path on the iPhone simulator.

Root cause: Hermes builds an EMPTY typed array from a Map's key iterator (Int32Array.from(map.keys())), with no error. The list of rows was empty, so no rectangle was ever written. Spreading the keys into a plain list first fixes it.

Why it escaped: the tests run in Node and the web runs V8, and both handle an iterator there. Only Hermes differs, and a wrong empty answer looks exactly like "the tap was on a line". It surfaced only because the region's size was logged on the device beside the browser's.

The rule now: pure code that must agree across screens is checked on the device too, and hands typed arrays a plain list, never an iterator.

2026-09-15 A tap on the fill surface had no position in the browser

Symptom: with the bucket picked, clicking the canvas on the web did nothing.

Root cause: a Pressable's press event carries locationX/Y on a phone, but a browser click arrives as a mouse event whose position is offsetX/Y. The missing number became NaN and the "would this change anything" check said no.

Why it escaped: typecheck trusts the event type, which promises locationX on every platform.

The rule now: read a tap's spot from locationX and fall back to offsetX, and drop a tap whose spot is not a finite number.

2026-09-14 The Enter fix did not hold: the guess still lost a letter

Symptom: with the fix below in place, a guest on the iPhone simulator typed "river" and pressed Enter; the feed showed "rive".

Root cause: the ref is written by the change event, and on iOS the Enter event can arrive before the last letter's change event. The ref was as late as the state it replaced.

Why it escaped: the first fix was checked by typing slowly, which never opens the gap. The miss was the hypothesis: "React has not re-rendered" was true but not the whole story.

The rule now: a submit from the keyboard reads the text the event itself carries (nativeEvent.text), never anything a change handler wrote.

2026-09-14 A guess sent with Enter lost its last letter

Symptom: on the iPhone simulator a guest typed "turtle" and pressed Enter; the feed showed "turtl — so close!".

Root cause: the submit handler read the guess from React state. Typed quickly, the last letter's state update had not re-rendered before the Enter event ran, so the handler saw the text one letter short.

Why it escaped: on the web and with the Guess button the render always lands first. Only fast native typing followed by Enter hits the gap, and no test drives a keyboard.

The rule now: a text input's submit reads the latest text from a ref updated in onChangeText, never from the state of the last render.

2026-09-14 The live site threw away its own home page on every dark or desktop visit

Symptom: the browser console on gamesforgroup.com showed a React hydration error ("the server rendered HTML didn't match the client", minified error 418) on the home page. Measured locally: every visit in dark mode, and every visit wider than a phone in either mode. The development build of the same pages also listed quieter mismatches on every page in light mode: icon colours, the stage colour, the search box's placeholder, the profile switches.

Root cause: the pages are drawn to HTML at build time, where there is no browser. There, uniwind reports the light theme and no theme colours at all, and the window is zero wide, so the build draws a light, phone-sized page with fallback colours. The browser's first pass read the real values instead: the moon icon where the HTML had the sun, a desktop header where it had the phone column. React found a different tree and rebuilt the page from scratch (dark, desktop), or kept the stale colours on the page (light). The colours came from every screen that reads the theme in script (the theme hook, CSS variables, and uniwind's colour props on a text box and a switch), the layout from the breakpoint hook.

Why it escaped: Metro in development never draws HTML first, so there is nothing to hydrate and nothing to disagree with. The production build reports only the one structural case, in a minified line. Only the development build served as static files shows the whole list, which no gate ran.

The rule now: on the first browser pass, read what the build could read. Anything that depends on the device (theme, CSS variable values, window size, storage) comes through ui/hydration.ts or useBreakpoint, which return the build's value during hydration and the real one a render later. The desktop layout still paints as the phone column until the script runs. Only CSS breakpoints would remove that, and they are not built.

2026-09-14 The snake slid, the camera followed, and the token never moved

Symptom: on TestFlight build 29, Karim's token sat on 17. He rolled a 5, which lands on the snake at 22 and slides straight back to 17. The line said "slid down a snake to 17", the camera zoomed in and rode the snake, the slide sound played, and the token stayed on 17 the whole time.

Root cause: each token remembered the last journey it had played by what the roll said: who, from, the die, to. A roll that ends where it started can come round again word for word (on 17, a 5, back to 17), and the second time the token found it had "already played" that journey and stood still. The camera and the sound were keyed on the move itself, so they went ahead. Ludo had the same seam: a lone token captured and brought back out of the yard repeats its entry exactly. Every move on screen now gets its own key from the presenter, never from its contents, and the die, the token, the camera and the sound all use that one key.

The miss first: the brief's guess was that the token only moves when its resting square changes. Measured on the simulator, that is not it: a token resting on 17 with nothing changing walked 17 to 22 and slid back perfectly the first time. It froze only on the repeat.

Why it escaped: every earlier check rolled moves that change square, and a move that changes square can never repeat exactly, because the next roll starts somewhere else. The unit tests fed each move once. Only playing the same loop twice shows it, and Karim's recording is the second time.

The rule now: anything that must happen once per move is keyed on the move's identity, never on what the move says. Two different moves can say the same thing. The dev menu has "Snake back to start" and "Bounce back to start" to replay both loops on demand.

Same family, the next layer down (same day): the key fixed how a move is played, but not how a move is noticed. The board still decided "is this a new move?" by comparing what the new state's roll said with what the shown one's said, so a state that repeats the shown one word for word was taken for a re-send and skipped. The brief's example, two bounces on 97, cannot happen (97 is the big snake's head), but a real one can: everyone on 17, everyone rolling a 5, and a watcher whose connection missed the rolls in between. Ludo's passes repeat the same way. Both games' states now carry a move count, +1 on every roll and move, and the board calls a state new when its count moved on. Identical states need a move counter; nothing about their contents can tell them apart.

And the miss it exposed: driving that scenario, the second move's die tumbled and its camera rode while the token stood still. The "lead is over" flag was a plain yes/no, and when one move landed and the queued next one took its place in the same update, the flag still said yes for one render. The token started its walk, the flag reset and cancelled it, and the walk never replayed because its key was already spent. The flag now names which move's lead is over. It escaped because every earlier check left a pause between moves; only moves queued back to back show it, which is exactly what an online watcher gets. Dev menu: "Same snake twice, rolls missed".

2026-09-14 A found Word Search word shrank to a dot on its first letter

Symptom: on TestFlight, now and then a word crossed off the list showed only a yellow dot on its first letter, and the rest of its letters sat dark with no band under them. Other words on the same board were fine.

Root cause: the band was an animated line whose far end lived only in the animation. The line's ordinary props still said the far end was the first letter, the value it was born with. When the board redrew later (the next find arriving), iOS sometimes put those born props back, and the finished band collapsed to a dot. The dark letters came from the find itself, not from the band, so they changed colour with nothing under them. Reproduced on the simulator: 4 of 20 bands ended as dots across two rounds of quick finds.

Why it escaped: the web preview never re-sends unchanged props, so the band always looks right there, and a single screenshot after one find shows the band that just drew, which is the one band that is always right. It only shows after a later redraw, on a phone.

The rule now: the same one Snakes & Ladders taught, in a new place: where a thing rests is ordinary props, and an animation only carries the way there. The band animates for its draw, then becomes a plain line whose end is a normal prop. After the fix: 0 of 21 bands short over three rounds. The dark letters went too, since the band is now see-through in dark and the letters keep their normal ink.

2026-09-14 The Ludo die was cut in half above an empty band at the bottom of the screen

Symptom: with four players on the iPhone 17 the standings wrapped to two lines and the die lost its lower third, with about 60 points of empty screen under it.

Root cause: every play screen put the page's bottom safe-area padding OUTSIDE its scroll area, so the padding shrank the scrolling window instead of sitting at the end of the content. A board a few points taller than that window was clipped at the window's edge, right above the padding. The same mistake had already bitten the host screen, and the fix (a flag that moves the padding inside the scroll) was written up in the page frame's own notes. The play screens never took it.

Why it escaped: the web preview has no safe areas, so the band is zero there and everything fits. Only a phone with a home bar shows it, and only once a board is tall enough.

The rule now: a screen whose body is a scroll area takes the frame's bottom-flush flag and puts the safe-area padding inside the scroll content. Dice boards also shrink to the height a phone has left, so the die is on screen without a scroll on a small phone too.

2026-09-13 A player leaving Ludo moved everyone after them into the next corner

Symptom: found while wiring Ludo to the shared "remove a player" flow, before anyone saw it. In a four-player game, removing the second player sent the third and fourth players' yards, home columns and tokens into the corner beside theirs, and the capture rule started comparing cells from the wrong starting square.

Root cause: a seat's corner was its position in the seat list. Removing a seat shortens the list, so every later seat's position, and with it its corner, shifted by one.

Why it escaped: the removal tests checked who was left and whose turn it was, never where anyone's tokens were. Every existing test removed the LAST seat of a two- or three-player game, the one case where nobody comes after it.

The rule now: a seat's place on a board is fixed at setup and stored, never derived from a list that can shrink. The test removes a middle seat and checks a capture that only lands if the survivor is still counted from its own corner.

2026-09-13 A rule written for online hid the die from the whole table

Symptom: after online Snakes & Ladders was changed to show the die only to the player whose turn it is, the die and the turn pill also disappeared on one shared phone, where every player is the viewer.

Root cause: the rule was written from me. Online, me is this device's player. On one phone the shell hands the Board the phone-holder as me, which changes at every turn, so "hide it unless it is me" read as "hide it" for most of every move. The same seam produced an earlier miss the same week: a Word Search "Go" panel that duplicated the same-device hand-off screen, because it was designed for one mode only.

Why it escaped: the change was two lines, made directly rather than through a builder, and checked in the online room only — the two windows looked exactly right. Typecheck and tests have no idea which shell a Board runs in, by design.

The rule now: anything shown to some viewers and hidden from others is decided with useSharedDevice(), never inferred from me, and is looked at in BOTH shells before it ships — however small the change.

2026-09-13 Answering "Remove Aafreen?" took the whole roster with it, but only on a phone

Symptom: on a phone, confirming or cancelling a removal closed the Players sheet too, so the host had to reopen it to see what happened.

Root cause: the bottom-sheet library's default for a second sheet is to switch: it slides the sheet underneath away. The centred dialog used on tablets and desktops stacks by nature, so it was right.

Why it escaped: the confirmation was reviewed in a desktop browser, where the dialog path runs. The two presentations of one Modal behave differently, and no test renders a sheet over a sheet.

The rule now: a sheet opened from a sheet asks for the stacking behaviour explicitly, and anything built on the shared Modal is checked at phone width as well as wide.

2026-09-13 A grey slab under the spinning die, and a chime nobody ordered on top of it

Symptom: Karim rolled the new 3D die and saw two things I had photographed and missed. A brown-grey shape sat around the cube's base for most of the throw. And one tap made three or four noises when it should have made one.

Root cause, the slab: the die's contact shadow is the hard dark rim every candy control wears, and it was told to fade out over the whole length of the throw rather than over the hop that leaves the table. So a solid rounded square sat at most of its opacity behind a cube that had already left the ground. Nothing was wrong with the cube; the thing behind it was wrong. The fix is two-part: a soft ellipse on the ground for the die in the air, and the hard rim strictly for the die at rest.

Root cause, the noises: the roll itself made exactly one sound. The second came from the play shell, which chimes when the turn becomes yours. On one shared phone the turn changes on the very tap that rolls the dice, so the chime landed on top of the throw — every time, in every game on that device. Asking what that chime is FOR answered it: it exists to make someone who has put their phone down look up, and nobody puts down the phone the whole table is playing on. It is now a connected-mode sound only.

Why it escaped: both are invisible to every gate we own, and — worse — to my own screenshots. A screenshot of a die that is mostly white on a pale ground does not tell you which element a light strip belongs to, and I twice explained away a seam that turned out to be a real face. What actually settled it was colouring the six faces six different colours in the running page: the cube was provably correct in one look. When a picture is ambiguous, stop reasoning about the picture and re-draw the thing so it cannot be ambiguous. For the sound, counting is what a person cannot do: the browser pass now patches the page's own audio plumbing and prints one line per noise, and it went from two per tap to one.

The rule now: a shadow belongs to the ground, not behind the object — anything with a hard edge drawn behind a rotating thing will read as a slab at some angle, so tie it to “is it touching the table” and nothing else. And count the sounds an interaction makes before calling it done; a shell that adds its own noise to a game's is something no game author will ever see.

2026-09-13 The chips sat on squares their players had already left

Symptom: in a three-player Snakes & Ladders game on the phone, a chip would now and then be drawn on a square its player had left several turns earlier — sometimes it disappeared from the board altogether — while the standings above the board, the sentence under it and the rules themselves all had that player in the right place. On the player's next turn the chip set off walking from the wrong square. Nobody could make it happen on demand; it simply turned up while playing.

Root cause: two separate things, and the first one hid the second.

The board was drawing itself from two different moments at once. The instant a roll is accepted the rules already have the roller on the new square and the turn already belongs to the next player — so the standings, the turn pill and the ROLL button jumped ahead while the chip was still walking. That is a design fault on its own (it is why the turn pill named the next player before the current one had finished moving) and it made every symptom below impossible to read.

Underneath it was the real one, and it is an iOS drawing rule rather than anything about this game. A chip's position was carried entirely by its animation. iOS hands a view its appearance again whenever anything around it is redrawn, and what it hands back is the appearance the view was BORN with — for a chip, the square it was on when the board opened, and the hidden state it had before it entered the board at all. A chip that happened to be moving at that moment was being redrawn every frame anyway and so looked fine; a chip standing still had nothing to correct it and simply stayed wrong until its own next turn.

Why it escaped: every gate was structurally incapable of failing. The rules were right, so the rules tests were right. The animation was right — measured on the phone, the value driving each chip matched the player's square 42 times out of 42 — so the fault was never in anything code can read; it was in what reached the screen. Nothing in the browser reproduces it: 450 rolls driven through the web build gave not one wrong chip, because the web draws animations a different way. And the builder's own screenshots passed because a screenshot taken right after a move always shows the chip that just moved, which is the one chip that is always correct. Only a screen recording across several turns showed it, which is exactly what Karim sent.

The rule now: where a thing rests is ordinary state; an animation may only say how far it currently is from there. Written that way, the appearance handed back by a redraw says "rest on square 40, nought away from it" — which is the truth — so the worst a redraw can do is tell a chip where it already is. Only something actually moving leans on its animation, and something actually moving is being redrawn every frame regardless. And the wider one: a screen draws itself from ONE moment. Snakes & Ladders now holds a single presented snapshot and advances it when the move on screen has finished travelling, so chips, die, standings, sentence, pill and button all change together in the same frame; a state that arrives while a move is playing is queued, never dropped. Measured the same way before and after: 11 wrong chips in 18 moves, then none in 47.

2026-09-12 The door was fixed in one mode and stayed broken in the other

Symptom: the rematch on one shared phone still behaved the way the online one used to — a “writing your words” wait card for a ready-made list that needed no writing, then a “your words are ready” card that had already been taken out, then the game's options all over again.

Root cause: the rematch had its own private route through the shared door instead of using the door's own steps, so every rule the door had learned — a pick that costs nothing raises no wait, words with no order get no preview — was written on the path the first set-up takes and simply not on the path a rematch takes. Why it escaped: both modes type-check and both pass their tests, because the two paths agree about everything a test can ask; only a person tapping through the OTHER mode sees it. The rule now: how the door behaves lives in the shared door component, never in a shell or a mode's adapter — and a second way into the same sheet is a second door, however small it looks.

2026-09-12 Every word you found threw away the minute you had to find the rest

Symptom: Word Search's first turn on one shared phone ended perfectly when the player cleared the board. The second player found five words and then the screen simply sat there — the bar ran down to nothing, the minute passed, and the phone never moved on. Only a turn that ended by finding EVERY word ever ended at all.

Root cause: not the game. The same-device session keeps one timer for the whole match, and after every single change to the state it cancelled that timer and then decided whether to set a new one. Its rule for "set a new one" was only if the deadline moved — which is right, and is exactly why nothing was ever re-armed: a player finding a word does not move the end of their minute. So the clock was thrown away by the first find and never replaced. The fix is one sentence of logic: a deadline that did not move keeps the timer it already has, and only a state that dropped its deadline entirely cancels one.

Why it escaped: no game on a shared phone had ever owned a clock that outlives a player's move. The quiz on one phone is read out by the host and has no clock at all; the number caller sets a brand-new deadline on every tick, so its timer was re-armed every time by luck, not by correctness. Online is untouched because the server's scheduler is a different mechanism entirely — so the reducer, which both modes share, was provably right and the bug lived in the half only one mode runs. And nothing can test it: a cancelled setTimeout looks exactly like a timer that has not fired yet, so the only way to see this is to sit in front of the screen and wait the minute out.

The rule now: when a mode shell mirrors something the server does, ask what happens on the ordinary path as well as the interesting one — here, the ordinary path is "the state changed and the clock did not", which is most actions in most timed games. And a clock that only ever proved itself on a game which re-arms it every tick has not been proved at all.

2026-09-12 Six different names, and the form called every one of them a duplicate of the others

Symptom: typing six family names into Word Search's new "write my own" list put "You already have that one." under every box, and the counter said one word was ready when six were typed. The boxes plainly held six different names.

Root cause: the writer asks the game to turn a typed line into a word and then to say what that word IS, so it can spot two of the same. The "say what it is" helper answered only for a word already stored in a pack — those carry an id, and a line just typed does not — so for every one of the six it answered with an empty string. Six empty strings are six identical answers, so every line looked like a repeat of every other one.

Why it escaped: everything about it is honest in isolation. The reading half was covered and correct, the duplicate rule was correct, and the "say what it is" helper was correct for the only input the tests gave it — an item out of a pack. Nothing asked the one question that mattered: does what a line READS as come back out when you then SHOW it? Neither half is wrong; the round trip between them was.

The rule now: when a game declares a pair of pure helpers that convert between two forms of the same thing, the test is the ROUND TRIP, not each half separately — and a helper that cannot understand its input returns nothing quietly, so anything comparing its answers must treat "nothing" as "no answer", never as a value two things can match on. Both are in rules.test.ts now.

2026-09-12 One board in fifteen quietly left a name off the list

Symptom: a Word Search board built from six family names sometimes showed five of them. No error, no message; the game simply played with one name missing.

Root cause: words go onto the grid in the order they were drawn, and each one gets a fixed number of tries to find a spot. A long word needs a nearly empty board; by the time ALEXANDRINA was tried, short words had been scattered over the grid and it ran out of tries. On a ninety-eight-word pool that is invisible and fine — the board just uses a different word. On a list of six names it is a person missing from the game.

Why it escaped: it is probabilistic, it depends on the seed, and the code was behaving exactly as documented ("words that can't be placed are quietly dropped"). A single test run, and the first play-through in the browser, both passed. Measuring it took two hundred seeds: thirteen of them dropped a word.

The rule now: WHICH words a board uses is the draw's business; the ORDER they are laid down in is the board builder's, and it lays the longest first. A rule whose failure is a matter of chance needs a test over many seeds, not one — the new one runs forty.

2026-09-11 The site footer linked to two pages the site does not have

Symptom: on gamesforgroup.com the footer's Components and Shelf links opened the not-found page.

Root cause: those are dev gallery pages. The site build strips them on purpose, but the footer showed its links on every build.

Why it escaped: in development the pages exist, so every link works. The build check makes sure the pages that should ship are there, not that no link points at one that was removed.

The rule now: a link to a dev-only page is itself dev-only. Check links on the built site, the artifact that ships, not on Metro.

2026-09-11 Discs sat a whole board too high on the simulator, and I could not make it happen

Symptom: on Karim's simulator, after a Connect Four round had drawn its winning line, discs dropped earlier sat above the grid, stacked at the top of their columns — exactly where a disc's fall STARTS — while the disc dropped last was in its hole. The browser, and my own simulator run of the same moves, put every disc where it belonged.

Root cause, as far as it can be known: a disc's fall is a transform driven on the UI thread; the React tree's own description of the disc never changes and still says "a full board above its hole" — the animation's opening frame. Anything that makes the renderer re-create or recycle a native view and re-apply the tree's props puts the disc back at that frame, silently. The change that evening had wrapped the grid's rows in a new plain view to host the line, and mounting the line inside it is the kind of thing that changes what the renderer makes of the views around it.

Why it escaped: no probe measures what a disc looks like AFTER a round: the browser check watches the fall and the slide, and the browser's renderer does not re-create views the way the native one can. It only showed on a device, only sometimes, and only for whoever played long enough.

The rule now: an animated piece's rendered props describe its REST state. While it moves, the animated style drives it; once it has landed it renders its plain rest style (no transform), so whatever re-applies the tree's props finds the piece where it is. And a celebration overlay mounts inside a view that is already real (a bordered panel), never inside a plain layout view that hosts the pieces.

2026-09-11 The column fell off the table again, one move after the pop

Symptom: in Connect Four's Pop Out, the pop itself animated well — the disc burst out, the column slid down one row. Then, on the NEXT move (a drop elsewhere), every disc in the popped column fell from the top of the grid all over again. Karim's recording, frame by frame, made it plain; "it looks like all the dessert is falling from the table".

Root cause, two layers: the board marked a popped column with a key that changed while the pop was the last move and changed BACK on the move after — and a changed key remounts, so every disc replayed its mount animation, the fall. Underneath that, the disc lived inside its tap target, whose accessibility role flips as the turn moves (landing cell, pop target, plain), and a remounted target remounts whatever is inside it — so even the bottom disc kept re-falling once the first fix landed.

Why it escaped: the browser check measured the board's POSITION before and after moves and the burst's presence, never a disc's motion on the move after. A human watching a recording caught in one pass what no still measurement could.

The rule now: an animated piece animates when it ARRIVES (its first mount, or a new arrival marker) and never when a marker merely goes away — one instance per hole, kept across moves, an "arrived" flag inside it. A piece never lives inside a control whose role or accessibility changes with the turn; it sits beside it. And a motion check samples the pieces' transforms across the move AFTER the one under test (the slide probe in the scratch scripts).

2026-09-11 The simulator app quit on launch after the package alignment, then the page-title helper threw on iOS

Symptom: the dev client on the simulator died the moment it loaded a bundle ("GG quit unexpectedly"), with a native abort inside the worklets engine. After a rebuild it ran, and immediately showed a red render error from our Seo helper: "Expo Head: add the handoff origin to the Expo Config".

Root cause, one: the app on the simulator was built on 8 Sep; the packages were aligned to the SDK's versions on 11 Sep. The JavaScript half of the animation engine was new, the native half inside the old binary was not, and the first call across that seam hit an assertion. Two: since the site became the App, every page renders expo-router/head tags for search engines. On iOS that component is the Handoff bridge, and it throws unless the config names an origin.

Why it escaped: after the alignment we rebuilt only the TestFlight archive, never the simulator or phone dev clients, and nobody opened a simulator between the alignment and the next test. The page tags were verified on the web export alone, which is where they matter, and a "native ignores it" comment stood in for a check.

The rule now: a package version change is a native change — rebuild every dev client (simulator and phone) in the same session, and say so in the report. Anything that exists only for the web (search tags, link cards) returns nothing on native, guarded by the platform, not by a comment.

2026-09-11 Airplane mode, then Start: a module URL on a purple screen

Symptom: Karim set up a same-device game on his phone, switched on airplane mode, pressed Start, and the whole page was replaced by "Something broke at the root" followed by the address of a JavaScript file.

Root cause: on the web every game's board is its own download (the game modules wrap the Board in React.lazy so the first page stays small). With no connection that download fails, the lazy component throws while mounting, and the only boundary above it was the root one, which printed the raw error on purpose as a last-resort diagnostic.

Why it escaped: nothing in our testing ever runs without a network. The simulators, the browser pane and the e2e scripts all have one, and the root boundary was written for developers (a blank page had once shipped), so its text was never read as copy a player would see.

The rule now: the board has its own safety net (BoardBoundary in the shared play shell) that says "The game didn't load · Check your internet, then try again" with one button, and keeps the screen around it alive. The root boundary says plain words too, with a Reload button, and shows the technical text only in dev builds. crashCopy is the one place that turns an error into words, and its test asserts no URL or module name ever gets through. A lazy import that failed is remembered by React for good, so the web retry is a page reload, not a re-render.

2026-09-11 The new root reloaded itself forever, and the first manual upload silently dropped every font

Symptom. Minutes after the one-site deploy went live, the home page painted blank with serif fallback fonts, and a headless check saw hundreds of document loads followed by a 403 from the host.

Root causes. Two, stacked. (1) The App Home still carried the rule from the two-site era — "on gamesforgroup.com, reload the root so the content site answers it" — so with the app now owning / it reloaded into itself until Netlify rate-limited the visitor. (2) The manual upload path (netlify deploy --dir) skips any path containing a node_modules segment, and the self-hosted fonts lived under assets/__node_modules/…; the CI path had never filtered them, so nobody knew.

Why it escaped. The bounce is gated on the production hostname, so localhost, the preview server and every test saw a home that never bounces; the font filter belongs to one upload tool that had never been used for this site. Both were invisible until the live root was opened in a real browser and its network log read — a curl of the HTML looked perfect.

The rule now. After a deploy, load the live root in a browser and read its network and console — the HTML alone proves nothing. The bounce is deleted; the export fixer renames every node_modules segment so no host or tool can skip an asset; build-site.mjs refuses a home page that hides from search.

2026-09-11 The redesigned App Home was never on the web: the deploy gave the root to a different page, and every check was green

Symptom. Karim opened gamesforgroup.com after the deploy and saw the old content-site home — no rooms carousel, no sign-in, none of the redesign — while localhost showed the App Home. Two homes, one address.

Root cause. The site build merged two outputs and, by design, let the content site win the root; the app's own home page was renamed out of the way and only /host, /local and /room/* were routed to the app. Weeks of App Home work shipped to native and to nobody on the web.

Why it escaped. Every gate ran on the app; the merge script's guards checked collisions and a dev URL, never "is the App Home reachable"; and after deploying I checked the bundle hash on the live site, not the page a person lands on. A build that is green and publishes the wrong root is the failure mode CLAUDE.md already names — and I verified the artifact's bytes, not its front door.

The rule now. After a site deploy, open the root as a stranger would and look at it. The structural fix is that there is no second home to win the root (decisions, 2026-09-11); build-site.mjs also fails if the home page hides itself from search or a public page is missing.

2026-09-11 Build 25 crashed on launch on every phone, and the warning that said so was read as "cosmetic"

Symptom. TestFlight build 25 aborted the instant it opened (Karim's crash report: "DYLD 4 Symbol missing"). Earlier that evening App Store Connect had mailed ITMS-90863 — "the app uses symbols that aren't present in macOS" — listing ten ExpoModulesCore symbols. I read that mail as a Mac-only nicety and opted the app out of Macs. It was the crash, spelled out in advance.

Root cause. The archive shipped Expo's modules as separate precompiled frameworks (SDK 57's default in release builds), and five of them — media library, font, file system, image, camera — were built against a different expo-modules-core than the 57.0.3 core in the same archive. The loader asked the core for a function it did not have and aborted. The dev client on the phone was fine because it compiles everything from source (the hand-set buildReactNativeFromSource also switches precompiled modules off). Underneath: the project's Expo packages had drifted from the versions Expo 57.0.4 expects.

Why it escaped. Every gate runs JavaScript or the dev client; none of them executes the release archive. Apple's mail was the one check that did, and it was dismissed because its headline was about Macs while its body named the missing symbols. I explained it away instead of opening the archive — one nm on the frameworks showed the core lacked the symbols the others imported.

The rule now. A warning that names concrete symbols is a defect report; test the artifact before deciding what it means. Release builds now compile Expo's modules into the app (ios.usePrecompiledModules: false), so one core and one ABI ship together, and npx expo install --check is part of the release checklist. The archive check in docs/releasing.html grows a third line: no Expo*.framework inside the .ipa.

Same build, second miss. The upload command waits for Apple's processing before returning, and that wait never ended; four hours on, the script had not reached its next line — attaching the testers. The upload now returns once the archive is handed over, and the tester step polls Apple for the build with its own deadline.

2026-09-10 The nightly reaper would have deleted a room mid-game, and every test of it agreed that was fine

Symptom. None yet — Karim asked, while reviewing the "jump back in" design, whether the cleanup would take a room people were still playing in. It would have: a room was "dead" once it was ended or older than a day, and a party that started at nine and ran past the next day's sweep loses its room, seats and match with no error anywhere.

Root cause. The reaper measured age, the one thing about a room that says nothing about whether anyone is in it. The signal that does — presence heartbeats, written every fifteen seconds by every member — sat in the next table over and was never consulted.

Why it escaped. Every reaper test seeded a room at the epoch and asserted it was deleted; none seeded a fresh heartbeat and asserted it was kept. A test suite that only ever proves the job deletes cannot fail when it deletes too much. The design review caught it, not a gate, because "what would this do to a live room" is a question about the rule, not about the code.

The rule now. A cleanup rule is stated in terms of the thing it must never harm. "Delete rooms older than a day" is a rule about rows; "delete rooms nobody has been in for thirty minutes" is a rule about people. Every reaper test suite carries at least one case of the form alive, and left alone.

Same day, same shape. The device's memory of rooms overwrote itself: a page that remembered a room before it had ever read the stored list wrote a list of one, and the second room replaced the first. Caught only by a browser run that joined two rooms from two pages of one browser — a write that does not first read cannot merge, and no unit test of one page can show it.

2026-09-09 A prop nobody destructured rendered "200" on every screen, and the type checker was fine with it

Symptom. Under Trivia's four options, in every phase, a lone "200". The status line that belonged there ("Time's up! Waiting on Karim…") never appeared.

Root cause. A new status prop was added to the component's props type and to the call site, but not to the parameter destructure. Inside the function status then resolved to the browser global window.status — a string, so {status ? <Text>{status}</Text> : null} typechecked and rendered whatever the page had put there.

Why it escaped. The app compiles with the DOM library in scope (it ships to the web), so status, name, length, event and friends are always defined identifiers. A missing destructure of one of those is not an error anywhere — not in tsc, not in tests that never mount the board. Only a screenshot showed it, and the first read of that screenshot went looking for a timer.

The rule now. When a prop's name is also a DOM global, a missing destructure is invisible — check the parameter list, not the type. Prefer prop names that are not globals (closedLine over status), and when a screen shows a value nothing should be producing, look for a global first.

2026-09-08 A shared catalog link filtered to the wrong games, and the route's own comment said it could not

Symptom. Open /games?time=5 from a pasted link and the shelf showed the 15-minutes-and-up games, with no filter chip lit. Tap the "≤5 min" chip yourself and it worked.

Root cause. A typed URL delivers 5 as a number; a chip tap stores "5" as a string. The route's search validator only accepted strings, so it rejected the value — but the rejected value still reached the screen. The chip compared strings and never lit; the time filter fell through every branch to its last return, which happened to be the longest band.

Why it escaped. The route carried a comment saying validation made the value safe, and the comment was believed. The tests exercised the filter through the chips, which only ever produce strings. Nothing ever arrived by URL, which is the one way a visitor arrives from Google — the whole reason the Shopfront exists.

The rule now. A validator that rejects a value must also decide what happens next; a fall-through return is a silent default. Both readers now coerce to the one shape, every band is named, and an unknown value means no filter. And when a screen can be reached by URL, test it by URL.

2026-09-08 The play header shrank the game's name on a phone and did nothing at all on the website

Symptom. In an online room the header over a running match read TRIVIA … instead of TRIVIA CLASH. The game's own name — the one thing that header exists to say — was the part that got cut.

Root cause. The header asked the text to shrink rather than clip, using the React Native property built for exactly that (adjustsFontSizeToFit with a floor of two thirds). React Native for Web does not implement it. On a phone the name shrank; on the web export the property was ignored and the name simply truncated, and the web export is the site.

Why it escaped. The file already carried a comment saying a long name must shrink because "SNAKES…" names nothing — the intent was written down, the mechanism was believed, and nothing ever asked whether it ran. Typecheck accepts the prop (it is a real prop). The tests never render it. The simulator shows the version that works. Only a screenshot of the web export shows the version that does not, and nobody had taken one.

The rule now. A layout that depends on a platform behaviour is a layout that has to be measured. The header now measures the space its controls left and picks the size itself, which every platform runs identically. More generally: a React Native property with no web implementation fails silently — it does not warn, it does not throw, the type is valid. When one carries a visual promise, either replace it with arithmetic or photograph the web export.

2026-09-08 A board can render nothing at all and still pass every gate — react-native-svg after a hot reload

Symptom — Connect Four's counters stopped appearing. The state had nine discs, the turn advanced, typecheck and the tests were green, and the board drew forty-two empty holes. A moment later the same screen threw an "Invariant Violation" red box from react-native-svg's own component registry, and earlier the app had died outright with "Attempt to unmount a view which is mounted inside a different view" naming an RNSVGSvgView.

Root cause — two separate things, both invisible to every gate. The library gets into a broken state after repeated fast refreshes of a file that draws SVG: it re-registers its native components and then draws nothing, or crashes. And the disc's position had been written as inset-[8%], one percent off a class the stylesheet already carried — the view collapsed to two pixels, which reads exactly like "the disc is not rendering".

Why it escaped — nothing about it is visible in source, in a type, or in a test: the state was correct, the component was mounted, and the only evidence was a screenshot of an empty board. Chasing it in a hot-reloaded app also produced two false conclusions on the way (a "dead" sheet footer that turned out to be a mis-measured tap, and a "broken" dialog that was the browser pane not painting while hidden).

The rule now — a board is judged after a cold relaunch, never after an edit. Terminate the app, launch it, then look. And a Board's geometry is spelled as an inline style rather than an arbitrary percentage utility: a class that silently does not exist costs an hour, and the pixels it saves are worth nothing.

2026-09-07 The code tiles took no taps on the phone — an invisible field stretched over them is not a tap target on iOS

Symptom. On the home's join door, tapping the four code tiles did nothing on the iPhone: no focus ring, no keyboard. On the web the same tiles worked.

Root cause. The tiles are decoration; the real text field sat over them at opacity zero and was meant to catch the tap. On the web it did. On iOS the touch went to the wrapping pressable instead, and nothing ever asked the field to focus.

Why it escaped. The home was signed off on the simulator, but every code-entry test typed into the tiles on the web. The two platforms disagree exactly on who receives a touch over an invisible native input, and no gate exercises touch routing.

The rule now. A decorative control focuses its field explicitly — the tap handler calls focus() through a ref — and the invisible field takes no touches itself (src/ui/CodeInput.tsx). "It works on the web" says nothing about a native touch.

2026-09-07 Sign in with Apple bounced with "something went sideways" — Apple sends null where Google sends a picture

Symptom. Apple's sheet accepted the password, the app came back, and a generic error toast sat at the bottom. Google sign-in on the same build was fine.

Root cause. The stock Apple provider mapping returns image: null (Apple has no profile pictures) and, after the first sign-in, no name. The users table takes a string or nothing for those fields; null is neither, so the insert failed schema validation inside the auth store mutation — after the OAuth round-trip had already succeeded. The client only saw "sign-in did not complete".

Why it escaped. Every sign-in test until then was Google, which always fills the picture. Nothing in typecheck or tests exercises a provider's profile shape against the table schema — that only meets at runtime, on the server, after a real Apple round-trip. The dev logs (bunx convex logs) had the answer in one line.

The rule now. A provider's profile() is mapped by hand and spreads in only the fields it actually has (convex/auth.ts). When a second provider joins, read the logs after the first real sign-in, not the toast.

2026-09-06 The party backdrop looked flat on the web — the pools were ellipses in percent, and only a wide window showed it

Symptom. On a phone the stage glowed the way the mock does; on a 1440px window Karim saw "plain purple" and asked whether the gradients had been left out.

Root cause. The mock's backdrop is five CSS radial-gradient(circle at X% Y% …) pools — circles whose size follows the box's farthest corner. The port drew them as SVG ellipses with radii in percent of WIDTH and HEIGHT on a stretched viewBox, so a pool that is round on a 390×844 phone becomes a 648×216 band on a 1440×900 window: wide, thin, and faint.

Why it escaped. Every check ran on a phone-shaped box, where the two formulas agree. Nothing compared the stage against the mock at desktop width, and a backdrop has no test that could fail. Same shape as the Modal lesson from the previous round: a component can be correct and still be wrong per device.

The rule now. When porting a CSS recipe, port its UNITS, not just its numbers — measure the box and compute what CSS would have computed (here: circle radius = reach × farthest-corner distance). And before hand-off, look at the desktop at 1440 next to the phone.

2026-09-06 Every themed colour rendered as black text on a dark stage — the tokens existed, the utilities didn't

Symptom — the first theme-aware build of the home painted "What are we playing?" in black on the indigo ground. bg-app, bg-card-2 and text-ink all resolved to nothing, while text-purple and bg-yellow (static tokens) worked. The CSS variables were present at runtime with the right values.

Root cause — uniwind's docs show theme colours declared only inside @layer theme { :root { @variant dark { --color-x } } }, and that is where the runtime VALUES come from. But Tailwind v4 only generates a utility class for a name it sees in @theme; a name that lives only in the layer gets no bg-x / text-x at all. Declaring the names in @theme (dark values) and the per-appearance values in the layer fixed it on web and native alike.

Why it escaped — typecheck cannot see class names, tests never render styles, and the page still painted: the stage came from the <body> fallback colour in +html.tsx, so the ground looked right and only the text gave it away. A custom @utility that referenced the same variable worked, which pointed at Tailwind's utility generation rather than uniwind's theming.

The rule now — a theme token is declared in both places (documented in global.css and the app card). When a class "does nothing" under uniwind, check whether Tailwind generated it before suspecting the runtime: getComputedStyle on the element on web tells you in one line.

A second, smaller catch from the same session: a card that is a Pressable with a NeonButton inside renders <button> inside <button> on web — invalid HTML, a hydration warning, and a click that fires twice. Sibling pressables (the art + title as one target, the footer button as another), never nested.

2026-08-31 The page glow never rendered on iOS at all — and it took a designer asking "where's the gradient?" to find out

Symptom — during the home redesign Karim compared the app to the design reference and asked why the background gradient was missing. It wasn't a taste dial: the ambient glow every screen paints behind its content (AppShell's two radial pools) had NEVER rendered on a phone. Web showed it; native showed flat black, since the original RN port weeks earlier.

Root cause — the SVG radial gradients were spelled with rx/ry radii on the <RadialGradient>. That's not part of the SVG gradient spec (a radial gradient has one r); browsers shrugged it off in a way that still drew something, but iOS resolved the radius to zero and painted nothing. The correct cross-platform spelling is r="50%" in default bounding-box units — the circle stretches to the ellipse's bounding box, which is the elliptical falloff we wanted all along.

Why it escaped — three reasons stacked. (1) The glow was designed as a whisper (peak opacity 0.14), so "it's missing" and "it's subtle" look identical to a human eyeballing a simulator. (2) Browser verification passed — web tolerated the invalid markup — and no gate can diff a native screenshot against a web one. (3) During the fix itself, two "no change" screenshots were nearly misread as proof the fix was wrong; they were stale bundles — the edits had never reached the device. Metro's bundle log, not the screenshot, is the ground truth for "is the code I'm judging actually running".

The rule now — same scar as the worklet lesson, new organ: a green web render proves nothing about native drawing, and react-native-svg accepts markup it will not draw. Anything painted by SVG that MATTERS must be eyeballed on a device/simulator once, at an opacity where absence is unmistakable. And when iterating visually over hot reload, confirm a fresh bundle line in Metro's log before interpreting a screenshot — judging a stale bundle turns a correct fix into "evidence" it was wrong.

2026-08-24 The cascade replay died on a message that changed nothing — and the cure had been sitting in a sibling game the whole time

Symptom — Chain Reaction's explosion replay (the game's whole payoff) worked for every player except the one who tapped. Online, the mover's board snapped straight to the settled result while the rest of the table watched the full wave-by-wave cascade.

Root cause — the Board decided "should I animate?" by comparing the incoming state OBJECT to the previous one. With zero-latency own moves on, the mover's move arrives twice: the instant local copy, then the server's confirmation — identical content, different object. The second arrival failed the "exactly one move ahead" check and tore the running animation down. Any re-delivered subscription (a reconnect, a re-render) does the same.

Why it escaped — every gate passed because every gate runs one delivery per move: unit tests call the reducer once, same-device play has no echo, and the browser smoke-test played same-device. Only online-with-optimistic-moves produces the double arrival, and only the mover sees it. It was caught by the review pass tracing how the shells deliver state, not by any test. Worse: Snakes & Ladders had already solved exactly this — its roll animation keys on a content string of the MOVE, not on state identity — and the replay was written fresh instead of copying the working solution (the same miss as the duplicated-screen post-mortem below).

The rule now — a Board animation that replays "what just happened" must key on the identity of the MOVE (move counter + actor + action), never on the identity of the state snapshot, and a re-delivery of the same move must leave the running animation alone. And before building any replay/animation mechanism, grep the other games for one that already animates a multi-step change — the shells deliver state the same way to every game, so a sibling's solution already encodes the delivery quirks.

2026-08-17 Twelve review agents approved a game that nobody would play twice — every gate asked "is it correct", none asked "how long does it last"

Symptom — Eliminate shipped fully reviewed: rules verified against spec, seven real defects found and fixed, animations polished, every suite green. Karim played it and was done in under a minute, every time, at every table size. The game was correct and pointless — it is now removed entirely.

Root cause — the flaw was structural and visible at DESIGN time, before any code: a game that eliminates a share of the room each round lasts "how many times can you halve the group" rounds, which is 2–5 for any real party. No tuning fixes logarithms. The clock of a game must live outside the room — content, a progress track, lives — and Eliminate's only clock was the room itself.

Why it escaped — every check we ran, human and agent, took the game's existence and shape as given and verified it was BUILT right. Correctness reviews, adversarial verification, browser play-throughs (which test that it works, not whether ten minutes with it feels good) — none owns the question "will a group be glad they picked this". A one-minute game passes every engineering gate flawlessly. Same family as principle 7 (ask why it exists), one step earlier: ask what an evening with it feels like.

The rule now — a duration test joins the design gates, before building: name what METERS the game's length, prove it is not the room's own size, and land in the 10–15 minute band (5 is the floor). It is written into the memory files and decisions.html carries the full ruling.

2026-08-17 Renaming the app silently broke the release pipeline — the signing config is keyed by a name the rename changes

Symptom — the first TestFlight build after renaming the app to "GG" failed twice: once with "Credentials are not defined in credentials.json", once with "Could not find target 'GamesforGroup' in project.pbxproj". Dev builds, tests, and the simulator were all fine.

Root cause — Expo derives the Xcode project's target name from the app's display name, and credentials.json maps signing credentials by target name. A release build regenerates the native project fresh, so the target became "GG" while the credentials file still said "GamesforGroup". Having both keys is also wrong: the build tries to assign credentials for every listed target and fails on the one that no longer exists. The file must carry exactly the current name.

Why it escaped — every local gate uses either pure TS or the existing dev native folder, which kept its old target name. Only a from-scratch release build regenerates the project, so the breakage was invisible until a real ship. Rule 8's territory again: the artifact, not the repo.

The rule now — renaming the app (expo.name) is a RELEASE-PIPELINE change, not a cosmetic one: update the key in apps/app/credentials.json (gitignored — edit it on the machine) to the new sanitized name in the same sitting, and expect the first release build after the rename to be the real test.

2026-08-17 I built the same screen twice, and the second copy shipped three bugs the first had already fixed

Symptom — three separate bugs in same-device play, all found by Karim playing it, none by any gate. The questions step was shipped and live but unreachable, buried under six game cards with Start pinned over it. Choosing a two-player game silently capped the roster at two with no error, so adding a third player just did nothing. And the sign-in sheet appeared and vanished within a second.

Root cause — every one of those was already solved, correctly, in the ONLINE lobby. The config sheet exists there precisely so a pre-game step cannot be scrolled past. Its seat picker exists precisely so a roster bigger than the game is a choice rather than a wall. Its seat effect depends on a string key and returns the same array reference when nothing changed, with a comment saying why — because depending on the array re-fires the effect on every render, and returning a fresh [] re-renders for ever. That last one is what made the sheet flash: the whole screen was remounting continuously.

I did not read any of it. I built same-device from the requirements instead of from the implementation that already met them, ten metres away in a sibling folder. The mental slip is specific and worth naming: the modes differ in their PLUMBING — a room, an account, a server — and I let that difference leak into the INTERACTION, where it does not apply at all. "Where does the host make decisions before starting?" has one answer for the whole product, and I answered it twice.

Why it escaped — nothing could see it. Two files that disagree both typecheck. Both suites pass. There is no test that can ask "do the two shells offer the same steps", and the repo's rules were all about GAMES (boards are mode-agnostic, never edit shared code) with nothing at all about mode SHELLS having to agree. So the divergence broke no stated rule, failed no gate, and was invisible until a human tried it. It is the same shape as every other entry on this page: a check that looked green was structurally incapable of failing.

The rule now — two parts, because a rule nobody is forced to read is worth little on its own.

First, structural: the two implementations no longer exist. Choosing questions is ONE component (src/ui/PackSetup.tsx) that both shells mount, and the only thing the modes supply is a PackStore — four functions describing where a pack is kept. That is the sole real difference between them, about four lines wide; 563 lines of near-duplicate became 249 shared. A second copy is now something you would have to set out to write.

Second, the standing rule, added to CLAUDE.md: before building anything in one mode shell, open the other one. If the same host-facing decision exists in both, it gets one implementation and a mode-specific adapter — never a second copy kept in step by hand.

2026-08-03 A dev-tool's cleanup step deleted the permission our whole offline mode depends on — but only in release builds

Symptom — found in review, hours before the first TestFlight build meant for real offline testing. Every release archive would have shipped with no NSLocalNetworkUsageDescription. iOS then denies local-network access with no prompt and no error, so an in-app guest's connection to the host phone simply hangs — the mode's headline feature dead on arrival, and nothing in our code to explain it. Dev builds work perfectly.

Root cause — two correct behaviours in another package, multiplied. expo-dev-launcher writes its OWN usage description into the generated Info.plist ("Expo Dev Launcher uses the local network to discover and connect to development servers…") and installs an Xcode build phase that DELETES the key from any non-Debug build whose description still mentions Expo Dev Launcher. Both are right for a dev client — the App Store should not see a developer tool's permission text. But our own string, set in app.json, lost to the plugin's, so the strip phase treated the app's real need as the dev tool's leftover.

Why it escaped — it lives in the gap every gate is blind to. TypeScript, the tests and the worklet scan never see a plist. The simulator never sees a release build. The generated ios/ folder is gitignored, so no diff ever showed the wrong sentence, and the deletion happens during the archive — the file on disk is right, the file in the app is not. A review that only reads our code cannot find it either: every line of ours is correct.

The rule now — a permission or capability the product depends on must be claimed by the code that depends on it, and verified in the built artifact. The LAN server's own config plugin now sets the description (so it is ours by the time the strip phase looks), and the release runbook checks the shipped .ipa rather than the repo. More generally: when a dev-only tool and the product both configure the same native key, assume the tool wins and prove otherwise.

2026-08-03 The freshness test guarded the copy that does not ship

Symptom — guestBundle.test.ts was written after a game went invisible to browser guests, and it was green. The copy actually inside the iOS app was a fortnight old and missing Word Search entirely.

Root cause — the export lives at modules/expo-lan-server/guest-dist and is mirrored into ios/guest-dist at pod install time. A local xcodebuild archive never runs pod install, so the mirror is whatever it was when pods were last installed. The test read the export — the fresh directory that never ships.

Why it escaped — the test was correct about the thing it named and wrong about the thing it protected, which is the hardest kind of wrong to notice. It also proved only that game ids appear as strings in the bundle, so a bundle stale in CODE (an old protocol, a fixed Board bug) passes green regardless. And it was written skipIf-guarded for machines with no export — a skipped guard reads exactly like a passing one.

The rule now — assert on the artifact that ships, not the one you built. The test now compares the iOS mirror's file set against the export's, both derived by walking the real directories, and it FAILS rather than skips under CI. Android is deliberately excluded: its mirror is refreshed by the build itself, so asserting it would only assert that someone ran a build.

2026-08-02 A guard test compared two hand-typed lists, so it certified its own omission

Symptom — Word Search had been finished and playable for weeks. On gamesforgroup.com it did not exist: no page, no gallery card, nothing indexable. The website's catalog is hand-typed, and consistency.test.ts existed precisely to catch a shipped game missing from it — but it compared the catalog against a list of slugs typed into the test file, and that list had forgotten Word Search too. Two copies of the same mistake agreeing with each other reads exactly like a passing test.

Root cause — the check was not derived from anything. It had been derived once: an earlier version imported the real game registry, and when the registry moved into the Expo app the import was replaced with a typed constant and a comment explaining why that was fine. The moment both sides of an equality are maintained by the same person from the same memory, the test stops testing and starts documenting an assumption.

Why it escaped — nothing else could see it. Type-checking cannot know a list is incomplete. The app was correct — the game really did work. Only the website was wrong, and no screen in development shows the website's catalog next to the app's registry. Once the check was re-derived from the real game metas it immediately found four MORE drifts nobody had noticed, including the site advertising Snakes & Ladders as 2–4 players / 15 min when it is 2–6 / 10.

The rule now — both sides of a consistency check must be derived; a check against a literal is a second copy wearing a test's clothes. If deriving is awkward (the marketing site cannot ship 114 KB of game rules), derive it in the TEST, which never ships. Recorded as a convention in CLAUDE.md.

2026-08-02 Fixing a seat-loss bug leaked seats instead, because the guard swallowed a state nobody was thinking about

Symptom — LAN removed a player 10 seconds after their socket dropped, which cost them their game whenever a phone locked. The fix: never reclaim a seat unless the room is in the lobby. That fix leaked seats permanently. A guest who closed their tab on the winner screen was never reclaimed, because the room was ended, not lobby — and nothing re-examined dropped sessions when the room later returned to the lobby. Since startGame seats the entire roster, the next match dealt a ghost into the game and froze on their turn.

Root cause — the guard was written as "protect the lobby case" when the real intent was "protect a match in progress". Those read as the same sentence and are not: the room has three states, and the negation of one is two, not one. Compounding it, the timer was fire-and- forget — when it fired at a bad moment it simply deleted itself, so a single unlucky tick lost the seat forever.

Why it escaped — the tests exercise the pure match host, which has no sockets and no timers, so nothing in the suite can observe a reclaim at all. The behaviour only exists in the transport glue, and it only misbehaves across a state transition (playing → ended → lobby) that takes a whole game to reach.

It took three attempts, and each looked right. Attempt 2 added a re-arm but kept a guard that returned before any timer was created, so a mid-match drop still scheduled nothing. Attempt 3 armed correctly but only POLLED on a 90-second window — and a host who taps Back to lobby and starts the next game in ten seconds beats that window every time, so the ghost was seated again and the match froze exactly as before. What finally worked was reacting to the state CHANGE (the room returning to the lobby) instead of asking repeatedly.

The rules now — three, and the third is the one that mattered. (1) Write a guard as the state you are protecting, not the state you happen to be in (=== 'playing', never !== 'lobby'): with three states a negation quietly captures a case you never considered. (2) A deferred action that finds the world unready must re-arm, not vanish. (3) If the correct moment to act is a transition, subscribe to the transition — do not poll for it. A poll is a race against whatever else reacts to that transition, and the competing reaction here was "seat the whole roster into the next game".

Why it stayed invisible — hostSession.ts imports the native LAN module, so it cannot even be loaded in a node test. There was no coverage at all, which is how three broken versions passed every gate. The decision now lives in a pure reclaim.ts that the real code imports. The first test written for it re-declared the policy inside the test file and would have stayed green through a full revert — the same self-agreeing-copy mistake as the Word Search entry above, made in the very commit that added that rule to CLAUDE.md.

The wider miss — every one of these was found by adversarially reviewing my own fixes, never by the suite. A fix deserves the same suspicion as the code it replaces; and when a third attempt at one function is still wrong, the problem is the approach, not the details.

2026-08-02 We optimised, then redesigned, a feature that no screen displayed

Symptom — the "Trending" rail was fixed twice. First a 12-agent audit found it was a scale hazard (one hot document per game, plus a query every home screen subscribed to) and it was sharded and snapshotted. Then Karim noticed it ranked by ALL-TIME plays, so it was rebuilt around a rolling window with hourly buckets, a noise floor and 14 new tests. Only while verifying that second rebuild did a grep reveal the truth: useTrending had zero importers. The website's rail was a hardcoded list of four games (that site runs no Convex at all), and the app's own home rail reads the registry directly under a different label. The counter, the table, the ten-minute cron, the snapshot and the query rendered nothing, anywhere.

Root cause — every question asked about the feature took its existence for granted. "Is it fast?" No. "Does it rank correctly?" No. Nobody asked "does anything read it?" The code was internally immaculate: typed, indexed, tested, documented, reviewed twice.

Why it escaped — this is the failure mode of a well-scoped review. The audit was scoped to security and load, so it evaluated the feature against its own design and reported it fixed. Tests pass because they test the pipeline, not its purpose. Typecheck passes because a hook with no callers is still valid code. Every automated signal is blind to irrelevance — nothing in a toolchain can tell you a correct thing is pointless. Worse, the two rounds of improvement made it look increasingly cared-for, which is the opposite of a smell.

The rule now — recorded in CLAUDE.md: grep for consumers before improving anything. If a module, query or table has no callers, the correct fix is deletion, not optimisation. And when reviewing a feature, ask what renders it before asking whether it is any good. A dead feature is not neutral: it cost a write on every single game start, a cron every ten minutes, three database tables and two rounds of expert attention.

The wider miss — Karim had to point this out, and had already had to point out the ranking bug before it. His framing is the lesson: a review must challenge whether something is the right design at all, not only whether the current design is correctly implemented. A narrow, thorough pass does not add up to that, and its thoroughness disguises the gap.

2026-08-02 Widening the room code shipped a screen that told users the rule the app had just stopped following

Symptom — room codes went from 4 to 6 characters. Hosting a room on production minted a perfectly valid WPYXNY, redirected to /room/WPYXNY … and the room screen answered "That code doesn't look right — room codes are 4 characters." The app was rejecting a code its own backend had issued a second earlier, and explaining itself with a rule that no longer existed.

Root cause — two literals, one screen, one updated. The validation regex in room/[code].tsx was changed to {6}; the sentence rendered directly beneath it still read "4 characters" and was never touched. Compounding it, the deployed bundle at that moment still carried the old {4} regex because the site build had not finished — so the failure had two independent causes stacked, and either alone would have produced the same screen.

Why it escaped — every automated gate passed, and none of them could have failed. Type-checking cannot see that a regex and an adjacent English sentence disagree; the unit tests cover reducers, not copy; and the local preview was never exercised against a freshly minted code, only against a hand-typed one. The one check capable of catching it was hosting a real room on the real site and following the redirect — which is what did catch it, and only because it was run.

The rule now — a constant that appears in prose is still a constant. The pattern is now built from ROOM_CODE_LENGTH and the sentence interpolates the same value, so the regex, the input's maxLength and the copy cannot drift apart again. Generally: when changing a rule, grep for the rule stated in words, not just in code — user-facing text is the part of the system with no compiler.

Also learned, and written into convex/lib/codes.ts — a change to the code length breaks joining in both deploy orders, because clients check an exact length: server-first and the old client rejects the new codes, client-first and it rejects codes already issued. With real users the client must accept both lengths across the change. Pre-launch the window is free, which is the only reason it was acceptable here.

2026-08-02 Deleting a rewrite rule "closed" six dev screens that stayed wide open, because a real file sat behind the route

Symptom — the dev-only galleries (/fx, /catalog, /gate, /caller-preview, /lan-spike) and expo-router's generated /_sitemap — a directory listing of every screen in the app — were being served on the public domain. The fix removed their rewrite rules from _redirects, the merged output was rebuilt, and the rule list was confirmed to contain only the six real app routes. Deployed. Every one of them still returned 200.

Root cause — the rewrite rules were never what made those URLs work. The Expo static export emits a genuine fx.html, catalog.html, … at the publish root, and Netlify's pretty-URL handling serves fx.html for /fx with no rule involved at all. The rules were redundant; deleting them changed nothing. The pages had to be deleted from the published directory, which is now done in scripts/merge-site.mjs (and the build fails if one survives).

Why it escaped — the verification tested the artifact I had just edited instead of the behaviour I claimed to have changed. "Grep the built _redirects and confirm only six rules remain" passes whether or not the URLs are reachable — it cannot fail for the reason that mattered, so it proved nothing. It also felt rigorous, which is what made it dangerous: there was a build, an inspection of real output, and a green result. The single curl that would have caught it took one second and was only run afterwards, out of routine, on the live site.

The rule now — for anything whose value is "this URL is not reachable", the only acceptable proof is requesting the URL. Not the config, not the rule list, not the file listing. More generally: when a fix removes a rule, first ask what else could be satisfying this request? — on a static host, a file with a matching name usually is. Absence of a rule is not absence of a route.

2026-08-02 A contract every game must implement was enforced only in the client, so one older mutation walked straight past it

Symptom — found by audit, not by a bug report, which is itself the point. rooms.leave deleted a player row unconditionally. If that player was seated in a running match, the stored game state kept pointing at them: the turn cursor names someone who no longer exists, so nobody can move; the departed player can't rejoin (a started room refuses new seats); and rematch refuses too ("a seated player left"). The match is dead with no way back except the host ending it.

Root cause — GameRules.onPlayerRemoved is a required contract method precisely so every game declares what losing a seat means, and engine.removePlayer is the path that honours it — rebasing the turn, or throwing for a duel that can't survive the loss. Its own doc-comment even boasts that "removal is never a reduce payload a client could fake". But rooms.leave predated that design and was never revisited: it takes {roomId, sessionId}, checks nothing about game state, and deletes. The rule that seated players must use the other path lived only in a client-side branch (room/[code].tsx chose removePlayer for seated players and leave for spectators).

Why it escaped — three reinforcing reasons. (1) The client was the only enforcement, and the client always behaved. Every test and every manual play-through drove the UI, which always picked correctly — so the wrong path was never exercised. A rule enforced by the caller is untested by definition when the caller is correct. (2) The reducer tests are excellent at what they cover — they test onPlayerRemoved thoroughly through removePlayer — but no test asked "can this contract be reached around?". Coverage of a path says nothing about whether the path can be bypassed. (3) It reads as safe: a player removing their own seat is obviously authorised, so the missing check looks like an authorisation question (it isn't) rather than a state-machine question (it is).

The rule now — if a server contract is mandatory, the server must be the thing that enforces it. When two mutations can reach the same table and only one honours a rule, the other one is the bug — treat "the client always calls the right one" as a statement about today's client, not about the system. Concretely: rooms.leave now refuses seated players in a live match. The tell to watch for is a doc-comment asserting an invariant ("removal is never faked") that is enforced somewhere else entirely — go and check the other doors into the same room.

The miss worth recording — the first framing was "a malicious guest can brick a room". True, but it undersold it and would have made this a low-priority hardening ticket. The sharper version, found by challenging that framing: no attacker is required. A guest tapping Leave in the lobby at the instant the host taps Start lands the delete a beat after the match begins. This was a latent crash for ordinary, well-behaved players.

2026-07-20 A drag-to-select grid claimed the wrong cells because the container was wider than its cells

Symptom — Word Search's new drag selection: dragging across a word that was plainly on the board (DEER on row 3) was rejected server-side with INVALID_ACTION "that is not a hidden word". The drag mapped one row too high (row 2 → CXJI), a nonsense line.

Root cause — the Board mapped a pointer to a cell with stride = (gridWidth + gap) / cols, measuring gridWidth from the grid container's onLayout and assuming the cells tiled it edge-to-edge. They didn't: the outer column's max-w-[460px] (a Tailwind arbitrary-value class) silently did not apply on RN-web, so the container measured 520px while the flex-1 aspect-square cells only spanned 460px, centred with ~30px of slack each side. The stride came out 65px vs the true 58px pitch, so the error compounded down the grid and threw the row off by one. Fix: cap the width with a numeric style={{ maxWidth: 460 }} (styles always apply, unlike a flaky arbitrary class) and size cells explicitly from the measured width so they tile it exactly — the same geometry the gesture maps against.

Why it escaped — nothing off-device could see it. typecheck, vitest (pure reducer), and scan:worklets never render layout; the reducer and its selectors were correct in isolation and their tests passed. The defect lived entirely in the pointer→cell geometry on the real renderer, and only surfaced by driving an actual drag in a live online match. The rule now: a gesture that maps screen coordinates to a model index must derive BOTH the origin and the pitch from one measured box whose children provably fill it — never assume a className-capped width took effect — and it has to be exercised by a real drag, not a unit test.

2026-07-18 A local TestFlight archive silently kept the OLD build number

Symptom — Bumped apps/app/app.json → expo.ios.buildNumber to 10 per the release doc, archived (~10 min), and the archive's CFBundleVersion was still 9 — the number already on TestFlight, which Apple would have rejected on upload. Caught by a PlistBuddy check on the archive before uploading, costing a re-archive.

Root cause — Path A (local build) runs no expo prebuild, and this repo's ios/ is generated/gitignored. app.json.ios.buildNumber only reaches the native project at prebuild time; without one, ios/GamesforGroup/Info.plist keeps its last-prebuilt literal (9). So editing app.json alone changes the source of truth for the NEXT prebuild/EAS build, not the archive you're about to make. (app.json and the Info.plist are two counters that drift, the local twin of the app.json-vs-EAS-remote-counter drift already noted in decisions.html.)

Why it escaped — nothing verifies "archived build number > last TestFlight build" until Apple rejects at upload, and the release doc said to edit only app.json. The rule now (releasing.html Step 1): for a LOCAL build, set the number in BOTH app.json (tracked) and ios/GamesforGroup/Info.plist (what the archive reads), and verify CFBundleVersion on the built .app before exporting.

2026-07-18 A bottom sheet opened once, then refused to reopen

Symptom — Tap the Tambola caller → history sheet opens. Close it, tap again → nothing. Same with the host's Players sheet, and (it turned out) any sheet closed by the user via backdrop-tap or swipe-down.

Wrong first hypothesis — I saw it first on the caller's sheet, which is a gorhom BottomSheetModal mounted inside the floating-overlay layer, and blamed the overlay: "a gorhom sheet inside a portal-ish, frequently-republished node must be re-using gesture-handler and wedging." So I rewrote the caller history as a plain in-overlay panel — which did fix the caller, but treated a symptom. The real tell came a round later: the Players sheet, an ordinary Modal nowhere near the overlay, had the exact same bug. (Once the real cause was fixed, the caller's bespoke panel was reverted back to the shared ~/ui/Modal — a one-off sheet sitting next to the standard one is exactly the bad pattern the next game copies.)

Root cause — our Modal drives gorhom off a declarative open prop: open → present(), !open → dismiss(). When the user swipes/backdrops the sheet closed, gorhom fires onDismiss → our onClose → parent sets open=false → the effect runs dismiss() again on an already-dismissed sheet. That second dismiss wedges gorhom's status machine at DISMISSING; the next present() mounts the sheet and the stale dismissal instantly unmounts it. Fix: guard present/dismiss on a "presented" ref (only dismiss what we actually presented), and clear that ref inside onDismiss so the open→false effect is a no-op after a user close. One fix, every sheet reopens.

Why it escaped — nothing exercises a sheet through open → user-close → reopen. Our tests are pure-reducer (no gorhom, no gestures); the browser preview drives sheets programmatically (the × button path, which happened to dodge the double-dismiss); and it only bites after a user-initiated close. The rule now: a declarative wrapper over an imperative present/dismiss API must guard BOTH directions on real presented-state and treat the library's own dismiss callback as the source of truth — never re-issue a dismiss the library already did. And the meta-rule this reinforces: when a bug looks specific to a fancy context (here, the overlay), reproduce it in the plainest context before "fixing" it — the second sighting is what found the real cause.

2026-07-18 A finished Ludo player stayed in the rotation and stranded the table

Symptom — (Caught in PR review, not by our tests.) In a 3–4-player Ludo game, the moment a player brought their fourth token home the whole table jammed: that player kept getting turns with no token left to move, rolling no-move pass after no-move pass forever. doMove even handed them an extra roll, because bringing a token home grants one — and their last token had just come home.

Root cause — Two coupled misses in doMove. (1) The "reached home → roll again" bonus fired even when it was the seat's last token, so a player with nothing to move kept the turn. (2) A finished seat was never removed from turn.order, so even without the bonus, advanceTurn would cycle back onto it every lap. Fix: a justFinished seat leaves the rotation via withoutPlayer (the turn passes to the next active player) and is granted no extra roll; its seat + finish position live on in seats/finished for the final ranking.

Why it escaped — every existing Ludo finish test used 2 players, where finishing one seat immediately satisfies decided() and ends the game — so the "a seat finishes while others keep playing" branch had literally never executed. The bug lived entirely in a code path our table only reached at 3+ seats. The rule now: when a rule's behavior forks on player count (finish, capture, walkover, turn-skip), a reducer test must exercise it at the count where the fork is live, not just the minimum-seat case that short-circuits it.

2026-07-18 Every gate was green and the board was unshippable

Symptom — Snakes & Ladders passed 395 tests, typecheck, the worklet scan, and cross-device E2E — and Karim's one-line review was "looking absolute trash, I can't ship this." The snakes were single strokes; the ladders were lines. Functionally perfect, visually a placeholder.

Root cause — Visual quality had no check at all. Every gate we run asserts behavior; none of them can fail on ugly. The screenshot step in validation proved "it renders", which is a different claim from "it looks like a product."

Why it escaped — the classic shape from this file's header: a check that looked green was structurally incapable of failing. There is no automatable assert for taste. The rule now: every game board gets a deliberate screenshot-critique loop (frontend-design skill, iterate until it would stand in an App Store screenshot), and player-facing art is crafted vector geometry (see boardArt.ts — pure, tested, deterministic), never placeholder strokes. "Tests green" ends a milestone; it does not end the work.

2026-07-18 The winner screen unmounted the board mid-animation

Symptom — When a Snakes & Ladders player landed on 100, the winner screen appeared instantly — the winning token never visibly finished its walk. The final, most satisfying moment of the game was being cut off.

Root cause — All four play-shell swap sites (local, online room, LAN host, LAN guest) switch to the winner screen the moment result() goes non-null / the room flips to ended. That unmounts the Board — and the token's in-flight animation with it.

Why it escaped — reaching a natural finish takes a ~20-roll game, so no quick smoke ever watched one end; unit tests assert terminal state, not presentation timing; and the E2E runs checked that the winner screen appears, which it did — too early. A human playing to the end caught it in one game. The rule now: terminal transitions are part of a game's UX contract — GameModule.endedRevealDelayMs lets a game keep its Board mounted after a live finish, and watching a full game end is part of visual QA, not optional.


Symptom — After adding Connect Four / Snakes & Ladders / Ludo (each a folder + the two registration lines in registry.ts and convex/games.ts), the new games showed up instantly in same-device and online play — but a LAN guest (the browser served by the host phone) would never see them.

Root cause — The LAN guest is NOT the Metro dev app. The host phone serves a pre-built static export (bun run export:lan-guest → modules/expo-lan-server/guest-dist) that is embedded in the native app at build time. So a new game reaches a LAN guest only after: (1) bun run export:lan-guest rebuilds the guest bundle, AND (2) the native app is rebuilt to embed the new guest-dist. The registry edits alone are invisible to the guest.

Why it escaped — adding-a-game.html says "one folder + two lines, nothing else," which is exactly right for the two modes that render from the live registry. LAN is the quiet exception: it renders from a frozen artifact. Nothing in the two-line workflow hints that a third, out-of-band rebuild gates the LAN guest — you only find out when a guest's game list is stale. The rule now: adding a game that must play on LAN also requires rebuilding the guest export + the native app; treat the guest bundle as a build artifact, not source.

2026-07-18 Metro won't discover a brand-new game directory without a restart

Symptom — After creating src/games/snakes-and-ladders/ and wiring it into registry.ts, the game did not appear in the running app — not after Fast Refresh, not after relaunching the app process, not after a full terminate + launch. Only killing Metro and restarting it (expo start --clear) surfaced it.

Root cause — Metro's file watcher had already crawled the module graph when it started; a newly-created directory that an existing file (registry.ts) now imports isn't reliably picked up by the watcher mid-session, so Metro kept serving a cached registry.ts transform that predated the new import. Relaunching the app re-requests the bundle, but from the same stale Metro — same bytes.

Why it escaped — Connect Four "just worked" because it existed before the first expo run:ios, so it was in the initial crawl. The failure only appears for a folder created after Metro is already running — exactly the mid-session case you hit while iterating. The rule now: after adding a new game directory, restart Metro (--clear) before expecting it in the app; don't trust HMR or a relaunch to find a new folder.

2026-07-18 The keyboard covered a settings input inside a bottom sheet

Symptom — Tambola's new per-prize point inputs sit in the host's "Start game" config, which is a bottom sheet. Tapping a lower field (Bottom Row / Full House) brought up the number pad, which sat on top of the field — you couldn't see what you were typing.

Root cause — The sheet is @gorhom/bottom-sheet, whose keyboard avoidance (keyboardBehavior="interactive") only tracks inputs rendered with its own BottomSheetTextInput (exported here as SheetTextInput). The generic NumberInputField used a plain React Native TextInput, which the sheet doesn't know about — so it never lifted the focused field above the keyboard. Fix: let SettingsFields take an InputComponent, and the sheet-based callers (online lobby + LAN host) pass SheetTextInput.

Why it escaped — There had never been a typed settings field before; every prior field was a stepper or a segmented select (no keyboard). numberInput was the first TextInput to render inside these sheets, so nothing had exercised the sheet-vs-keyboard interaction. And the web export can't catch it either: there the "sheet" is just a modal with no gorhom keyboard-avoidance behavior at all, so the browser's own layout hides the bug.

Rule now — Any TextInput rendered inside a ui/Modal bottom sheet must be SheetTextInput, not a bare TextInput, or the keyboard covers it. If you add a new typed field kind to SettingsFields, thread the caller-provided InputComponent through to it.

2026-07-18 Every game sound was silent on native, and the preview "proved" it worked

Symptom — The app has a full SFX palette (join, win, daub, claim, a winner fanfare) wired up and firing. On a real iPhone/Android: total silence. No error, no warning — the calls to play(...) ran fine and did nothing.

Root cause — ArcadeAudio synthesizes every sound from Web Audio oscillators, and Web Audio only exists in a browser. getAudioContextCtor() looked up window.AudioContext, which is undefined on the native (Hermes) runtime, so arm() no-op'd, armed stayed false, and play() returned silently. There were no bundled audio files as a fallback. The engine had always been browser-only; the code comments even documented it as a "known gap" — but it read as a working feature.

Why it escaped — We only ever heard the sounds in the web dev preview and the web export, where Web Audio is real and everything genuinely plays. The web preview is structurally incapable of catching a "no Web Audio on native" bug — it has Web Audio. Same shape as every entry here: a green check that couldn't fail. Unit tests didn't catch it either — they run in node with no window, so "stays silent" is the asserted-correct behaviour there. Nothing in the test or preview surface exercises the actual native audio path.

Rule now — Audio is native-only proof. A sound "working" in the browser proves nothing about the phone — it must be heard on a device/simulator (and even there a human confirms audibility; logs can only prove the engine armed). More generally: any capability whose only backend on one platform is a Web API (window.*) is silently absent on native until proven otherwise. The fix was to give the same synth a native Web Audio backend (react-native-audio-api) — see decisions.html.

2026-07-17 Google sign-in silently dropped the login on the play domain

Symptom — Sign in with Google on play.gamesforgroup.com, land back on the marketing homepage, apparently not signed in. Nothing errored.

Root cause — The play app deliberately forwards its ROOT to the marketing site (it only owns /host, /room/…, /local — src/app/index.tsx). But makeRedirectUri() resolves to the origin with no path on web (expo-linking's web createURL builds new URL('', origin)), so OAuth returned to exactly that root. The forward fired and took the ?code= with it.

Why it escaped — This bug cannot exist in dev. Locally the root IS the app's own home, so the forward never runs. Only the production domain has the marketing site to forward to. Every local test passed, correctly, forever.

Rule now — Web OAuth names /host explicitly; the root refuses to forward while a sign-in is in flight. Check Google sign-in on every play deploy — it's in releasing.html.

Also worth knowing — The first hypothesis (stale SITE_URL on prod Convex) was wrong, and changing it didn't fix anything. What settled it was document.referrer on the landing page: it read play.gamesforgroup.com, which proved the backend had redirected correctly and the app itself had left. Ask the page who sent it rather than reasoning about who should have.

2026-07-17 Animations crashed on device while the browser stayed green

Symptom — /fx crashed instantly on the simulator with "[Worklets] Tried to synchronously call a Remote Function". Web was flawless. Two rounds of "fixed" were still broken on the phone.

Root cause — Animations run on a separate UI thread; code there can only call other UI-thread code. Two distinct traps, and the second is nasty:

  1. A helper missing its 'worklet' marker (CardStack3D's range()).
  2. An inline callback inside a worklet — .sort((a, b) => a - b). The marker was present. React Compiler hoists closure-free arrows to module scope as _temp, which strips their worklet-ness. Nothing in the source looks wrong.

Why it escaped — Web runs worklets on the JS thread, so calling ordinary code from them is legal there. A green browser pass is not weak evidence of worklet correctness — it is zero evidence.

Rule now — Verify animation work on a simulator, never only the browser. bun run scan:worklets catches trap 1 statically without a device. Trap 2 is only visible post-compile — never write inline sort/map/filter callbacks inside a worklet; build the value on the JS thread instead. Hoisting the work out usually beats sprinkling 'worklet': if it doesn't depend on a shared value it shouldn't run every frame anyway.

Diagnosis note — Guessing failed twice. What worked: compiling the file and reading the output, which showed const {positions, _temp} = this.__closure.

2026-07-17 Ease-out was silently linear on the web for weeks

Symptom — None. Animations just felt slightly off on web, and 6 console warnings nobody read.

Root cause — Reanimated 4 drives web through CSS, which can only express easings CSS understands. Easing.out(Easing.cubic) is a composed JS function, so it was silently downgraded to linear. It warned; the warning was noise-blind.

Rule now — Spell curves as Easing.bezier(…), which maps to cubic-bezier(). Cubic ease-out is Easing.bezier(0.33, 1, 0.68, 1). See src/fx/motion.ts.

Lesson — A warning nobody reads is a bug nobody has found yet. When a platform says "not supported, using X instead", it is describing a behaviour change, not being chatty.

2026-07-17 Sidebar jump did nothing on phones

Symptom — Tapping a section in the gallery closed the sheet and didn't scroll.

Root cause — measureLayout(getInnerViewNode()) is a no-op under the New Architecture — it warns and returns. Web was unaffected: it uses a DOM lookup.

Rule now — Measure with useAnimatedRef + measure() on the UI thread. Same shape as the worklet lesson: the web path and the native path were different code, so testing one proved nothing about the other.

2026-07-17 A list rendered completely blank on web

Symptom — SortableList showed its title and caption with empty space between.

Root cause — Every row is absolutely positioned, so it contributes zero width. Under an items-center parent the ScrollView shrank to nothing. The rows were rendering perfectly — at 0px wide.

Rule now — A component whose children are all absolutely positioned must set its own width; it cannot inherit one from a shrink-to-fit parent.

Debugging note — Measurements taken during a hot-reload reflow read 0 for everything and sent me chasing a phantom. Re-measure on a settled page before believing a number.

2026-07-16 The whole app shipped blank

Symptom — A completely blank app.

Root cause — className is a silent no-op on third-party components (react-native-svg, Skia, expo-*). AppShell's ambient <Svg className="absolute inset-0"> therefore fell into normal flex flow at 100% height and pushed every screen off-view.

Rule now — On any third-party component use inline style or withUniwind. Never assume className applied just because the code reads like it did.

Deploy-time traps (the expensive kind)

Games for Group · Learnings — the scar tissue, so we stop re-learning the same lesson. Companions: decisions · principles · architecture.