Skip to content

Server core design

rebaserHEAD edited this page Oct 16, 2026 · 20 revisions

design, published verified 2026-10-15

The design for the server core we're building: how one process splits the game between owners, how every message is declared, how state reaches the database, how the core is hosted, and how we check all of it against the client. It's agreed and mostly not built yet; Roadmap says in what order it gets built and how far along that is. Creature AI, the volume of combat rules and the volume of content are out of scope here: this page fixes their shape, not their contents.

The idea

In short

One process holds three kinds of owner (the account, the realm, and one per map instance), each single-threaded over its own state and reached only through its message queue. A gateway holds every client connection and routes each message by an opcode table, checked against the tag the client already puts on every message. Every message is declared once, with the width of every field, and its reader and writer are generated from that declaration and checked against the client's own layouts. An open-world zone is one map that everyone in it shares. The core is built to be hosted publicly, and ground truth is the client and play, never agreement with another server.

Why a new core

NexusTomorrow started from NexusForever's game_rework branch. We kept the leaves that carry over cleanly (the game table layer, the enums, cryptography, file IO, the map tools) and we're rewriting the trunk: process topology, threading, entity lifecycle, persistence, the codec and verification. The login front door, permissions and GM command definitions lived in the trunk, so they come back later, lifted from the old code when the core is ready for them.

The trunk gets rewritten either way. The player core, maps, the send path and persistence are mostly plumbing that this design replaces, whichever route we took.

The riskiest systems need mechanisms the old trunk doesn't have: one event dispatch, transfers between owners, replication that serializes a change once, server-side geometry, a settled content API. Retrofitting them into code written on the assumption they don't exist costs more than building on them from the start.

Most of the future is new behaviour anyway. The old server sent 270 of its 609 message models. The game rules port: about 40 to 45% of the old game code is rules that carry over nearly as written.

The cost is a dark period. There's no playable server of our own until milestone one runs. What would reverse the decision is a need for a continuously playable server built from our own code before then, or too few hands to carry the porting. Neither holds today.

What the client fixes

The client is fixed, and these facts about it shape everything below.

Fact What it decides
Every in-game client message carries a routing tag: 3 in the world, 11 realm, 19 account. Pregame messages carry none Three owning sides; the opcode table routes to them and checks the client's tag
One socket per session, and the client changes server only at login or Realm Select Maps in other processes would need every session relayed to them; in one process the gateway hands each message straight to its owner
The client owns its own movement and drops server movement for a unit it controls Corrections revoke control, move with a time reset, and grant it back
Replication is absolute snapshots, with no culling or cap on the client, and duplicate creates are ignored AOI (area of interest, who is sent what) is ours, and resending current state is always safe
The client never extrapolates another entity: after its last movement key, it holds there until the next one arrives Sending a distant entity fewer keys means playing it later, not just sending less
The update interval is 125 ms by default, settable from 25 to 1,000 ms per instance, with a clock per entity The tick rate and the update interval are ours to choose, per instance
The server decides every spell outcome; the client predicts only the global cooldown Every hit test and all combat geometry live on the server
The client evaluates the same table rows the server must: spell conditions, target rules, prerequisites, telegraph shapes Shared evaluators have to agree with the client's
The transport pings with raw opcode 0 every 500 ms and expects opcode 1 back The gateway answers it
384 game tables, all loaded by the client, with their schema embedded All 384 are typed data, and the client's schema is the authority
Never shipped with the client: spawns, patrols, loot, vendor stock, creature AI, encounter scripts, trigger volumes Ours to invent or source
A cast is sent once its blocker has 500 ms or less left The server keeps one pending cast per caster, not a tolerance
Crowd control needs two messages with one id: the CCStateSet effect and ServerEntityCCStateSet (0x087F) Crowd control is a two-message protocol
Effect ticks never run on the client, and effect flag 0x2 means "until removed" The server emits every tick's result

How it fits together

The shape

flowchart TD
    CL["Client connections"] --> GW["Gateway"]
    WEB["Console, portal, CLI"] --> CMD["Command layer"]
    GW -->|tag 19| AC["Account owner"]
    GW -->|tag 11| RE["Realm owner"]
    GW -->|tag 3| MA["Map owners"]
    GW -->|telemetry, reports| OPS["Operations sink"]
    CMD --> AC & RE & MA
    AC & RE & MA -->|saves, loads| WB["Write-behind queue"]
    WB --> DB[("PostgreSQL 18")]
Loading
Owner Holds Runs on
Account Accounts, entitlements, account currency, account-wide collections Its own thread
Realm Characters at rest, presence, chat, social, guilds, groups, housing communities, mail, matching Its own thread, built as modules
Map, one per map instance The world: its entities, and the characters in it A shared pool for the long tail of maps, or a dedicated thread for a heavy one; each map runs on one thread at a time, to its own deadline

The gateway frames, decrypts and unwraps each message, looks up its opcode's row in the opcode table, checks the client's tag against it and posts the message to the owner's message queue. It also runs the login exchange itself, and never touches game state. Pregame messages carry no tag, so login goes to the account owner and character select to the realm. Everything else in the process, the web host and the write-behind queue included, talks to owners by message.

Knobs

Knob Default Sets
TickMs 25; 125 for housing plots and small social instances The map tick per instance type, 40 Hz in the open world. It divides 250 ms
ReplicationEveryTicks 5; 2 for housing plots and small social instances The update interval per instance type, 125 or 250 ms, sent to the client explicitly
MapPoolThreads not set yet The shared pool that runs the long tail of maps
DedicatedMapThreads not set yet How many threads heavy maps may hold between them
CastQueueWindowMs 500 How early a queued cast may arrive, the client's own default
EventCascadeLimit not set yet How deep events raised by event handlers may chain in one drain
MaxUnsentBytesPerSession not set yet The soft limit on a session's unsent backlog
UnsentOverLimitSeconds about 10 How long a backlog may stay over the soft limit
HardUnsentBytesPerSession several times the soft limit The backlog that drops a session at once
SaveWorkers, LoadWorkers not set yet The write-behind queue's two worker pools
IdBlockSize 1000 How many ids a sequence hands out per refill
Client listener addresses loopback Where the STS, Auth and World ports listen
ConsoleUrl loopback Where the admin console listens

Which map types start on a dedicated thread, and how many overruns promote a pool map, are data as well. AOI and sleeping cells have their own settings, listed with them below.

The systems

The core is built as these systems. Each one's design is on this page until the work to build it is planned; then it moves to a page of its own, and an explanation and reference pages follow once it lands, as Documentation standards describes. This table always points at a system's current page.

System Does Described on
Host Composition root, configuration, start-up and orderly shutdown Ownership and the tick, Hosting
Gateway Client connections: framing, decryption, the opcode table, login, and each session's writer The gateway and the client ports
Codec Reads and writes every message from one declaration Codec design, Message audit design
Owners The account, realm and map owners and their message queues Ownership and the tick
Map scheduler Runs maps to their deadlines on the pool and dedicated threads Ownership and the tick
Timer wheel Millisecond timers, waits and cooldowns per owner Ownership and the tick
Event stream Typed game events per owner, dispatched from a queue Events and read models
Read model What a map needs from the realm or account, pushed as refreshes Events and read models
Entities World entities, units, players and creatures, and player components Entities, replication and the codec
Replication Dirty sets, AOI, serializing once, the flush Entities, replication and the codec
Persistence Change sets, the write-behind queue, id blocks, one context per schema Persistence
Rules The cast evaluator, the effect engine and the shared formula curves Authority and combat
Content Tables, content files, the importer and validator, trigger volumes Content and encounters
Encounters C# scripts bound by data id, the signal router, waits Content and encounters
Command layer Typed admin commands, permission checks and the audit Admin console and player portal
Web host The admin console and the player portal Admin console and player portal
CLI The local command front end Admin console and player portal
Harness The server in-process with headless clients, and its controls Verification
Tools Bring-up, the content importer and validator, later the geometry bake Repository and releases

Decisions

Ownership and the tick

One world. An open-world zone is one map instance that every player in it shares, never copied into layers, shards or channels, so the people in a place can always see and interact with each other. A crowded zone is answered by making its map's tick cheaper: AOI, update rates, a dedicated thread. Instanced content (dungeons, raids, PvP, housing) stays instanced, as the game designed it. That makes one map's capacity the ceiling on how many players can gather in one place, and the load test under Hosting measures exactly that.

A character belongs to the realm at rest and to its map while in the world. On world entry the realm hands the character off to the map, which owns it until it leaves or logs out and then hands it back. Maps feed the realm a location record for /who, friends and local chat.

Seven request-and-reply protocols cover every message whose first check and owner differ. The client tags a message by where its first check runs, not by who owns the result, so forwarding is normal and the boundary doesn't move.

Protocol Direction Used by
P1 Map to realm, after a local check Groups, matching
P2 Realm to map Local chat, item links, guild notices
P3 Escrow, map to realm Sending mail, listing on the market
P4 Release, realm to map Taking mail, market fills, the guild bank
P5 Account spend The storefront, account currency
P6 Character handoff, realm to map and back World entry and exit
P7 Map to map, through the realm Housing donations and placement

Every account change a map asks for goes reserve, confirm, apply, commit: the Try-Confirm/Cancel (TCC) pattern.

The realm is built as modules: presence, chat, social, guilds, groups, housing communities and the realm core, each owning its data and calling the others directly on the realm's thread. Moving one into an owner of its own later is a table edit and a thread, taken when the load test shows the realm is the bottleneck, presence and chat first. A map keeps the same kind of seams (positions, combat, inventory and economy, progression, housing) on its one thread.

Maps share a pool, and heavy maps get their own thread. A pool sized to the cores runs the long tail (housing plots, small instances, quiet zones), earliest deadline first, each tick run to completion. Map types named in data as heavy (capital cities, raids, warplots) start on a dedicated thread, and a pool map that keeps overrunning its tick is promoted to one and returns to the pool when it settles. Pool and dedicated threads together stay within the cores left after the gateway, the realm and account owners and the garbage collector.

The map tick runs in a fixed order: drain the message queue, run due timers, update active entities, drain the event queue, flush replication on the update interval, and hand dirty state to the write-behind queue. A missed tick is skipped, not replayed, and every tick records its duration and allocations.

The tick and the update interval are set per instance type, as data in the world files, with a runtime-settings override per type that takes effect at the next map load.

  • The tick divides 250 ms (25, 50, 125 or 250 ms), so every effect tick on a multiple of 250 ms, 98% of them, still lands exactly.
  • The update interval is a whole number of ticks inside the client's 25 to 1,000 ms, sent to the client, and fixed for the instance's lifetime.
  • The open world, capitals, dungeons, raids and PvP start at a 25 ms tick and 125 ms updates; whether PvP needs a shorter interval is the load test's call. Housing plots and small social instances start at a 125 ms tick and 250 ms updates. All of them get tuned from play at milestone one.

Every rule is in milliseconds, never in ticks. Timers, cooldowns and effect ticks live on a timer wheel, so the tick rate only sets the quantization. Cooldowns are absolute timestamps.

Queued work holds ids, never references. Queued messages, timers and continuations look their entity up again when they run, and drop out if it's gone.

Only what a player can reach runs. Each grid cell in a map carries an awake flag, recomputed when a player crosses a cell boundary.

State When What runs
Awake A player is within the wake radius Full AI, sent to clients
Offline An ambient population with no player near A zone director moves the population's record at its own cadence; no creatures exist
Asleep Static and named spawns with no player near Nothing. Respawns and cooldowns stay correct because timers are absolute

The radii nest (fold back beyond become real, become real at or beyond wake, wake beyond AOI's admit radius), so nothing is ever seen asleep and records don't thrash, and a cap limits how many records turn real per check. Creatures in combat, encounters in progress and map-level systems (public events, respawn timers, the director) stay awake regardless. The settings are the wake radius, the become-real and fold-back radii, records turned real per check, and the director's cadence per map type. The core builds the cell flags, the skipping, a slot in the tick for map-level systems on their own cadence, and the call that turns a record into creatures and back; what the director decides belongs to the combat track.

Nothing ticks or saves in step. Each map's first deadline and its save sweep are jittered.

A stalled owner is reported, not waited on. A watchdog reports an owner that stops completing ticks or draining its message queue, with its stack; in the test harness, a stall fails the test.

A failure costs one session or one entity, not the server. A message handler that throws is logged with the message and the session, that session is disconnected, and the tick goes on. A map update that throws skips that entity for the tick and counts the fault; repeated faults stop the map, disconnecting its players with their state as of the last confirmed save. No catch hides an error.

Shutdown is orderly: stop accepting connections, drain the message queues, hand every character back to the realm, wait for every save, exit.

Note

On Windows the core waits on a high-resolution timer, because .NET's timers there step in 10 to 16 ms increments, too coarse for a 25 ms tick. On Linux it sleeps.

The gateway and the client ports

The gateway routes by an opcode table, one row per client opcode: its owner, its message priority, its rate limit per session (a token bucket, with a rate and a burst), the action past that limit (drop, log, kick or ban) and a kill switch, flipped through the runtime settings so an exploited system can be turned off without a restart. The client supplies the routing tag, so the gateway checks it against the row and disconnects on a mismatch. CI checks the table against the client facts: every opcode the client sends has a row, and every row's tag is the one the client uses. Client telemetry, bug reports, tickets and surveys go to an operations sink and never reach a map.

Each message queue drains by priority. High-priority messages (movement, casts, targeting) drain in full every tick, held in check by their rate limits so they can't starve the rest. Normal messages drain within a time budget per tick with a guaranteed floor, and the leftovers wait a tick.

The gateway runs the login exchange itself: the STS exchange and its SRP6 proof, reading the salt and verifier with an async read, which it can do because it owns no game state. The account owner only decides: banned or suspended, already online, issuing and checking the game token and the realm session key, and loading entitlements.

  • An unknown account gets a fake but stable salt and verifier, so the exchange looks the same either way and never reveals which accounts exist.
  • A second login replaces the first. The old session is dropped, and its character is handed back and saved before the new session can select it.
  • Tokens and session keys are single use and expire within about a minute.

The client ports take hostile input. The inbound frame size is capped: the frame length is 32 bits wide, and the codec's reader already bounds every count and string by the bits left in the body, so the frame is the one unbounded allocation. Every client port limits connections per IP and times out a connection that hasn't logged in, login attempts are rate-limited on STS and Auth, and a session that sends past its overall message rate limit is dropped.

Entities, replication and the codec

Entities are classes in a shallow hierarchy that mirrors the client's entity types: world entities, units, players, creatures and the rest. A player's state is a set of components, each registered once against a lifecycle: load from the saved character, tick only if it opts in, snapshot for saving, send on world entry at a declared position, dispose. The world-entry order is client protocol, so a start-up check fails if a component skips the lifecycle or is missing from the order.

A change is serialized once. Each entity keeps a dirty set; at the flush, each changed message is written once into a pooled buffer and fanned out to every session whose AOI list holds that entity. Each session's single writer then encrypts its own copy with its own key.

A slow client catches up instead of replaying. A newer update of the same kind for the same entity replaces an unsent older one in a session's send queue (conflation), so what lingers in a lagging backlog is events and entry bursts. Two limits decide when a session is dropped, and no other session ever waits on it:

  • Past the soft limit, MaxUnsentBytesPerSession, a session is dropped only if its backlog stays above it for UnsentOverLimitSeconds, so world entry, a packed capital or a decorated house never trips it.
  • Past the hard limit, HardUnsentBytesPerSession, it's dropped at once, which bounds memory against a client that reads slowly on purpose.

The console shows each session's backlog.

AOI lives inside each map, and everyone in range is visible. An entity is admitted at one radius and dropped at a larger one, with a limit on swaps per check. Distance sets how often an entity updates, not whether it's seen: nearby entities at the instance's update interval, distant ones in slower tiers.

  • A slower tier sends fewer keys and plays the entity later, by a timestamp offset per tier. The client never extrapolates, so fewer keys on the old schedule would make a distant player stop and start. An entity that changes tier gets a TimeReset, so its stream never switches clocks silently.
  • Creatures go as path or spline segments the client plays on its own, which costs little at any distance.
  • A per-viewer cap stays only as a safety valve, set high per map type and filled by priority: group first, then target, then whoever is attacking the viewer, then nearest.

The settings are the admit and drop radii, the tiers and their update rates, the safety cap per map type, the priority order and swaps per check, with defaults set from play at milestone one. The cost is that a crowded map's outbound traffic grows with what its players can see, which the load test measures.

Movement is validated and relayed. The server checks speed, jump distance, clock skew and any displacement the client runs itself, and keeps each entity's timestamps monotonic. On a violation it revokes control (0x0639), moves the unit with a time reset, and grants control back (0x0636).

Transport is Kestrel's socket handling, with NoDelay on, send backpressure, one flush per session per tick, the frame length written at the header's true width, and the client's transport ping answered.

Each message is declared once. A declaration is a C# type marked with its opcode and direction, every field carrying its bit width and signedness, plus four computed shapes: a string, a counted array, a union picked by a type field, and a byte-aligned blob. A source generator writes the reader, writer and size for both directions, so the test harness's client side comes from the same declaration as the server's. A field without an explicit width is a build error. A one-off converter turned the old message models into declarations, and was deleted once its work was proven. Codec design has the declaration format, the generator's rules, the facts table and the conversion.

Events and read models

Each owner has one typed event stream, about 46 events. Five of them carry nearly half the bindings: creature killed, progress granted, entity activated, area presence and quest completed.

Dispatch is queued. Events raised during a tick are drained after the entity update and before the replication flush, looping until the queue is empty. Events raised by handlers join the same drain, up to EventCascadeLimit levels deep; past that the cascade is logged and cut. No handler ever runs inside another handler's stack.

Consumers find their rows through indexes, not scans. Quests, achievements, challenges, path missions, public events, reward tracks and scripts match table rows through indexes built at load, keyed by type and data id, so an event touches only the rows that name it.

Crossing owners is a message. Five kinds of event leave a map: guild progress, realm-first claims, content results, account grants and read-model refreshes.

Prerequisites read a pushed read model. Of 273 prerequisite types, 170 can be answered from the map's own state and 38 need data the realm or account pushes as refreshes, such as guild membership and entitlements. The other 65 are unknown or deprecated, and fail closed.

Every collection check goes through one interface: mounts, pets, flair, costumes, dyes and titles. Each type declares whether it's per character or per account, as data, and storage follows that scope.

Persistence

One PostgreSQL 18 database, a schema per owner: account, realm, content (rebuilt from files, below) and ops (the console's audit log and settings). Each schema has its own DbContext and its own migration history, and a CI guard applies every migration to an empty database.

PostgreSQL 18 is the minimum because names and emails use a case-insensitive ICU collation (und-u-ks-level2), and LIKE works on such collations from 18. uint and ulong map to bigint through one convention. jsonb holds only blobs the client owns; unlock bitsets are bytea.

Ids come from sequences in blocks of IdBlockSize, handed out in memory and refilled off the tick, so an id exists before the save does.

No owner waits on the database once the server runs. Owners ask, workers read, and the results come back as messages; only loading at start-up, before an owner ticks, is exempt. The character list and the full load at character select run on load workers. Each load carries a ticket, so a result nobody still wants is dropped, and the result arrives as a message rather than an await on the realm's thread, so the realm can't skip re-checking what changed while it waited. The account owner's entitlements and a housing map's plot load the same way, and a map receives its character in the handoff.

A dirty mark clears only when its commit is confirmed. An owner snapshots its dirty state on its own thread into an immutable change set and hands it to the write-behind queue, where a save worker applies it with a pooled DbContext, one SaveChanges and Npgsql's retry strategy.

The write-behind queue keeps saves and loads in order. Every change set names the persistent keys it touches: character, account, guild, plot. A sequencer keeps one queue per key, all in a single arrival order, and hands a set to a free worker once the set heads every one of its keys' queues, so a transfer that touches several keys stays ordered with no deadlock and no starvation. The sequencer only keeps the books; it never touches the database.

  • One database operation per key is in flight. A load queues behind any save for the same character, and a newer snapshot merges into a waiting save for the same key, carrying both dirty sets. A transfer that touches several keys never merges.
  • A failed save is retried, never dropped. The change set is retried with backoff and holds only its own keys, while owners keep their dirty state. The console shows the backlog, and new logins are refused while the database is unreachable.
  • Transfers are all or nothing. A trade is one change set holding both characters, committed before either client hears about it. Escrow and account spend commit both owners' rows in one transaction.

The connection pool is sized to the workers plus the console. What survives a crash: everything committed. What's lost: anything since the last commit, bounded by the save interval. Transfers are never half done.

Authority and combat

The server decides everything except the player's own movement, which it validates, and displacement the client runs itself (ForcedMove, effect type 3, and VectorSlide, type 142), which it validates against the effect's own math. It runs the same math for creatures.

The cast evaluator does the client's checks, from the same columns: edge-to-edge range (minimum, maximum and vertical), facing, line of sight and its two opt-outs, the 11-bit condition vocabulary, crowd-control masks, the global cooldown kinds and charges. A failed check returns the cast result the client would show. A cast that arrives while its blocker has CastQueueWindowMs or less left takes the caster's single pending slot and starts when the blocker ends.

Effects run on the timer wheel in milliseconds: delay, tick, duration, and "until removed". The server emits every tick's result. Handlers for the 150 effect types decode their data words through the client's own per-type schema, and get built in order of how often each type appears in the tables.

Telegraphs are nine shapes. A target is hit when its centre lies inside the shape grown by its hit radius (not grown under flag 0x200), gated by the effect's phase flags.

Crowd control is two messages with one id, the CCStateSet effect and ServerEntityCCStateSet (0x087F), removed with 0x0880, and timed by the same GameFormula curve the client displays. The rating curves are one shared family, for the same reason.

Two interim defaults hold until the evidence exists. Line of sight passes, counted and logged, until the geometry bake exists. The server revokes a player's movement control for the length of a stun or root, until an in-game check shows whether the client already blocks its own movement.

The map tick allocates nothing in steady state, asserted in the test harness. The runtime uses Server GC, switched to sustained low latency after load.

Content and encounters

All 384 tables load once at start-up as typed, immutable data, each checked against the client's embedded schema.

World data is files, and the database is built from them. One JSONL file per zone, one record per line: spawns, trigger volumes, spline bindings, our vendor stock and loot, creature overrides, script bindings. Records are sorted by id with a fixed key order and canonical numbers, and hold no client strings. An importer rebuilds the read-only content schema from the files in one transaction.

A private pack can layer over the public files. Should a licence ever bring server data that can't be public, such as drops, spawns or creature AI, the importer layers a pack from the server's own disk over the public Content/ files, the private one winning. Public CI validates the public pack and the local tier validates the private one. Nothing from a private pack reaches the repository: no ids, constants or names in code, tests or commits. Behaviour in it is data the public engine runs, because on the FSF's reading a private script assembly loaded into the server would form one combined program with it under the AGPL.

Ids are permanent. A spawn id carries its zone: the world id in the high bits and a per-zone sequence below.

The existing world database is converted once, by a direct parser: 78,031 spawns plus hand-built instances.

Validation runs in two tiers. The public tier checks format, id uniqueness, enum ranges and that every script name exists. The local tier, which needs the client's tables, checks every id against the client and reports coverage: worlds without spawns, vendors and quest givers not placed. That report drives content work.

Creatures' presentation comes from the client's own data. A small per-creature sequencer runs the client's Creature2Action rules (birth, death, combat and awareness events, random branches, waits) as visual effects, plus the bark text. It carries no fight behaviour, because those rules carry none.

Encounters are C# scripts bound by data id: a spell's script id, a creature, a map or instance, a quest, a public event or a trigger volume.

  • A signal router dispatches spell effect type 81 (selector, signal id, payload) by signal id.
  • Handlers cover spawn, death, entering combat, signals and entering a volume.
  • await Wait(ms) resumes on the map's timer wheel, on the map's thread, without allocating, and is cancelled when its entity despawns or its map unloads.
  • Scripts hold ids and look them up again after every wait. The registry is built at start-up.

Trigger volumes are content too, keyed by the volume ids the tables already reference. A one-time generator seeds them from the objectives' marker locations, which cover 468 of the 511 volumes; the other 43 are placed by hand. Each map tests only players against them, through its grid, with hysteresis on enter and leave.

Collision, line of sight and navigation come from an offline bake of the client's terrain and model files, extending the map generator. Its output is built from the client, so it stays on your machine with the rest of your client data.

Admin console and player portal

Every admin action is a typed command, declaring its parameters, its permission and its owning side. The in-game GM chat, the web console and a local CLI all dispatch the same commands. The dispatcher checks the permission server-side, refuses a console-only command from in game, posts the command to the owner's message queue, waits for the reply and writes one audit record (actor, front end, action, target, details, address, outcome, denials included). The console never writes state an owner holds, so an online character is changed by its map.

Permissions are the old role-based set, lifted, with one dotted key per command. Built-in roles can't be deleted. Commands too dangerous without a second factor (role changes, bulk deletes, settings) are console-only, since the game client can't do two-factor.

The web host is Blazor Server inside the process, on its own listener, bound to loopback. Exposing it is a TLS reverse proxy and a configuration change, never a code change.

Signing in to the web uses the same credential as the game. Holding any console permission requires an enrolled TOTP second factor, enforced at the admin gate and when a role is granted. Sessions live on the server with expiry and revocation, sign-in attempts are rate-limited, antiforgery is on, and the host refuses to start without a configured Data Protection key.

The console accepts only its own origin. WebSocketOptions.AllowedOrigins is set, since ASP.NET Core accepts WebSocket connections from any origin by default; CORS is off on the Blazor hub, with a Content Security Policy, X-Frame-Options and WebSocket compression off.

Runtime settings are a typed registry (type, limits, default, description, when a change takes effect). Owners read an immutable snapshot at the start of each tick, swapped by the command layer, and the audit records old and new values.

The player portal replaces creating accounts by console command. It covers registration with email verification, password reset (which writes a new SRP6 verifier), account settings, two-factor enrolment, character pages, and a link to the running server's source, which the AGPL's section 13 owes every player. The message of the day carries the same link.

The command layer and the CLI ship with milestone one, because the test harness drives the server through them. The web console and the player portal follow right after.

Hosting

The core is built to be hosted publicly, and a fresh install is safe by default. Every listener binds to loopback until it's configured otherwise, which still suits hosting for yourself beside your client. A public deployment is configuration, never a code change.

Linux is the reference host, Ubuntu LTS, the same as the CI runner. Windows stays supported for development and for hosting beside the client; the map tick's wait is the only code that differs by OS. Before Linux is called the reference, the table and map extraction runs once on Linux, since the client's paths come from a case-insensitive file system.

A public deployment adds what one process can't do for itself:

  • PostgreSQL backups with point-in-time recovery, and a restore that's been tested;
  • metrics exported from System.Diagnostics.Metrics to an outside collector through OpenTelemetry, as well as to the console;
  • a host with network-level DDoS protection, since nothing inside the process stops a flood.

A load test finds the ceiling. Once the core skeleton lands, a harness scenario puts hundreds of headless clients in one map. Its numbers say how many players one place holds, whether PvP needs a shorter update interval, and when anything below has to change.

It stays one process until one of three things holds: one machine is no longer enough, which the load test says; script faults the process can't survive (a stack overflow, running out of memory, a loop that never yields) take the server down; or garbage-collection pauses shared by every map exceed the tick budget. The split then is map-host processes, each running a pool of maps behind a gateway, with the realm and account in a core process. Layering isn't one of the options.

Verification

Ground truth is the client and play. Nothing is verified by agreeing with another server.

Every message is checked against a client-facts table: per message, its direction, whether it's fixed or computed, its total bits on the empty path, and its field widths in order. The table is our own data, public in the repository. It catches wrong widths, missing or extra fields, wrong enum widths, opcode swaps between different layouts and order swaps of different widths. It can't see swaps of equal width, signedness or meaning. Generated round-trip tests cover every message.

The test harness runs the server in-process with headless clients speaking the real protocol. Scenarios drive the server through the command layer and assert on what the clients receive, with expectations written from what the client needs.

Harness control Does
Virtual clock Owners tick on test time
Tick advance n ticks, or until a condition or a timeout
Seeded randomness Logged per test, so a failure replays
Scripted login From the first handshake to in the world in one call
Setting overrides Per test, through the settings registry

Two gates fail a test outright: any steady-state allocation per map tick or per flush, and an owner that stops ticking.

In-game checks are a short checklist per feature on the real client. A test-only add-on, installed only in your client's own add-on folder, records every stock add-on's status and errors after world entry.

Tier Runs
Public CI, GitHub-hosted, no client data The codec facts check and round trips, rule tests on hand-made fixture tables, the migration guard, harness scenarios on fixture tables
Local, with your client's tables and maps Harness scenarios on real data, local content validation, in-game checks

Public CI never prints the client's data. No assertion or log prints a table row, and workflows run with read-only permissions, actions pinned by commit, and no self-hosted runners.

Repository and releases

The layout is Source/, Content/ (the public per-zone files), Tests/ and Tools/ (bring-up, the content importer and validator), with the client-facts table beside the codec. What's there today is on Repository layout.

Bring-up is one PowerShell script: check prerequisites, extract tables and maps from your own client, start PostgreSQL (installed, or one container), run the migrations, import content, start the server. It uses the same binary and configuration shape as a real deployment.

A pushed tag builds a release: a framework-dependent publish, one migration bundle per schema, and checksums. Versions are CalVer, and each release records the content version it imported. A server deploys only tagged releases, which keeps the source it runs public, as the AGPL's section 13 asks.

Rejected

Idea What killed it
Refactoring the old trunk in place The riskiest systems need mechanisms it doesn't have, and retrofitting them into code that assumes they don't exist costs more than building on them
Layering an open-world zone into copies, shards or channels Players in one place have to be able to see and interact with each other. A crowded zone gets a cheaper tick instead
Maps in separate processes from the start The client holds one socket per session and changes server only at login or Realm Select, so separate map processes need every session relayed to them. One process relays nothing, and the split waits for one of the three triggers under Hosting
A thread per map Every house is an instance, so thousands of mostly idle threads would each wake for their own tick, and once they outnumber the cores they're preempted mid-tick
An actor framework for the owners None of them has a tick boundary, and the map tick is the unit everything else is timed against
An entity component system The client fixes the entity vocabulary and takes every change as its own message, so an ECS's flexibility buys nothing the client can use
Routing on the client's tag alone The client supplies the tag. The opcode table decides, and a tag that disagrees with it disconnects
A per-viewer cap that hides players in range In one world everyone in range is seen. Distance changes how often an entity updates, not whether it's there
Checking our behaviour against another server Agreement proves agreement, not correctness. The client and play are the reference
Default field widths in the codec The old models' default 64-bit width for enums caused most of the 70 layout mismatches found against the client
Rules counted in ticks Changing the tick rate would change every rule. In milliseconds, the rate is only a quantization knob
Clearing dirty state before the commit confirms A failed write then loses the change silently
Saving a transfer as two saves The one save-ordering item duplication on record in another emulator came from exactly that
A tolerance for early casts The client sends a queued cast inside a 500 ms window on purpose; a pending slot matches what it does
citext for names and emails An ICU collation does the same job, and PostgreSQL 18 lets LIKE work on it
Permission checks only in the console's interface Any account that gets in could grant itself any role. Every command checks its permission on the server
Reloading scripts while the server runs, in the first version A restart is cheap until it's the bottleneck

Verification status

Claim Basis
The client facts in the table above Established from the 16042 client and its tables, each checked on its own
The telegraph hit rule Inferred, not yet seen in game: the first thing checked once combat runs
The AOI tiers Designed from how the client plays other entities, not yet seen in game: checked at milestone one's "see another player move"
Prerequisites: 170, 38 and 65 of 273 Counted from the tables
98% of effect ticks on a multiple of 250 ms Counted from the tables
The old server's figures (270 of 609 models sent, 40 to 45% of game code portable, 70 layout mismatches) Counted from the old code
The tick rates, 40 Hz and 8 Hz for housing Chosen. Confirmed by measurement once the loop runs
How many players one map holds Not known. Measured by the load test
Stun and root, and the client's own movement Not known. The interim default holds until an in-game check
The world database's 78,031 spawns Counted from its files

Open questions

  • Tick rates: 40 Hz for most maps and 8 Hz for housing are chosen; measure once the loop runs.
  • Milestone one: an in-game pass that opens every window once; whether pregame sends what the client needs to offer the Expert start and the level-50 entry; whether a distant player in a slower AOI tier moves smoothly on another client, and how a tier change looks.
  • The load test: how many players one map holds, whether PvP needs a shorter update interval, and whether a lagging session's distant entities should move to slower tiers before it's dropped.
  • Collection scope: per character or per account, settled for each collection type before its system is built.
  • The combat track: a general creature AI by archetype and difficulty, fed by per-creature ability lists that never shipped; the living world as the direction for creature behaviour, with a director per zone keeping off-screen life as records; lag compensation for telegraph hits, testing a target where its own client reported it at the moment of the hit and bounding how far back; whether stun and root block the player's own movement on the client; ForcedMove's details; the signal selector values and what a spell's script id means at run time; the meaning of the many GameFormula rows beyond those named here; collision and line of sight through the geometry bake.

Clone this wiki locally