World
Enter-world, spawn/despawn, spatial interest, zones/instancing, server entities, bounds.
Seams you implement 4
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 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).
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.
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.
Providers you can swap 4
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.
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 3
Crossplay implements these. Resolve them from DI and call them from your own systems.
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).
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 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.
-
bool TryGetPositionAt(long entityId, uint serverTimeMs, out float x, out float z)An entity's recorded position at a past server time.
IWorldService
Manages world entities and interest-based visibility. Used by the Movement piece too.
-
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.
-
void LeaveWorld(ISession session)Removes the session's entity and despawns it for nearby observers.
-
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.
-
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.
-
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).
-
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<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).
Configuration 2
Every tunable lives in an options object — there are no magic numbers to hunt for.
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) = disabled; a missing directory is treated the same (the game ships config before its first bake). A corrupt file fails the boot — never "walls silently missing". Convention: "bounds" — the folder inside the game's Server directory that 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.
-
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.
-
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.
-
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 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.
-
float MinX { get; set; }Minimum X when Bounded.
-
float MinZ { get; set; }Minimum Z when Bounded.
-
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 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.
-
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.
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 3
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.
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.
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 (self + remotes).
-
bool TryGet(long entityId, out WorldEntityView view)Looks up an entity view by id.
-
event Action<WorldEntityView> SpawnedRaised when an entity (self or remote) spawns and its avatar is built.
-
event Action<long> DespawnedRaised (with the entity id) after an entity despawns and its avatar is destroyed.
-
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.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
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.