Phase 1: reducer, SQLite, rooms & invite links

Turns the Phase 0 deployment shell into a playable async multiplayer game:

- shared/game.js + shared/board.js: pure reducer (reduce(state, action) ->
  newState) over a small branching board subset mirroring the sketched
  design (Career/Education fork, Relationship/Investment fork, High-Risk/Safe
  fork, race to Finish). Dice randomness is generated server-side and shipped
  inside the ROLL action payload, so the reducer itself stays fully pure and
  is identically importable by both server and browser.
- server/db.js: SQLite (better-sqlite3) schema for games/players/tokens,
  config and state stored as JSON. tokens covers both room invite links and
  per-player reconnect secrets.
- server/rooms.js: in-memory room registry that is the only place the shared
  reducer is invoked server-side — validates intents, applies actions,
  persists, and broadcasts to every socket in the room.
- server/index.js: REST endpoints to create/join/inspect a game, and a
  room-aware /ws that authenticates via a first {type:'AUTH'} message rather
  than a URL query param (keeps session tokens out of access/proxy logs).
- public/client.js + public/index.html: NetworkTransport wrapping the
  WebSocket, localStorage-backed session persistence so a reload resumes as
  the same player, and a lobby/waiting-room/game-view UI.
- Dockerfile: adds python3/make/g++ so better-sqlite3's node-gyp fallback
  builds on Alpine when a prebuilt binary isn't available for the exact
  Node/musl combo.

Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
This commit is contained in:
2026-07-21 17:10:56 -07:00
parent 1f51c7d541
commit d40bc09867
12 changed files with 1296 additions and 152 deletions
+60 -54
View File
@@ -1,12 +1,15 @@
# Life Journey — Deployment Shell (Phase 0)
# Life Journey — Phase 1
An empty, deployable shell for the async multiplayer board game. It contains no
game logic yet — its only job is to prove the deployment path works: the server
runs in Docker, is reachable through Nginx Proxy Manager over HTTPS, and
WebSockets survive the reverse proxy.
An async multiplayer, Game-of-Life-style board game. Phase 0 proved the
deployment path (Docker → Nginx Proxy Manager → HTTPS → WebSocket). Phase 1
adds the actual game: a pure shared reducer, SQLite persistence, and
rooms with shareable invite links, so a few people can play together across
devices and tab closes.
Once both checks are green, adding the real game (Phase 1 onward) never touches
the deployment story again.
The server is the sole authority over game state. It generates the only
source of randomness (the dice roll) and applies it through the exact same
reducer (`shared/game.js`) the browser imports — client and server can never
disagree about the rules.
## What's here
@@ -15,63 +18,66 @@ lifegame/
├── docker-compose.yml
├── Dockerfile
├── package.json / package-lock.json
├── server/index.js # health endpoint + WebSocket echo
└── public/index.html # live HTTP + WebSocket status page
├── shared/
│ ├── board.js # the Phase 1 board graph + movement helper
│ ├── game.js # pure reducer: reduce(state, action) -> newState
│ └── game.test.js # node --test coverage of the whole rules engine
├── server/
│ ├── index.js # REST + WebSocket, static hosting
│ ├── rooms.js # in-memory room registry, applies/broadcasts actions
│ ├── db.js # SQLite schema + data access (games/players/tokens)
│ └── ids.js # id/token/join-code generation
└── public/
├── index.html # lobby / waiting room / game UI
└── client.js # REST wrappers + NetworkTransport (WebSocket)
```
## 1. Put it on the homelab
## Running locally
Drop the folder in your services directory (e.g. `~/homelab/lifegame/`) and build:
```bash
npm install
npm test # reducer unit tests — no server needed
npm run dev # starts on :3000, creates data/lifegame.db on first game
```
Open two browser tabs at `http://localhost:3000`. Create a game in one tab,
copy the invite link, open it in the other tab, join, and start the game once
both players are in the lobby.
## The board (Phase 1 subset)
The full hand-drawn board (`assets/game_board.png`) has ~150 spaces across two
thematic passes (Career, Education, Gap Year, Relationship/Family,
Investment, High Risk). Phase 1 encodes a small subset with the same shape —
a Career-vs-Education fork, a Relationship-vs-Investment fork, a
High-Risk-vs-Safe fork, converging to Finish — enough to prove the reducer,
persistence, and rooms all work end to end. More spaces can be inserted into
any branch later without touching the reducer or database schema.
## Deploying on the homelab
Same as Phase 0 — see `homelab-config.md` for the full infrastructure
reference. Set `PUBLIC_URL` in `.env` (or the compose environment) to your
public domain so invite links generated by the server are shareable rather
than pointing at an internal address:
```bash
cd ~/homelab/lifegame
docker compose up -d --build
docker compose logs -f # expect: "Life Journey (Phase 0) listening on :3000"
docker compose logs -f # expect: "Life Journey (Phase 1) listening on :3000"
```
Verify locally on the host first:
```bash
curl http://localhost:3000/api/health
# {"ok":true,"service":"life-journey","phase":0,...}
```
## 2. Point Nginx Proxy Manager at it
Add a **Proxy Host**:
- **Domain**: your chosen name, e.g. `game.example.com`
- **Scheme**: `http`
- **Forward Hostname / IP**:
- *Option A (default compose):* the homelab's LAN IP (e.g. `192.168.x.x`)
- *Option B (shared network):* `lifegame`
- **Forward Port**: `3000`
- **Websockets Support**: **ON** ← the easy-to-forget one
- **SSL tab**: request a new Let's Encrypt certificate, Force SSL on
## 3. Confirm it works
Open `https://game.example.com`. You should see two rows go green:
- **Server (HTTP)** — the health endpoint responded
- **WebSocket** — a live socket connected through the proxy
Click **Send WebSocket ping** and the status should show the echo came back.
If HTTP is green but WebSocket is red, the proxy isn't upgrading the
connection — go back and toggle **Websockets Support** on the proxy host.
## Data & backups
The `lifegame-data` volume (mounted at `/app/data`) is where the SQLite
database and uploaded player tokens will live in later phases. It's empty now,
but include it in the same backup routine as your other self-hosted data.
The `lifegame-data` volume (mounted at `/app/data`) holds `lifegame.db`
every game, player, and token. Back it up like your other self-hosted data;
losing it loses every in-progress and finished game.
## What Phase 1 adds
## What's next
- The shared `game.js` rules engine (the pure reducer from the prototype)
- SQLite data model: games, players, board/rule config, tokens
- Rooms + shareable invite links / join codes
- `NetworkTransport` on the client — the one-line swap that makes the game
run across devices
- Persistence & reconnection, since async means tabs close and servers restart
- Expand `shared/board.js` toward the full sketched board
- Richer board rendering (the current UI is a functional list view, not the
illustrated board)
- Tighter reconnection/presence handling (who's online right now, not just
who's joined)
- Admin page (`ADMIN_PASSWORD`, already stubbed in `.env.example`)