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

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.