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:
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user