Server architecture
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:
- Discovery is meant to be real — “the world contains all its secrets physically” (§8). If transmutation and recipe-matching ran client-side, the secrets would live in client code, inspectable and fakeable.
- The economy (blueprints as tradeable knowledge, the barter marketplace, see
barter-marketplace) needs a single source of truth for what any player actually holds.
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
- Data model for per-cell floor/occupant slots on the grid.
- Recipe-matching engine: matched via the cauldron — not spatial pattern recognition on the grid — mechanism details still to be designed (§8, corrected 2026-08-12).
- The budget-rule derivation function itself is still to be decided (§9, flagged in the source docs as “the hardest design object in the game”) — but its enduring goal is fixed: the game must not permit god-mode, and that anti-god-mode intent is the durable requirement any derivation function has to satisfy.
- Authority/networking model: what’s authoritative server-side vs. what the client predicts/renders locally — decided below, not open any more.
Non-goals
- Not deciding hosting/deploy/environments — that’s
server-instance, which depends on this epic landing first. - Not designing the marketplace’s economic rules beyond what it needs from this epic (state ownership, transactions) — see
barter-marketplace. - Not the ground-type/traversal spec — see
world-traversal-and-ground, which is explicitly a separate, still-undefined layer. - Not the actual droplet/hosting work — that’s
server-instance, which executes against this epic’s decisions rather than making its own.
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:
- A player requests an area.
- The director checks membership.
- An area process spawns and hydrates its state from Postgres.
- Live simulation runs, with periodic snapshots back to Postgres.
- The last player leaves.
- A final snapshot is written.
- 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:
- Keeps GDScript out of the database entirely — Godot has no first-class Postgres driver, and the most critical durability path shouldn’t rest on a third-party addon; Node’s
pgis mature. - Clean separation: area = simulation only; director = orchestration + all persistence. One codebase owns schema/credentials/pool/migrations.
- Symmetric with hydration, which already flows director→area at spawn — state crosses one boundary, both directions, over a channel that already exists.
- Known costs, accepted: the director is a persistence funnel (trivial at this project’s scale of dozens of areas per droplet) and a director crash pauses snapshots for all areas — which sits inside the already-stated 30-second crash-tolerance budget.
Required mitigations, part of the decision, implemented starting Stage 2 (server-architecture/006):
- Snapshots are idempotent — keyed by area id + monotonic tick/sequence number, so retries are always safe.
- The area process buffers its last unacknowledged snapshot and retries on reconnect if the director is briefly unavailable.
- 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:
- Simulation: 20 Hz.
- Network snapshots to clients: 10 Hz, with the client interpolating over roughly a 100 ms buffer to smooth the gap between snapshots.
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:
- A player must have an account, and must be signed in, to play — full stop. There is no credential-less guest state anywhere in this client any more, not even transiently at first boot.
- An account and its one auth identity (email+password, Google Play Games, Apple Game Center, or — later — Steam) are created together, in the same transaction, and stay paired one-to-one forever. No attaching a second identity later, no linking an existing account to another provider, no “guest now, add an email later.” Enforced at the database level (
auth_identities.account_idis nowUNIQUE), not just by client behaviour. - Cross-device linking (link codes, QR codes) is gone entirely. The only way to sign in on a second device is to know the email+password (the one credential type that travels); a platform-provider account (Play Games / Game Center) is inherently tied to that platform’s own device-level sign-in and was never link-code-portable in a meaningful sense anyway.
- “Remembered logins” on a device are a local convenience list only — display name, email address (or “Game Center”/“Play Games” for a platform entry), and an avatar seed, stored client-side so a returning player can tap their name instead of retyping an email. They are never a second credential and never synced or portable between devices.
- Steam stays speced-only (schema + protocol seam), unbuilt, same as before — this decision doesn’t change that.
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/004–008 for the tracked stages.
Shape of the plan, each stage gated on Kris verifying before the next begins:
- Stage 0 — spec approved. This “Architecture decisions” section, and deliverable #4. Already done — see
tasks/004. - Stage 1 — director service + Postgres + protocol. No Godot involved. Verified by a CLI test client proving an area hydrates from Postgres, persists across a full process restart (kill the director, restart it, the area is still there), and that membership routing actually enforces who can enter which area.
- Stage 2 — Godot headless area process with grid movement, server-authoritative (client sends intent, server computes position), automated tests passing.
- Stage 3 — Godot client with client-side prediction and server reconciliation; two clients connected simultaneously, each seeing the other move — plus portal routing: a player crosses a portal from area A to area B, with membership enforced at the destination, the destination area spawned/hydrated on demand if not already running, and the player’s entity state transferred through the director with no loss or duplication. Also, added 2026-08-03: Stage 3’s close-out includes a web export of the client deployed to the droplet — the concrete way Kris human-verifies the stage (two browser tabs, each seeing the other move), and the substance of deliverable #6 (
deliverables/006-play-in-your-browser.md). Seetasks/007’s step 8 for the deploy-key and HTTP/TLS prerequisites this carries. - Stage 4 — interest management, creatures, flow-field pathfinding; hundreds of agents processed within the tick budget (§ Netcode, § Pathfinding above).
- Task 009, added 2026-08-03 — Android APK export & download link, blocked on Stage 3 (
tasks/007) rather than gated to a specific stage number of its own: same unchanged Stage 3 client, packaged as an installable APK and linked from the deliverables page. Substance of deliverable #7 (deliverables/007-the-two-phones-android-apk.md). Explicitly zero new gameplay/netcode — an export target, not a build stage.
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
land-of-lor/docs/README.md— §2 (three-tier authorship), §3 (one substance/two topologies), §5 (fields — cut from this architecture 2026-08-12; the idea may live on elsewhere as a stashed concept, but it is not part of this epic’s design), §7 (changing lines), §8 (recipes/formulas), §9 (budget rule), §13 (open engineering flags).land-of-lor/docs/thought-specs/battle-areans-and-element-types.md— the ley-lines/conduit vision that originally implied real-time shared field state during battles (fields since cut, 2026-08-12); the sensing-via-spatial-hash decision above is one way that vision survives without needing them.deliverables/004-server-setup-guide.md— the hosting/deploy side these runtime decisions assume (droplet, systemd units per-process, Postgres alongside).lor-elementals/scripts/entities/entity.gdandentity_node.gd— the existing data/rendering split this epic’s server/client architecture reuses directly.lor-elementals/scripts/systems/grid_manager.gd— the grid pattern to learn from and specifically not repeat (string-keyedDictionary) in the new implementation.
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)
- #8 — 3D people walking — the browser world gets bodies (2026-08-04)
- #7 — The two phones — Android APK (2026-08-03)
- #6 — Play it in your browser — the multiplayer world, live (2026-08-03)
Tasks (22)
needs-input (4)
-
server-architecture/011— Google Play Games sign-in on Android (priority 2, estimate M , blocked byserver-architecture/010) -
server-architecture/012— Set up Resend for real account emails (priority 2, estimate S ) -
server-architecture/018— Register spellgrove.com and set it up for the site and email (priority 2, estimate M ) -
server-architecture/020— Apple Game Center sign-in on iOS (priority 2, estimate M )
backlog (2)
-
server-architecture/017— Password reset by hand — a support address, not self-service (priority 3, estimate S ) -
server-architecture/022— Multiple characters under one login (priority 3, estimate L )
done (16)
-
server-architecture/001— Field-simulation scaling memo (priority 1, estimate M ) -
server-architecture/004— Stage 0 — spec approved (priority 1, estimate S ) -
server-architecture/005— Stage 1 — director service + Postgres + protocol (no Godot) (priority 1, estimate L ) -
server-architecture/010— Real accounts — sign up, log in, and attach a device to an existing account (priority 1, estimate L ) -
server-architecture/013— Account-first boot flow, character-seed placeholder, and client robustness (priority 1, estimate L ) -
server-architecture/021— Single-identity accounts + unified account screen (priority 1, estimate L ) -
server-architecture/002— Server runtime/engine options memo (priority 2, estimate M ) -
server-architecture/006— Stage 2 — Godot headless world server with grid movement (priority 2, estimate L ) -
server-architecture/007— Stage 3 — Godot client with prediction and reconciliation; two clients (priority 2, estimate L , blocked byserver-architecture/006, delivers #6) -
server-architecture/014— Server hardening batch — the 010 audit's security fixes (priority 2, estimate M ) -
server-architecture/015— First iOS build — run the game on an iPhone from Kris's Mac (priority 2, estimate S ) -
server-architecture/016— Split PUBLIC_BASE_URL into EMAIL_VERIFY_BASE_URL and LINK_CODE_BASE_URL (priority 2, estimate S ) -
server-architecture/019— Account screens redesign — two panels, and this device's remembered logins (priority 2, estimate M ) -
server-architecture/003— Tick-rate / real-time-vs-lazy-evaluation memo (priority 3, estimate S ) -
server-architecture/008— Stage 4 — interest management, creatures, flow-field pathfinding at scale (priority 3, estimate L , blocked byserver-architecture/007) -
server-architecture/009— Android APK export & download link (priority 3, estimate M , blocked byserver-architecture/007, delivers #7)
Ask an agent about this epic
Paste your AI Task Runner API key once. It stays in this browser and is sent only to kris.ai-task-runner.com.
Also available as raw markdown.