Spatial Cells
How one world could run on several zone-server nodes - the coordinate partition, who owns an entity, the gateway role, ghost entities, routing by account, and the handoff. Design only; none of it is built.
One world, several processes. The world is one 128 km torus. There are no instances, dungeons or maps to split along, so the only axis is coordinates. Each node simulates a fixed region of the surface. A player connects to any node, and their entities are simulated wherever the map puts them.
This is the second of two ways to grow. Path to a Cluster lists what blocks it, what already helps, and the order of work. Read it first.
The partition
The surface is divided into a fixed grid of square regions. The grid never moves. Which node owns which region is data: a table that changes at a chosen moment, not continuously.
Boundaries do not move on their own, on purpose. The whole interest layer is keyed on a stable ChunkPos.
Moving a boundary invalidates announced, sent, subscribers and residents for every chunk that changes
hands, on both nodes at once, and it needs a mass handoff of everything inside. That is fine in a maintenance
window. Done continuously, it is a handoff storm.
Region size
A region needs a ghost band along its border: the strip in which it must know about the entities of its neighbours. The band is 7 chunks, 224 m. A client can hold chunks up to 6 chunks away (the view radius of 5 plus a release margin of 1), and one more chunk is the margin for interactions.
With a region edge E and a band b, the share of a region that lies in the band is 1 - ((E - 2b) / E)².
| Region edge | Regions in 128 km | Share in the band |
|---|---|---|
| 2 km | 4096 | 40% |
| 4 km | 1024 | 21% |
| 8 km | 256 | 11% |
| 16 km | 64 | 6% |
8 km is the knee. Smaller regions spend too much on borders, and larger ones give less room to spread load.
The torus is not a detail
worldgen.wrap-x and worldgen.wrap-y are both true, so the surface has no edge. Every region has neighbours
on all sides, a node can be its own neighbour, and the same peer can appear twice with two different
coordinate offsets. No region gets to skip the wrap case.
The terrain layer already handles this: ChunkService.normalise says which chunk a coordinate really is, and
ChunkEntityVisibility keys its chunks through it. The gameplay layer does not wrap.
Vec3L.distanceis plain Euclid on raw coordinates.AreaOfInterestServicekeys its grid cells on raw coordinates, with no modulo.SpawnerCellIndex.keyOfdoes not wrap.
WorldWrap in the worldgen module has deltaX, deltaY and distance for this, but no gameplay code calls
them. The gap never shows today. The seam sits inside the 2.5 km forced ocean margin, which is far wider than
the 208 m a client can hold. Splitting by coordinate makes the seam reachable from everywhere. A toroidal
gameplay layer is a prerequisite, not a follow-up. It is worth doing on its own, with tests, before any
distributed work starts.
Who owns an entity
The node that owns the region containing an entity’s
Positionis the only writer of that entity. Every other node holds, at most, a read-only replica.
Authority is derived, never stored. Given a position and the region table, any node can compute the owner without a lookup. A table from entity to node would have one row per entity, and it would have to stay consistent across the whole cluster. A table of regions is small, can be cached on every node, and changes rarely. Only the few entities in the middle of a handoff need an override.
The gateway role
A player connects to any node, round-robin. That node is their gateway. It is usually not the node that simulates their master.
This is on purpose. The alternative is to pin the socket to the node that owns the master and move it on every border crossing. On a seamless world that means either a reconnect hitch every few minutes, or a relay that is torn down and rebuilt all the time. Round-robin keeps one connection for the whole session. It also gives one code path: to the simulation, every player is remote. No fast path exists to rot while the slow path carries production traffic. The cost is one hop inside the data centre on player input, which is small against a 50 ms tick.
graph TB C[Client] <-->|socket| G["<b>Gateway role</b><br/>any node, round-robin<br/>streams terrain itself"] G <-->|"input forwarded,<br/>updates relayed"| S1["Sim node A<br/>owns the master"] G <-->|"OwnerOnly updates<br/>for an owned bestia"| S2["Sim node B<br/>owns a bestia"]
What the gateway does:
- Owns the socket. Authentication, rate limits, idle checks and the one batch per client per tick
(
TickOutbox) already live insocket/andmessage/. It also receives every message addressed to that account, from anywhere in the cluster. - Serves terrain itself. A chunk is a base plus player edits. The base is a pure function of the world
seed, so every node can generate any chunk. The gateway streams the player’s view volume out of its own
ChunkService, whoever owns those regions. Only the edits have to travel. - Holds a ghost of the player’s own master, as the anchor for that streaming.
ChunkStreamSystemfinds each client’s anchor with a local query overPosition,AccountandActivePlayer, so an anchor must exist locally in some form. An explicit anchor source would remove that need. - Keeps the session epoch. The directory maps an account to its gateway and to the node that owns its
master, with a lease. Each new connection bumps the epoch. Late messages from an old gateway are dropped.
Today only channel identity guards this (
ChannelRegistryremoves a channel only if it is still the current one), and that works only inside one node.
The gateway is a role first, a process later. The first version runs the role inside every zone node. That
reuses the Netty pipeline, ChunkService and TickOutbox as they are. A standalone gateway process would
need its own chunk streaming stack (chunk generation, subscriptions and the anchor), and that is the costly
part. Split it off only if measurements ask for it.
Ghost proxies
A node needs to know about entities it does not own, to draw them for its own clients and so its own creatures can notice them. Ghosts come in two tiers, because these are two problems with very different prices. A client holds chunks up to about 208 m away, but interaction reach (sight, talk, trade, skills) is about 10 m.
Render ghost: not an ECS entity
This is the cheap tier. It covers the whole ghost band. A render ghost is an entry in a replica store,
EntityId → Map<ComponentType, EntitySMSG>, holding the last message per component type, as sent.
Everything a node does with a foreign entity is already message-shaped:
- Index it so
observersOffinds it. This needs only itsPosition, throughChunkEntityVisibility.reindex. - Forward its updates to local sockets. The update is already an
EntitySMSG. - Send the “appeared” burst to a client that just subscribed.
EntitySnapshotBuilderalready returns a list ofEntitySMSGfor each viewer.
So the replica’s state is the list of messages. There is no decoding into components, no component stores, and no way for a system to write to a ghost by accident.
Which components replicate needs no new rule. Replicate those whose syncTargets resolves to
PublicInRange. That is what a stranger may see, and it is the filter the snapshot builder already applies.
OwnerOnly components never replicate. Components addressed to Accounts(ids) or OwnerAndParty reach their
accounts through routing (below), not through a ghost.
Health shows that the rule is right. It returns PublicInRange for an entity with no Account and
OwnerAndParty for one with an owner. A mob ghost carries HP, and a player ghost does not. That is
what happens in one process today.
Simulation ghost: a real, marked entity
The replica store is invisible to world.query. That is the point, but it means perception cannot see a
player who stands a few metres across a border, and creatures would stand idle at the line.
So inside the interaction band only, about one chunk deep, promote the replica to a real entity. Create it
with the same id through World.createEntity(id) { … }, give it a small set of components and a Ghost
marker, and register it in EntityAOIService, so perception and area effects find it. It must be
excluded from every system’s query.
That needs a negative term on Query. Today World.query only takes positive types, and callers filter with
world.has inside each. The addition is small but touches every system, so it lands on its own.
The band is about 2% of an 8 km region, against 11% for the render band. So most ghosts stay cheap message relays.
Node-level chunk subscription
This is how a node learns about entities it does not own. The protocol is ChunkSubscriptionService with a
node id where the account id goes, and it attaches to hooks that exist today.
| Single process today | Between nodes |
|---|---|
subscribers: chunk → Set<accountId>, plus a count per column | chunk → Set<nodeId>, held by the owner |
markSent / unsend, counted | The same, counted per node |
onChunkSent / onChunkUnsent, onFirstSubscriber / onLastSubscriber | The same hooks, with a second listener pair |
residents: chunk → Set<EntityId>, residentsOf(chunk) | Unchanged. The owner walks it to answer a subscribe |
observersOf(entityId) | Returns the local accounts and the remote nodes |
EntityVisibility.Delivery(accountId, appeared, vanished) | NodeDelivery(nodeId, appeared, vanished) |
Which chunks are simulated is a separate question from which a client holds. ChunkResidency refcounts
every holder: all clients together are one, and each simulation anchor - every master and player bestia - is
another (see Long-Distance Travel). Walk data and ground marks
follow residency; props and what each client is owed follow the subscription.
Subscribe by chunk, not by entity. An entity subscription has a discovery problem. A node must know an entity exists before it can subscribe, which costs a round trip for every mob that walks into view. A chunk is a static address and needs no discovery. The number of subscriptions follows the view volume, not the number of entities.
The flow: the observing node listens on onChunkSent and onChunkUnsent, keeps the chunks it does not own,
counts them per owning node, and sends a batched subscribe. The owner records chunk → nodeId, walks
residentsOf(chunk) and sends a snapshot burst. After that it sends each entity’s broadcast to the
subscribed nodes, one message per node and not one per account.
On the observer, the messages go into the replica store and the entity goes to reindex. From there on,
nothing knows a ghost exists. observersOf, drain(), Delivery and the per-account appear and vanish
folding all treat it like a local entity.
Routing messages by account
OutMessageHandler is the seam. It is an interface, ChannelRegistry is its one production implementation,
and OutMessageProcessor sits in front of it and is injected in many places. A routing implementation slots
in behind it, and no call site changes.
OutMessageProcessor (unchanged)
└── RoutingOutMessageHandler (new)
├── local account → ChannelRegistry (unchanged, no added latency)
├── remote account → cluster link, forwarding already-framed bytes
└── unknown → dropped, as today
ChannelRegistry keeps its meaning: local sockets. That includes the atomic displacement that settles two
clients racing to log in as the same account.
How each audience resolves:
OwnerOnlyis one account id, taken from the entity’sAccountcomponent, and handed to the router. This works without any change. It alone makes a bestia that is simulated on another node update its owner’s client. No ghost, no subscription, nothing spatial is involved.Accounts(ids)works the same, per account, and so doesOwnerAndPartyonce it is resolved to the owner and the party. Group them by node, so a party spread over three nodes costs three messages and not five.PublicInRangeis the one that changes. Locally it fans out tosubscribersOf(chunk)as today. Remotely it goes once per subscribed node, and each node resolves its own local observers. Thirty players on one node who watch a boss on another cost the owner one message.
Two things must be fixed first.
- One audience resolver.
sendToObserversOfalready follows the chunk subscriptions. One radius sender is left,sendToAllPlayersInRange, and its only production caller isBestiaTrapSystem. Move it over, then delete the radius path. - Encode once per audience. Chunk data is framed once and shared (
NettyChunkFanOut).ChannelRegistry.broadcastdoes the same, but no production code calls it. Entity updates and events still loop oversendToPlayerand are encoded for every recipient. For a remote node, a message must be encoded once and sent once.
Handoff
This is what happens when an entity crosses into a neighbour’s region.
Trigger. ZoneEngine.reindexMoved() already drains the log of moved entities and calls
entityVisibility.moved. The check belongs there. Hand over when the entity is about a chunk past the line,
not at it. Without that margin, a player who paces on the border is handed over 20 times a second. The chunk
stream uses the same idea, with release-margin-chunks.
Protocol. Freeze (a marker that takes the entity out of every system), snapshot, transfer, and the target creates the entity with the same id. Then the target acknowledges, and the source evicts.
There are three traps.
Do not reuse
EntityPersister.snapshot. By contract it is lossy: “a snapshot only needs to carry mutable state”, and everything else is rebuilt from templates on load.
Do not build the handoff from
toEntityMessage()either. That method makes a message for the client, not a copy of the component. The snapshot builder also skipsOwnerOnlycomponents, such asInventory,EquipmentandExp. Examples of what a message leaves out:
Castingsends the seconds left, not the skill, its level or the target.Craftingsends seconds, not the recipe.StatusEffectsdrops the source entity, andPathdropsgroundResolved.AiAgentis not synced at all: its goal, plan and memory exist only on the server.- About two dozen more components have no sync, among them
TakenDamage,AttackTarget,InCombat,Trading,PartyMembershipandKnownSkills.A handoff needs its own codec over the full component set. A boot check that fails when a component has no codec and no “not handed over” mark would keep a new component from being forgotten.
Do not use
World.destroy. It runs the destroy listeners, and theZoneEnginelistener sends aVanishEntitySMSGto every observer. Every client on both sides would see the entity blink out and back in at each crossing. A handoff needsWorld.evict(id): it removes the entity from the stores and indices with no vanish, and puts a replica in its place, because the old node is now very likely an observer of its own former entity.
Fencing. Each entity has an epoch that goes up on every transfer. A replication message or command with a
stale epoch is dropped. That is one int, and it makes the protocol safe against a link flap or a relay
that arrives late. It is a different counter from the session epoch at the gateway.
Why the client sees nothing. The id never changes. The handoff takes a couple of ticks, and MoveSystem
sends a position only every 8th step anyway (POSITION_RESYNC_STEPS). The client animates from Path and
Speed and already accepts gaps of several steps. A gap of about 100 ms in authority is below the detail that
the protocol carries.
Transport
There are four jobs with different needs. Do not use one mechanism for all of them. Of these, zone-server has only Valkey today, run for the API Server and the login server’s kicks, with a directory of which node holds an account.
| Job | Needs | Suggested choice |
|---|---|---|
| Replication and relay of messages | High rate, low latency | Direct node-to-node Netty with protobuf |
| Handoff and calls between nodes | Rare, reliable, in order | A second stream on the same link, plus the epoch fence |
| Region table, node ids and membership | Rare, and consistent | etcd with leases |
| Session directory, account to node | Frequent reads, may be a bit stale | Valkey |
| Party, chat, trade escrow, economy | Low rate, must arrive | Valkey: pub/sub for chat, Streams for the rest |
Netty, the framed protobuf codec and the fan-out are in the repository, so the first two jobs need no new
dependency. A broker is not advised for them. It adds a hop on exactly the path that is being sped up, in
exchange for subscriptions that ChunkSubscriptionService already does. Ordering inside one TCP connection
is also exactly the guarantee needed, and a broker cannot give it.
The last two rows share one Valkey deployment. API Server gives the reasons, the licence and the failover setup, and adds the companion app as a second client.
The region table and the session directory must not share a store. etcd holds a lock. The region
assignment is the fence that makes two writers of one entity impossible, and that is the one failure this
design cannot absorb, because two writers duplicate a player’s items. The same lease can give each node its
zone.shard-id, so two nodes never mint the same snowflake node part. Valkey holds a cache. A stale
account → node entry costs one extra hop or one dropped message, which is what an unknown account costs
today.
Open problems
These are listed, not hidden. None has a cheap answer. Each needs a decision before the feature it governs can cross a border. The blockers that are not about space, such as global sweeps, are in Path to a Cluster.
- Trade across a border.
TradeServicekeeps its sessions in per-process maps. The pairedputIfAbsentinclaim()is the only thing that stops a player being in two trades. Across nodes it guarantees nothing, and a player in two trades is the classic way to duplicate items.TradeRangeSystemalso ends a trade withPARTNER_GONEwhen it cannot find the partner’sPosition. The trade range is 10 m, so a trade can only cross a border inside the ghost band. That makes it a small, closed problem. - A mob that is fought from two nodes.
TakenDamageis one component on the victim. It feeds EXP and aggro. With two writers there are two damage tables and no place to merge them, because death destroys the entity in the same tick.DeathSystemfinds the attacker’s account through theAccountcomponent, so a remote attacker has none and is counted as a creature without an owner. - Aggro ends at the border.
PerceptionSystem.recentAttackerdrops an attacker that is not alive in the local world. A player can reset a fight by stepping over the line. - Dens are stocked twice.
WildSpawnerBootRunnercreates the den entities on every boot, with new ids.SpawnerSystemfills a den when local players are near. Two nodes that see the same den on a border will both stock it, with different entity ids. Authority over a den has to follow authority over the region. - Navigation stops at the border. Local pathfinding treats chunks that are not resident as impassable, and
residency follows the players and anchors of one node, so the far side of a border reads as unwalkable.
MacroGraphServiceholds one blocked-edge overlay, so a bridge destroyed on one node stays walkable on the others until a traveller there sees it.
Last updated 09 Nov 2026, 23:57 +0100 .