- Shipped
- September 8, 2026 at 5:26 PM UTC
- Author
- Kamo
- Commit
- 7ce21b0
The felt re-rendered a STATE. It never presented an EVENT. Of twenty-six things that happen in a hand of poker, exactly two had any presentation at all — a card mounting, and the lamp brightening on an all-in — and a check, a call, a bet, a raise, a re-raise, a fold, a street change, a split pot, a player joining or leaving existed only as a value quietly changing, or as a line in a history panel that is maximised-only and behind a toggle. A member who looked away for two seconds could not find out what they had missed. THE ARCHITECTURE. holdemEvents.ts turns two consecutive authoritative frames into the list of things that happened between them. Pure — two frames in, a list out, no React and no timers — and idempotent by IDENTITY rather than by bookkeeping: every event carries an id derived from the state that produced it (`{handId}:a{seq}`, `{handId}:flop`), so a re-render produces nothing, a repeated frame produces ids already seen, and a window that missed three frames produces the three missing events in order. Actions are read out of hand.actions, which is the whole list rather than the last thing that happened, so catching up is reading forward. The first frame a window ever sees produces NOTHING: a window opened halfway through a hand has not just watched four people act. useHoldemChoreography.ts turns those into decorations, and the rule it is built around is that AN ANIMATION IS NEVER ALLOWED TO DECIDE ANYTHING. Every value decorates a felt already rendering whatever the last frame said. A late packet, an interrupted animation, a minimised window, a throttled tab, a reconnect — the felt is still correct, only less decorated. Decorations carry the moment they stop being true and ONE timer is armed for the earliest of them, so ten labels cost one timer, an unmounted window costs none, and anything whose moment passed while the tab was frozen is simply gone rather than running late. WHAT IS PRESENTED. A compact broadcast-style label beside the seat that acted — CHECK, CALL 600, RAISE 900, RE-RAISE 2,700, FOLD, ALL IN 12,300 — in five treatments across three tiers, so a table is not equally loud about a check and a shove. The street's name sweeping across the felt as the cards arrive. Board cards that TRAVEL, from the deck beside the dealer, per street (the flop's three stagger; the turn and the river no longer wait 165ms and 220ms alone for nothing). A turn handoff that runs from the seat that was on the clock to the seat that is on it now, so the eye is led round the table. A dealer button that WALKS. Four levels of clock pressure instead of two. A showdown that turns hands over one at a time, in dealer order from the button, then names the winner and the hand — translated, and with the five cards that actually won it lit. Per-pot resolution, because two winners of two different pots is not a split and saying so implies one of them beat the other. A fold that mucks rather than dimming. An elimination that reads differently from a sit-out. A local echo when YOU click, replaced by the server's own frame and expiring on its own clock if that frame never comes. AND SOUND, through the app's own catalogue rather than beside it: a `games` category and nine holdem events, configurable at the platform, organization and member layers like everything else that makes a noise. holdem.card.dealt alone has no throttle — a flop is three cards 95ms apart and any throttle worth the name turns a deal into a click. FOUR DEFECTS FOUND ALONG THE WAY, three of which only a browser could show: A showdown never flipped. Seat.tsx rendered face-down and face-up cards as two BRANCHES, and React swaps a branch by unmounting one and mounting the other — so the 3D flip PlayingCard was built for never ran at the one moment in poker worth animating. Two stable elements whose faceDown changes is the whole fix. A "not before" moment that never fires. The verdict and the per-seat reveals were read as `Date.now() >= at` at render time. The timer armed for them fired, found nothing expired, returned the same state object — and React, correctly, did not re-render. The winner was never named and the cards never turned over. Both are state changes now. Every unit test passed throughout, because a test that asks the hook a question calls it and gets the answer. A callout above the top three seats went off the felt entirely; a browser put one at y = 0. Which side it goes is a fact about where the seat is drawn. ActionBar's "call all in" label sat behind `callAmount >= toCall && callAmount < toCall`, which cannot be true. A player calling for less than the bet — going all in — was told they were calling. The raise control now says what actually LEAVES YOUR STACK as well as the total for the street, because a big blind raising to 600 is putting in 400 and every client that shows only the first number has made somebody shove by accident. Verified in a real browser at 1440/1200/960/880 and in the compact layout, with nine maximum-length labels on screen at once: nothing off any edge, nothing covering a name, no page overflow in either direction. Under prefers-reduced-motion, measured: no animation running at all, every callout still readable at opacity 1 with no transform, the decorative bloom and flare gone. Somebody who asks for less motion is asking to be told without being moved, not to be told less.