---
id: server-architecture/006
title: Stage 2 — Godot headless world server with grid movement
epic: server-architecture
state: done
priority: 2
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

Add the actual Godot headless world-server process — the thing stage 1's director spawns/hydrates/snapshots/exits, per `overview.md`'s "Persistence" lifecycle.

1. A Godot 4 headless project (in `land-of-lor/pocs/multiplayer-poc/`, alongside stage 1's director) implementing grid-based entity movement server-side, following the `Entity`/`EntityNode` split `overview.md`'s "Runtime" section names — server runs the pure-data `Entity` logic, no rendering.
2. Grid storage uses the **required fix** from `overview.md`'s "Pathfinding and sensing" section from the start — a flat `PackedInt32Array` indexed by `(layer * h + y) * w + x`, statically typed hot-loop variables — not `lor-elementals`' string-keyed `Dictionary` pattern. This stage is exactly where that correction has to actually land in code, not just in spec.
3. **Server-authoritative movement:** the world server accepts *intent* messages (over the stage-1 protocol, extended with movement messages) and computes position itself; nothing about position is ever trusted from a client message.
4. Hydration/snapshot hooks that actually plug into stage 1's director-driven lifecycle (spawn → hydrate from Postgres → run → snapshot every 30s + on shutdown → exit).
5. Automated tests: movement logic, grid indexing correctness, hydrate/snapshot round-trip of actual entity state (not just the director's world-record scaffolding from stage 1).
6. A headless test-client script (extends stage 1's CLI client) that connects, sends movement intents, and asserts the server's resulting authoritative position matches expectations — no manual clicking required to verify this stage.
7. Handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-2.md`.

## Definition of done

Automated tests pass, the headless test-client script demonstrates server-authoritative grid movement end-to-end, the handoff note exists. Set `state: review`, not `done` — same rule as stage 1.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/`.

## Docs to read first

`server-architecture/overview.md`'s "Runtime" and "Pathfinding and sensing" sections. `lor-elementals/scripts/entities/entity.gd`, `entity_node.gd`, and `grid_manager.gd` — the pattern to reuse (`Entity`/`EntityNode` split) and the pattern to specifically *not* repeat (the string-keyed grid `Dictionary`). Stage 1's handoff note (`handoff/stage-1.md`) for the protocol/schema this stage builds on.

## Status (2026-08-03)

**State: `review`, not `done`** — awaiting Kris's verification per the epic's build-plan gating; do not start Stage 3 (`007`) until he moves this to `done`. No `delivers:` gate applies to this task (unlike 007/009) — Kris's sign-off alone unblocks Stage 3.

**Built:** `land-of-lor/pocs/multiplayer-poc/world-server/` — a headless Godot **4.7.1-stable** project (toolchain already pinned/provisioned at `/workspace/lor/godot-setup-for-pocs/`, no new provisioning needed) implementing the real world-server process that replaces Stage 1's in-memory stub. Server-side `WorldEntity` (pure `RefCounted`, no `Node`) and `WorldGrid` (flat `PackedInt32Array`, `(layer*h+y)*w+x` indexing, statically typed — not `grid_manager.gd`'s string-keyed `Dictionary`). Two WebSocket channels: a control channel (world↔director, reusing the director's existing WS port, extending Stage 1's protocol with `world_register`/`world_snapshot`/`world_shutdown`) and a game channel (game client↔world process directly, via `WebSocketMultiplayerPeer` at the low-level packet-peer API). Server-authoritative movement at the decided 20Hz sim / 10Hz broadcast rates; illegal intents (walls, out-of-bounds) and unrecognised message types (standing in for direct position assertion) are refused/rejected, tested explicitly. All three required persistence mitigations from the 2026-08-03 "snapshots flow via the director" decision are implemented and tested against real OS processes: idempotent snapshots keyed by world id + seq, buffer-last-unacked + retry-on-reconnect, and shutdown blocking on a persistence ack. `director/src/worldInstance.ts` rewritten to actually spawn/hydrate/register/snapshot/shut down the real Godot process (env-overridable `GODOT_BIN`/world-server path), with `protocol.ts`/`server.ts`/`worlds.ts` extended accordingly.

**Verification, independently re-run and confirmed by this session (not just the building agent's own report):** `npm test` — 23/23 passed (17 from Stage 1 + 6 new). `npm run test-client` — 9/9 passed (Stage 1 unmodified, no regression). `npm run test-client-2` — 17/17 passed, the actual Stage 2 proof: server-computed movement, wall/out-of-bounds/unknown-type rejection, and — the hardest property — killing the Godot world process directly (SIGKILL, not the director) mid-play, rejoining, and confirming a fresh OS process (different pid) respawns and hydrates from the last persisted snapshot. Godot pure-logic test runner (`test_runner.gd`) — 31/31 passed standalone, no networking.

Full detail, including deviations from the original design note (SIGTERM doesn't reach headless GDScript; `saveWorldState` now merges instead of replaces; `WebSocketMultiplayerPeer`'s leading system packet; `preload()`-based cross-file references instead of bare `class_name`): `land-of-lor/pocs/multiplayer-poc/handoff/stage-2.md`.

## Assumptions

- **Game clients connect directly to the spawned world process's own port (`gamePort`), not through the director**, for gameplay traffic — the director's `join_world` response now includes `gamePort` and only resolves once the process has registered, so the port is always live. This wasn't explicitly specified in the task body, but follows directly from `overview.md`'s "Runtime" rationale for choosing WebSocket (client and server run the same codebase, talking directly) and from the epic's own Stage 3 framing ("two clients connected simultaneously to the same world instance").
- **Control channel reuses the director's existing WS server/port** rather than a second listener, per the persistence decision's "existing world↔director channel" wording — a spawned world process connects to it as a client alongside game clients, distinguished only by message type, not by a separate port or connection role.
- **A per-spawn random `spawnToken`** (not tied to accounts/auth) is the trust mechanism between a specific spawned process and the director's in-memory record of it — sufficient for this PoC stage; not a security mechanism intended to survive into a production identity story.

## Closed 2026-08-03 — `state: done`

Kris directed Stage 3 to proceed, in direct conversation with his assistant (2026-08-03), rather than by personally re-running the suites himself. Recorded honestly: this sign-off rests on the Stage 2 closing session's own independent re-verification logged above (23/23 vitest, 9/9 Stage 1 client, 17/17 Stage 2 client, 31/31 Godot logic tests), not on a fresh personal run by Kris. This unblocks `server-architecture/007` (Stage 3).