World
Enter-world, spawn/despawn, spatial interest, zones/instancing, server entities, bounds, pathfinding (A*), and spatial enter/exit/dwell trigger volumes.
Seams you implement 14
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayWorld(); and it wins.
IBoundsArea game SPI
A playable area on the XZ plane — the abstract "shape of the map" both tiers evaluate with the SAME code (this assembly ships to the server and, as the embedded contracts DLL, to the Unity client), so client-side prediction and server authority always agree at a wall. Pure data + math: no meshes, no navmesh, no engine types — a game derives an area from its map however it wants (hand-authored box, baked mask, generated maze) and registers the same area on both sides. Reach for a built-in shape first — BoxArea, CircleArea, GridMaskArea — and only implement this interface for a shape they don't cover.
// A ring-shaped arena (walkable annulus) the game shares with BOTH tiers:
sealed class RingArea : IBoundsArea
{
public bool Contains(float x, float z)
{
float d2 = x * x + z * z;
return d2 >= 10f * 10f && d2 <= 60f * 60f;
}
} -
bool Contains(float x, float z)True when (x, z) lies inside the playable area.
-
bool IntersectsSegment(float fromX, float fromZ, float toX, float toZ, float sampleStep)True when the straight segment from (fromX, fromZ) to (toX, toZ) touches the area at ANY point — the swept counterpart of Contains, used by consumers that must catch a fast mover crossing a thin shape entirely between two ticks (e.g. trigger volumes registered with TriggerVolumeOptions.Swept). Method per shape kind: BoxArea clips the segment against its slabs and CircleArea tests the closest point on the segment — both EXACT, ignoring sampleStep; GridMaskArea samples at sub-cell steps; this DEFAULT (any custom shape that does not override) tests both endpoints and then samples the segment every sampleStep world units — a custom shape thinner than that spacing can slip between samples, so override with exact math for razor-thin custom shapes.
-
bool TryGetBoundingCircle(out float centerX, out float centerZ, out float radius)A circle guaranteed to contain the whole shape, when the shape can cheaply provide one — lets spatial consumers (trigger volumes, area queries) pre-filter with an index query instead of scanning a zone. Default: false (unknown extent → caller falls back to a scan).
IBoundsVolume game SPI
A 3D playable/collision volume — the volumetric sibling of the 2D IBoundsArea, evaluated with the SAME code on both tiers (this assembly ships to the server and, as the embedded contracts DLL, to the Unity client), so server authority and client prediction agree at a wall or a kill plane. Pure data + math: no meshes, no engine types. A game reaches for a built-in shape (BoxVolume, SphereVolume, PlaneVolume) or implements this for a shape they don't cover. The core primitive is SweepSphere (a moving sphere — the mover/projectile test); Raycast is the zero-radius case and is provided by default.
-
bool Contains(in Vec3 point)True when point is inside the volume.
-
long Id { get; }Stable id for this volume (reported in a GeometryHit/GeometryOverlap).
-
int Kind { get; }Game-defined kind tag (kill/hazard/water/solid…); the framework never interprets it.
-
GeometryMask Layer { get; }Which collision layer(s) this volume belongs to (see GeometryMask).
-
bool Raycast(in Vec3 origin, in Vec3 dir, float maxDistance, out float distance, out Vec3 normal)Casts a ray (zero-radius sweep) against this volume. Default: SweepSphere with radius 0.
-
bool SweepSphere(in Vec3 from, in Vec3 to, float radius, out float t, out Vec3 normal)Sweeps a sphere of radius from from to to and returns the first contact with this volume, if any.
-
bool TryGetBounds(out Vec3 min, out Vec3 max)An axis-aligned bounding box of the whole volume (for broad-phase pre-filtering).
IBroadcastCullingPolicy game SPI
Network-LOD seam: decides, per subject→observer pair, how often that observer receives the subject's EntityMove. Consulted by the broadcast pipeline at emission time. Not registered → every observer gets every tick (the previous behavior) — the culling layer adds/removes at composition time.
public sealed class DistanceCullingPolicy : IBroadcastCullingPolicy
{
public int SendDivisorFor(WorldEntity subject, WorldEntity observer)
{
float d2 = SquaredDistance(subject, observer);
if (d2 > _farSq) return 0; // beyond the far ring: suppress entirely
if (d2 > _midSq) return 4; // far: every 4th tick
if (d2 > _nearSq) return 2; // mid: every other tick
return 1; // near: full rate
}
} -
int SendDivisorFor(WorldEntity subject, WorldEntity observer)Send divisor for this pair: 1 = every tick (full rate), N = every Nth tick (reduced rate for far/unimportant pairs), 0 = never (suppressed entirely). Called on the poll thread at broadcast rates — implementations must be cheap and allocation-free.
IEntityBounds game SPI
Optional SPI: a PER-ENTITY playable area, tighter than the zone's. IWorldBounds answers "what shape is this map", which every entity in the zone shares; this answers "and where may THIS one go" — a goalkeeper confined to the box, a runner locked to its lane, a rail-shooter pinned to its track, a leashed pet, a turret bolted to its arc, a player held still during a cutscene. Return null (the default) for an entity the zone alone constrains.
public sealed class LaneBounds : IEntityBounds
{
private readonly Dictionary<long, IBoundsArea> _lanes = new();
public IBoundsArea? AreaOf(long entityId)
=> _lanes.TryGetValue(entityId, out IBoundsArea? area) ? area : null;
}
// services.AddSingleton<IEntityBounds, LaneBounds>(); // BEFORE AddCrossplayWorld() -
IBoundsArea AreaOf(long entityId)This entity's own playable area, or null when only the zone constrains it.
IEntityRegionAnchor game SPI
Optional companion to IEntityBounds: where INSIDE its own region an entity belongs. Implemented by whatever defines the regions, so there is never a second copy of the geometry to drift out of step with the first. The question it answers is "this entity's region moved — where do I put it": a lane that re-divided when a participant left, a leash whose anchor walked away, a turret arc that rotated. Bounds are enforced on SWEPT movement only, so the placement that follows legally crosses into the new region; without one, an entity whose region moved out from under it is stranded outside it and can only inch back in.
-
bool TryGetAnchor(long entityId, out float x, out float z)The point inside entityId's own region it should occupy.
-
bool TryProject(long entityId, float towardX, float towardZ, out float x, out float z)The point inside entityId's own region that is CLOSEST to (towardX, towardZ) — "where in my territory do I stand to be nearest that". The default falls back to TryGetAnchor, so an implementation that only knows a region's middle still answers usefully. Why it exists: a body confined to a thin curved region cannot simply be steered AT a target, because the straight line to it leaves the region immediately and the shared resolver holds a step longer than the region is thick. Steering at the projection instead keeps every step inside.
IEntityTerritory game SPI
Optional companion to IEntityBounds: what an entity DEFENDS, as opposed to where it may move.
-
IBoundsArea TerritoryOf(long entityId)The ground entityId is responsible for — its region widened to the whole share it was carved from. Null when the entity has no territory.
IInstanceSpawnPolicy game SPI
Optional per-arrival spawn placement into an instanced zone — the seam the composition-layer Zone instance realizer consults so a SPATIAL game gets scatter (or any per-player layout) WITHOUT writing a full custom realizer. Defined here (the World piece) because it is spatial and both the realizer (a composition bridge over World) and a game (a World consumer) can reach it, and no other shared home may hold a spatial type — the Director piece is Core-only (genre-agnostic, no spatial assumptions), so it cannot. Registered with the framework's DI (a single optional binding, the [InjectOptional] shape): with NONE registered the Zone realizer keeps its stock single-point behavior (every arrival on the world spawn point, no furnishing) — this piece and every consumer behave byte-identically to before the seam existed. A game registers one to customize. The policy is the single IInstanceSpawnPolicy, so it sees EVERY Zone-kind arrival; branch on data (the instance's opaque game payload — map id / mode / queue id) to place only the instances it owns and leave the rest on the default point.
// Scatter an arena's arrivals around a ring; furnish a centre beacon once.
sealed class ArenaSpawns : IInstanceSpawnPolicy
{
public ZoneSpawnPoint SpawnFor(string zone, int arrivalIndex, int rosterSize, long accountId, byte[] data)
{
if (!IsArena(data)) return new ZoneSpawnPoint(0f, 0f, 0f, 0f); // not ours => default point
float angle = arrivalIndex * (6.2832f / rosterSize);
return new ZoneSpawnPoint(20f * MathF.Cos(angle), 0f, 20f * MathF.Sin(angle), 0f);
}
public void Furnish(string zone, byte[] data) { if (IsArena(data)) SeedCentreBeacon(zone); }
} -
void Furnish(string zone, byte[] data)One-time furnishing at container realization — seed opaque server entities the game wants in the arena (a centre beacon, cover, pickups). Runs on the node that REALIZES the container (cluster-correct), which nothing the game could seed post-allocation on the forming node can guarantee. Default: nothing. Like SpawnFor, branch on data to furnish only the instances this policy owns.
-
ZoneSpawnPoint SpawnFor(string zone, int arrivalIndex, int rosterSize, long accountId, byte[] data)The spawn point for arrival number arrivalIndex (0-based, per instance zone) of account accountId into zone zone, whose roster holds rosterSize members and whose opaque game payload is data. Called once per admit. Return the world spawn point to keep the stock single-point behavior for arrivals this policy does not own.
IInterestVisibilityPolicy game SPI
The game's "may this observer KNOW this subject exists" rule — fog of war, spotting systems, bush/stealth mechanics, GM invisibility. Consulted on the interest path for every in-range pair, PER DIRECTION (A may see B while B cannot see A — that is the point of vision mechanics): a subject an observer may not see is never spawned to that observer's client, receives no movement or state broadcasts for it, and is despawned the moment visibility is lost — so a map-hacking client has nothing to read, because the data never left the server.
// Team vision: enemies are visible only while spotted by my team (precomputed elsewhere).
public bool CanSee(WorldEntity observer, WorldEntity subject)
{
if (observer.Owner is null) return true; // NPCs see everything
ushort mine = _teams.TeamOf(_scope, OwnerAccount(observer));
ushort theirs = _teams.TeamOf(_scope, OwnerAccount(subject));
return mine == theirs || _vision.IsSpottedBy(mine, subject.Id);
} -
bool CanSee(WorldEntity observer, WorldEntity subject)May observer's owner know subject exists right now? False hides the subject from that observer entirely (no spawn, no broadcasts, despawn if currently visible). Asked per direction — the reverse pair is a separate call.
ITriggerListener game SPI
SPI the game implements to give volumes meaning — the plug-in point of this piece. The framework does the geometry + the enter/leave/dwell bookkeeping; the rules decide what a capture point or a damage floor does. How to implement. Register your listener in DI BEFORE AddCrossplayTriggers (it is a TryAdd seam). Read Data to tell one volume's purpose from another (capture point A vs. a lava pool), and apply your effect in OnEnter / OnExit / OnDwell. All three fire on the world tick (poll thread).
public sealed class LavaFloorListener : ITriggerListener
{
private readonly IStatService _stats;
public LavaFloorListener(IStatService stats) => _stats = stats;
public void OnEnter(TriggerVolume v, WorldEntity e) { }
public void OnExit (TriggerVolume v, WorldEntity e) { }
public void OnDwell(TriggerVolume v, WorldEntity e, float dt)
=> _stats.Add(e.Id, StatId.Health, -10 * dt); // damage per second while standing in it
} -
void OnDwell(TriggerVolume volume, WorldEntity entity, float intervalSeconds)An entity is STILL inside, fired once per DwellIntervalSeconds. Never fires for a swept pass-through (through-and-out entities never occupy the volume).
-
void OnEnter(TriggerVolume volume, WorldEntity entity)An entity crossed INTO a volume this tick. For a swept volume's pass-through (a fast mover that crossed the shape entirely between two ticks), this is immediately followed by OnExit in the same tick.
-
void OnExit(TriggerVolume volume, WorldEntity entity)An entity that was inside has LEFT (moved out, changed zone, or despawned) — or, for a swept volume's pass-through, crossed straight through this tick (paired with the OnEnter just delivered).
IWorldBounds game SPI
SPI the game implements to give zones a playable-area shape ("drop a map, it has bounds"): return an IBoundsArea (box, circle, grid mask, or the game's own shape) per zone, or null for an unbounded zone. When registered, the world enforces it server-authoritatively on every swept movement update (gate + wall slide via BoundsResolver) and validates player placements (enter-world restore, zone-travel targets) against it. Absent → nothing is enforced, exactly the previous behavior — the layer adds/removes at composition time. Register the SAME area data on the client (IClientWorldBounds) so prediction slides along walls exactly like the server does — the shared resolver guarantees agreement. How to implement: return a shape per zone (reuse the same instances — the world may call AreaOf on every placement/travel), and register your implementation with DI BEFORE AddCrossplayServer. Because AddCrossplayWorldTryAdds the default (FileWorldBounds — baked areas from Bounds:ContentPath when configured, otherwise every zone unbounded), your registration wins. Most games never implement this SPI at all: they bake their levels in the editor and drop the files in the content folder.
sealed class MyMapBounds : IWorldBounds
{
public IBoundsArea? AreaOf(string zone) => zone switch
{
"town" => new BoxArea(-200f, 200f, -200f, 200f),
"arena" => new CircleArea(0f, 0f, 60f),
_ => null, // this zone is unbounded
};
}
services.AddSingleton<IWorldBounds, MyMapBounds>(); -
IBoundsArea AreaOf(string zone)The playable area of zone, or null when that zone is unbounded.
IWorldGeometry game SPI
SPI the game implements to give a zone its 3D collision volumes — the volumetric sibling of IWorldBounds ("drop a map, it has walls, floors and kill zones"). Return the authored IBoundsVolumes for a zone (boxes, spheres, half-space floors/ceilings, the game's own shapes), or null for a zone with no analytic geometry. The AnalyticGeometryProvider answers 3D queries (IWorldQueries3D) against these. Absent → no analytic geometry (every query misses), exactly the previous behavior; the layer adds/removes at composition time. Register the SAME volumes on the Unity client mirror so a server-authoritative predicted mover reconciles exactly — both tiers run the shared VolumeGeometry math.
sealed class MyMapGeometry : IWorldGeometry
{
public IReadOnlyList<IBoundsVolume>? VolumesOf(string zone) => zone switch
{
"arena" => new IBoundsVolume[]
{
new PlaneVolume(new Vec3(0,0,0), new Vec3(0,1,0)), // floor at y=0
new BoxVolume(new Vec3(-1,0,-30), new Vec3(1,10,30)), // a wall
new BoxVolume(new Vec3(-30,-5,-30), new Vec3(30,-1,30), GeometryMask.Trigger, id: 1, kind: KILL),
},
_ => null,
};
}
services.AddSingleton<IWorldGeometry, MyMapGeometry>(); -
IReadOnlyList<IBoundsVolume> VolumesOf(string zone)The collision volumes of zone, or null when it has no analytic geometry.
IWorldSurfaceMap game SPI
SPI the game implements (or the baked default answers) to give zones a per-cell SURFACE plane — the bounds grid-mask's opaque sibling. SurfaceAt returns one game-defined byte for any world position (terrain type, grip zone, footstep material — the framework never knows what a value "is"); 0 is the conventional default/unknown surface and is also the answer for zones without a plane and for points outside a plane's rectangle. A surface read never fails — it just answers "default" where there is no data — so server sims (vehicles, movement rules, AI) can sample it unconditionally on hot paths. How to implement: most games never do — they enable the surface plane on their editor bake configs and the default (FileWorldSurfaceMap, which reads the plane from the SAME <zoneId>.bake files FileWorldBounds loads, instance zones included) serves it. To compute surfaces procedurally instead, register your implementation with DI BEFORE AddCrossplayServer; the registration is a TryAdd, so yours wins. Mirror the same data client-side through IClientWorldSurfaceMap (shared contracts) when a client predictor needs it.
sealed class LavaRingSurfaces : IWorldSurfaceMap
{
public byte SurfaceAt(string zone, float x, float z)
=> zone == "volcano" && (x * x) + (z * z) < 100f ? (byte)3 : (byte)0; // 3 = the game's "lava"
}
services.AddSingleton<IWorldSurfaceMap, LavaRingSurfaces>(); -
byte SurfaceAt(string zone, float x, float z)The game-defined surface id under a world position in a zone; 0 (default/unknown) when the zone has no plane or the point is outside it. Hot-path safe: O(1) and allocation-free in the shipped default.
IZoneAdmissionPolicy game SPI
The game's "may THIS session enter THAT zone" rule — per-party instances, personal housing, solo trials, who may spectate which match. Consulted on every CLIENT-requested placement (enter-world and spectate alike), which is the only surface that needs it: server-side placement (MoveToZone, a piece moving a party into an instance) is already authoritative and is never asked.
// Only the party the instance was created for may enter it; every other zone is unaffected.
public sealed class OwnInstanceOnly : IZoneAdmissionPolicy
{
public bool MayEnter(ISession session, string zone)
=> !zone.StartsWith("dungeon:", System.StringComparison.Ordinal)
|| _instances.IsMember(zone, session.AccountId);
}
// services.AddSingleton<IZoneAdmissionPolicy, OwnInstanceOnly>(); // BEFORE AddCrossplayWorld() -
bool MayEnter(ISession session, string zone)Whether this session may enter this zone. The default admits everything.
IZoneSpawnSlots game SPI
The claim book over ZoneSpawnSlots: hands each entrant its OWN authored placement out of a zone's ordered slot list, and takes it back when that entrant leaves. This is what turns "one spawn pose per zone" (ZoneSpawns) into "N entrants, N authored start positions" without any game code — a start grid, a shooter's spawn room, a MOBA fountain and a battle-royale drop ring are the same table with different numbers in it. Bookkeeping is per zone INSTANCE: arena:7 and arena:8 each get a full independent set from the single arena template, so instanced sessions never contend for each other's slots. Claims are exclusive and idempotent (re-claiming in the zone an entity already holds returns the SAME slot), and every claim is released on zone travel, world exit and zone teardown — the World service wires all three. A zone with no slot entry answers false and costs nothing: placement then resolves exactly as it did before slots existed. Past the last free slot the claim also fails — deliberately, and loudly (the default implementation logs at Warning naming the zone and its slot count), so an over-subscribed zone is a visible decision instead of a silent pile of interpenetrating entrants.
-
int FreeCount(string zone)How many of zone's slots are currently unclaimed — diagnostics (0 with a non-zero SlotCount means the next entrant overflows).
-
bool Release(long entityId)Releases whatever slot entityId holds, in any zone (world exit).
-
bool ReleaseIn(long entityId, string zone)Releases entityId's slot only if it is held in zone — the zone-travel form, so releasing the zone an entity just LEFT can never take back the slot it just claimed in the zone it arrived in.
-
void ReleaseZone(string zone)Drops every claim in a zone instance and forgets its bookkeeping — the zone-teardown form, so released instances never leak slot state.
-
int SlotCount(string zone)How many slots zone has in total (0 = no slot table) — diagnostics and capacity checks.
-
bool TryClaim(string zone, long entityId, out ZoneSpawn slot)Claims a free slot in zone for entityId — or returns the slot it already holds there (idempotent, so the out-of-bounds fallback path can re-ask safely).
-
bool TryGetAssignment(long entityId, out ZoneSlotAssignment assignment)Which slot entityId currently holds, as a ZoneSlotAssignment — its authored index and count, plus its compacted position among the slots occupied right now. A pure query: it claims nothing and changes nothing.
Providers you can swap 5
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IPositionStore provider
Persists a character's last zone + world position, so a player resumes where they logged out (see RestoreSavedPosition). Keyed by character id. The default is in-memory (survives re-entries within a server run); the document-backed adapter persists it across restarts and shares it cluster-wide. How to implement:Save is called every time a player leaves the world (explicit leave or disconnect) with its selected character's final pose; TryGet is called on enter-world (when RestoreSavedPosition is on) to resume the player there. Register your store with DI before AddCrossplayWorld — it TryAdds the in-memory default, so yours wins.
-
void Save(long characterId, string zone, float x, float y, float z, float yaw)Saves a character's zone + position (called when its player leaves the world/disconnects).
-
bool TryGet(long characterId, out string zone, out float x, out float y, out float z, out float yaw)Reads the saved zone + position for a character; false when none exists.
ISpatialIndex provider
2D spatial index over the XZ plane for interest queries — the swappable strategy behind the interest system (one index per zone). Most games use the built-in SpatialHash; a game only implements this to change the acceleration structure (e.g. a quadtree for wildly non-uniform density), plugging it in via a custom ISpatialIndexFactory. Contract: single-threaded (poll-thread), and the position of a live id is whatever it was last Added / Moved to.
-
void Add(long id, float x, float z)Inserts an id at a position.
-
bool Move(long id, float x, float z)Updates an id's position. Returns true if the id moved to a different grid cell (the signal an interest recompute may be needed) — false if it stayed in the same cell.
-
IEnumerable<long> Query(float x, float z, float radius)Ids within radius of (x, z), exact distance-filtered.
-
void Query(float x, float z, float radius, List<long> into)Allocation-free Query: clears into and fills it with the same ids. The interest hot path (RecomputeInterest, per entity per cell crossing) uses this to avoid a per-query iterator allocation at 1000+ players. The default delegates to the enumerable version (still allocates — only implementations that override it are alloc-free), so existing index strategies keep compiling.
-
void Remove(long id)Removes an id.
ISpatialIndexFactory provider
Creates one spatial index per zone/instance (each zone is its own interest space). The DI seam for swapping the interest acceleration structure: register a custom factory to have every zone use your ISpatialIndex instead of the default SpatialHash. The built-in SpatialHashFactory hands each zone a hash sized by CellSize.
-
ISpatialIndex Create()Creates an empty index for a new zone.
IWorldGeometryProvider provider
The swappable back-end that actually answers 3D geometry queries for a zone — the seam (RULE 13) that lets the fidelity scale without changing callers. IWorldQueries3D delegates to the composed provider(s); a game/piece registers the one it needs and they compose: AnalyticGeometryProvider (ships in World, default) — authored box/sphere/plane volumes from IWorldGeometry; deterministic and engine-free.a baked-mesh provider (the planned Crossplay.Collision3D piece) — real level triangles.a Bepu provider (an adapter over the RigidBodies IPhysicsBackend) — full dynamics + queries. A zone with no provider answering simply misses (unbounded) — the layer is removable.
-
bool ContainsPoint(string zone, in Vec3 point, GeometryMask mask)True when point is inside masked geometry in zone.
-
bool GroundProbe(string zone, in Vec3 point, float maxDrop, out float floorY, out Vec3 normal)Highest solid surface at or below point within maxDrop.
-
bool MirroredOnClient { get; }GAP-29a: whether a CLIENT can see this same geometry, so its prediction agrees with the server about where the walls are. Reported in the boot composition report, and a false here is the one thing that explains a whole class of otherwise mystifying symptom. Why it is worth a whole interface member. A client predicts locally against whatever geometry it has. Geometry the server consults and the client cannot reach produces a wall that stops you server-side and does not exist client-side: you walk in, you are corrected out, repeatedly, and nothing is broken anywhere — the two tiers simply disagree about the world. That is close to undiagnosable from the symptom, and completely obvious from one boot line naming the provider. Defaults FALSE, deliberately. A provider is assumed unmirrored until it says otherwise, so a game's own custom provider is reported as server-only until the game confirms it ships the same geometry to clients. Over-warning is recoverable — you read the line and set the flag; under-warning is the bug. The alternative, an allow-list of known-mirrored provider TYPES kept somewhere central, is a hand-maintained list that goes stale the first time anyone adds a provider. A default implementation, so no existing provider has to change to compile.
-
void Overlap(string zone, in Vec3 center, GeometryMask mask, IList<GeometryOverlap> results)Appends masked volumes overlapping center in zone to results (does not clear it).
-
bool Raycast(string zone, in Vec3 origin, in Vec3 dir, float maxDistance, GeometryMask mask, out GeometryHit hit)Raycast against blocking geometry in zone.
-
bool SweepCapsule(string zone, in Vec3 from, in Vec3 to, float radius, in Vec3 axisHalf, GeometryMask mask, out GeometryHit hit)First blocking contact of a swept CAPSULE — the character-mover and vehicle-body primitive.
-
bool SweepSphere(string zone, in Vec3 from, in Vec3 to, float radius, GeometryMask mask, out GeometryHit hit)First blocking contact of a swept sphere (radius 0 = a ray) in zone.
IZoneDirectory provider
Who owns which zone — the seam that turns zone-per-node scale-out from static config into a live cluster directory. NodeFor answers "which node should this zone's traffic go to" (null = this node / unclaimed → host it locally); ClaimZone is called the first time this node actually hosts a zone, so shared directories (e.g. Redis) can record the claim. The default (StaticZoneDirectory) reads the static ZoneNodes map and claims nowhere. How to implement: back the three methods with a shared store (Redis, a config DB…): NodeFor is consulted at the two zone seams (enter-world and zone travel) to decide redirect-vs-host-locally; ClaimZone writes this node as the owner the first time it realizes a zone; ReleaseZone withdraws the claim on instance teardown so the next toucher takes over. Register your implementation with DI to replace the static default.
-
void ClaimZone(string zone)Records that this node now hosts zone locally.
-
string NodeFor(string zone)The owning node's "host:port", or null when the zone is ours to host.
-
void ReleaseZone(string zone)Records that this node no longer hosts zone locally (its spatial index was reclaimed — instance teardown). Live directories release the zone's claim so the next toucher takes over immediately and stop tracking it as hosted; the default is a no-op, so static/config directories (and existing implementations) are unaffected.
Services you call 14
Crossplay implements these. Resolve them from DI and call them from your own systems.
IClientWorldSurfaceMap
CLIENT-side mirror of the server's IWorldSurfaceMap SPI, the same way IClientWorldBounds mirrors IWorldBounds: give the client the identical per-zone surface planes the server reads, so a client predictor (surface-dependent grip/speed, footstep or particle selection driven by simulation) samples the SAME baked bytes through the SAME SurfaceGrid decode and can never disagree with the authority. The interface lives in the shared contracts — next to the ReadSurface loader — so a Unity (or any other) client only wires an implementation over its own asset pipeline. Return 0 (default/unknown) for zones without a plane and for points outside a plane's rectangle — a surface read never fails, it just answers "default". Optional: with no implementation registered, clients simply have no surface data and only the server reads it.
-
byte SurfaceAt(string zone, float x, float z)The game-defined surface id under a world position in a zone; 0 (default/unknown) when the zone has no plane or the point is outside it. O(1), allocation-free.
IEntityBodyLean
Sets an entity's body LEAN — the up-vector it should be drawn against — so it reaches every observer on the ordinary movement broadcast, not just its owner.
-
bool SetBodyLean(long entityId, float upX, float upZ)Records the up-vector an entity should be drawn against. Only X and Z are taken: the vector is a unit, so Y is recovered on the client, and an up-normal's Y is never negative. (0, 0) — the state of every entity that never calls this — means "no lean", and clients render exactly the flat yaw-only rotation they always did.
IEntityBodyOrientation
Sets an entity's full body ORIENTATION — the rotation it should be drawn with, all three axes — so it reaches every observer on the ordinary movement broadcast.
-
bool ClearBodyOrientation(long entityId)Clears an entity's orientation, returning it to the lean/yaw-only rendering path. Distinct from setting the identity rotation, which is a real orientation meaning "perfectly level facing +Z".
-
bool SetBodyOrientation(long entityId, float x, float y, float z, float w)Records the rotation an entity should be drawn with. The quaternion is normalized here, so a caller may hand in an un-normalized one; a degenerate zero quaternion clears the orientation and the entity falls back to its lean (or to the flat yaw-only rotation).
IMirroredWorldQueries3D
The client-agreeing view of IWorldQueries3D: the same queries, answered ONLY by providers that declare MirroredOnClient — geometry both tiers hold, so server and client compute the same answer from the same data. Who must inject this: any PREDICTED path. A query whose answer a client re-computes locally — the vehicle obstacle sweep and ground probe are the shipped cases — must agree with that client bit-for-bit, and a server-only provider makes that impossible by construction: whatever it blocks (or grounds) reads as background prediction corrections, not as a wall. GAP-35 measured exactly that — composing a physics engine put its broadphase into the predicted vehicle sweep, and a car on empty asphalt collected 1.5–2 m corrections from geometry its client could not see. Who should NOT inject this: server-only consumers. AI line of sight, spawn validation, game rules — anything whose answer no client re-computes — keep asking IWorldQueries3D, which fans across EVERY provider including the engine tier. Nothing about a provider being server-only makes it wrong; it is only wrong inside a prediction contract. That is why this is a property of the QUERY (which view the consumer injects), never a composition switch that hides part of the world from the server wholesale. If you replace IWorldQueries3D wholesale (rather than registering providers, which is the intended seam), register your own implementation of THIS interface too — both registrations are TryAdd. The default here filters the provider list, which a bespoke query service bypasses.
IMotionAuthority
Per-entity motion authority: lets a server-side game simulation take over driving a PLAYER entity through Move — the server-side twin of the client's own-transform mode. Normally a player entity only moves via its session's validated inputs and Move refuses its id; a server-authoritative sim (a vehicle, a sports carrier, a cutscene rig — the base never knows which) flips the entity to server-driven and the pipeline inverts, per entity.
-
bool IsServerDriven(long entityId)Whether entityId is currently server-driven.
-
void SetServerDriven(long entityId, bool serverDriven)Grants (true) or returns (false) motion authority over one entity to the server. Idempotent in both directions; flagging an id that is not in the world is harmless (the flag simply has no effect until — and unless — such an entity exists, and world removal clears it).
INavigationService
Zone-aware pathfinding over the SAME areas the bounds layer registered ("drop a map" gives bounds and navigation from one bake): grid-mask zones route with 8-direction A*; convex zones (box/circle) and unbounded zones are trivially straight-line. The framework never knows what walks the path — game AI (NPCs, escorts, patrols) consumes this and drives IServerEntityMovement itself.
-
bool TryFindPath(string zone, float fromX, float fromZ, float toX, float toZ, List<PathPoint> waypoints)Computes a walkable route between two positions in zone, writing corner-simplified waypoints into waypoints (cleared first). False when no route exists within the configured search budget.
IServerEntities
Server-spawned entities — NPCs, props, pickups, anything the game itself places in the world. They flow through the exact same spatial index and interest system as player avatars: nearby players receive the standard EntitySpawn/EntityDespawn (and, when the game moves them via the Movement piece, EntityMove). Ids are negative — never colliding with player entity ids (connection ids). What an entity IS lives entirely in the opaque appearance payload: the framework doesn't know an NPC from a treasure chest, and doesn't need to.
-
bool Despawn(long entityId)Removes a server entity (despawning it for everyone who could see it).
-
bool SetPosition(long entityId, float x, float y, float z, float yaw)Repositions a server entity through the interest system (spawn/despawn deltas on cell crossings) WITHOUT a movement broadcast — right for teleports and static-prop placement. For visible, interpolated motion use the Movement piece's server-entity mover.
-
long Spawn(float x, float y, float z, float yaw, byte[] appearance, string zone = null)Spawns a server entity at a position; returns its (negative) entity id. Lands in the default zone unless zone names another (each zone is its own interest space).
IServerEntityMovement
Moves server-spawned entities (NPCs/props) with the full movement pipeline: position + interest via the World piece, then the same batched, budgeted, per-poll-sliced EntityMove broadcast players get — so NPCs interpolate on clients exactly like players do, and a horde of them enjoys the same pile-up protection. The game's AI decides WHERE things move; this only ships the result.
-
bool Move(long entityId, float x, float y, float z, float yaw, byte locoState, float velocityX = 0, float velocityY = 0, float velocityZ = 0)Moves a server entity to a position and broadcasts it to current observers. The locomotion byte and optional velocity feed client-side animation mapping and extrapolation, exactly as for players. Returns false for an unknown id or a player entity (players move via inputs) — unless the server holds motion authority over that player entity (the Movement piece's IMotionAuthority.SetServerDriven), in which case the id is accepted and driven through the identical pipeline (swept bounds gate, movement gate, batched broadcast, plus the owner's self-view stream).
IServerEntityPlacement
PLACES a server entity — the teleport form of IServerEntityMovement: it does not sweep, and it DOES broadcast. The player equivalent is IMovementService.Teleport.
-
bool Place(long entityId, float x, float y, float z, float yaw)Places a server entity at a position and broadcasts it to current observers as a zero-velocity, Idle state — the entity did not travel, it was put there.
ITriggerSequenceService
Server-side reads over sequence progress.
-
event Action<TriggerSequenceProgress, bool, bool> AdvancedAn entity advanced through a sequence: crossed the next volume in order, completed a cycle, or finished the sequence. The one outbound seam — a composition bridge turns it into a stat, a match result, a reward, without this piece knowing any of those exist.
-
IReadOnlyCollection<long> Participants(string sequence)Every entity with progress in a sequence (empty when the sequence is unknown).
-
bool ResetProgress(string sequence, long entityId)Clears one entity's progress in one sequence — a restart, a disqualification, a new heat. False when there was nothing to clear.
-
bool TryGetProgress(string sequence, long entityId, out TriggerSequenceProgress progress)How far entityId is through sequence, or false when either is unknown to this service.
ITriggerService
Server-side API for placing volumes.
-
long Add(string zone, IBoundsArea area, byte[] data = null)Registers a spatial volume; occupancy tracking begins on the next tick.
-
long Add(string zone, IBoundsArea area, byte[] data, TriggerVolumeOptions volumeOptions)Registers a spatial volume with per-volume options — e.g. swept crossing detection (Swept) so a fast mover crossing a thin shape entirely between two ticks still fires. Occupancy tracking begins on the next tick.
-
IReadOnlyCollection<long> Occupants(long triggerId)The entity ids currently inside a volume (empty when the id is unknown).
-
bool Remove(long triggerId)Removes a volume (fires OnExit for anyone inside).
-
int VolumesWithData(string zone, string data, List<long> into)Every volume in a zone whose Data is exactly this UTF-8 string, appended to into (not cleared). Returns how many were added.
IWorldQueries
Server-side spatial queries over the world's zone indexes — the resolution primitives every combat style composes its hit detection from: beat-em-up sweeps and melee arcs are cones, shotguns are cones, hitscan rifles are rays, explosions are radii. The framework answers "which entities are THERE"; what a hit MEANS (damage, knockback, parry) is the game's rules. Results land in a caller-provided buffer (cleared first) — no allocation on the hot path. With PositionHistorySeconds > 0, the *AtTime overloads answer against each entity's recorded position at a past server time — lag compensation: validate the shot against what the shooter actually saw.
-
void QueryCone(string zone, float x, float z, float dirX, float dirZ, float range, float halfAngleDegrees, List<WorldEntity> results)Entities inside a cone: apex (x, z), direction (dirX, dirZ), range, half-angle. Melee arcs, beat-em-up sweeps, and shotgun spreads are all cones.
-
void QueryConeAtTime(string zone, float x, float z, float dirX, float dirZ, float range, float halfAngleDegrees, uint serverTimeMs, List<WorldEntity> results)Cone query against positions as they were at serverTimeMs — the rewound form of QueryCone, for validating a melee arc or a shotgun spread against what the attacker saw. Without this a game had to rewind by hand: query a radius at time T, then re-derive the cone test itself, duplicating geometry this interface already owns — so the live and rewound answers could disagree. Both paths here share one implementation. Falls back to current positions when history is off.
-
void QueryRadius(string zone, float x, float z, float radius, List<WorldEntity> results)Entities within radius of (x, z) in a zone.
-
void QueryRadiusAtTime(string zone, float x, float z, float radius, uint serverTimeMs, List<WorldEntity> results)Radius query against positions as they were at serverTimeMs (requires position history; falls back to current positions when history is off).
-
void RaycastEntities(string zone, float fromX, float fromZ, float dirX, float dirZ, float maxDistance, float rayRadius, List<WorldEntity> results)Entities whose XZ position lies within rayRadius of the ray (from → dir, up to maxDistance), ordered nearest-first — hitscan.
-
void RaycastEntitiesAtTime(string zone, float fromX, float fromZ, float dirX, float dirZ, float maxDistance, float rayRadius, uint serverTimeMs, List<WorldEntity> results)Hitscan against positions as they were at serverTimeMs — the rewound form of RaycastEntities, ordered nearest-first BY THE REWOUND positions (the order a shot actually hit things at that moment, which can differ from the order they stand in now). Falls back to current positions when history is off.
-
bool TryGetPositionAt(long entityId, uint serverTimeMs, out float x, out float z)An entity's recorded position at a past server time.
IWorldQueries3D
Server-side 3D geometry queries — the vertical-axis-aware sibling of the 2D IWorldQueries (which answers entity queries) and the 2D IWorldBounds (flat XZ walls). This answers questions about the world's SHAPE: raycasts, swept sphere/capsule casts, ground probes, and volume overlap/containment, in full 3D. Composed from one or more IWorldGeometryProviders (analytic volumes today; baked mesh / Bepu later); a blocking query returns the NEAREST hit across all providers. The Unity client mirror answers the same queries against the same data for prediction. What a hit MEANS (blocked step, kill, hazard tick, line of sight) is the game's rules — this only reports the geometry. Genre-agnostic: rays, sweeps and volumes, never a game concept.
-
bool CapsuleCast(string zone, in Vec3 from, in Vec3 to, float radius, float height, GeometryMask mask, out GeometryHit hit)Swept-capsule cast (an upright capsule of radius and height) — the character-mover primitive. Phase 1 (analytic provider): approximated as a SphereCast of radius at the capsule centre; height is honored by the baked-mesh / Bepu providers. Documented so a caller knows the analytic tier is a conservative sphere.
-
bool CapsuleCast(string zone, in Vec3 from, in Vec3 to, float radius, in Vec3 axisHalf, GeometryMask mask, out GeometryHit hit)Swept-capsule cast for a capsule at ANY orientation — the vehicle-body primitive, and the one the upright CapsuleCast is a convenience over.
-
bool ContainsPoint(string zone, in Vec3 point, GeometryMask mask)True when point is inside masked geometry (kill plane, hazard, water…).
-
bool GroundProbe(string zone, in Vec3 point, float maxDrop, out float floorY, out Vec3 normal)Highest solid surface at or below point within maxDrop (the jump/landing primitive).
-
void Overlap(string zone, in Vec3 center, GeometryMask mask, IList<GeometryOverlap> results)Masked volumes overlapping center; CLEARS results then fills it (no allocation).
-
bool Raycast(string zone, in Vec3 origin, in Vec3 dir, float maxDistance, GeometryMask mask, out GeometryHit hit)Raycast against blocking geometry; the nearest hit, if any.
-
bool SphereCast(string zone, in Vec3 from, in Vec3 to, float radius, GeometryMask mask, out GeometryHit hit)Swept-sphere cast from from to to; the first blocking contact.
IWorldService
Manages world entities and interest-based visibility. Used by the Movement piece too.
-
long AddObserver(ISession session, string zone, float x, float z, float radius, byte slot)Adds a read-only OBSERVATION of another zone without disturbing the session's own entity — a window into a place you are not in, rather than a door into it.
-
void CollectEntitiesInZone(string zone, List<WorldEntity> into)Clears into and fills it with every entity currently in zone — the whole-zone scan trigger volumes and area effects walk.
-
void CollectObserverEntities(long entityId, List<WorldEntity> into)Entity-level flavor of CollectObservers: clears into and fills it with the observing entities (each carries its owner session AND its position), which per-observer policies — e.g. distance-based broadcast culling — need to reason about the pair.
-
void CollectObservers(long entityId, List<ISession> into)Allocation-free ObserversOf: clears into and fills it with the same observers. Per-tick hot paths (the movement broadcast) pass a reused scratch list instead of taking a fresh list per entity per tick. The default delegates to ObserversOf, so existing implementations keep working unchanged.
-
long EnterAsSpectator(ISession session, string zone, float x, float z)Enters the world as an INVISIBLE spectator at (x, z) in a zone: the session receives every broadcast in view (spawn/despawn/move/ability/stats/effects) but is transmitted to no one and sends no gameplay. The free camera is the game's.
-
void EnterWorld(ISession session, byte[] appearance, long characterId = 0, string requestedZone = null)Spawns the session's entity into the world and exchanges spawns with nearby entities. When a characterId is supplied and a saved zone+position exists (see RestoreSavedPosition), the player resumes there; an explicit requestedZone instead places it fresh at that zone's spawn point.
-
event Action<long> EntityRemovedRaised after any entity (player or server-spawned) is removed from the world — the typed cross-layer hook higher pieces use to drop their per-entity state (e.g. Movement's tick bookkeeping) without the World piece knowing they exist.
-
event Action<WorldEntity, WorldEntity> EntityRevealedRaised when the first entity (revealed) enters the second entity (observer)'s interest set (its EntitySpawn was just sent) — the typed hook pieces use to replay per-entity data a late-arriving observer must see (e.g. EntityState's current blob). Fired only for observers with a session; covers enter-world, wandering into range, and zone travel alike.
-
event Action<WorldEntity> EntitySpawnedRaised whenever ANY entity is registered into the world — a player entering, a server entity spawning, a spectator — after its zone/position are set (the entity is already in the spatial index and queryable via TryGetEntity). The symmetric partner of EntityRemoved: composition layers use it to attach per-entity concerns (stat seeding, brains) without the piece knowing what those are.
-
bool IsClientZoneAdmissible(string zone)Whether a CLIENT may be placed into zone under the ad-hoc zone budget (MaxClientCreatedZones). True for an empty request, for a zone config declares, for a zone already live, and while the budget has room — false only when admitting the request would intern one MORE never-seen zone than the server is willing to carry. Handlers ask this before entering/spectating so an invented zone name is refused with a notification instead of silently costing an index, a zone id and a cluster directory claim. Server-side placement (MoveToZone, entity spawning) is the game's own authority and is never budgeted.
-
void LeaveWorld(ISession session)Removes the session's entity and despawns it for nearby observers.
-
void MoveObserver(long observerId, float x, float z)Moves the window (the observed point) and refreshes what it sees.
-
void MoveSpectator(long entityId, float x, float z)Repositions a spectator (camera pan) and refreshes what it observes.
-
bool MoveToZone(long entityId, string zone, float x, float y, float z, float yaw)Moves an entity (player or server-spawned) to another zone/instance at the given pose. The interest system emits all despawns/spawns on both sides; a player additionally receives ZoneChanged so its client resets prediction/interpolation. Games drive map changes, dungeon entries, and match instancing with this.
-
int ObserverCount(ISession session)How many observations session currently holds. Diagnostics and tests.
-
IReadOnlyList<ISession> ObserversOf(long entityId)The owners of the entities currently in entityId's interest set — i.e. the observers that have already been sent its spawn. Preferred over QueryObservers for per-tick movement broadcast: it never targets a client that hasn't seen the spawn, and costs O(neighbours) with no spatial query.
-
IReadOnlyList<ISession> QueryObservers(string zone, float x, float z, float radius, long? excludeEntityId = null)Sessions whose entities are within radius of (x, z) in zone.
-
bool RefreshAppearance(long entityId, byte[] appearance)Replaces a live entity's opaque appearance blob and re-reveals it to every observer that currently has its spawn (GAP-27: the garage/livery edit made visible without leaving the world). Each observer receives a despawn+spawn pair on the reliable ordered channel — the stock client rebuilds the view through its factory with the new blob — and the EntityRevealed hooks re-fire per observer so replay-on-reveal pieces (e.g. EntityState) re-apply their per-entity data to the rebuilt view. Works for player avatars and server entities alike; interest sets are untouched (nobody gains or loses the entity). The default implementation returns false so custom implementations keep compiling.
-
void RefreshInterest(long entityId)Re-evaluates the entity's interest pairs NOW (or on the next tick when the recompute budget is active) — the hook a game calls when an IInterestVisibilityPolicy answer changed without anyone moving: a ward expired, a spotted timer ended, someone entered a bush, a GM toggled invisibility. Reveals and conceals flow as ordinary spawn/despawn deltas. A no-op for unknown ids. Interest otherwise refreshes only on grid-cell crossings.
-
bool ReleaseZone(string zone)Tears an (instance) zone down: despawns every remaining SERVER entity through the standard removal path and reclaims the zone's spatial index and bookkeeping — without this, instanced zones (matches/dungeons) accumulate indexes forever. Refused while any player is still inside (move them out first).
-
bool RemoveObserver(long observerId)Closes one observation. Its entities despawn on the client like any other loss of interest.
-
bool ReturnToPlay(ISession session, string zone = null)Puts a SPECTATING session back into play, as the appearance it last played.
-
void SetAlwaysRelevant(long entityId, bool value)Marks an entity as relevant to EVERYONE in its zone regardless of distance (raid boss, objective, match ball) — or clears it. Applies immediately (spawns/despawns flow). Keep the number of always-relevant entities small; each one fans out to the whole zone.
-
void SetVisibilityRadius(long entityId, float radius)Sets how far others can see this entity (0 = the standard interest radius). A landmark with a larger radius is revealed to viewers far beyond normal interest. Applies immediately.
-
bool TryGetEntity(long entityId, out WorldEntity entity)Looks up a live entity by id.
-
void UpdateEntity(long entityId, float x, float y, float z, float yaw, bool swept = true)Updates an entity's position/yaw (and the spatial index). swept movement (the default — player inputs, NPC steps) is gated by the zone's IWorldBounds area (blocked steps slide along walls); pass false for placements (teleports, game repositioning) that may legally jump across blocked space.
-
event Action<string> ZoneActivatedRaised the moment a zone MATERIALIZES on this node — its spatial index is created on first touch (an enter-world, a travel target, a spectator, or a server-entity spawn) — BEFORE the touching entity is placed and BEFORE its interest is computed. The populate-before-admission hook: a piece that realizes per-zone content (authored level objects, seeded props) spawns it HERE, so the very first entrant's initial snapshot already carries that content — "entered the zone" then implies "the zone's content exists". Subscribers may spawn server entities into the activating zone from the callback (the index is already registered, so those spawns take the ordinary path); they must not admit or remove players from inside it. Fires again when a released zone re-materializes; ZoneReleased is the symmetric teardown.
-
event Action<WorldEntity, string> ZoneEnteredRaised after an entity LANDS in a zone — on enter-world and on zone travel, after its interest set is rebuilt. Unlike EntityRevealed (entity-pair-scoped, silent in an empty zone) this is the reliable "session entered zone Z" hook — pieces use it to replay per-ZONE data (e.g. ZoneState's current blob).
-
event Action<string> ZoneReleasedRaised after a zone is torn down and its spatial index and bookkeeping are reclaimed — the typed hook pieces use to drop their per-ZONE state (e.g. trigger volumes, cached zone blobs) so released instance zones never leak piece bookkeeping. Fires on EVERY successful reclaim path: an explicit ReleaseZone (dungeon/match teardown funnels through it) and the automatic reclaim of an emptied ad-hoc client zone (ReleaseEmptyClientZones). A REFUSED release (players still inside) does not raise it.
Configuration 9
Every tunable lives in an options object — there are no magic numbers to hunt for.
ArcSlotRegionOptions
Per-zone arc layouts that turn a zone's slot assignments into per-entity playable regions, from CONFIGURATION — the data-driven binding for IEntityBounds, the way FileWorldBounds is the data-driven binding for IWorldBounds. Empty (the default) registers nothing: IEntityBounds stays unbound and the world costs exactly what it did before this existed. Bound from Crossplay:World:ArcSlotRegions. Arcs are the first layout because they are the one that cannot be faked with a box: a ring of participants each defending a stretch of a circle — a circular arena, a ring corridor, a curved racetrack lane, a turret's field of fire. Other layouts (straight lanes, boxes) belong beside this one as their own options type when a game needs them, not as a mode flag on this one.
-
Dictionary<string, ArcSlotLayout> Zones { get; set; }Zone (or zone-instance prefix) → its arc layout. Empty = the layer is off.
NavigationOptions
Configurable navigation parameters (defaults here; never inlined in logic).
-
int MaxExpandedNodes { get; set; }Most cells one A* search may expand before giving up — bounds worst-case CPU on huge masks (an unreachable goal otherwise floods the whole grid). Unit: grid cells. Default 20000. Raise it for very large maps where legitimate long routes get truncated; lower it to cap CPU harder.
-
int SnapSearchRadiusCells { get; set; }How far (in cells) an endpoint that lands on a blocked/off-grid cell is snapped to the nearest walkable cell before the search runs. Unit: grid cells. Default 8. Raise it to be more forgiving of start/goal points slightly off the walkable area; lower it to reject such requests sooner.
PolygonSlotRegionOptions
Per-zone POLYGON layouts that turn a zone's slot assignments into one straight-edge region each — the flat sibling of ArcSlotRegionOptions, and the data-driven binding for IEntityBounds on any arena whose walls are lines rather than curves. Empty (the default) registers nothing.
-
Dictionary<string, PolygonSlotLayout> Zones { get; set; }Zone (or zone-instance prefix) → its polygon layout. Empty = the layer is off.
TriggerOptions
Configurable trigger parameters (defaults here; never inlined in logic).
-
float DwellIntervalSeconds { get; set; }Seconds between dwell callbacks while an entity stays inside a volume (0 = no dwell).
-
int SweptMaxSegmentSamples { get; set; }Cap on segment samples for one swept test — a per-tick move long enough to need more (a teleport-scale hop over a sampled shape) is sampled more coarsely instead of doing unbounded work. Exact shapes (box/circle) are unaffected.
-
float SweptSampleStep { get; set; }Sample spacing (world units) for swept-volume segment tests against shapes without exact segment math: custom IBoundsArea implementations sample at exactly this spacing, and grid masks sample at their own sub-cell steps but never finer than this becomes on long segments. Box and circle volumes use exact math and ignore it. A swept custom shape thinner than this spacing can be missed — lower the value (or override IntersectsSegment with exact math) for razor-thin custom volumes.
-
Dictionary<string, List<TriggerVolumeSpec>> Volumes { get; set; }Volumes to register at startup, keyed by zone id — the config-driven way into Add.
TriggerSequenceOptions
Configuration for TriggerSequenceService.
-
Dictionary<string, TriggerSequenceSpec> Sequences { get; set; }The sequences, keyed by a game-chosen name. Empty (the default) = the service is completely inert: no state, no events, no per-crossing work.
TriggerVolumeOptions
Per-volume registration options for Add.
-
bool Swept { get; set; }Swept crossing detection: additionally test the SEGMENT from each entity's previous-tick position to its current one against the volume, so a fast mover that crosses a thin shape entirely between two ticks still registers. A crossing that starts outside and ends outside (a pass-through) delivers OnEnter immediately followed by OnExit, in that order, in the same tick; OnDwell never fires for a pass-through and the entity never appears in Occupants. Detection needs one tick of history — the first pass after the zone's first swept volume (or a newly seen entity) only records positions. Zone travel is a teleport, never a crossing. Segment testing is exact for box and circle shapes; grid masks sample at sub-cell steps and custom shapes at SweptSampleStep (see IntersectsSegment). Default: false — plain per-tick point-in-area occupancy, byte-identical to before.
WorldBoundsOptions
Baked-bounds loading — "drop a .bake file per zone in a folder; zones get walls". Consumed by FileWorldBounds, the default IWorldBounds. Everything here is config (Crossplay:World:Bounds); with ContentPath empty the layer is disabled and every zone is unbounded, exactly the pre-bounds behavior.
-
string ContentPath { get; set; }Directory scanned (once, at startup) for baked areas: each <zoneId>.bake file becomes that zone's playable area. Empty (default) = the layer is off and every zone is unbounded, silently — that is the documented "I did not ask for walls" configuration. A NON-empty path that yields no bakes is the opposite statement — "I asked for walls and they are not there" — and is reported: a warning naming every absolute path searched, or a hard boot failure under Strict. A corrupt file always fails the boot. A RELATIVE path is resolved against the process working directory first (historical behaviour, so a working deployment cannot change meaning) and then against AppContext.BaseDirectory, the folder the server binary itself lives in — which is where bakes deployed alongside the build land, and the one that survives running as a service, from another directory, or in a container. The first candidate that actually contains bakes wins, and the resolved absolute path is logged. Convention: "bounds" — the folder the editor baker auto-targets in the standard Client/Server project layout.
-
string InstanceSeparator { get; set; }Separator between an instance zone's prefix and its id. Matches the Dungeons piece's zone naming ("prefix:id") by default; empty disables instance resolution entirely.
-
int MaxCachedInstanceAreas { get; set; }Cap on remembered instance-zone → area resolutions (a tiny string + reference each). Live instances re-resolve allocation-free within the cap; beyond it, lookups still resolve correctly but are not remembered — a bound, not a behavior change.
-
bool RequireContent { get; set; }Treat a configured ContentPath that yields no bakes as a boot FAILURE rather than a warning. false (the default) preserves today's behaviour — the server starts, unbounded, and says so. true suits a deployment where losing collision is not survivable: the process refuses to start rather than run a world players can walk out of. Ignored when ContentPath is empty, since that configuration asks for no bounds at all.
-
Dictionary<string, string> Templates { get; set; }Instance-zone template map: instance prefix → the zone file that holds its geometry. Runtime-created instance zones are named prefix + InstanceSeparator + id (e.g. the Dungeons piece's "dungeon:17"); this maps "dungeon" to e.g. "crypt-level" so every instance shares the one baked mask. An unmapped prefix falls back to the convention <prefix>.bake.
WorldOptions
Configurable world parameters (defaults here; never inlined in logic).
-
string AdvertisedEndpoint { get; set; }This node's public "host:port" — what a dynamic zone directory (e.g. Redis) writes when this node claims a zone, and what redirected clients dial. Empty (default) = single-node / static config only.
-
string AllowedZoneNameCharacters { get; set; }The non-alphanumeric characters a CLIENT-supplied zone name may contain; letters and digits are always allowed. Zone names end up in log lines, directory keys and file-backed bake lookups, so the charset is an allowlist (not a blocklist of "bad" characters): anything not listed here is refused at ingress. The default covers the instance-id shapes games actually use — dungeon:42, match-9c1f, zone_2, world.a. Empty = letters and digits only.
-
List<string> AllowedZones { get; set; }Zones a client may enter when RestrictClientZoneEntry is on: an exact zone name, an instance PREFIX (matches prefix + InstanceSeparator + id), or a "prefix*" wildcard.
-
ArcSlotRegionOptions ArcSlotRegions { get; set; }Turns slot assignments into per-entity playable REGIONS: each slot owns an arc of a ring, and the arcs re-divide the ring as participants leave. This is the data-driven binding for IEntityBounds — configure it and a game gets per-participant territory with no server code, exactly as ZoneSpawnSlots gives it per-participant start poses. Empty (the default) leaves IEntityBounds unbound and costs nothing.
-
bool BatchSpawnReveals { get; set; }When true, a mass interest reveal to one observer (world entry, zone travel, teleport into a crowd) is sent as a single EntitySpawnBatch instead of one EntitySpawn per neighbor — far fewer reliable-channel entries and packets when a player suddenly sees many entities (also easing the reliable-window pressure on LiteNetLib's packet pool). Off by default for wire compatibility: only enable it once every connected client understands EntitySpawnBatch (a client that doesn't will silently drop the batch and miss those spawns). A single-neighbor reveal always sends a plain EntitySpawn (a batch of one saves nothing). Steady-state trickle reveals (one wanderer at a time) are unaffected.
-
bool Bounded { get; set; }When true, every position update is clamped to the [Min,Max] X/Z box below — server-authoritative containment so a client cannot walk out of the playable area. Default off (unbounded).
-
WorldBoundsOptions Bounds { get; set; }Baked-bounds loading (the editor baker's output): point Bounds:ContentPath at a directory of <zoneId>.bake files and those zones get real interior walls, enforced server-authoritatively. See WorldBoundsOptions / FileWorldBounds. Unset = every zone unbounded (unless the game registers its own IWorldBounds).
-
float CellSize { get; set; }Spatial-hash cell size (world units).
-
string DefaultZone { get; set; }The zone/instance players (and server entities) land in when none is specified. Zones are game-defined names; each is its own interest space over the same world machinery.
-
int FrameBufferInitialBytes { get; set; }Initial capacity (bytes) of the reused outbound frame buffer for interest deltas (spawn/despawn). Grows to the high-water mark and never shrinks; sizing it past the largest spawn frame (appearance blob included) makes steady state allocation-free.
-
float InterestRadius { get; set; }Interest radius for spawn/despawn/broadcast (world units).
-
int MaxClientCreatedZones { get; set; }How many DISTINCT ad-hoc zones clients may bring into existence at once — zones that are not declared anywhere in config (see IsDeclaredZone). Without a budget, one account spamming a fresh zone name per request grows the per-zone state (index + lists + zone id + cluster claim) without limit; past the budget a new client-named zone is refused while every declared zone and every zone already live keeps working. 0 = unlimited (pre-hardening behavior).
-
int MaxInterestRecomputesPerTick { get; set; }Cap on interest recomputes performed per server tick. A cell crossing normally recomputes interest inline (0 = that default — unbounded, lowest latency). A positive value defers crossings into a queue drained up to this many per tick, so a teleport/mass-crossing storm (everyone crosses cells the same tick) degrades to a bounded per-tick cost instead of one O(crossings×candidates) spike — interest then lags a real crossing by a few ticks under that storm, exactly the graceful trade the movement-broadcast budget makes. Requires the World service's server tick (registered as an IServerTickable).
-
float MaxRewindSpeed { get; set; }Fastest an entity can move (units/s) — pads the candidate search when rewinding.
-
float MaxX { get; set; }Maximum X when Bounded.
-
float MaxZ { get; set; }Maximum Z when Bounded.
-
int MaxZoneNameLength { get; set; }Longest zone name a CLIENT may request (enter-world / spectate). A zone name is interned permanently by the world service, so an unbounded one is free server memory for the sender: this caps each entry's cost before anything is allocated for it. 0 = no length cap (not recommended for a public server). Server-side placement (game code, MoveToZone) is never length-checked.
-
float MinX { get; set; }Minimum X when Bounded.
-
float MinZ { get; set; }Minimum Z when Bounded.
-
List<string> PlayerIsolatedZones { get; set; }Whether a CLIENT may enter requestedZone: true when entry isn't restricted, the request is empty (default placement), or the zone matches an AllowedZones pattern (exact / instance-prefix / "prefix*" wildcard). Zones in which PLAYERS are mutually invisible: an exact name, an instance PREFIX, or a "prefix*" wildcard — the same shapes AllowedZones accepts.
-
PolygonSlotRegionOptions PolygonSlotRegions { get; set; }The FLAT sibling of ArcSlotRegions: each slot owns one straight EDGE of a regular polygon, and the polygon re-forms with fewer, longer sides as participants leave. Empty (the default) does nothing.
-
float PositionHistorySeconds { get; set; }Seconds of per-entity position history kept for lag-compensated queries (IWorldQueries.QueryRadiusAtTime — "validate against what the shooter saw"). 0 (default) = off, no memory cost; typical FPS values are 0.25–1.0.
-
bool ReleaseEmptyClientZones { get; set; }When true (default), an ad-hoc client-named zone is reclaimed the moment its LAST entity leaves — index, entity list, zone id and directory claim all released, exactly as ReleaseZone would. This is what keeps MaxClientCreatedZones a live concurrency budget instead of a lifetime quota that a reconnect loop slowly exhausts. Only zones no config declares are reclaimed, so a game's own zones keep their claim across an empty moment; set false to restore the pre-hardening "intern forever" behavior.
-
bool RestoreSavedPosition { get; set; }When true (default), a player entering the world with a selected character resumes at that character's last saved position (saved on leave/disconnect via IPositionStore) instead of the spawn point. Turn off for games that always start fresh (arenas, lobbies).
-
bool RestrictClientZoneEntry { get; set; }When true, a CLIENT may only enter (or spectate) zones listed in AllowedZones (an empty requested zone — the default/saved placement — is always allowed). Server-side placement (game code, MoveToZone) is never restricted. Default false = today's behavior (any zone).
-
float SpawnX { get; set; }Default spawn X.
-
float SpawnY { get; set; }Default spawn Y.
-
float SpawnYaw { get; set; }Default spawn facing (radians).
-
float SpawnZ { get; set; }Default spawn Z.
-
Dictionary<string, string> ZoneNodes { get; set; }Zone-per-node scale-out (static v1): zones OTHER nodes own, mapped to their "host:port". Entering or travelling to a listed zone answers with a NodeRedirect instead — the client reconnects there and enters (positions ride the shared store). Zones not listed are owned locally; an empty map (default) keeps everything on this node. Works because zones never share interest: distribution is just placement + redirects at the two zone seams.
-
ZoneSpawnSlotMode ZoneSpawnSlotMode { get; set; }How ZoneSpawnSlots picks which free slot an entrant claims. Default FirstFree (deterministic and ordered — slot 0 first).
-
Dictionary<string, List<ZoneSpawn>> ZoneSpawnSlots { get; set; }Per-zone spawn SLOTS: zone name (or an instance PREFIX, resolved via InstanceSeparator) → the ORDERED list of authored placements entrants claim one each, so N entities entering the same zone land on N distinct authored poses instead of stacking on the single ZoneSpawns point. Bookkeeping is per zone INSTANCE (arena:7 and arena:8 each get a full set from the one arena template), and a slot is released when its holder leaves the zone or the world. Entirely genre-agnostic: the same table is a start grid, a shooter's spawn room, a MOBA fountain and a battle-royale drop ring. Empty (the default) = placement behaves exactly as it did before slots existed. More entrants than slots is never a silent overlap: the overflow falls back to this zone's ZoneSpawns point (or the global spawn) and is logged at Warning naming the zone and the slot count. Slots are claimed at FRESH placement only — a player resuming a saved position (RestoreSavedPosition) is honoured where it left off, so turn that off for games whose entrants must always start on a slot.
-
Dictionary<string, ZoneSpawn> ZoneSpawns { get; set; }Per-zone spawn points: zone name (or an instance PREFIX, resolved via InstanceSeparator) → where a player appears when entering that zone. Consulted at enter-world placement and both out-of-bounds fallbacks; a zone with no entry falls back to the global SpawnX/SpawnY/SpawnZ/SpawnYaw.
ZoneKeyDiagnosticsOptions
Reports configured ZONE KEYS that no live zone matches (Crossplay:ZoneKeyDiagnostics). On by default.
-
float AfterSeconds { get; set; }How long the server runs before the one report is emitted.
-
bool Enabled { get; set; }Whether the check runs at all. On by default: its whole value is telling somebody something they did not think to ask.
Wire messages 9
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
EnterWorldRequest Client -> server: enter the world. The server uses the session's selected character.
EnterWorldResponse Server -> client: result of entering the world, plus the player's own spawn state.
EntitySpawn Server -> client: an entity entered interest range. Carries the opaque appearance blob.
EntityDespawn Server -> client: an entity left interest range / disconnected.
ZoneChanged Server → client: your entity was moved to another zone/instance. The interest system already despawned everything from the old zone and is spawning the new zone's entities; the client should treat this like a teleport — reset local prediction/interpolation to Position.
NodeRedirect Server → client: the zone you asked for lives on another node — reconnect there and enter. Zone-per-node scale-out works because zones never share interest: distributing a world is just zone placement plus this redirect at the only two seams that name a zone (enter-world and zone travel). The client's game layer owns the follow-up (connect to Endpoint, log in, enter world) because it owns the credentials; travel position is already saved server-side, so the destination node restores it on entry.
SpectateRequest Client → server: enter a zone as an INVISIBLE spectator — receive every broadcast in view (spawns, moves, abilities, stats, effects) without an avatar, without being seen. The reply is the ordinary EnterWorldResponse (with the spectator's own entity id); what the camera looks like is the game's.
SpectateMove Client → server: pan the spectator camera to a new point (refreshes what's in view).
EntitySpawnBatch Server -> client: a batched interest reveal — the entities that entered one observer's interest range at the same moment (world entry, zone travel, teleport into a crowd), carried in a single framed message instead of one EntitySpawn per neighbor. Fewer reliable-channel entries and packets on a mass reveal; the client applies each element exactly as if it had arrived as an individual EntitySpawn. Opt-in server-side via WorldOptions.BatchSpawnReveals; a client that does not handle this id simply drops it (harmless) — which is why the server only emits it when the option is enabled.
Unity seams you implement 5
The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.
IClientEntityBounds unity SPI
SPI the game implements to give the CLIENT the same PER-ENTITY areas it registered on the server (IEntityBounds) — the client mirror of IClientWorldBounds, which mirrors the zone's shape. A lane, a goalkeeper's box, a leash, a turret's arc: whatever limits one entity in particular rather than the whole map. Registering it is what keeps local prediction in step with authority at a personal boundary: the server resolves the zone's area and then the entity's own through the shared BoundsResolver, and the predictor does the same, in the same order, so both tiers land a blocked step on the same point and the boundary never rubber-bands. Optional: not registered → prediction is limited only by the zone and the server still enforces (correct, just visibly late under latency). Asked once per rendered frame for the local player, so keep it a cheap lookup and return reused area instances. An area that changes shape is expressed by returning a new instance — nothing is cached, so the next frame sees it.
-
IBoundsArea AreaOf(long entityId)This entity's own playable area, or null when only the zone constrains it.
IClientWorldBounds unity SPI
SPI the game implements to give the CLIENT the same per-zone playable areas it registered on the server (IWorldBounds) — the shared BoundsResolver then makes local prediction slide along walls exactly like the server's authoritative containment, so boundaries never rubber-band. Return null for an unbounded zone. Optional: not registered → prediction is unbounded and only the server enforces (correct, just visibly late at walls under latency).
-
IBoundsArea AreaOf(string zone)The playable area of zone, or null when that zone is unbounded.
IClientWorldGeometry unity SPI
SPI the game implements to give the CLIENT the same 3D collision volumes it registered on the server (IWorldGeometry) — the volumetric sibling of IClientWorldBounds. The shared VolumeGeometry math then lets a server-authoritative predicted mover reconcile exactly (identical ray/sweep/ground result on both tiers). Return null for a zone with no analytic geometry. Optional: not registered → client queries are unbounded (only the server enforces — correct, just visibly late at walls under latency).
-
IReadOnlyList<IBoundsVolume> VolumesOf(string zone)The collision volumes of zone, or null when it has none.
ITerrainHeightProvider unity SPI
SPI the game implements to ground remote replicas: the server is authoritative over X/Z but does not simulate Y, so the client samples the terrain height for spawns and moving entities. Optional — if the game binds none, Crossplay uses the server-provided Y as-is (fine for a flat world).
-
float SampleHeight(float x, float z, float fallbackY)Returns the ground Y at (x,z), or fallbackY if it can't sample.
IWorldEntities unity SPI
Read model over the entities the client currently knows about (self + observed remotes).
-
long SelfEntityIdThe local player's entity id, or -1 before entering a world.
-
IReadOnlyCollection<WorldEntityView> AllAll currently-known entities in the zone you are IN (self + remotes). Never contains an entity seen through an observation — see Observed.
-
IReadOnlyCollection<WorldEntityView> Observed(byte slot)The entities of a zone the server gave you a read-only OBSERVATION of, by its slot. Empty for a slot you hold no observation on.
-
bool TryGet(long entityId, out WorldEntityView view)Looks up an entity view by id — own OR observed, because a pose or a state update addresses an entity without knowing which of your views it is in.
-
event Action<WorldEntityView> SpawnedRaised when an entity in the zone you are IN spawns and its avatar is built. Observed entities raise ObservedSpawned instead, so existing game code cannot silently start attaching gameplay to a player who is in another world.
-
event Action<long> DespawnedRaised (with the entity id) after an entity in your own zone despawns and its avatar is destroyed.
-
event Action<WorldEntityView> ObservedSpawnedRaised when an entity seen through an observation spawns and its avatar is built.
-
event Action<byte, long> ObservedDespawnedRaised (with the slot and the entity id) after an observed entity despawns.
-
event Action<UnityEngine.Vector3, float> SelfRepositionedRaised when the server relocated the local player (zone travel): the new position and yaw. Movement uses this to reset prediction/interpolation — the world around you was replaced.
-
event Action<string, string> NodeRedirectRequestedRaised when the zone you asked for lives on ANOTHER node (zone-per-node scale-out): ("host:port", zone). The GAME performs the hop — reconnect there, log in, enter world — because it owns the credentials; your travel position is already saved server-side.
-
bool IsSpectatingTrue while this session is WATCHING rather than playing: present in the zone, receiving everything in view, holding no body of its own.
Unity services you inject 3
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IClientWorldGeometryProvider
One SOURCE of client-side 3D geometry — the client mirror of the server's IWorldGeometryProvider, and the seam that lets the client's prediction see the same KINDS of geometry the server enforces. Why the client needed the server's composed-provider shape. IClientWorldQueries3D used to be one class answering from one source (the game's analytic volumes), while the server's IWorldQueries3D fans out across a LIST of providers — analytic volumes AND the baked triangle mesh AND, when composed, a physics backend's own broadphase. So on a baked-mesh zone the two tiers consulted different worlds: the server stopped a predicted capsule at real triangles, the client swept authored boxes, and every disagreement surfaced as a reconciliation shove at exactly the walls that matter. Not a math divergence — the shared kernels are bit-identical — a COMPOSITION divergence: the client had nowhere to put a second source of geometry. This interface is that place. Providers compose; they do not replace. Register another implementation and ClientWorldQueries3D takes the nearest blocking hit across all of them, exactly as the server facade does. A game adds its own source (a procedural level, a streamed heightfield) as one more binding — it never has to re-implement the query service to do it. A zone a provider knows nothing about simply misses; the facade treats a miss from every provider as unbounded, so the layer stays removable piece by piece.
-
bool SweepSphere(string zone, in Vec3 from, in Vec3 to, float radius, GeometryMask mask, out GeometryHit hit)First blocking contact of a swept sphere (radius 0 = a ray) in zone.
-
bool SweepCapsule(string zone, in Vec3 from, in Vec3 to, float radius, in Vec3 axisHalf, GeometryMask mask, out GeometryHit hit)First blocking contact of a swept CAPSULE — the character-mover and vehicle-body primitive.
-
bool Raycast(string zone, in Vec3 origin, in Vec3 dir, float maxDistance, GeometryMask mask, out GeometryHit hit)Raycast against blocking geometry in zone.
-
bool GroundProbe(string zone, in Vec3 point, float maxDrop, out float floorY, out Vec3 normal)Highest solid surface at or below point within maxDrop.
-
bool ContainsPoint(string zone, in Vec3 point, GeometryMask mask)True when point is inside masked geometry in zone.
-
void Overlap(string zone, in Vec3 center, GeometryMask mask, IList<GeometryOverlap> results)Appends masked volumes overlapping center in zone to results (does NOT clear it — the facade owns the clear, so providers merge).
IClientWorldQueries3D
Client mirror of the server's IWorldQueries3D — the same 3D geometry queries (raycast / sphere-cast / ground-probe / overlap / containment) run locally against the volumes the game supplied through IClientWorldGeometry. A client-side predicted mover uses this so its motion stops at the same walls and lands on the same floors the server will authorize (the shared VolumeGeometry math guarantees agreement). Engine-free Vec3 for exact parity with the server; a Unity caller converts to/from Vector3 at the boundary.
-
bool Raycast(string zone, in Vec3 origin, in Vec3 dir, float maxDistance, GeometryMask mask, out GeometryHit hit)Raycast against blocking geometry; the nearest hit.
-
bool SphereCast(string zone, in Vec3 from, in Vec3 to, float radius, GeometryMask mask, out GeometryHit hit)Swept-sphere cast; the first blocking contact.
-
bool CapsuleCast(string zone, in Vec3 from, in Vec3 to, float radius, float height, GeometryMask mask, out GeometryHit hit)Swept-capsule cast (an upright capsule of radius and total height, caps included) — the mirror of the server's IWorldQueries3D.CapsuleCast, and the character-mover primitive.
-
bool GroundProbe(string zone, in Vec3 point, float maxDrop, out float floorY, out Vec3 normal)Highest solid surface at or below point within maxDrop.
-
bool ContainsPoint(string zone, in Vec3 point, GeometryMask mask)True when point is inside masked geometry.
-
void Overlap(string zone, in Vec3 center, GeometryMask mask, IList<GeometryOverlap> results)Masked volumes overlapping center; CLEARS results then fills it.
IWorldClient
Enter-world RPC plus a read-only registry of entities currently in interest range.
-
UniTask<EnterWorldResponse> EnterWorldAsync(byte[] selfAppearance, string zone = "", CancellationToken ct = default)Enters the world as the session's selected character. selfAppearance is the local player's appearance blob (Crossplay doesn't know it — the game supplies the selected character's bytes) so the self avatar can be built from the same SPI as remote ones. zone optionally names the zone/instance to enter (empty = the server's default, or the character's saved zone when restoration is on).
-
string CurrentZoneThe zone/instance the player is currently in (empty before entering a world).
-
void LeaveWorld()Destroys every entity view (self included) and resets the registry, as if each had received a despawn. Call this alongside a DELIBERATE IServerConnection.Disconnect() — that teardown is silent by design (no Disconnected event), so without this the avatars of whatever was in view outlive the session as orphans. Lost connections and reconnects clean up on their own; calling this twice is harmless.
-
event Action<string> ZoneEnteredRaised with the zone name whenever the player lands in a zone — after the initial enter-world AND after every zone travel. Observers (zone content streaming, music, UI) subscribe HERE rather than registering the wire messages: the dispatcher is single-handler per message id and those ids belong to this client.