Skip to content

Modernise toolchain (Vite + React 19 + Tailwind/shadcn), extract tested game logic, fix gameplay bugs - #32

Open
peterclark wants to merge 5 commits into
masterfrom
claude/codebase-review-modernize-ohzdp5
Open

Modernise toolchain (Vite + React 19 + Tailwind/shadcn), extract tested game logic, fix gameplay bugs#32
peterclark wants to merge 5 commits into
masterfrom
claude/codebase-review-modernize-ohzdp5

Conversation

@peterclark

Copy link
Copy Markdown
Owner

Why

The project does not build any more. react-scripts@3.4.1 and node-sass@4.14 cannot install on Node 22, so npm install fails before you get as far as a dev server. GitHub also reports 193 vulnerabilities on master (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 audit on the new tree: 0 vulnerabilities. This also makes all 26 open Dependabot PRs obsolete — they all target dependencies that no longer exist here.

Toolchain

Before After
Create React App / react-scripts 3.4.1 Vite 8
React 16.13 (ReactDOM.render) React 19.2 (createRoot)
JavaScript TypeScript 5.9, strict
Bootstrap 4 + react-bootstrap + node-sass Tailwind CSS v4 + shadcn/ui
Font Awesome kit <script> lucide-react
lodash native JS (removed)
yarn.lock package-lock.json
Firebase 7 (namespaced) Firebase 12 (modular)

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 test failed on a clean checkout).

Deleted dead code: the unused Deck component, serviceWorker.js, logo.svg, and stale barrel files.

Bugs fixed

Correctness:

  • Firestore listeners were never unsubscribed. 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.
  • Unseated players were dealt into the game. Anyone who joined the lobby without taking a seat got a hand and could be handed the first turn, stalling the game on someone not at the table.
  • Seat 5 was unreachable as "seated." inRange(position, 5) is exclusive, so the player in the last seat counted as unseated and could claim a second seat.
  • Moves rewound the deck. Each client read the draw pile from its own snapshot, shift()ed it, and wrote the whole array back — simultaneous plays duplicated or lost cards. Moves now run inside runTransaction.
  • A one-eyed jack could remove your own team's chip. The rules say it removes an opponent's.
  • Turn hand-off could deadlock. It wrote isActive: false for the current player and true for the next; when they were the same player, the false won and nobody had a turn.
  • Wrong hand sizes. Always dealt 6 regardless of table size; now 7 for two players, 6 for 3–4, 5 for 5–6.
  • Seat claiming is transactional, so two people tapping the same seat cannot both get it.

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.
  • Board spaces were <a href="#5♥"> with no preventDefault, 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.
  • useMemo dependency arrays contained the whole props object, defeating the memoisation they existed for.
  • href on a <span> (invalid DOM attribute).

Input and URL handling:

  • A three-space name passed validation. size(userName) > 2 counted raw characters, so " " cleared the check and stored an empty player name. The input is now controlled and validated through normalizeName, and Enter submits.
  • useGameId rebuilt the URL as protocol//host/pathname?gameId=..., discarding every other query param and the fragment. It also used pushState per change and ignored popstate, so the back button desynced the UI from the URL.

Behaviour:

  • An in-flight guard stops a second move landing while the first transaction is still open.
  • Playable spaces are now genuinely highlighted and unplayable ones dimmed. The old rule was dead CSS — opacity: 1 !important with nothing ever setting a lower opacity.
  • Writes that fail now surface a message instead of console.log(error).

Layout

Two problems found by testing the rendered page rather than the code:

  • Tailwind's table utility (display: table) collided with the .table class the vendored card stylesheet keys off, which wrapped every board row into two or three lines on narrow screens.
  • Hand lists had no intrinsic width (their cards are absolutely positioned), so a fanned hand spilled sideways over the neighbouring player's seat.

The board now scales fluidly from one font-size rather 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.rules is committed for the first time — the database was previously wide open to any caller. The rules require authentication, make createdBy/createdAt immutable, restrict deletion to the host, and deny everything outside games/.

Two limitations remain and are documented in the README rather than papered over:

  • Hands are readable by anyone in the game. The whole document, including every player's cards, is streamed to every client; the UI just declines to render other people's hands face up. Devtools defeats that. Fixing it properly means per-player subcollections.
  • Gameplay is not enforced server-side. Moves are validated client-side and re-checked in transactions, which stops races and honest mistakes, but a player already in a game can still write an arbitrary board directly. Real enforcement needs Cloud Functions.

Breaking changes

  • Teams are renamed from the Bootstrap contextual classes success/primary/danger to green/blue/red. Any game in flight at deploy time will not carry over.
  • Environment variables move from REACT_APP_FIREBASE_* to VITE_FIREBASE_*. See the new .env.example; the app renders setup instructions instead of crashing when they are missing.
  • Dev server is now on port 5173, and npm run dev replaces yarn 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) and npm run build are 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

claude added 2 commits August 8, 2026 23:32
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

Copy link
Copy Markdown
Owner Author

Netlify deploy failure — fixed, with one manual step left for you

The Deploy Preview failed on the first commit. Cause: the Netlify project's build settings are still the Create React App ones and publish build/, but Vite writes to dist/, so the deploy had no output directory to upload.

Fixed in 8c9cc24 by committing a netlify.toml (publish = "dist", command = "npm run build", NODE_VERSION = "22" — Vite 8 needs ^20.19 || >=22.12). netlify.toml takes precedence over the UI settings, so no dashboard change is needed for the build itself.

Verified from a clean clone: npm ci && npm run build produces dist/index.html and never creates build/.

Needs you: rename the environment variables in Netlify

Vite only exposes variables prefixed VITE_, so the three Firebase variables need renaming in Site configuration → Environment variables:

Old New
REACT_APP_FIREBASE_API_KEY VITE_FIREBASE_API_KEY
REACT_APP_FIREBASE_AUTH_DOMAIN VITE_FIREBASE_AUTH_DOMAIN
REACT_APP_FIREBASE_PROJECT_ID VITE_FIREBASE_PROJECT_ID

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

Copy link
Copy Markdown
Owner Author

Closing the loop on the deploy: netlify.toml fixed it. The Deploy Preview is green on 8c9cc24https://deploy-preview-32--sequential.netlify.app

I can't load that URL from my sandbox (the network policy here blocks netlify.app), so I've confirmed the build and deploy succeeded but not what the page renders. If the VITE_FIREBASE_* environment variables aren't renamed yet, expect the "Sequence needs configuring" screen rather than the game — the table in my previous comment has the three renames.


Generated by Claude Code

claude added 3 commits August 9, 2026 03:19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants