---
id: server-architecture/005
title: Stage 1 — director service + Postgres + protocol (no Godot)
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-02
updated: 2026-08-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
---

## What to do

Build the director service and its Postgres-backed persistence, with no Godot process involved yet — this stage proves the persistence model (`overview.md`'s "Persistence" section) and the message protocol shape (`overview.md`'s "Netcode" section) work, before any game engine is in the loop at all.

1. **Schema:** a `worlds` table (at minimum: id, owner, member list or a join table, `last_played_at` — per the decided-from-day-one column even though nothing reads it yet — and whatever snapshot-state column/blob the world's serialised state lives in) and whatever `accounts`/identity table backs the "no auth for MVP, but a real durable ID" decision.
2. **Director process:** owns world lookup, membership checks, and (stubbed is fine at this stage, since there's no Godot process yet to actually spawn) the world-instance lifecycle hooks. Runtime: agent's choice between the two candidates `overview.md` names (Go, or Node/TS leaning per the stated tooling-reuse reason) — pick one, state which and why in the handoff note; this task is what actually answers the epic's one remaining open question, in practice if not yet by updating the epic's own prose.
3. **Protocol:** the versioned, JSON, schema-defined message envelope described in `overview.md`'s "Netcode" section — implement it for whatever minimal message set this stage needs (create world, join world, world-state response), not the full in-game protocol yet.
4. **CLI test client:** a script (not a GUI) that: creates/loads a world, confirms it hydrates from Postgres, and — this is the actual proof this stage exists to deliver — **kill the director process, restart it, and confirm the world's state (and membership) survived the restart intact.** Also confirm membership routing actually rejects a non-member's request to join a world they don't belong to.
5. Automated tests covering the above (schema round-trip, protocol envelope encode/decode, the restart-persistence property, the membership-rejection property).
6. Write the handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-1.md` — what was built, which runtime was chosen and why, how to run the tests and the CLI test client, and what stage 2 can assume exists (the protocol shape, the schema, how to talk to the director).

## Definition of done

Automated tests pass; the CLI test client demonstrably proves world-hydration-from-Postgres and survival-across-a-full-process-restart; the handoff note exists. Set this task's own `state` to `review` once that's true — **do not mark it `done` yourself**; that's Kris's call (or a session explicitly acting on his confirmation) once he's verified it, per `overview.md`'s "Build plan" section. Marking `done` unblocks stage 2.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/` (per `overview.md`'s "Where PoC code lives").

## Docs to read first

`server-architecture/overview.md` in full, especially "Persistence," "Runtime" (director runtime candidates), and "Netcode" (protocol envelope, no direct-position messages). `deliverables/004-server-setup-guide.md` §4 for the Postgres shape this stage's schema will eventually run against on the real droplet (this stage can run against a local/dev Postgres — it doesn't need the droplet from `server-instance` to exist yet).

## Status (2026-08-03)

**State: `done`.** Kris personally ran `npm test` and `npm run test-client` in `land-of-lor/pocs/multiplayer-poc/director/` on 2026-08-03: all tests passed, including the 9 test-client checks. This is his explicit confirmation per the epic's build-plan gating rule — Stage 2 (`006`) is unblocked.

**Director runtime: Node/TypeScript** — decided directly by Kris on 2026-08-03 (not an agent assumption this time; this was the epic's one open question and he closed it explicitly before this task was picked up). `overview.md`'s "Runtime" section and "Key open questions" are updated accordingly.

**Built:** `land-of-lor/pocs/multiplayer-poc/director/` — a Node/TS + `ws` + `pg` + `zod` director service, a Postgres schema (`accounts`, `worlds`, `world_members`), a versioned JSON message envelope, 17 passing automated tests (`npm test`, includes a real SIGKILL + process-restart persistence test and a membership-routing test), and a headless CLI test client (`npm run test-client`) that proves all three of the stage's verification bullets end-to-end with no human interaction. Local Postgres for dev/test is provided by the `embedded-postgres` devDependency (a real Postgres binary run userspace, no root needed) — the production code only ever talks to `DATABASE_URL` and doesn't know or care that it's this vs. the droplet's real apt-installed Postgres.

Full detail: `land-of-lor/pocs/multiplayer-poc/handoff/stage-1.md`.

## Assumptions

- **Membership semantics:** the world owner is automatically inserted as a member at world-creation time (one row in `world_members`, no separate "is owner" special case anywhere else) — matches `overview.md`'s "membership, not matchmaking" framing, where the owner is just the first member.
- **`last_played_at` is written on every snapshot** (periodic, and on last-player-leave), even though nothing reads it yet — the column exists per the decided architecture; writing it opportunistically now costs nothing and means Stage 4+ offline-progression work won't also need a backfill.
- **Local/dev Postgres via `embedded-postgres`** rather than an apt-installed system Postgres — this container has no root/sudo, so `apt install postgresql` (as deliverable #4 assumes on the droplet) isn't available here. `embedded-postgres` gives a real Postgres binary with no privilege requirement; it's a devDependency only, irrelevant to how the director talks to Postgres in production.
- **Protocol message set kept to the stage's stated minimum** (`create_account`, `create_world`, `join_world`, `leave_world`, `add_member`, `set_world_note`) rather than anticipating Stage 2+ messages — `set_world_note` stands in for a real gameplay intent purely to prove a mutate-then-snapshot-then-restart round trip exists; it is not meant to survive into Stage 2's actual protocol.