← all epics

Server architecture

server blocked

repo: land-of-lor · updated 2026-08-28

What this is

The architecture for the software that runs Lor: The Ten Thousand Things’ world simulation: the grid, recipe matching, and the mechanics governing every Complex Item in it (trees, towers, dragons, players themselves — §8).

Why it exists — the key architectural stance

Kris’s explicit position: much of the game runs server-side; the client is substantially an interface rather than the core mechanics. This is not a generic “make it multiplayer” note — it follows directly from the design itself:

So the client’s job is largely: render the grid, send intents (place this item, cast this recipe, walk this direction), and display authoritative state pushed back from the server. This is a bigger, harder build than a typical single-player-with-cloud-save mobile game, and it’s worth stating plainly rather than discovering it halfway through a client-first build.

Performance — a standing requirement, stated 2026-08-12

Kris, by voice: performance is front and centre. The game is meant to load in many players at once, and on the server that matters enormously. Any decision anywhere in this epic that carries a performance cost must be pushed back on and explored for a best-practice alternative, not quietly accepted because it’s the simplest thing to build. This is a review criterion for every decision here, alongside the offline-play constraint (see “Offline play” below) — stated in the same spirit, so neither gets silently deprioritised.

Scope

Non-goals


Architecture decisions (2026-08-02 architecture conversation)

The three memo tasks below (§ tasks/001–003) were superseded by a direct architecture conversation rather than by the memos they were meant to produce — see each task file for how it was closed out. This section is the durable record of what was actually decided; treat it as ground truth for future sessions, ranking above the now-closed tasks if the two ever seem to disagree.

Topology — sharded areas, not one shared world

Roblox-style, not MMO-style. Many independent areas, each serving dozens of players, connected by portals — explicitly not thousands of players in one simulated space. Area processes never talk to each other directly; anything crossing a portal goes through the director (below), not a direct process-to-process link.

Membership, not matchmaking. An area record has an owner and a member list. The director routes a player to areas they’re actually a member of — semantics borrowed from a Minecraft server whitelist, economics borrowed from Roblox’s on-demand-instance model (an area process only exists, and only costs anything, while someone’s actually in it).

Servers vs. areas — terminology fixed 2026-08-12. A server is a new, distinct outer concept from an area: an independent instance of the entire server stack (director + Postgres + area processes) — a hard-configuration deployment thing, not something a player crosses through play. All servers are literally independent instances of the same server code. The client, Minecraft-style, specifies which server to connect to — chosen deliberately because players already understand Minecraft’s server-selection model. Consequences: portals route only within a server, via that server’s own director; area processes never talk to each other directly, whether within a server or across one; moving between servers is a client-side reconnect to a different server, not a portal; and accounts, membership, and all persistence live per-server. An area, by contrast, is a scene/room/region players portal between — all pathfinding and running activity happens within one. Players each have a home area; there are shared areas; a battle arena is a special kind of area; and offline play happens in a special area or areas — consistent with the “different areas impose different rules” concept in the Offline play section and world-map-and-portals/001 (the terminology now matches that concept exactly). A portal between areas can also take the form of a border/boundary crossing, not only a literal portal object.

Persistence — the core architectural idea

Area process lifetime and area persistence are independent. The process is disposable; the area record is durable. This is the idea everything else in this section serves.

(Naming note, 2026-08-12: the Stage 1–3 PoC code and Postgres schema still use world naming throughout — e.g. the worlds table and its last_played_at column. Renaming code and schema to area is follow-up work, not part of this doc edit; this document describes the target terminology, not the code as it currently stands.) CLOSED 2026-08-16 — that follow-up landed as world-map-and-portals/015: the table is areas, the wire says areaId, and the GDScript matches. The note above stays because it is the record of the terminology being fixed four days before the code could follow it.*

Lifecycle:

  1. A player requests an area.
  2. The director checks membership.
  3. An area process spawns and hydrates its state from Postgres.
  4. Live simulation runs, with periodic snapshots back to Postgres.
  5. The last player leaves.
  6. A final snapshot is written.
  7. The process exits. The area’s row in Postgres persists — nothing about the area is lost by the process going away.

Decided: an empty area is a frozen area. No simulation runs while an area is empty, and there is no offline fast-forward when a player returns — time simply does not pass for an unoccupied area. Design consequence, stated plainly so it doesn’t get silently reversed later: machines and any Complex Item with its own internal mechanics (§8 of docs/README.md — a tree, a tower, a dragon) do not run while every player who could observe them is away. A player returning to their home base after a week away finds it exactly as they left it, not further along, not decayed, not caught up.

Carry a last_played_at column on the area record from day one, even though nothing reads it yet — this keeps a later, deliberate decision to add offline progression (fast-forwarding an area by however much time passed) possible without a schema migration blocking it. Adding the column costs nothing now; retrofitting it once areas already exist without it is real migration work.

Snapshot cadence: every 30 seconds during active play, plus one on graceful shutdown. Rationale is crash tolerance, not bandwidth or storage efficiency — a mid-session crash should cost a player at most 30 seconds of building, never more.

Snapshots flow via the director — decided 2026-08-03 by Kris, after reviewing the trade-offs. The area process produces each snapshot (a serialised state blob sent over the existing area↔director channel); the director persists it to Postgres. Area processes never hold DB credentials or a Postgres client — the director owns all database access, schema, and migrations.

Rationale:

Required mitigations, part of the decision, implemented starting Stage 2 (server-architecture/006):

  1. Snapshots are idempotent — keyed by area id + monotonic tick/sequence number, so retries are always safe.
  2. The area process buffers its last unacknowledged snapshot and retries on reconnect if the director is briefly unavailable.
  3. Graceful shutdown requires a persistence ack from the director before the area process exits.

This decision strengthens the Postgres-as-transfer-medium candidate for portals (snapshot player out of area A via the director, hydrate into area B) — see “Not yet decided: the portal-transfer mechanism itself” below, still pending Kris’s confirmation at Stage 3 pickup.

Runtime

Area processes: Godot 4, headless, one OS process per area, over WebSocketMultiplayerPeer — not ENet. The load-bearing reason for WebSocket over Godot’s usual ENet multiplayer stack isn’t browser compatibility (though it helps) — it’s that the same Godot codebase runs on both the authoritative side and the predicting client, which eliminates prediction-divergence bugs, the dominant risk category whenever client-side prediction is reimplemented in a second language against a first language’s authoritative sim. One codebase, one set of movement/collision rules, both sides.

lor-elementals’ existing split is already the right shape for this and should be the pattern the new server-side code follows: Entity (a pure RefCounted data class — stats, blueprint, inventory, no Node dependency) is exactly what serialisable, server-authoritative state should look like; EntityNode (the CharacterBody2D wrapper handling physics/rendering) is exactly client-side concern. The area process runs Entity logic (plus whatever area state it owns); the client additionally runs EntityNode for rendering and local prediction. This isn’t a new pattern to invent — it’s lor-elementals/scripts/entities/entity.gd and entity_node.gd, reused on purpose.

Director service: a separate, long-running process owning the area registry, membership, area-process lifecycle (spawn/hydrate/snapshot/exit above), portal routing between areas, and accounts. Decided 2026-08-03 (confirmed directly by Kris): Node/TypeScript, for the tooling-reuse reason already stated here — the droplet (deliverable #4) already carries Node tooling for the site and its deploy watcher, so there’s no second toolchain to provision. Go is no longer an open candidate. Implemented in land-of-lor/pocs/multiplayer-poc/director/ (server-architecture/005, Stage 1).

Database: PostgreSQL, on the same droplet as the area/director processes (per deliverable #4).

Escape hatch, stated so it isn’t reached for prematurely: if GDScript’s performance genuinely proves insufficient somewhere (most likely a pathfinding or interest-management inner loop), move only that specific hot function to a GDExtension (C++/Rust) call. Do not rewrite the game, or even the surrounding system, in a different language — this is a targeted escape hatch for one function at a time, not a signal to abandon Godot server-side.

Netcode

Server-authoritative, always. The client sends intent (“I am pressing move-north,” “I am casting this recipe”) and never sends position directly — position is something the server computes and the client is told, never something the client asserts.

Client-side prediction plus server reconciliation, from day one. Not deferred: retrofitting prediction onto a netcode layer that didn’t have it from the start is close to a rewrite, not an incremental addition, so it’s built into stage 3 of the PoC build plan (see deliverable #4’s session and the epic-placement question raised there) rather than left until performance or feel demands it.

Transport: WebSocket. Message format: JSON for v1, behind a versioned, schema-defined message envelope — the envelope’s job is specifically to make a later move to a binary codec (MessagePack, a hand-rolled binary format, etc.) a serialisation-layer change only, never a protocol-shape change. Don’t let JSON’s convenience during early development leak into the message shape in ways that would make swapping the encoding later a bigger job than it needs to be.

Interest management from day one, not added later. Each client receives state only for entities within some radius, computed via a spatial partition (a grid-based spatial hash is the natural fit given the area is already grid-based). This isn’t an optimisation to defer — broadcasting full state to every client is O(n²) in player/entity count and fails outright somewhere around a few hundred entities, well within what a single populated area could plausibly hold.

Tick rates, deliberately decoupled from each other:

Pathfinding and sensing

Fields are cut — decided 2026-08-12. Pathfinding is the real scaling concern for this project’s actual target load: hundreds of creatures, from multiple spawn points, converging on a handful of targets (a home castle, a player) — the tower-defence-style load battle-arena-poc and the series’ recurring “castle defence” thread both point toward.

Use flow fields (Dijkstra maps), not per-agent A*. One outward Dijkstra sweep from a target produces a direction grid covering the whole reachable map; every creature moving toward that target then does a single array lookup per tick, not a pathfinding search. Cost scales with the number of distinct targets, not the number of agents converging on them — the mechanism that makes “hundreds of creatures” tractable at all. Practical bounds: recompute a target’s flow field only when it moves to a new cell (not every tick); cap total pathfinding work to a few milliseconds per tick via a work queue, with a slightly stale field being an acceptable, imperceptible trade-off rather than something to chase perfect freshness on. Reserve genuine per-agent A* for the actual exception case: a single agent with a unique destination nobody else shares (e.g. a player’s own click-to-move target, matching lor-elementals’ existing player_character.gd pattern).

Required change to the grid data structure this depends on: lor-elementals/scripts/systems/grid_manager.gd currently keys its cell dictionary by the string "layer,x,y" (a Dictionary lookup with string concatenation on every access). That’s fine for a single-player prototype; it is a performance disaster inside a flow-field inner loop, which is exactly the kind of code this project now needs to run fast, many times per tick. Whatever grid implementation lands in land-of-lor (informed by, but not copy-pasted from, lor-elementals’ pattern) must instead use a flat PackedInt32Array indexed by (layer * h + y) * w + x, with every hot-loop variable statically typed (var x: int, not untyped var x) — GDScript’s static typing measurably matters in code that runs this often. This is a concrete, actionable correction to carry into whatever replaces grid_manager.gd’s pattern for the new server-side world, not a note about the dormant lor-elementals repo itself.

Sensing: register emitters (position, strength, radius — e.g. a hoard of stored food, same design intent as docs/README.md §3’s “a beacon of scent and vitality”) in a spatial hash, and answer “what can this creature sense from here” as a spatial query against that hash. A large hoard advertises itself; a quiet one doesn’t. Line of sight: grid shadowcasting. Both of these ride on the same spatial index that interest management (above) already requires — one piece of infrastructure serving two systems, not two separate builds.

Identity

Persistent accounts from day one. Area ownership requires durable identity — an area record’s owner field has to point at something that outlives a single session. No authentication for the MVP: issue and remember an ID (a token in local storage / a save file, however the client stores it) with no password or login flow behind it yet. The reason this is safe to defer and unsafe to skip entirely: adding real auth to an existing, populated user-record table is straightforward; retrofitting durable identity onto sessions that were built anonymous is not — it means matching up orphaned data to accounts after the fact, per-player, by hand or by heuristic. Get the identity column and its foreign keys right now; the login form can come later.

Accounts & auth — one identity per account (2026-08-28)

Superseded by Kris, 2026-08-28 (server-architecture/021): no more guest accounts, and no more “attach, don’t merge.” The MVP-era identity model above and historic/010-accounts-plan.md’s “one durable account, several auth identities attached over time” premise are both retired. Going forward:

This closes the loop historic/010-accounts-plan.md opened: that doc’s “attach, don’t merge” design is kept for provenance, not as live guidance.

Offline play — a standing requirement, stated 2026-08-12

Kris, by voice: the finished game must be playable offline. He acknowledged it is probably not achievable on the current build, and stated it now on purpose — so that nothing gets architected in a way that forecloses it. Treat it as a constraint on every decision in this epic from here on, not as a feature request sitting in a backlog.

What it does not mean: the same game with the network removed. Shared areas are inherently unavailable offline, and resource rules may have to differ. What it does mean is that “offline” should fall out of a more general concept Kris raised in the same breath — different areas impose different rules (a battle arena, for instance, likely won’t let you bring your whole inventory in). Once an area carries its own rule set, offline is one more rule set rather than a second codebase. That concept is being specified in world-map-and-portals/001.

Concretely, for this epic: an authentication or session design that makes the client useless without a live director violates this (see tasks/010), and the worlds table’s last_played_at column — carried from day one specifically to keep offline progression possible without a migration — is the kind of foresight this requirement asks for everywhere else. Still open, deliberately: what happens to resources gathered offline, and whether offline progress reconciles on reconnect.

Where PoC code lives

land-of-lor/pocs/multiplayer-poc/ — following this repo’s existing pocs/ convention (adopt/ditch/partial marking, per land-of-lor/pocs/README.md), not a new location invented for this.

Build plan — decided 2026-08-02: lives in this epic, not a new one

Kris’s decision: the staged multiplayer PoC build plan is tracked as tasks under server-architecture itself — not a new epic, and not folded into battle-arena-poc, character-composition-poc, world-traversal-and-ground, or object-system-experiments (none of which touch server implementation; all four are client-facing content/design epics). This closes out the placement question the previous session raised rather than decided unilaterally. See tasks/004008 for the tracked stages.

Shape of the plan, each stage gated on Kris verifying before the next begins:

Every stage ships automated tests plus a headless test-client script, not a manually-clicked demo — the AI Task Runner container this work is developed in likely cannot expose a listening port for a human to connect to directly, so a stage’s own automated verification has to stand on its own; a human (Kris, on the droplet once server-instance/001 lands) does the final real-world check, but a stage isn’t “done” pending that — it’s review pending that. Every stage ends by writing a handoff note into the repo at land-of-lor/pocs/multiplayer-poc/handoff/stage-N.md — what was built, how to run its tests/test-client, exactly what Kris needs to verify, and what the next stage assumes. Each stage task’s own state goes to review (not done) once its automated tests pass and its handoff note is written; only Kris (or a session acting on his explicit confirmation) moves it to done and unblocks the next stage.

Decided 2026-08-03, at Stage 3 pickup: the portal-transfer mechanism is Postgres-as-transfer-medium, via the director. A portal crossing forces an out-of-cadence, final snapshot of the player’s entity state out of area A (not waiting for the next periodic tick) — sent over the same area→director control channel Stage 2 already built for persistence. The director validates the player’s membership in area B, then spawns/hydrates area B on demand if it isn’t already running (the same spawn/hydrate path any player joining an area already goes through), and hydrates the player’s entity into it. Area processes never talk to each other directly — everything crosses the director, consistent with the topology decision above. Confirmed consistent with the 2026-08-03 “snapshots flow via the director” persistence decision, which this reuses rather than introduces a second mechanism alongside. Load-bearing constraint carried into Stage 3’s implementation and tests: a crash mid-transfer must leave the player safely recorded in Postgres — never lost, never duplicated — which Stage 3’s automated verification tests directly with a real process kill during a portal crossing.

Key open questions

None. The director runtime/language (Go vs. Node/TypeScript) was the epic’s one remaining open question; it closed 2026-08-03 (see “Runtime” above) — Node/TypeScript.

Relevant docs

Status

4 needs-input2 backlog16 done

Next: 021 (single-identity accounts + unified account screen) built, tested, and DEPLOYED LIVE 2026-08-28 — needs Kris's own visual/on-device look (no display in the building container, same gap as 019) plus the Android APK rebuild+upload before that ships to phones. Remaining, unchanged: 018 spellgrove.com; 011's Play Console setup; 012's iFastNet DNS records; 020's Apple checklist; 017 (backlog) needs a support address picked.

Deliverables (3)

Tasks (22)

needs-input (4)

backlog (2)

done (16)

Ask an agent about this epic

    Also available as raw markdown.