Large worlds: regions across hosts
When one host cannot hold the whole world, @net-mesh/browser/world cuts the
map into square regions. Each region is its own
store, hosted by whichever node announces it (usually
a dedicated Node host), and a player holds replicas of the regions around it.
There is no central directory service: the regions are found through the mesh's
own signed announcements.
Regions and the directory
A region of size units is named r:<rx>:<rz>: it covers rx*size <= x < (rx+1)*size, and the same for z. regionOf(x, z, size) names the region at
a position; regionsAround(x, z, { size, radius }) lists the square of regions
around it.
A region host serves one store per region, named by region (several may share a
node), and keeps announcing them. An announcement is a lease, so
announceRegions re-announces every 2 seconds:
import { hostStore } from '@net-mesh/browser';
import { announceRegions, regionDirectory } from '@net-mesh/browser/world';
const host = hostStore({ definition: region, store: 'r:4:7', transport, /* … */ });
const stop = announceRegions(node, 'my-world', ['r:4:7', 'r:4:8']);
const directory = regionDirectory({ node, world: 'my-world', trustedHosts: HOSTS });
await directory.lookup('r:4:8'); // { status: 'hosted', host } | 'unhosted' | 'ambiguous'The directory is discovery, not a root of trust. Any node can announce any
region. trustedHosts names the node ids (16 hex) that may host this world, and
announcements from anyone else are ignored. Without it, a region that more than
one node claims is ambiguous and is never joined, rather than joined through
whichever node answered first.
A player's view
import { joinWorld } from '@net-mesh/browser/world';
import { bindEntities } from '@net-mesh/browser/three';
const view = joinWorld({
node, world: 'my-world', definition: region, collection: 'ships',
position: { x, z }, size: 256, key: 'player', maxEventBytes: 8104,
trustedHosts: HOSTS,
positionOf: ship => ship, // where an entity is, to break ties
});
bindEntities({ store: view, select: ships => ships, /* … */ });
view.setPosition(x, z); // as the player moves
await view.act('fire', input); // to the region the player is in- The view keeps replicas of the regions within
radius(default 1: a 3×3 block) and lets go of those beyondradius + 1, so a player walking along a border does not churn.getState()/subscribe()are one merged entity map, which is whatbindEntitiesreads. actgoes to the region the player is in, and rejects if that region is not ready.currentRegion()names it;regions()reports each held region's phase:looking,unhosted,ambiguous,joining,readyorfailed(retried everyretryMs, default 2000).- No pop at borders. An entity two regions briefly both hold shows once:
positionOfpicks the copy from the region that contains it. One that vanishes from a region the view still holds lingers at its last state forlingerMs(default 500) until it appears elsewhere. A handoff freezes an entity at its source before the destination shows it, and without the linger it would blink.
Moving entities between regions
An entity crossing a border moves from one region host to the next at most once. It is never live in two regions and never applied twice. The failure a crash can cause is an entity that is nowhere live for a while, reported typed, never a duplicate.
import { persistStore } from '@net-mesh/sdk';
import { handoffLink, regionHandoffs, storeRegion } from '@net-mesh/browser/world';
const link = handoffLink({
transport, label: 'my-world.handoff',
peerOf: directory.peerOf, refresh: directory.lookup,
});
const saving = persistStore(host, { file });
const handoffs = regionHandoffs({
link, region: 'r:4:7',
...storeRegion(host, { region: 'r:4:7', collection: 'ships', ledger: 'handoff' }),
persist: () => { saving.flush(); }, // durable BEFORE any message is sent
onEvent: event => { /* moved | refused | unresolved | admitted */ },
});
await handoffs.handoff('ship-7', 'r:4:8'); // frozen here now, live there on accept- The source freezes the entity: it leaves the live entities and waits in
the ledger under a fresh id.
handoffs.locate('ship-7')answersin-transit, and an input for it must be refused, never applied. - The source offers it, and repeats the offer until it hears back.
- The target decides once per id, admitting or refusing, and records the decision. Every repeat of that offer gets the same answer.
- The source settles.
moved: the entity is the target's.refused: it is live here again. Only an explicit refusal brings it back, because silence proves nothing. - No answer within
giveUpMs(default 60 s) isunresolved: the entity stays frozen and is nowhere live twice.handoffs.reoffer(id)re-offers it under the same id when the destination is back, and the destination answers from its record.
Durability before send is the one requirement. persist is awaited after
every change and before that step's messages go out. Without it, a crash could
duplicate an entity.
storeRegionkeeps a region's entities (a store collection) and its handoff ledger (another top-level key) in one document, so the store's persistence saves both together. The definition validates the ledger withparseHandoffLedger(raw.handoff, parseEntity)and hides it from players withvisibility: { handoff: 'nobody' }.handoffLinkcarries the messages and authenticates them: a message that says it comes from region X is accepted only from the nodepeerOf(X)names, so a player cannot forge an offer or a reply.refresh(for exampledirectory.lookup) re-reads the directory when a region is unknown or a sender does not match it; the protocol's retry then succeeds.droppedcounts what was refused, by reason.- Timing (
timing):retryMs(250),giveUpMs(60 000) andhandledRetentionMs(600 000), which must exceedgiveUpMs. A target that forgot a decision could admit a late re-offer a second time.admit(entity, state)may refuse an arrival with a reason.
Across the border
Acting on a neighbour's entity. The owning region decides, and runs each action at most once:
regionHandoffs({ /* … */ actions: {
hit: (ships, input, fromRegion) => ships[input.ship]
? { entities: { ...ships, [input.ship]: damaged(ships[input.ship]) }, output: 'hit' }
: 'no such ship', // a string refuses
} });
await handoffs.forward('r:4:8', 'hit', { ship: 's9' }); // the neighbour's answerA forward rejects with BorderActionError. refused carries the neighbour's
reason. unresolved means no answer within giveUpMs: the action may have run,
but never twice. The target records each outcome in the same commit as its
effect.
Ghosting. With ghosting: { size, margin, positionOf }, a region host sends
each neighbour its entities within margin of their shared border, read-only.
handoffs.ghosts() and onGhosts(listener) give the neighbours' entities near
this region's borders, for collisions or line of sight in the host's own
simulation. They are not authoritative here: to act on one, forward. A
neighbour that goes quiet has its ghosts expired.
The pure protocol steps are exported too (beginHandoff, onHandoffOffer,
onHandoffReply, retryHandoffs, reofferHandoff, onForwardedAction,
ghostTargets, …) for a host that drives them itself.
Native hosts and players
A region host or a player can run in Node on @net-mesh/sdk's
meshStoreTransport(mesh, { listen: [...] }), which also provides the
announce / query this module uses for the directory.
Not built yet: splitting and merging regions for load.