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.htmlrecords choices we made and why. Thedocs/architecture/*.htmlpages 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 wrong | Why our checks passed it | The 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.
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.
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.
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.
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.)
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.
__d broke the board it was testingSymptom: 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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?
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.
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.
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.
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.
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?
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.
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.
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.
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.
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.
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.
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.
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).
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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").
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.
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.
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.
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.
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.
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.
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.
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.
setup never left the server,
so online alone kept reading the generic lineSymptom: 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
null where Google sends a pictureSymptom. 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
'worklet' marker (CardStack3D's range())..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.
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.
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.
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.
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.
netlify.toml is committed: the Netlify UI settings still date from the 2025
site and would 404 everything.bunx convex deploy. Push client code that calls a new function without it and the live
site calls something that doesn't exist. Convex bundles each game's pure game.ts — not
Board.tsx, not index.ts — so a UI-only change needs no backend deploy
(verify with git diff --name-only <last> -- 'apps/app/src/games/*/game.ts').export:lan-guest
on a local app build and browser guests get a blank page — the app itself looks fine, so nothing
tells you. Android release builds now fail loudly; iOS warns.expo prebuild --clean deletes two hand-patched ios/Podfile
lines (modular_headers for CocoaAsyncSocket and HTTPParserC). Pod install fails
afterwards until they're re-added.appVersionSource: remote),
separate from app.json. Two counters; they drift the moment you switch between local and
cloud builds.