Modernise toolchain (Vite + React 19 + Tailwind/shadcn), extract tested game logic, fix gameplay bugs - #32
Conversation
The project no longer built: react-scripts 3.4.1 and node-sass 4.14 cannot install on Node 22. Rather than patch around that, move to a current stack and fix the bugs the rewrite surfaced. Toolchain - Create React App -> Vite 8, TypeScript 5.9 (strict), Vitest - React 16 -> 19 (createRoot), Firebase 7 namespaced -> 12 modular - Bootstrap 4 / react-bootstrap / node-sass -> Tailwind CSS v4 + shadcn/ui - Removed the Font Awesome kit <script> (a render-blocking dependency on one personal FA account that the entire chip and seat UI relied on) for lucide-react - Dropped lodash entirely; flat ESLint config; split the Firebase vendor chunk - yarn.lock -> package-lock.json Structure - src/game/ holds the rules as pure modules (board, deck, cards, moves, players) with no React or Firebase imports, so they are directly unit testable. 82 tests, up from one that asserted a "learn react" link that never existed in this app. - Deleted dead code: Deck component, serviceWorker.js, logo.svg, stale barrels Gameplay and correctness fixes - Firestore listeners were never unsubscribed and the effect re-ran on every game-id change, stacking live listeners and re-authenticating each time - Players who joined but never took a seat were dealt hands and could be handed the first turn, stalling the game on someone not at the table - Seat 5 was excluded by an `inRange(position, 5)` check, so the player in the last seat counted as unseated and could take a second seat - Moves rewound the deck: each client read the pile from its own snapshot, shifted it and wrote the whole array back. Moves now run in transactions. - A one-eyed jack could remove your own team's chip - Turn hand-off wrote isActive false and true for the same player when they were the only one seated, deadlocking the game - Hands were dealt 6 cards regardless of table size; now uses the real per-player-count sizes (7 for 2 players, 6 for 3-4, 5 for 5-6) - Board and hands used lodash uniqueId() as React keys, remounting all 100 spaces on every snapshot; keys are now stable coordinates - Board spaces were <a href="#5♥"> with no preventDefault, so every tap pushed a history entry and jumped scroll on mobile; they are now buttons with labels - A three-space name passed the length check and stored an empty player name - useGameId rebuilt the URL from scratch, discarding other query params and the fragment, used pushState per change, and ignored popstate - An in-flight guard stops a second move landing mid-transaction - Playable spaces are now actually highlighted; the old rule was dead CSS Also: Tailwind's `table` utility collided with the vendored card stylesheet's `.table` class, wrapping every board row on narrow screens. The board now scales fluidly instead of via three fixed breakpoints, and fits down to 320px. Firestore rules are committed for the first time (previously the database was wide open). Hands are still readable by everyone in a game and gameplay is not enforced server-side; both are documented in the README as known limitations. Team names change from Bootstrap contextual classes (success/primary/danger) to colours (green/blue/red), so games in flight at deploy time will not carry over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFabH9jr8HTPLYSj1rQ6hQ
The Netlify project's build settings were configured for Create React App and still publish `build/`. Vite writes to `dist/`, so the deploy failed with the output directory missing. Committing the config keeps it in step with the repo instead of drifting in the UI, and pins Node 22 (Vite 8 requires ^20.19 || >=22.12). Verified with a clean clone: `npm ci && npm run build` produces dist/index.html and never creates build/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFabH9jr8HTPLYSj1rQ6hQ
Netlify deploy failure — fixed, with one manual step left for youThe Deploy Preview failed on the first commit. Cause: the Netlify project's build settings are still the Create React App ones and publish Fixed in 8c9cc24 by committing a Verified from a clean clone: Needs you: rename the environment variables in NetlifyVite only exposes variables prefixed
Values are unchanged. Until that's done the preview will build and deploy fine but render the "Sequence needs configuring" screen rather than the game, because the config check finds nothing — so a green deploy on its own won't confirm the app works end to end. I'll keep watching the PR and report the next deploy result. Generated by Claude Code |
|
Closing the loop on the deploy: I can't load that URL from my sandbox (the network policy here blocks Generated by Claude Code |
Vite statically replaces import.meta.env.VITE_* at build time, so the previous deploy has the pre-rename (undefined) Firebase values baked into its bundle and would still render the "needs configuring" screen. The env vars have now been renamed REACT_APP_FIREBASE_* -> VITE_FIREBASE_* in the Netlify project; this empty commit exists only to force a fresh build that picks them up. No code changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFabH9jr8HTPLYSj1rQ6hQ
Firestore transactions get no latency compensation: unlike a normal write, they are not applied to the local cache, so the snapshot listener stays silent until the server commits. Converting takeSeat/placeChip/removeChip to transactions therefore turned actions that used to feel instant into visible round trips — and nothing in the UI acknowledged the click meanwhile, so a slow seat tap read as a dead button. takeSeat is back to a plain updateDoc, so the seat fills the moment you tap it. The race a transaction prevented — two players tapping one seat in the same instant — is now repaired after the fact instead of taxing every tap: shouldYieldSeat() breaks the tie on lowest user id, which every client computes identically from the same document, so exactly one player stands back up. Moves keep their transaction, because the deck race it fixes is real. Instead the chip is drawn optimistically the instant you click and rolled back if the transaction is rejected. Whether the optimistic chip still applies is derived by comparing it to the board rather than cleared in an effect, so the chip does not blink off in the gap between the snapshot arriving and the promise resolving. Seat buttons now show a spinner on the seat being claimed and disable every seat while a claim is in flight, so an impatient double-tap cannot produce two claims. 7 new tests: the tie-break invariant (exactly one of any two players yields, including for ids where lexicographic order is unintuitive), optimistic place, optimistic remove, and rollback on rejection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFabH9jr8HTPLYSj1rQ6hQ
A game could become permanently unstartable. Only the host could start it, where "host" was createdBy matched against an anonymous Firebase uid — and those uids live in per-origin browser storage, so a game created in a private window lost its host for good the moment that window closed. The state is unrecoverable and undetectable: the host's player record persists in the document either way, so nothing distinguishes an absent host from a present one. Any seated player can now deal, which removes the failure mode rather than trying to detect it. For a party game the host distinction bought nothing. startGame's transaction enforces the same rule, requiring the caller to be seated instead of to be the host. The lobby was also silent, which made a stuck game impossible to diagnose from the UI. With one player seated there was no start button, no explanation, and nothing indicating that the URL is the invite. Worst of all, the most common cause — someone joined but never took a seat, so they do not count toward the two needed — looked identical to a full table. The new Lobby names all of that: how many players are seated out of the two required, who has joined without sitting down, what to do next, and a copy-invite button so sharing the game does not depend on knowing to copy the address bar. 14 new tests, including the orphaned-creator case that used to be unstartable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFabH9jr8HTPLYSj1rQ6hQ
Why
The project does not build any more.
react-scripts@3.4.1andnode-sass@4.14cannot install on Node 22, sonpm installfails before you get as far as a dev server. GitHub also reports 193 vulnerabilities onmaster(17 critical, 90 high).Rather than patch around that, this moves to a current stack and fixes the bugs that reviewing the code surfaced along the way.
npm auditon the new tree: 0 vulnerabilities. This also makes all 26 open Dependabot PRs obsolete — they all target dependencies that no longer exist here.Toolchain
ReactDOM.render)createRoot)<script>The Font Awesome removal is worth calling out: every chip, seat and team indicator rendered through
fas fa-life-ring, loaded from a render-blocking<script>pointing at one personal Font Awesome kit. If that kit ever went away, the entire game UI became invisible.Also: flat ESLint config, Vitest, and the Firebase SDK split into its own chunk so editing a component no longer invalidates the cached vendor bundle.
Structure
src/game/now holds the rules as pure modules —board,cards,deck,moves,players— with no React and no Firebase imports. That is the part worth testing, and keeping it isolated means the tests need neither a DOM nor a network.82 tests, up from one that asserted the presence of a "learn react" link that has never existed in this app (so
yarn testfailed on a clean checkout).Deleted dead code: the unused
Deckcomponent,serviceWorker.js,logo.svg, and stale barrel files.Bugs fixed
Correctness:
onSnapshot's return value was discarded and the effect re-ran on every game-id change, so listeners stacked up and kept firing after unmount. Authentication re-ran on the same effect.inRange(position, 5)is exclusive, so the player in the last seat counted as unseated and could claim a second seat.shift()ed it, and wrote the whole array back — simultaneous plays duplicated or lost cards. Moves now run insiderunTransaction.isActive: falsefor the current player andtruefor the next; when they were the same player, thefalsewon and nobody had a turn.React:
lodash.uniqueId()was used as React keys on the board and in hands, producing new keys every render and remounting all 100 board spaces on every Firestore snapshot. Keys are now stable coordinates.<a href="#5♥">with nopreventDefault, so every card tap pushed a history entry (the back button became useless) and jumped scroll position on mobile. They are now<button>s with accessible labels.useMemodependency arrays contained the wholepropsobject, defeating the memoisation they existed for.hrefon a<span>(invalid DOM attribute).Input and URL handling:
size(userName) > 2counted raw characters, so" "cleared the check and stored an empty player name. The input is now controlled and validated throughnormalizeName, and Enter submits.useGameIdrebuilt the URL asprotocol//host/pathname?gameId=..., discarding every other query param and the fragment. It also usedpushStateper change and ignoredpopstate, so the back button desynced the UI from the URL.Behaviour:
opacity: 1 !importantwith nothing ever setting a lower opacity.console.log(error).Layout
Two problems found by testing the rendered page rather than the code:
tableutility (display: table) collided with the.tableclass the vendored card stylesheet keys off, which wrapped every board row into two or three lines on narrow screens.The board now scales fluidly from one
font-sizerather than three fixed breakpoints, so the full 10×10 grid fits every width down to 320px instead of only the three that were special-cased.Security
firestore.rulesis committed for the first time — the database was previously wide open to any caller. The rules require authentication, makecreatedBy/createdAtimmutable, restrict deletion to the host, and deny everything outsidegames/.Two limitations remain and are documented in the README rather than papered over:
Breaking changes
success/primary/dangertogreen/blue/red. Any game in flight at deploy time will not carry over.REACT_APP_FIREBASE_*toVITE_FIREBASE_*. See the new.env.example; the app renders setup instructions instead of crashing when they are missing.npm run devreplacesyarn start.Out of scope
The unfinished gameplay in the README's TODO list is untouched and still listed there — most notably there is still no win detection, so players have to spot a completed sequence themselves. Also still missing: draw-pile UI, dead-card discard, and a pass button. I kept these out to hold the diff to the migration and bug fixes; happy to take win detection next, as it is self-contained and unit-testable against the new
src/game/modules.Verification
npm run typecheck,npm run lint,npm test(82 passing) andnpm run buildare all clean with no warnings.Beyond that I drove the built app in Chromium, since most of the layout bugs above were invisible in the source. Verified against a seeded game: 96 card spaces render as buttons with the 4 corners correctly non-interactive; face-card artwork resolves; chips render per team; playable/unplayable states match the rules exactly (with a wild jack in hand: 90 open spaces + 4 opponent chips = 94 playable, and the 2 unplayable are the player's own chips); no board-row wrapping or horizontal overflow at 1440/820/390/320px.
Not verified: anything requiring a real Firebase project. The transaction logic and the rules file have not been exercised against live Firestore, so those are worth a look on a real deploy before merging.
Generated by Claude Code