Instance Director
Allocate game instances (zone/room/lockstep) on match/lobby formation across nodes, hand each player a single-use join token, run the lifecycle, and return them to the hub.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayDirector(); and it wins.
IExternalAllocator game SPI
The game/infra adapter to an EXTERNAL fleet manager (Agones, a Kubernetes operator, a studio's own allocator API). Selected by DirectorOptions.Placement = "External"; the game registers ONE implementation BEFORE AddCrossplayServer (the external-identity-validator pattern) and the composition layer makes ExternalAllocatorPlacement win. With no implementation registered, External degrades to SameNode with a startup warning (removability — never a hard startup failure). Async by contract. A fleet allocation is an out-of-process I/O call (an Agones Allocation gRPC, a REST POST, a K8s API round-trip) — it must never block the Director's poll thread. ExternalAllocatorPlacement invokes this off that thread and marshals the verdict back onto the Director tick through the thread-safe IPlacementCompletion, so the instance simply waits in Allocating until the fleet answers. The allocated endpoint must be a Crossplay node sharing the cluster store (join tokens and instance records validate by store lookup) — an Agones fleet of Crossplay Hosts pointed at the same Redis satisfies this out of the box. De-allocation on teardown is the game's own concern: subscribe to Ended and return the fleet server to the pool there — the SPI stays a single-purpose "give me a server" seam, no new Director coupling.
public sealed class MyFleetAllocator : IExternalAllocator
{
private readonly HttpClient _http;
public MyFleetAllocator(HttpClient http) => _http = http;
public async Task<AllocationResult> AllocateAsync(
InstanceSpec spec, long instanceId, CancellationToken ct)
{
var resp = await _http.PostAsJsonAsync("/allocate",
new { kind = spec.Kind, count = spec.Roster.Count }, ct);
if (!resp.IsSuccessStatusCode) return AllocationResult.Failed();
var body = await resp.Content.ReadFromJsonAsync<AllocResponse>(ct);
return AllocationResult.Allocated(body!.Endpoint, body.NodeId);
}
} -
Task<AllocationResult> AllocateAsync(InstanceSpec spec, long instanceId, CancellationToken cancellationToken)Request a server for this spec from the fleet manager. Return the placed node's advertised endpoint (and optional node id) via Allocated, or Failed when the fleet cannot serve it. Must observe cancellationToken: the placement cancels it at DirectorOptions.AllocatorTimeoutSeconds, and a well-behaved allocator aborts its in-flight call (e.g. HttpClient.PostAsync(url, body, cancellationToken)) and throws OperationCanceledException — which the placement maps to a failed allocation. Thrown exceptions are caught and turned into a failure, never propagated.
IInstanceRealizer game SPI
The kind bridge: how an InstanceKind becomes a real container on THIS node. RoomInstanceRealizer ships in the piece (rooms are Core); Zone/Lockstep realizers are composition-layer bridges (they reference World/Lockstep, which this piece must not). A game adds its own realizer for a custom kind the same way.
-
bool Admit(InstanceInfo instance, ISession session)An authenticated, token-validated session arrived — put it inside. Zone: IWorldService.MoveToZone into the instance zone. Room: room.Add(session). Lockstep: no-op (the client presents ContainerId as its SessionKey itself).
-
int CountOccupants(InstanceInfo instance)Occupancy probe for the abandoned sweep (the Dungeons poll idiom — it also observes zone-leave/room-leave, which no session event can). Room: room.Count. Zone: players in the zone. Lockstep: connected players in the session.
-
string CreateContainer(InstanceInfo instance)Create the container on THIS node and return its id. instance arrives with ContainerId already minted ("{prefix}:{id}") — remote assignments must carry the container name before any realizer has run, so implementations create THAT container and return it. Zone: the "instance:{id}" zone name (created lazily by World's first index touch). Room: "instance:{id}" via IRoomRegistry.GetOrCreate. Lockstep: the id IS the session key (join-is-create) — creation is a no-op.
-
void DestroyContainer(InstanceInfo instance)Destroy the container. Zone: IWorldService.ReleaseZone (players must be out first — it refuses otherwise). Room: registry Remove. Lockstep: no-op.
-
void Evict(InstanceInfo instance, ISession session)Teardown eviction for a still-local session (zone: MoveToZone to the return zone — the Dungeons pattern; room: room.Remove).
-
byte Kind { get; }Which InstanceKind this realizer builds.
-
void ScrubLostContainer(InstanceInfo instance)The container's OWNER NODE died (a NodeLost reap running on a SURVIVING node): the container itself is unreachable — no local container work is possible or attempted — but residue it left in SHARED stores survives the crash and must be unmade here, e.g. saved positions still pointing into a dead instance zone (the ghost-zone class through the owner-death door). Roster-driven by necessity: the dead node's local arrival tracking died with it, so instance's roster is the only surviving map to the residue. Default: no-op (Room and Lockstep containers leave no shared residue — rooms and lockstep sessions die with their node).
Providers you can swap 3
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IInstanceStore provider
Instance-record store. In-memory default here; the cluster twin persists records in the shared store with ids from a shared INCR (per-node counters collide — the lobby-id lesson). Kept deliberately small so a twin is a drop-in.
-
void CollectAll(List<InstanceRecord> results)Snapshots every live record into results (cleared first) — the sweep scan.
-
long NextId()Atomically returns the next instance id (starts at 1).
-
bool Remove(long instanceId)Removes the record and its account index entries. Returns false if absent.
-
void Save(InstanceRecord record)Creates or updates the record (and its roster→account index).
-
bool TryGet(long instanceId, out InstanceRecord record) -
bool TryGetByAccount(long accountId, out InstanceRecord record)The instance whose roster contains this account (status re-fetch), if any.
IJoinTokenStore provider
Single-use join-token store. In-memory default here; the cluster twin keys {prefix}:director:join:{token} with atomic GETDEL consume, so a token minted on the allocating node validates on the destination node. Tokens are bearer secrets: never logged.
-
void InvalidateInstance(long instanceId)Teardown sweep: unconsumed tokens for this instance are invalidated.
-
void Store(string token, long accountId, long instanceId, int ttlSeconds)Stores a freshly minted token with its arrival deadline.
-
bool TryConsume(string token, out long accountId, out long instanceId)Single-use, atomic: a token validates exactly once; expired or unknown fails.
IPlacementStrategy provider
Placement SPI. SameNode (default) and LeastLoaded complete inline; an external adapter completes later — the Director holds the instance in Allocating and drains completions on its tick (single poll thread, no cross-thread handler execution: completions may be REPORTED from any thread, they are only ACTED on from the tick).
-
void Place(InstanceSpec spec, long instanceId, IPlacementCompletion completion)Choose (or begin choosing) a host node for this spec.
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
IInstanceDirector
The Director: allocate an instance for a roster, push each member an InstanceAssignment (endpoint + single-use join token when a hop is needed), validate arrivals, run the lifecycle, return players to the hub. Genre-agnostic — a card table, an FPS round, and an RTS match all ride the same contract. How a game uses it. Resolve IInstanceDirector from DI. When a group is ready (a formed match, a lobby start, a party entering a dungeon), call Allocate with an InstanceSpec; the piece places the instance, mints per-member join tokens, and pushes each player an InstanceAssignment — you write no networking. Subscribe to MemberArrived to fold each player into your round as they actually arrive, and to Ended to release game state / an external fleet server when the instance tears down.
-
long Allocate(InstanceSpec spec)Allocate an instance for this roster. Returns the instance id immediately (phase Allocating or Ready), or 0 when refused (empty roster / no realizer for Kind). Assignments are pushed to every roster member once placement resolves and tokens are minted; on placement failure the roster gets notification 4204 and Ended fires with AllocationFailed.
-
event Action<InstanceInfo, byte> Ended(info, reason) — reason is an InstanceEndReason. Fires synchronously at the teardown (a terminal signal — cleanup is idempotent and safe re-entrant).
-
event Action<long, long> MemberArrived(instanceId, accountId) — a rostered member was ADMITTED (an inline same-node admit at allocation, or a token-validated hop arrival). Fired once per admit. Like PhaseChanged it is DEFERRED to the Director tick (never re-entrant inside Allocate or a join handler), so a game folds each player into its round as they actually arrive — including cross-node members that had no session at formation (the LocalSession==null gap). Resolve the session by account (IAccountDirectory) — on the admitting node it is local.
-
event Action<InstanceInfo, byte> PhaseChanged(info, phase) — the instance advanced to a new InstancePhase (Ready once placed, Live on the first admit). Lets a co-located game learn the instance filled/came alive without POLLING occupancy. Unlike Ended, this is DEFERRED to the Director tick — never fired re-entrantly inside Allocate — so a game may create its round in the same call that allocates and still receive the arc afterward (the ordering hazard: today's inline admits fire before the allocating call returns). Terminal phases stay on Ended (richer reason); this covers the coming-to-life arc only.
-
bool Release(long instanceId, byte reason = 0)Explicit teardown: evict remaining players (return-to-hub assignments), destroy the container, release the record. Idempotent. reason is the InstanceEndReason reported by Ended — the non-default value a cluster reaper passes is NodeLost (owner node died; no local container work is attempted for a container this node never realized).
-
bool TryGet(long instanceId, out InstanceInfo info)Snapshots one instance's current state.
IPlacementCompletion
How a placement strategy reports its verdict (thread-safe to call).
-
void Failed(long instanceId, ushort reasonCode)The roster gets notification 4204 (AllocationFailed).
-
void Placed(long instanceId, in Placement placement)
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
DirectorOptions
Director tuning (defaults here, never inline — rule 4). Bound from Crossplay:Director by the composition layer.
-
double AbandonSeconds { get; set; }Live → empty grace before the abandoned teardown (the Dungeons default).
-
string AdvertisedEndpoint { get; set; }This node's public host:port, handed to players on other nodes when an instance is placed here ("" = single-node, never needed). The composition layer defaults it from the World advertised endpoint when present.
-
double AllocatorTimeoutSeconds { get; set; }External-allocator (Placement = "External") request deadline. A fleet call that does not answer within this window fails the allocation (roster gets 4204) rather than stranding the instance in Allocating; keep it under PendingSeconds so a hung allocator fails before the never-arrived sweep. Ignored by SameNode/LeastLoaded — they resolve inline.
-
string ContainerPrefix { get; set; }Container-name prefix (container = "prefix:id").
-
int JoinTokenTtlSeconds { get; set; }Join-token arrival deadline (not a session lifetime).
-
double PendingSeconds { get; set; }Ready → never-arrived teardown window.
-
string Placement { get; set; }Placement strategy: "SameNode" (default) | "LeastLoaded" | "External". The composition layer maps this to the registered IPlacementStrategy; the piece alone always has SameNode.
-
string Region { get; set; }This node's region (e.g. "us-east"), published on the cluster load beacon so Placement = "Region" can co-locate an instance with the region its players prefer (Region). "" = unspecified — the node still serves as a GLOBAL least-loaded fallback candidate, it just never matches a region PREFERENCE. Additive and back-compat: an unset region publishes an empty beacon field. Bound from Crossplay:Node:Region by the composition layer (a node concept, not a Director-tuning one — but it rides DirectorOptions so a card-game node with no World still carries it).
-
string ReturnZone { get; set; }Zone kind: where return-to-hub lands ("" = the world default — the Matches/Dungeons convention). Rides return-to-hub assignments as the ContainerId.
-
bool RouteLobbies { get; set; }Opt-in composition bridge: route lobby starts into the Director. Default off.
-
bool RouteMatchmaking { get; set; }Opt-in composition bridge: route formed matches into the Director. Default off — with the piece absent or this false, Matchmaking behaves byte-identically to today.
-
double SweepSeconds { get; set; }Sweep cadence for the teardown triggers.
-
int WarmPoolRefillPerTick { get; set; }Upper bound on how many warm instances the Director creates OR tears down per tick while reconciling each kind's pool toward WarmPoolSize — the refill/shrink is spread across ticks so a large pool never bursts container creation in one frame (rule 11). Ignored when WarmPoolSize is 0.
-
int WarmPoolSize { get; set; }Warm instance pool: pre-created idle, Ready-but-empty instances kept per realizer kind so a match can start with NO allocation latency. 0 (default) = OFF — bit-for-bit today's behavior (nothing pre-created, every Allocate runs placement + container creation). When > 0, the Director keeps this many warm instances of each kind; an Allocate CLAIMS a warm instance instantly (O(1) — skips placement and container creation, giving it the real roster + tokens + assignments as normal) and the pool refills in the background on the tick. Pool empty ⇒ falls back to a normal allocate. Trade-off: a warm instance is always LOCAL (pre-created before any roster/region is known), so warm-claimed instances ignore Placement region/least-loaded selection in exchange for zero start latency — set 0 where strict placement must win.
Wire messages 5
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
InstanceAssignment Server → client push: where this player's group now plays. InstanceId = 0 means return-to-hub — the flow is identical (maybe-hop, then enter the container named by ContainerId). An empty Endpoint means "the server you are already on" — no reconnect, no token. JoinToken is this player's own single-use bearer credential (never another player's), presented in InstanceJoinRequest after authenticating on the destination node.
InstanceJoinRequest Client → server: token-validated arrival, sent on the DESTINATION node after auth.
InstanceJoinResponse Server → client: the join verdict.
InstanceStatusRequest Client → server: re-fetch my current assignment (reconnect-safe — a client that resumed can re-ask instead of relying on a push it may have missed).
InstanceStatus Server → client: the caller's current instance (InstanceId = 0 = none).
Unity seams you implement 1
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.
ITransferAuthenticator unity SPI
Narrow re-authentication seam the transfer flow rides after each dial: "restore my session on the server I'm now connected to — did it work?". Exists as its own interface (instead of the transfer using IAuthClient directly) for two deliberate reasons: (1) it keeps the hop testable — EditMode test assemblies cannot precompile-reference Crossplay.Core.Contracts.dll (the known asmdef quirk), so a fake must not need the ResumeResponse wire type; (2) it is a game SPI — a game whose auth is platform-ticket-shaped can re-authenticate a hop its own way by rebinding this, without touching the transfer machinery.
-
UniTask<bool> ResumeAsync(CancellationToken ct = default)Re-authenticates the current connection from stored credentials (the resume token). True = the session is restored; false = rejected (the transfer surfaces LoginRequired — it never falls back to credential login itself).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IDirectorClient
The Director piece, client side: consume InstanceAssignment pushes and execute the node hop they describe — the generalized transfer World's NodeRedirect was waiting for (docs/INSTANCE-DIRECTOR.md §8). Presentation-agnostic and genre-agnostic: an assignment is ids + an endpoint + an opaque payload; a text-only client prints "table 12 ready on 10.0.0.7:7778" and calls TransferAsync.
-
event Action<InstanceAssignmentView> AssignmentReceivedRaised for every assignment push (and for a ReturnToHubAsync replay), after Current updates. A game with a "Match found — Accept?" UI subscribes here, turns DirectorClientOptions.AutoTransfer off, and calls TransferAsync itself.
-
event Action<TransferPhase, string> TransferPhaseChangedThe transfer state machine, live: (phase, human-readable detail). Detail strings never contain the join token.
-
InstanceAssignmentView? CurrentThe most recent assignment, or null before any arrived.
-
TransferPhase PhaseThe most recent transfer phase (Idle before any transfer).
-
bool HasReturnAssignmentTrue when a server-issued return-to-hub assignment is held and can be replayed by ReturnToHubAsync.
-
UniTask<bool> TransferAsync(CancellationToken ct = default)Executes Current's hop: capture the previous endpoint → deliberate disconnect + dial (IServerConnection.ConnectTo) → resume the session → present the join token → await the verdict. An empty Endpoint short-circuits (the server already moved this session — no reconnect, no token). On failure the client redials the captured previous endpoint and resumes there (best effort), then reports Failed. Returns true when the transfer ended Inside.
-
UniTask<bool> TravelAsync(string endpoint, CancellationToken ct = default)The join-less node hop (dial + resume, no InstanceJoinRequest): the World redirect convergence (RFC §8.3) and any game-driven server move ride this. endpoint is host:port. Same phase events, same previous-endpoint fallback as TransferAsync.
-
UniTask<bool> ReturnToHubAsync(CancellationToken ct = default)Dev/demo return-to-hub. HONEST SCOPE: the Director wire (4101–4105) has NO client→server "send me home" request — returns are server-initiated (teardown pushes an assignment with InstanceId = 0 carrying the return endpoint). This method REPLAYS the most recent such assignment through the normal transfer flow (useful when AutoTransfer is off, or to retry a failed return). Returns false — refusing honestly — when no return assignment has ever been received.
-
UniTask<InstanceStatusView> RefreshStatusAsync(CancellationToken ct = default)Reconnect-safe assignment refetch (InstanceStatusRequest, wire 4104): a client that resumed can re-ask instead of relying on a push it may have missed. A timeout returns a default view (HasInstance false).
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayDirectorView
Narrow contract the presenter drives. Render calls in (plain strings/flags — the presenter owns all wire→text mapping), user intents out. No wire types, no ids the UI must interpret.