← all epics

Server architecture

server

repo: land-of-lor

Where this stands (2026-11-01)

Release B is live on both realms (2026-11-01, with 0.3.1’s age fix; ops/DEPLOY_LOG.md). The servers-and-capacity plan (docs/handoff/servers-and-capacity.md, 2026-10-30) split the work into releases after Release A (houses, villagers, furniture, the funnel — live since the same morning). Release B is server-only, merged to master (fc20f709), proved on staging, then taken live at 15f2183d with the process rail at 48 and a capacity sample a minute on both realms:

Release C is live on both realms (2026-11-01, master 6fb78d9c). Four lanes merged in one go: realms as units (the template unit, realmctl new <slug> private <n>, the Caddy realm files with an on-demand certificate gate, the env split — rehearsed in a local container, since the DigitalOcean token has expired), who may enter (a private realm’s members and invite codes in its own tables, so it keeps letting its family in when spellgrove.com is down; the Builder’s registry, realms.json, /building/realms), pooling homes (up to ten homes in one Godot process), and the realms client (a refused join always says why, the Realm chip and screen, sessions per realm, invite codes, the full / closed notice, the Village’s objects sent once instead of ten times a second). The merge added the piece between them — an invite code now finds its realm by itself (/builder/realms/resolve) — and the staging run fixed a join that could land in an Area just as it closed. On staging, ten pooled homes cost 92 MB where five dedicated ones cost 370 MB, with the same ticks and update gaps; killing the pool’s process took exactly its ten homes and they came straight back. Two more soak runs passed, so live pools homes ten to a process too. The box cutover is done: every realm is now one unit with its own env file, database, Caddy file and backup timer (realmctl new makes one in about ten seconds; a probe realm was made, joined over its own Let’s Encrypt certificate, tested with invite codes and destroyed). The first run stopped at the Caddy step on a script bug and was put back; the second found that www.spellgrove.com would lose its certificate under the new Caddy file, was put back again, and the cause was found and fixed before the third go (docs/handoff/realms-ops.md §4.1). Live was down a few seconds at each switch. What waits: REALM_CODES_MIN_CLIENT=0.4.0 once the 0.4.0 store build is in both stores; the first real private realm (Release D sells them); S13 on a real droplet (the token works again) (docs/handoff/servers-and-capacity.md §4 “Release C”).

Private servers (#43, 2026-10-26) — notes, not yet work. Kris’s idea by voice: sign up and pay on spellgrove.com for a server of your own and enter its address on the account screen. The issue holds his words, what already exists (two realms side by side on the droplet, the hidden server picker, the AI Task Runner Platform’s sign-up-and-container shape) and the research to do first — above all whether the stores require it to be an in-app purchase too. It ties to the own-house work (world-map-and-portals, #42): a server’s eighty homes make it full.

Eighteen of twenty-two pieces done: the memos, stages 0–4, the APK, real accounts (2026-08-12) with their audit and hardening (2026-08-13), the first iOS build, the base-URL split, the account screens and single-identity accounts (2026-08-28), and the Play Games and Game Center server halves — their device halves built by platform-sign-in on 2026-09-12. Then the pre-launch security audit of 2026-09-12/13 (docs/handoff/security-audit.md): 27 findings patched — sessions expire, the Area processes are capped, the control channel checks every message, the box runs two sandboxed service users. Superseded by that audit’s plan: Resend (was 012) and reset by hand (was 017), both folded into issue #1, mail from the box; the domain (was 018) is live. Idea held: multiple characters under one login (was 022) — a design sketch, not scheduled. Open issues: epic:server-architecture — #2 the capacity picture (below), #3–#6 its follow-ups, #43 private servers.

#2, 2026-09-14 — the instruments are built; the soaks have not run. An Area can now be asked for its tick percentiles, its bytes and its RSS every N seconds while it is live (--tick-stats-sec, game/world/tick_stats.gd); the director prints its own line including event-loop delay (src/stats.ts, DIR_STATS_SEC); the five knobs area_server.gd has always parsed but the director never passed (--interest-radius and friends) are now env-settable — so production has been running the GDScript defaults by accident, and interest_radius=0 (every peer gets every entity, ten times a second) can finally be A/B’d against a real radius. Everything defaults off and a test asserts the spawn argv is byte-identical without it (director suite 341/341, GDScript suite 458/458). Also built: an on-box sampler (ops/droplet/bin/capacity-sample.sh), a load driver that simulates players with plain WebSockets rather than whole Godot clients (cli-test-client/capacity.ts), a probe seeder that goes around the 20-signups-an-hour limit by writing to Postgres directly, and tools/capacity/analyse.mjs. The plan (Kris’s calls: a scratch droplet for the destructive ladder, a second syd1 droplet as the load source, the “several Areas in one process” spike included, an overnight endurance hold) is in docs/handoff/capacity-picture.md. The numbers landed the same day. The ladder ran: 60 Areas / 60 players sustained on the 1 vCPU box at a 99 ms median update gap with zero stutter, breaking at 80. Applied to live from it: OOMPolicy=continue (one Area being OOM-killed used to restart the director and drop every player — measured, then fixed, then proved fixed by re-running the rung), MAX_ACTIVE_AREAS 12 → 40, and the swapfile 2G → 4G. The follow-up work is filed as issues #3 (pool home Areas), #4 (flow fields re-sweep on every player step — 70% of a mini-game’s tick), #5 (the broadcast is quadratic in peers) and #6 (skip subsystems an Area does not need). Write-ups: docs/handoff/capacity-picture.md and docs/handoff/area-tick-costs.md. One is already in hand and it is the shape of the answer: an Area with zero players still runs its full 20 Hz loop and broadcasts ten times a second — the epic’s own “an empty area is a frozen area” decision is not what the code does. Past task files (to 2026-09-13): workshop/historic-docs/epics/server-architecture/tasks/.

What this is

The architecture for the software that runs Spellgrove’s 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. Since Release C (2026-11-01, docs/handoff/pooling.md): homes may share a process. With POOL_SIZE above 1 a pool host (game/world/pool_host.gd) runs up to that many home Areas in one Godot process — each still its own Area with its own port, control socket, spawn token and snapshots — because an Area’s cost is the engine and its scripts (~50 MB), not its content (~0.16 MB). Everything that is not a home stays one process per Area. POOL_SIZE=1 is the old shape exactly.

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 workshop/historic-docs/epics/server-architecture/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 workshop/historic-docs/epics/server-architecture/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:

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

Deliverables (3)

Work on this epic

The granular work is GitHub Issues, one label per epic: epic:server-architecture (private repository — sign-in required). The dated Where this stands section above names the open ones. The task files that carried this epic until 2026-09-13 are kept in the repo: task files to 2026-09-13 .

Ask an agent about this epic

    Also available as raw markdown.