This is a complete refactor/rewrite of a similar website I created. Here's the repo: https://github.com/oyuh/games-arch
Games is a TypeScript monorepo for browser-based party games. The frontend is a React 19 + Vite single-page app, the backend is a Hono-powered Node service, realtime state replication is handled through Rocicorp Zero, admin broadcasts and targeted messages flow through Pusher Channels, presence is tracked via periodic HTTP heartbeats, and persistent state lives in Postgres via Drizzle schema definitions.
This repository contains six implemented game flows:
Multiplayer (Zero-synced):
- Imposter — social deduction
- Password — team word guessing
- Chain Reaction — competitive word-chain puzzle
- Shade Signal — color-guessing party game
- Location Signal — GeoGuessr-style map guessing
Single-player:
- Shikaku — timed grid logic puzzle with leaderboard
↑ A fresh puzzle every time you load the page. Click to play · Full viewer with download
- Project Summary
- Monorepo Layout
- Technology Stack
- How the System Works
- Data Model and Shared Contracts
- Local Development
- Environment Variables
- Build, Typecheck, Test, and Database Tasks
- Deployment
- Operational Notes
- Known Constraints
- Mobile UI
- Admin Dashboard
- Reference Docs
The core idea of the repo is simple:
- The browser loads a React SPA from
apps/web. - The app creates or restores a local session ID in the browser.
- The browser connects to Rocicorp Zero for synchronized query/mutation state.
- Zero forwards query and mutation work to the API service in
apps/api. - The API service executes queries and mutators backed by Postgres.
- In parallel, the browser sends periodic HTTP heartbeats to the API so the server can keep session
lastSeenvalues fresh. - The browser subscribes to Pusher Channels to receive admin broadcasts (toasts, kicks, status updates).
- Game state updates are persisted in Postgres and reflected back into the UI through Zero subscriptions.
This architecture gives the project a fairly clean separation:
apps/webis responsible for rendering, routing, local session persistence, and client-side subscriptions.apps/apiis responsible for HTTP endpoints, Zero query/mutation handling, Pusher event triggers, presence heartbeats, and cleanup jobs.packages/sharedis the contract layer used by both apps: schema, types, queries, and mutators.
.
├─ apps/
│ ├─ api/ # Hono + Node server, Zero query/mutate endpoints, Pusher broadcast
│ └─ web/ # React 19 + Vite frontend
├─ packages/
│ └─ shared/ # Shared schema, types, Zero queries, Zero mutators (modular), Drizzle config
├─ docs/ # Deployment notes and game design docs
├─ docker-compose.yml # Local Postgres + Zero cache container stack
├─ turbo.json # Monorepo task orchestration
├─ pnpm-workspace.yaml # Workspace package discovery
├─ vercel.json # Web deployment config
└─ railway.toml # API deployment config
This is the client application. It is a Vite-built React SPA with React Router. The frontend does not contain a traditional REST data layer; instead it consumes Rocicorp Zero queries and mutators from the shared package, which keeps the client and server contract aligned.
Important responsibilities in the web app:
- Bootstrap React and mount the SPA
- Configure a singleton Zero client
- Generate or restore a persistent browser session ID
- Maintain local display name and recent-game history in
localStorage - Send periodic HTTP presence heartbeats to the API service
- Subscribe to Pusher Channels for admin broadcasts and targeted messages
- Render game routes and game-specific UI
- Expose connection/debug state for Zero, API metadata, and presence connectivity
This is the backend service. It uses Hono on Node, exposes health and debug endpoints, receives Zero query/mutate requests, triggers Pusher events for admin broadcasts, and periodically cleans stale games and sessions.
Important responsibilities in the API app:
- Handle
POST /api/zero/query - Handle
POST /api/zero/mutate - Handle
GET /health - Handle
GET /debug/build-info - Handle
GET|POST /api/cleanupwith bearer auth - Handle
POST /api/pusher/authfor Pusher channel authentication - Handle
POST /api/presence/heartbeatfor session liveness - Handle
POST /api/presence/heartbeatfor session liveness - Handle
GET /api/shikaku/leaderboardfor single-player scores - Handle
POST /api/shikaku/scorefor score submission with server-side validation - Handle
GET /api/maps/configandGET /api/maps/geocodefor Location Signal map tiles - Handle
POST /api/game-secret/init|pre-reveal|keyfor game secret encryption - Trigger Pusher events for admin broadcasts (toasts, kicks, status, name restrictions)
- Run scheduled stale-session / stale-game cleanup
This package is the technical center of the system. It contains the shared contracts used everywhere else.
Important contents:
- Drizzle Postgres schema definitions
- Zero schema and query definitions
- Zero mutator implementations (modular — see structure below)
- Shared TypeScript types for game state
- Drizzle migration configuration
This design keeps the frontend and backend from drifting. The web app imports the same mutators and query definitions that the backend resolves and executes.
The Zero mutators live in packages/shared/src/zero/mutators/ and are split by domain:
packages/shared/src/zero/mutators/
├─ index.ts # Barrel — composes defineMutators, re-exports public symbols
├─ word-banks.ts # Word banks and category data (imposter, chain reaction, password)
├─ helpers.ts # Shared utility functions (now, code, shuffle, pickRandom, etc.)
├─ sessions.ts # Session mutators (upsert, setName, attachGame, touchPresence)
├─ imposter.ts # Imposter game mutators (12 mutators)
├─ password.ts # Password game mutators (15 mutators)
├─ chat.ts # Chat mutators (send, clearForGame)
├─ chain-reaction.ts # Chain Reaction game mutators (12 mutators)
├─ shade-signal.ts # Shade Signal game mutators (15 mutators)
├─ location-signal.ts # Location Signal game mutators
└─ demo.ts # Dev-only demo seeders (4 seeders)
The barrel index.ts imports all domain-specific mutator objects and composes them into a single mutators export via defineMutators(). Consumers still import everything from @games/shared — the split is internal to the shared package.
- TypeScript 5.8 across the entire monorepo
- pnpm workspaces for package management
- Turbo for cross-package task orchestration
- React 19
- React Router 7
- Vite 6
- Tailwind CSS 4 via
@tailwindcss/vite - Flowbite / Flowbite React for some UI building blocks
react-iconsfor iconography
- Rocicorp Zero
0.25.13 - Zero React bindings in the frontend
- Zero server request handlers in the API service
- A separate Zero cache service in deployment and Docker-based development
- Hono 4 for HTTP routing
@hono/node-serverfor Node-based servingpusherfor server-side Pusher Channels event triggersdotenvfor local environment loading
- PostgreSQL 16 in local Docker setup
- Drizzle ORM for typed schema access
- Drizzle Kit for schema generation, push, and studio
pgfor database connections
- Vercel for the frontend SPA
- Railway for the Node API service
- Railway for the Zero cache service
- Railway Postgres or Neon for the database
This section focuses on technical flow rather than UX flow.
When the web app starts:
- React mounts from
apps/web/src/main.tsx App.tsxcreates a module-scoped Zero singleton exactly once per page load- the app resolves a browser session ID from
localStorageor creates one withnanoid - the app reads the user display name from
localStorage - the app upserts the session into the shared
sessionstable through a Zero mutator
Why the Zero instance is module-scoped:
- recreating the Zero client can reset mutation counters and destabilize synchronization
- keeping one client per page load produces more stable realtime behavior
The frontend is a single-page application using browser routing. The major routes are:
/for the home screen and game creation/join flow/imposter/:idfor Imposter games/password/:id/beginfor Password pre-round setup/password/:idfor active Password rounds/password/:id/resultsfor Password results/chain/:idfor Chain Reaction/shade/:idfor Shade Signal/location/:idfor Location Signal/shikakufor the single-player Shikaku puzzle
Because this is an SPA, production hosting requires a rewrite rule that sends unknown paths back to index.html. That is already configured in vercel.json.
The browser stores lightweight identity and convenience state locally:
- session ID
- player name
- recent games
- a visited flag
This is handled client-side so a returning browser tab behaves like the same player identity without requiring account auth.
Server-side, the sessions table stores:
- session ID
- name
- current game type and game ID
- created timestamp
lastSeentimestamp
That means session presence is a hybrid model:
- identity originates in browser local storage
- liveliness is maintained server-side through HTTP heartbeat updates
The app uses Rocicorp Zero as the main synchronization layer.
High-level flow:
- The frontend imports shared query and mutator definitions from
@games/shared. - The frontend issues
useQuery(...)calls andzero.mutate(...)calls. - Zero sends those requests to the Zero cache service.
- The Zero cache service forwards query requests to the API service at
/api/zero/query. - The Zero cache service forwards mutation requests to the API service at
/api/zero/mutate. - The API service resolves query names and mutator names against the shared definitions.
- Drizzle-backed database operations run against Postgres.
- Updated data becomes visible to subscribers through Zero.
This is the main reason the app feels realtime without the frontend manually polling game state.
Presence is not handled by Zero directly in this repo. It is handled with periodic HTTP heartbeats from the client to the API service.
Flow:
- A game page calls the
usePresenceSockethook. - The hook sends a
POST /api/presence/heartbeatrequest with the session ID, game ID, and game type. - Every 60 seconds the client sends another heartbeat request.
- The API service updates the
sessionsrowlastSeenfield and game association.
This lets the system infer connected/disconnected state from lastSeen freshness.
Admin broadcasts (toasts, kicks, custom status messages, restricted names) are delivered through Pusher Channels.
Flow:
- The admin dashboard triggers an action (e.g. broadcast toast, kick user, set custom status).
- The API service calls
pusher.trigger()to send the event on the appropriate channel. - Global events go on the
games-broadcastchannel. - Targeted events (e.g. kicks) go on
private-user-{sessionId}channels. - The client subscribes to both channels via the
useAdminBroadcasthook usingpusher-js. - Pusher channel authentication is handled through
POST /api/pusher/auth, which validates session ownership and checks bans.
The API service has two cleanup paths:
- scheduled cleanup every 15 minutes
- manual cleanup through
/api/cleanupwith a bearer token
Cleanup behavior includes:
- marking stale games as
ended - detaching stale sessions from ended games
- deleting old ended game rows
- deleting very stale session rows
This matters operationally because multiplayer game rows are long-lived enough to support reconnects, but not intended to accumulate forever.
The frontend periodically probes /debug/build-info on the API service. That endpoint exposes:
- deployment platform hint
- commit SHA / branch metadata when available
- build timestamp metadata when available
- service start time
- uptime
- Node version
- environment name
This is used for connection diagnostics and for understanding which backend build a browser is currently talking to.
The main persistent entities are defined in packages/shared/src/drizzle/schema.ts.
Tracks browser-backed users and their current association with a game.
Key fields:
idnamegameTypegameIdcreatedAtlastSeen
Stores the full state machine for an Imposter match.
Notable data stored directly in JSON columns:
- players
- clues
- votes
- kicked players
- round history
- announcement
- settings
This is a denormalized game-state model. It is intentionally optimized more for simple state snapshots and mutation logic than for highly normalized relational analysis.
Stores Password game state, including:
- teams
- rounds
- scores
- active rounds
- kicked players
- settings
Stores Chain Reaction game state, including:
- players
- current chain data
- submitted custom chains
- current turn
- scores
- round history
- kicked players
- settings
Stores Shade Signal game state, including:
- players (with cumulative scores)
- leader rotation order
- grid seed, target coordinates
- clues (two per round)
- guesses per round
- round history with scoring
- kicked players
- settings (hard mode, leader pick, durations, rounds per player)
Stores Location Signal game state, including:
- players (with cumulative penalty scores)
- leader rotation order
- encrypted target coordinates and map scope
- clues (up to 4 per round)
- guess coordinates per player per round
- round history with distance scoring
- kicked players
- settings (rounds, guess timer, clue timer, map scope)
Stores Shikaku leaderboard entries, including:
- session ID and player name
- seed, difficulty, score, time in ms
- puzzle count and replay data
- server-side validated (minimum time, max score cap, duplicate seed protection, ban check)
Stores server-side encryption keys used for game secrets (imposter secret words, shade signal targets, location signal coordinates).
Stores session, IP, and region-based bans managed through the admin dashboard.
Stores blocked name patterns and per-session forced name overrides.
Stores per-game chat history keyed by game type and game ID.
Shared query definitions cover:
- sessions by ID
- sessions by game
- Imposter by ID and join code
- Password by ID and join code
- Chain Reaction by ID and join code
- Shade Signal by ID and join code
- Location Signal by ID and join code
- chat messages by game
Because these definitions live in the shared package, the browser and server reference the same query names and argument contracts.
Shared mutators cover:
- session upsert / naming / attachment / presence touch
- game creation
- join / leave flows
- game-phase transitions
- per-game action flows such as clue submission, voting, guessing, scoring, and progression
The mutators are the real source of game-state transitions. If you are changing behavior, this is usually the first place to inspect.
Mutators are organized into separate files by game domain under packages/shared/src/zero/mutators/. Each game has its own file (e.g. imposter.ts, password.ts, chain-reaction.ts, shade-signal.ts, location-signal.ts), with shared utilities in helpers.ts and word bank data in word-banks.ts. The barrel index.ts composes them all via defineMutators().
Note: Shikaku does not have Zero mutators — it uses REST API endpoints for leaderboard and score submission, with all game logic running client-side.
Install the following before starting:
- Node.js 20+ recommended
- pnpm 10.x or really any node
- Docker Desktop or a compatible Docker runtime
pnpm installThe repo includes docker-compose.yml, which starts:
- Postgres 16 with
wal_level=logical - a Zero cache service
Start it with:
docker compose up -dWhy wal_level=logical matters:
- Zero relies on logical replication semantics
- if this is not enabled on the upstream Postgres instance, Zero will fail or behave incorrectly
At the repository root, create .env with at least:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/games
PORT=3001
API_PORT=3001
CLEANUP_SECRET=cleanup-localThe API service explicitly loads ../../.env, so the root .env file is the expected local configuration source.
pnpm db:pushpnpm devIf you want one command that starts local Docker services, waits for Postgres, pushes the schema, refreshes the Zero replica, and then launches the dev servers:
# Windows
pnpm local:up
# macOS
pnpm local:up:mac
# Linux
pnpm local:up:linuxThese shell scripts use plain Docker commands, so Docker Desktop, OrbStack, Colima, or another Docker daemon must already be running.
To stop the local stack and clear the Zero replica volume:
# Windows
pnpm local:down
# macOS
pnpm local:down:mac
# Linux
pnpm local:down:linuxThis runs the workspace dev scripts in parallel through Turbo.
Expected local endpoints:
- web:
http://localhost:5173 - api:
http://localhost:3001 - zero cache:
http://localhost:4848
The web app defaults to local endpoints if environment variables are not set:
VITE_ZERO_CACHE_URL=http://localhost:4848VITE_API_URL=http://localhost:3001
If you want to be explicit, create apps/web/.env.local:
VITE_ZERO_CACHE_URL=http://localhost:4848
VITE_API_URL=http://localhost:3001For Pusher Channels to work locally, you also need:
VITE_PUSHER_KEY=<your_pusher_key>
VITE_PUSHER_CLUSTER=<your_pusher_cluster>And in the root .env for the API:
PUSHER_APP_ID=<your_pusher_app_id>
PUSHER_KEY=<your_pusher_key>
PUSHER_SECRET=<your_pusher_secret>
PUSHER_CLUSTER=<your_pusher_cluster>
SESSION_COOKIE_SECRET=<long_random_session_secret>The frontend only exposes Vite-prefixed variables to browser code.
- Required for production
- URL of the Zero cache service
- Example local value:
http://localhost:4848 - Example production value:
https://<zero-domain>
- Required for production
- Base URL of the API service (used for Pusher auth, presence heartbeats, admin status)
- Example local value:
http://localhost:3001 - Example production value:
https://<api-domain>
- Required for production
- Pusher Channels app key (from dashboard.pusher.com)
- Required for production
- Pusher Channels cluster (e.g.
us2,eu,ap1)
- Optional
- If set to
true, the app renders the style preview route instead of the full realtime app
- Primary Postgres connection string for the API service
- Fallback DB source used by the API database provider if
DATABASE_URLis absent - also required by the Zero service itself
- API listen port
PORTis preferred by many hosting platforms
- Standard runtime environment hint
- Bearer token required for manual cleanup endpoint access
- Pusher Channels app ID (from dashboard.pusher.com)
- Pusher Channels app key
- Pusher Channels app secret
- Pusher Channels cluster (e.g.
us2,eu,ap1)
- Recommended for production
- Secret used to sign the session cookie and
x-zero-session-proofheader - If omitted, the API falls back to
PUSHER_SECRET - Use a separate long random value if you want the session signing key decoupled from Pusher
- Optional database sentinel key checked by the footer status probe
- Defaults to
footer
- Optional database sentinel value checked by the footer status probe
- Defaults to
ok - If the row exists but the value does not match, the footer shows
Unknown
For deployed Zero cache:
ZERO_UPSTREAM_DBZERO_QUERY_URLZERO_MUTATE_URLZERO_ADMIN_PASSWORD- optionally
ZERO_CVR_DB - optionally
ZERO_CHANGE_DB
Important constraint:
- do not define
ZERO_PORTas the literal string"$PORT"on Railway - Railway does not shell-expand env values in that way
At the repository root:
pnpm dev
pnpm local:up
pnpm local:up:mac
pnpm local:up:linux
pnpm build
pnpm typecheck
pnpm test
pnpm lint
pnpm db:push
pnpm db:generate
pnpm db:studioRuns the dev script in workspace packages in parallel through Turbo.
Runs workspace builds through Turbo.
- web: Vite production build
- api: TypeScript compile to
dist - shared: TypeScript type validation through its build script
Runs package-level TypeScript checks.
Runs package vitest scripts through Turbo.
Currently present at the workspace level, but linting is effectively a placeholder right now. The package scripts currently print No lint configured yet rather than running a real linter.
pnpm db:pushapplies schema changes to the target databasepnpm db:generategenerates Drizzle migration artifactspnpm db:studioopens Drizzle Studio
The footer status now checks the API's /debug/build-info response, and that API route performs a direct Postgres read against a sentinel row in the status table.
The status logic is:
Operationalwhen the database is reachable andstatus.key = DB_STATUS_KEYmatchesDB_STATUS_EXPECTED_VALUEUnknownwhen the database is reachable but the row is missing or the value does not matchOfflinewhen the API cannot reach Postgres at all
Recommended setup:
- Push the latest schema:
pnpm db:push- Insert or upsert the sentinel row:
INSERT INTO status (key, value, updated_at)
VALUES ('footer', 'ok', EXTRACT(EPOCH FROM NOW())::bigint * 1000)
ON CONFLICT (key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = EXCLUDED.updated_at;- Set API environment variables:
DB_STATUS_KEY=footer
DB_STATUS_EXPECTED_VALUE=okIf you want to force the footer into the Unknown state for testing, update the row to a different value than DB_STATUS_EXPECTED_VALUE.
The intended production architecture is:
- frontend on Vercel
- API service on Railway
- Zero cache on Railway as a separate service
- Postgres on Railway Postgres or Neon
Browser
|
| HTTPS (static SPA)
v
Vercel: apps/web
| Pusher Channels
| HTTPS for presence (admin broadcasts,
| heartbeat + Pusher auth targeted messages)
| Zero cache URL for |
| realtime sync v
v Pusher Cloud
Railway API -----------------------> Postgres
^ ^
| |
+----------- Railway Zero -----------+
The repo already includes vercel.json with the correct monorepo settings.
Current Vercel config:
- install command:
pnpm install --frozen-lockfile - build command:
pnpm --filter @games/web build - output directory:
apps/web/dist - SPA rewrite to
/index.html
- Import the GitHub repository into Vercel.
- Keep the project root at the repository root.
- Confirm the build settings match vercel.json.
- Add the required environment variables.
Required Vercel variables:
VITE_ZERO_CACHE_URL=https://<zero-domain>
VITE_API_URL=https://<api-domain>
VITE_PUSHER_KEY=<pusher_key>
VITE_PUSHER_CLUSTER=<pusher_cluster>Without the rewrite, deep links like /imposter/<id> or /chain/<id> will 404 when refreshed directly in the browser.
The repo includes railway.toml.
Current Railway config in the repo:
- builder: Nixpacks
- build command:
pnpm --filter @games/api build - start command:
pnpm --filter @games/api exec tsx src/index.ts - healthcheck path:
/health
That means the current Railway deployment starts the TypeScript entrypoint through tsx even though a compiled dist build is also available. This works, but you should be aware of it because it differs from a stricter node dist/index.js production model.
NODE_ENV=production
DATABASE_URL=<postgres_url>
CLEANUP_SECRET=<strong_secret>
PUSHER_APP_ID=<pusher_app_id>
PUSHER_KEY=<pusher_key>
PUSHER_SECRET=<pusher_secret>
PUSHER_CLUSTER=<pusher_cluster>
SESSION_COOKIE_SECRET=<long_random_session_secret>After deploy, verify:
GET https://<api-domain>/healthreturns{ "ok": true }GET https://<api-domain>/debug/build-inforeturns metadata JSON- Pusher auth endpoint
POST https://<api-domain>/api/pusher/authis reachable
Zero should be deployed as its own service, separate from the API service.
Recommended start command:
pnpm dlx @rocicorp/zero@0.25.13 zero-cache --port "$PORT"Required Zero service environment variables:
NODE_ENV=production
ZERO_UPSTREAM_DB=<postgres_url>
ZERO_QUERY_URL=https://<api-domain>/api/zero/query
ZERO_MUTATE_URL=https://<api-domain>/api/zero/mutate
ZERO_ADMIN_PASSWORD=<strong_secret>Optional variables:
ZERO_MUTATE_ALLOWED_CLIENT_HEADERS=...Only needed if you intentionally rely on custom client-provided mutate headers. The current session-proof flow uses Zero's auth token path for mutations, so this is no longer required for normal production setup.
ZERO_CVR_DB=<postgres_url>
ZERO_CHANGE_DB=<postgres_url>- the upstream Postgres endpoint must support
wal_level=logical - use a direct Postgres endpoint rather than a transaction pooler for
ZERO_UPSTREAM_DB VITE_ZERO_CACHE_URLin Vercel must point at the Zero public domain
You can use either:
- Railway Postgres
- Neon
Requirements regardless of provider:
- direct connection string available to the API
- direct connection string available to Zero
- logical replication support for the Zero upstream DB path
- Push the repository to GitHub.
- Provision Postgres.
- Deploy the API service to Railway.
- Deploy the Zero cache service to Railway.
- Configure the Zero service with the API query and mutate URLs.
- Deploy the web app to Vercel.
- Configure Vercel with the Zero cache URL, API URL, and Pusher key/cluster.
- Open the web app and run smoke tests.
- Open the production web app.
- Create an Imposter room.
- Create a Password room.
- Create a Chain Reaction room.
- Create a Shade Signal room.
- Create a Location Signal room.
- Play a Shikaku run and verify leaderboard submission.
- Join from a second browser tab or second device.
- Verify that player presence updates.
- Verify that game actions propagate in near realtime.
- Verify
/healthand/debug/build-infoon the API. - Verify the Zero service is reachable and not returning 502s.
- Vercel: promote the previous successful frontend deployment
- Railway API: rollback to the previous working deployment
- Railway Zero: rollback independently if only the cache service broke
- Database: restore from backup if the issue is schema or data related
The frontend includes connection-debug plumbing that tracks:
- Zero connection state
- Zero online/offline events
- API heartbeat latency
- API metadata probe status and latency
This is useful when diagnosing issues that otherwise look like generic “realtime is broken” symptoms.
Presence is inferred from recent HTTP heartbeats (every 60 seconds) rather than a durable authentication/session framework. If you change the heartbeat cadence or timeout assumptions, make sure to update both:
- client heartbeat timing in
usePresenceSocket - backend stale/presence cutoff logic
The cleanup job uses two broad windows:
- a stale window that ends abandoned games
- a delete window that removes older ended games and stale sessions
If you extend reconnect tolerance, you will likely also want to revisit those cleanup constants.
- Authentication is browser-local identity only; there is no account system.
- Linting is not fully configured yet even though
lintscripts exist. - Game state is heavily JSON-column based, which is pragmatic for mutable party-game state but less ideal for analytical querying.
- The Railway config currently starts the API through
tsx src/index.tsinstead ofnode dist/index.js. - Shade Signal is fully implemented with desktop and mobile UI, including leader picking, hard mode, and auto-advance timers.
- Location Signal is fully implemented with desktop and mobile UI, including interactive map, distance scoring, and geocode proxy.
- Shikaku is a single-player puzzle with no multiplayer sync — it uses REST endpoints for leaderboard only.
The application includes a fully separate mobile UI that activates automatically on screens 768px or narrower. The mobile UI lives entirely under apps/web/src/mobile/ and does not share any page components with the desktop UI — this is intentional to prevent mobile changes from regressing the desktop experience.
- Detection: A
useIsMobilehook (inapps/web/src/hooks/useIsMobile.ts) usesmatchMediaviauseSyncExternalStoreto detect viewport width at the 768px breakpoint. - Routing: Each desktop page component checks
useIsMobile()and returns the corresponding mobile component early if true. The desktop code path is never reached on mobile. - Layout:
MobileLayout.tsxprovides the mobile app shell with a bottom navigation bar (Home, Chat, Info, Options) and bottom-sheet modals. - Styling: All mobile CSS is in
apps/web/src/mobile/mobile.css, using them-prefix for all class names. No Tailwind utility classes are used in mobile components — all styling is custom CSS with the same CSS variables as desktop.
apps/web/src/mobile/
├─ MobileLayout.tsx # App shell with bottom nav + sheet modals
├─ mobile.css # All mobile-specific CSS (~2100+ lines, m-* prefix)
├─ components/
│ ├─ BottomSheet.tsx # Reusable bottom sheet overlay
│ ├─ MobileChatSheet.tsx # Chat bottom sheet
│ ├─ MobileGameHeader.tsx # Compact game header with code + phase
│ ├─ MobileInfoSheet.tsx # Info/rules bottom sheet
│ └─ MobileOptionsSheet.tsx # Options + dev demo tools
└─ pages/
├─ MobileHomePage.tsx # Home, join, create game
├─ MobileImposterPage.tsx # Imposter game (all phases)
├─ MobilePasswordBeginPage.tsx # Password pre-round
├─ MobilePasswordGamePage.tsx # Password active rounds
├─ MobilePasswordResultsPage.tsx # Password results
├─ MobileChainReactionPage.tsx # Chain Reaction (all phases)
├─ MobileShadeSignalPage.tsx # Shade Signal (all phases)
└─ MobileLocationSignalPage.tsx # Location Signal (all phases)
- Never modify desktop components to accommodate mobile. Mobile gets its own components.
- All mobile class names use the
m-prefix to avoid collisions with desktop CSS. - When adding a new game, create a corresponding
Mobile<Game>Page.tsxin the mobile pages directory. - Shikaku is currently desktop-only and does not have a mobile page.
- Use the dev demo buttons in the mobile Options sheet (visible in
DEVmode only) to quickly test any game phase on mobile. - Run
npx vite buildto verify both desktop and mobile code compile cleanly.
The admin app lives in apps/admin/ and is a Next.js application with authentication.
| Route | Purpose |
|---|---|
/login |
Admin authentication |
/ |
Dashboard home |
/clients |
Connected clients / active sessions |
/games |
Active games management (view, end, kick) |
/bans |
Session / IP / region ban management |
/broadcast |
Global toast, force refresh, custom status, update warnings |
/names |
Name overrides + restricted name patterns |
/shikaku |
Shikaku leaderboard management (view, edit, delete scores) |
- View all connected sessions and their current game associations
- End individual games or all games at once
- Kick players from games
- Ban by session ID, IP address, or region
- Broadcast toast messages globally or to specific users
- Force browser refresh across all clients
- Set a custom status banner shown site-wide
- Send timed update warnings
- Override player display names
- Block name patterns via regex
- Manage Shikaku leaderboard entries (edit scores, delete entries, wipe by difficulty)
- docs/game-imposter.md
- docs/game-password.md
- docs/game-chain-reaction.md
- docs/game-shade-signal.md
- docs/game-location-signal.md
- docs/game-shikaku.md
If you just want to get the repo running locally:
pnpm install
docker compose up -d
pnpm db:push
pnpm devThen open http://localhost:5173.