Architecture
Architecture
Tabletop is layered so each ring depends only on the ones beneath it. The engine core never imports React or a backend.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β pages/ Gallery Β· Setup Β· Play Β· Online Β· Room β React routes
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β ui/ themes Β· assets (SVG) Β· components Β· hooks Β· β React layer
β GameSurface (shared chrome) β
β games/views/ one React view per game (registry) β
βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ€
β net/ SyncAdapter β storage/ save/load β adapters
β ββ LocalSyncAdapter (tabs) β (localStorage) β
β ββ FirebaseSyncAdapter (RTDB)β β
βββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββ€
β games/ GameDefinition + reducer per game (registry) β game defs
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β engine/ types Β· rng Β· match (applyAction) Β· client Β· β ENGINE CORE
β serialize Β· registry Β· cards Β· board Β· dice Β· β (pure TS, no
β rules β React/Firebase)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Module boundaries
| Layer | Imports | Never imports |
|---|---|---|
engine/ |
nothing app-specific | React, Firebase, net/, ui/ |
games/*.ts (defs) |
engine/ |
React |
games/views/* |
engine/, ui/ |
net/ |
net/ |
engine/ |
React, ui/ |
storage/ |
engine/ |
React |
ui/, pages/ |
everything below | β |
This is the same βindependent module, duplicate intentionallyβ discipline the rest of the repo follows. The engine could be extracted to its own package without code changes.
The two registries
- Engine registry (
engine/registry.ts) β mapsgameId β GameDefinition. Framework-agnostic. Populated by each game fileβsregisterGame(def). - View registry (
games/views/registry.tsx) β mapsgameId β React view. Kept separate precisely so the engine stays React-free.
GameSurface ties them together at render time: it looks up both by
state.gameId, computes the viewer perspective, and renders the view inside
shared chrome (turn banner, scoreboard, result, error, event log, toolbar).
Why a separate flow-event channel?
A gameβs reducer(game, action, ctx) returns only the next game state. To
end a turn, change phase or end the game it calls ctx.events.endTurn() /
setPhase() / endGame(). The engine collects those requests and applies them
to the engine-managed TurnState/status after the reducer returns. This
keeps generic flow logic out of every game and game-specific logic out of the
engine. See Engine Concepts.