Core Multiplayer
The always-on kernel: connect, login, sessions, rooms/groups, and the messaging SDK.
Seams you implement 12
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayCore(); and it wins.
IAccountCharacterSource game SPI
Where an account's character ids come from. Core cannot know the Characters piece — the piece registers the real source; the default says "none" so Core alone still works.
-
IReadOnlyList<long> CharactersOf(long accountId)The character ids owned by accountId, or an empty list when the game has no character system. Used to fan account-wide operations (GDPR export/erase) across a player's characters.
IAccountDataParticipant game SPI
GDPR seam: one participant per data domain. On delete, every participant purges what it holds for the account (and its characters); on export, it writes its rows into the report. Registering a participant is all a piece/store needs to join the cascade — the account service never learns what the data means.
-
string Domain { get; }A short section name for export reports ("inventory", "friends"…).
-
void Erase(long accountId, IReadOnlyList<long> characterIds)Purges everything this domain holds for the account.
-
IEnumerable<string> Export(long accountId, IReadOnlyList<long> characterIds)Everything this domain holds for the account, as JSON-ish text lines.
IAnomalySink game SPI
Where anomaly flags go. Detection is the framework's; CONSEQUENCE is the game's — implement this to log, shadow-flag, kick or ban (via IModerationService). The default only logs and counts, so turning detection on never auto-punishes anyone.
-
void Flag(int connectionId, long? accountId, AnomalyKind kind, string detail)Reports one detected anomaly. Implement to decide the consequence (log / shadow-flag / kick / ban via IModerationService); the framework only detects.
IFleetLifecycle game SPI
The provider-neutral lifecycle contract between this node and the fleet orchestrator that HOSTS it (PlayFab Multiplayer Servers, Amazon GameLift Servers, an Agones sidecar). The inbound mirror of the Director's outbound IExternalAllocator: that SPI asks a fleet for a server; this one is how the hosted server process talks back to its platform — "I'm listening", "a session was assigned to me", "the platform is retiring me". OPTIONAL by contract (the External-placement pattern): with no implementation registered the server runs standalone, exactly as before this seam existed. An adapter that cannot reach its platform agent/sidecar must degrade to standalone with a single warning — never a startup failure. One implementation per process (the node runs inside exactly one orchestrator). Threading.NotifyListening and NotifySessionComplete are called on the server thread and must not block (platform calls happen on background tasks). Both events are raised ON the server thread — adapters marshal their SDK-callback threads through IServerWorkQueue (see FleetLifecycleBase), so subscribers stay lock-free like every other kernel service. Genre-neutral and presentation-free: session payloads are opaque strings (a PlayFab SessionCookie, GameLift GameProperties, Agones labels) — meaning is the game's, per the wire philosophy. Health is deliberately NOT on this interface: adapters self-register an IServerTickable so their platform pings ride the real tick loop and a wedged simulation fails health honestly.
-
event Action<FleetAllocation> AllocatedThe platform assigned a session to this process. Raised on the server thread. Session-based fleets raise this when a standby server is promoted (PlayFab allocation, GameLift onStartGameSession, Agones Ready→Allocated); a persistent-shape deployment sees it once at boot-allocation. Subscribers (game glue, a Hosting bridge) create/seed their room or world from the opaque payload.
-
void NotifyListening()The transport is bound and the router is live — tell the platform this server can take players. Called by Start immediately after the transport starts (Agones Ready; GameLift ProcessReady; PlayFab Start + a background ReadyForPlayers). Must return promptly; never blocks on the platform.
-
void NotifySessionComplete()The game session this server was allocated for is over — return the server to the platform (Agones Shutdown; GameLift ProcessEnding; PlayFab: begin the drain-and-exit that recycles the slot, since an Active server can never re-enter StandingBy). Session run-mode calls this when the last room empties; persistent servers never call it. Idempotent.
-
event Action<FleetTermination> TerminationRequestedThe platform wants this process gone — scale-down, Spot interruption, VM maintenance, or an operator kill. Raised on the server thread, at most once. The deadline may be generous (PlayFab maintenance: ~23 h) or tight (GameLift Spot: ~2 min). The default glue notifies sessions and begins IServerDrain so the host loop can exit gracefully.
IHealthCheck game SPI
A readiness check for one subsystem (e.g. the persistence backend or the transport). The metrics endpoint aggregates every registered check into GET /health. Genre-neutral: a game or piece contributes checks by registering IHealthCheck implementations.
-
HealthResult Check()Runs the check. Should be fast and must not throw (throwing is treated as down).
-
string Name { get; }Short stable name shown in the health report (e.g. persistence).
IMessageHandlerRegistrar game SPI
Implemented by any package/piece that contributes message handlers. The server resolves all registrars at startup and lets each register its handlers into the router — this is how a puzzle piece plugs in without the kernel being edited.
public sealed class DiceHandlers : IMessageHandlerRegistrar
{
private readonly IDiceRules _rules; // your own injected services
public DiceHandlers(IDiceRules rules) => _rules = rules;
public void RegisterHandlers(IMessageRouter router) =>
router.Register<RollDice>((msg, session) => session.Send(_rules.Roll(msg)));
}
// in your AddCrossplayDice(...) DI extension:
services.AddSingleton<IMessageHandlerRegistrar, DiceHandlers>(); -
void RegisterHandlers(IMessageRouter router)Registers this piece's handlers into the shared router.
IMetricsTextSource game SPI
A pluggable extra section for the /metrics endpoint. Any piece can register one to expose its own Prometheus-format lines (handler latency histograms, zone populations, queue depths…) without the kernel knowing what they mean — the observability mirror of the message-handler seam.
-
void AppendPrometheus(StringBuilder builder)Appends Prometheus text-exposition lines (each terminated by '\n').
IRestResource game SPI
One read-only JSON resource on the companion-app gateway: GET /api/{name}?args → Get. Pieces and games register resources (their OWN vocabulary — rosters, leaderboards, live events…); the gateway only routes and guards. Everything is read-only by design — writes stay on the game wire and the audited admin surface.
-
ValueTuple<bool, string> Get(IReadOnlyDictionary<string, string> query)Serves one request. Ok=false becomes a 400 with the body as the error text.
-
string Name { get; }The path segment after /api/ (lowercase, no slashes).
IServerInfoProvider game SPI
Builds the ServerInfo advertised to clients on connect.
-
ServerInfo Build()A fresh snapshot (current player count is read live).
IServerPollable game SPI
Incremental per-poll work hook: Poll calls Pump every cycle — a high-frequency, event-driven cadence (typically thousands of times per second under load, and at least the host's idle poll floor otherwise). Use it to pace latency-sensitive work: e.g. the movement broadcast emits its tick's batch in slices here, so a single burst never monopolizes a transport flush cycle — which is what turns a big tick into everyone's ping spike. Same thread as Poll/Tick; no locking needed.
-
void Pump()Runs one slice of incremental work on the poll thread.
IServerTickable game SPI
Implemented by pieces that need periodic server work decoupled from the inbound-message poll — e.g. the Movement piece batches its observer broadcast into a fixed-rate tick (serialize once per entity, send to many) instead of re-serializing per input per observer. The host drives the tick rate; Core stays genre-agnostic about what ticks. All tickables run on the poll thread (no locking).
-
void Tick(float deltaSeconds)Runs one server tick. deltaSeconds is the time since the previous tick.
ISessionLifecycleListener game SPI
Implemented by pieces that need per-connection lifecycle hooks — e.g. World removes a player's entity on disconnect. The server resolves all listeners and notifies them on connect/disconnect.
-
void OnConnected(ISession session)Called after a session is established and registered.
-
void OnDisconnected(ISession session)Called when a session ends (before it is removed from rooms).
Providers you can swap 20
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IAccountDirectory provider
Resolves online players by ACCOUNT id — the account-keyed sibling of IOnlineDirectory (which resolves by display name). Single-node this is a thin view over ISessionRegistry; a cluster provider (e.g. Redis) resolves accounts living on ANY node, returning a sendable remote-session proxy for players connected elsewhere. Pieces that push to a player by account id (Friends/Guild/Tournaments/Trade) depend on this seam instead of the node-local registry, so their pushes cross nodes with no piece changes.
-
bool IsLocal(long accountId)True only when the account's live session is on THIS node.
-
bool TryGetByAccount(long accountId, out ISession session)Finds the online session bound to accountId — the real local session when the player is on this node, otherwise a sendable proxy routing to the owning node. Returns false when the account is offline everywhere.
IAccountStore provider
Account persistence. Synchronous by design so the login handler can complete and send its response on the transport poll thread (offloading to a continuation has caused dropped responses). Implementations: in-memory (dev/test) or a database adapter.
-
Account Create(string username, string passwordHash)Creates and persists a new account, returning it with an assigned id.
-
bool Delete(long id)Deletes an account by id. Returns true if it existed.
-
Account FindById(long id)Returns the account for an id, or null if none exists.
-
Account FindByUsername(string username)Returns the account for a username, or null if none exists.
-
void UpdateEmail(long id, string email, bool verified)Sets the account's recovery email; a change always resets EmailVerified until the owner proves the new address. Default keeps game-supplied stores compiling — override to persist (the built-in stores do).
-
void UpdatePasswordHash(long id, string passwordHash)Replaces an account's password hash.
IAuditSink provider
A durable destination for audit entries (so the trail survives a restart).
-
void Append(AuditEntry entry)Appends one entry.
-
IReadOnlyList<AuditEntry> Load(int max)Loads up to max of the most recent entries (oldest-first).
IBanStore provider
Persists account bans. The default is in-memory (dev/single-node); a game can register a document-backed implementation to persist across restarts and share across a cluster.
-
void Ban(long accountId)Bans an account.
-
bool IsBanned(long accountId)True if the account is currently banned.
-
bool Unban(long accountId)Lifts a ban. Returns true if the account was banned.
IClusterBus provider
Cross-node message bus — publish/subscribe of opaque bytes on named channels. A node never receives its own publishes (echo suppression is the implementation's job). This is the genre-neutral scale-out seam: Core defines it; a provider (e.g. Redis) implements it. With no provider registered the server is single-node and this is never used.
-
void Publish(string channel, byte[] payload)Publishes payload to every OTHER node subscribed to the channel.
-
IDisposable Subscribe(string channel, Action<byte[]> handler)Subscribes to a channel; returns a token whose disposal unsubscribes.
IClusterInbox provider
A hand-off queue from background bus threads to the server poll thread. Cross-node deliveries are enqueued off-thread (on the bus's callback thread) and executed during CrossplayServer.Poll, so all session I/O stays on the single poll thread — matching the rest of the kernel.
-
void Drain()Runs all queued deliveries (called on the poll thread).
-
void Enqueue(Action delivery)Queues a delivery to run on the next poll.
IClusterSessionControl provider
Cross-node SESSION CONTROL — the act-on sibling of the account directory's remote proxies. A proxy can Send to a player on another node, but its Disconnect() is a structural no-op (the connection lives elsewhere), so operations that must ACT on the remote session — admin kick, single-session supersede, room eviction — ask the OWNING node to perform them locally. Consumers take this as an optional dependency (null single-node, where a local registry miss simply means offline); a cluster provider (e.g. Redis) registers the real implementation, which routes each request to the account's home node over the cluster bus.
-
bool Disconnect(long accountId, ushort notificationCode)Asks the node owning accountId's live session to send it the coded notification notificationCode (0 = none) and then disconnect it — the same notify-then-drop shape as the local paths. Fire-and-forget. Returns true when a live session on ANOTHER node was found and the request was dispatched; false when the account is offline, local to this node, or only recorded on a dead node.
-
bool RemoveFromRoom(long accountId, string roomId)Asks the node owning accountId's live session to remove it from the room roomId on that node (room membership is node-local). Same fire-and-forget and return semantics as Disconnect.
IConnection provider
A single transport-level connection (one peer). The kernel writes framed bytes to it and never references a specific networking library.
-
void Disconnect()Closes the connection.
-
int Id { get; }Stable connection id, assigned by the transport.
-
string RemoteAddress { get; }The peer's remote address (IP), for per-address throttling/moderation. May be empty.
-
void Send(byte[] data, bool reliable)Sends a framed message; reliable-ordered when reliable is true.
-
void Send(byte[] data, int offset, int length, bool reliable)Sends a slice of a shared buffer (the allocation-free hot path — see Crossplay.Core.Server.Protocol.FrameWriter). The buffer may be reused by the caller immediately after this returns, so implementations must not retain it. Transports that can transmit a slice without retention override this; the default copies the slice and stays safe for every existing implementation.
IDistributedLock provider
Cross-node mutual exclusion for the rare operations that must not run twice concurrently anywhere in the cluster (cross-node matchmaking sweeps, market settlement, scheduled jobs). LEASE-based and non-reentrant: an acquisition holds for ttlSeconds and then expires on its own, so a crashed holder can never wedge the cluster — long holders extend explicitly. Synchronous like every Core seam (the poll-thread server model). Single-node default is in-process; a cluster provider (e.g. Redis) overrides with a shared lease.
-
bool Release(string name, string token)Releases the lock IF this token still holds it. A stale release (after expiry and re-acquisition elsewhere) returns false and never clobbers the new holder.
-
string TryAcquire(string name, double ttlSeconds)Tries to take the named lock for ttlSeconds. Returns the holder token to release/extend with, or null when another holder has it.
-
bool TryExtend(string name, string token, double ttlSeconds)Extends the lease IF this token still holds it (false = lost: it expired and someone else may own it now — stop the guarded work).
IEmailSender provider
Outbound email delivery — the game/ops plug in SMTP/SES/SendGrid; the framework never speaks a mail protocol itself. The default only logs, so verify/reset flows are testable (and visible) with zero infrastructure.
-
void Send(string to, string subject, string body)Delivers one message.
IExternalIdentityValidator provider
Platform ticket validation — the game plugs Steam/Google/Apple (usually an HTTPS call to the platform; it runs on the auth work queue, never the poll thread). The default answers for NO provider, so external login ships refused until a validator is registered.
-
bool TryValidate(string provider, string ticket, out ExternalIdentity identity)Validates a ticket for a provider. False = unknown provider or bad ticket.
IIdentityLinkStore provider
The external-identity ↔ account link table: a proven platform identity (provider + externalId) maps to the account it was attached to. This is what lets a player who created a password account later "link Steam" and keep their progress — the linked identity resolves to their existing account instead of auto-creating a fresh ext:{provider}:{id} one.
-
void RemoveAllForAccount(long accountId)Removes every external-identity link owned by an account (account deletion / GDPR erase).
-
long? Resolve(string provider, string externalId)The account currently linked to this external identity, or null if none is.
-
bool TryLink(string provider, string externalId, long accountId)Links the identity to accountId. Returns false when it is already linked to a DIFFERENT account (the caller refuses); true when linked now or already linked to this account.
-
bool Unlink(string provider, string externalId, long accountId)Removes the link if it belongs to accountId. Returns true when a link was removed; false when the identity was not linked to this account.
IOutboundShaper provider
Decides whether an outbound frame to a connection may go out under that connection's bandwidth budget. Consulted once per send on the poll thread (must be cheap + allocation-free). The default token-bucket implementation charges bytes per connection and sheds low-lane unreliable traffic first; a game can supply its own.
-
bool Enabled { get; }True when shaping is active. When false, the server does not even wrap connections — every send passes at zero overhead.
-
void Forget(int connectionId)Discards a connection's bucket on disconnect.
-
bool ShouldSend(int connectionId, ushort messageId, int byteCount, bool reliable)Charges byteCount against the connection's budget and returns whether the send proceeds. A reliable send is always allowed (and still charged, so the budget reflects reality); an unreliable send is allowed only while the bucket stays above its lane's reserve floor. A dropped send is counted (metrics) and does not deplete the budget.
IPasswordHasher provider
Hashes and verifies passwords using a salted, slow KDF.
-
string Hash(string password)Produces a self-describing hash string (salt + digest) for password.
-
bool Verify(string password, string hash)Constant-time verification of password against hash.
IResumeTokenStore provider
Tracks issued reconnect tokens so a later ResumeSession can be validated. A token survives the drop of its connection (that is the whole point of reconnect) and expires only by its lifetime. Implementations: in-memory (dev/single-node) or a shared/distributed store.
-
void Invalidate(string token)Explicitly revokes a token (e.g. on logout).
-
void Store(string token, long accountId)Records a freshly issued token for an account.
-
bool TryResume(string token, out long accountId)Validates a token: returns true and the account id if present and unexpired, refreshing its lifetime (sliding window); false otherwise.
IServerClock provider
A monotonic millisecond clock. Abstracted so time-based infrastructure (rate limits, idle timeouts) is deterministically testable — tests inject a fake clock and advance it by hand.
-
long NowMs { get; }Milliseconds since an arbitrary fixed origin (monotonic, never goes backwards).
IServerMetrics provider
A pluggable telemetry sink. The kernel emits counters + gauges by name; a game routes them to Prometheus / StatsD / OpenTelemetry / logs by implementing this. The default is a no-op, so metrics cost nothing until a sink is registered.
-
void Count(string name, long delta = 1)Adds delta to a monotonic counter.
-
void Gauge(string name, double value)Sets the current value of a gauge (e.g. active sessions).
IServerTransport provider
A server transport. Concrete adapters (e.g. a LiteNetLib UDP adapter) raise these events during Poll. The kernel depends only on this interface, never on a transport library — so the same kernel runs over UDP, an in-memory loopback, or a test double.
-
event Action<IConnection> ConnectedRaised when a new connection is established.
-
event Action<IConnection> DisconnectedRaised when a connection ends.
-
void Poll()Pumps queued network events, raising the events above on the calling thread.
-
event Action<IConnection, ReadOnlyMemory<byte>> ReceivedRaised when a framed message arrives from a connection.
-
void Start()Begins listening.
-
void Stop()Stops listening.
ISessionTokenFactory provider
Creates opaque, unguessable session tokens used for reconnect.
-
string NewToken()Returns a fresh cryptographically-random token (hex-encoded).
ITransportSignal provider
Optional low-latency extension of IServerTransport. A transport that queues events from its internal threads exposes a wake signal the host loop can block on — so inbound packets are processed the moment they arrive instead of on the next fixed-interval poll — plus an explicit outbound flush the kernel invokes after each dispatch batch, so replies leave immediately instead of waiting for the transport's next internal update. A transport without this interface is simply polled on a timer, as before.
-
WaitHandle EventsAvailable { get; }Signaled while transport events are waiting for Poll.
-
void Flush()Pushes queued outbound sends onto the wire now (e.g. wakes the transport's update thread).
Services you call 27
Crossplay implements these. Resolve them from DI and call them from your own systems.
IAccountCreatedListener
Notified exactly once, on the poll thread, when a brand-new account is created (a successful registration). A composition seam higher layers subscribe to — a starting-balance grant, a welcome mail, analytics — without the auth layer knowing what any of them do. Register any number; the auth service resolves them as a collection and never learns their meaning (genre-neutral).
-
void OnAccountCreated(long accountId, string username)Called after the account row is persisted, before the client logs in.
IAccountDataService
GDPR export/delete over every registered IAccountDataParticipant — the account service never learns what the data means, and adding a domain to the cascade is one registration.
-
int EraseAll(long accountId)Purges every participant's data for the account. Returns domains swept.
-
string ExportAll(long accountId)A human-readable report of everything held about the account.
IActivityMonitor
Tracks the last-activity time of each session and identifies idle ones. The kernel records activity on every inbound frame and, on a throttled sweep, evicts whatever this reports.
-
IReadOnlyList<int> CollectIdle(long nowMs)The connection ids whose last activity is older than the idle timeout.
-
bool DueForSweep(long nowMs)True if enough time has passed to run another idle sweep (and resets the timer).
-
void Forget(int connectionId)Stops tracking a session (on disconnect).
-
void Record(int connectionId)Marks a session as active now.
IAdminApi
Dispatches named admin actions (kick / ban / unban / broadcast / who) from string args — the transport-neutral core an admin surface (HTTPS endpoint, console, …) drives. Genre-neutral.
-
ValueTuple<bool, string> Execute(string action, IReadOnlyDictionary<string, string> args)Runs an admin action. Returns (ok, human-readable message).
IAdminAuditLog
Records admin actions (who — by named token identity + source IP — what, when, outcome) to a bounded in-memory ring, a durable IAuditSink, and the structured log. Rehydrates the ring from the sink on startup so /admin/audit shows history across restarts.
-
IReadOnlyList<AuditEntry> Recent(int max)The most recent entries (oldest-first), capped at max.
-
void Record(string actor, string remote, string action, string args, bool ok, string message)Records one admin action.
IAnalytics
Game-event telemetry: the game calls Track with ITS vocabulary ("level_complete", "purchase", "duel_won") and an opaque JSON properties blob; the framework batches and ships them to a sink. Analytics must never hurt the server — buffered, budgeted, dropped over backpressure.
-
void Track(string eventName, string propertiesJson = null)Records one event. propertiesJson is the game's opaque JSON object (or null).
IAuthService
Authenticates credentials. Genre-neutral; touches neither transport nor sessions.
-
AuthResult Authenticate(string username, string password)Validates credentials, applying sign-up-on-first-login per AuthOptions.
-
AuthResult AuthenticateExternal(string provider, string ticket)Logs in with a platform ticket (Steam/Google/Apple…) through the registered IExternalIdentityValidator. The account is created on first sight under an ext:{provider}:{id} username with an unusable password. Default: refused — external login is inert until a validator is registered.
-
AuthResult ChangePassword(long accountId, string oldPassword, string newPassword)Verifies the old password and, if it matches, sets a new one for the account.
-
AuthResult DeleteAccount(long accountId, string password)Verifies the password and, if it matches, deletes the account.
-
AuthResult LinkExternalIdentity(long accountId, string provider, string ticket)Attaches a validated external identity to an already-authenticated account, so a future AuthenticateExternal with that identity resolves to accountId (keeping its progress) instead of auto-creating a fresh ext:{provider}:{id} account. Refuses with Auth.IdentityAlreadyLinked when the identity already belongs to another account. Default: refused (external login inert until a validator is registered).
-
AuthResult Register(string username, string password)Explicitly creates an account (username + password policy per AuthOptions); works regardless of AllowSignUpOnFirstLogin but is gated by AllowRegistration. Does NOT establish a session — the caller logs in afterwards. Default: refused (keeps game-supplied implementations compiling; the built-in service implements it).
-
AuthResult UnlinkExternalIdentity(long accountId, string provider, string ticket)Detaches a previously linked external identity from an account (symmetric with LinkExternalIdentity; the ticket is re-validated). Refuses with Auth.IdentityNotLinked when the identity was not linked to this account.
IAuthWorkQueue
Runs CPU-heavy auth work (Argon2id hashing) off the poll thread with bounded concurrency, delivering the result back on the poll thread via IServerWorkQueue. Without this, one login hashes ~tens of ms inline and every other player's packets queue behind it — a join wave visibly spikes everyone's latency, and login throughput caps at ~1/hash-time.
-
void Enqueue(Func<AuthResult> work, Action<AuthResult> onCompleted)Schedules work on a worker and posts onCompleted (with the result) back to the poll thread. With MaxConcurrentLogins = 0 both run inline on the caller — the legacy synchronous path.
IConnectionThrottle
Decides whether a new connection from a remote address is allowed right now.
-
bool Allow(string remoteAddress)Records + tests a new connection from remoteAddress. False = reject.
IHandlerTimings
Sink the router reports per-dispatch latencies to.
-
void Record(ushort messageId, double elapsedMs)Records one handler dispatch for messageId.
ILoginAttemptLimiter
Tracks failed login attempts per username and blocks further attempts once too many occur within the window — brute-force / credential-stuffing defence. A successful login clears the record.
-
bool IsBlocked(string username)True if the username is currently blocked from attempting.
-
void RecordFailure(string username)Records a failed attempt.
-
void RecordSuccess(string username)Clears the record for a username (on a successful login).
ILoginQueue
Capacity gate for logins. Below the cap a successful login completes immediately; at the cap the player waits in a FIFO queue and is admitted (its login completed for it) as slots free. A cap of zero means unlimited — the queue is inert. Genre-neutral.
-
void NoteAdmitted(ISession session)Records a session admitted outside the normal login path (e.g. a reconnect/resume) so that it occupies a slot and frees one when it disconnects.
-
void Submit(ISession session, AuthResult authResult)Handles a player that has just authenticated: completes the login now if a slot is free, otherwise enqueues them and sends a QueueStatus.
IMessageRouter
Typed inbound message dispatch keyed by wire id — the server-side extension socket. Feature pieces register handlers here at startup; the kernel is never edited to add a message.
-
void Register<T>(Action<T, ISession> handler)Registers handler for message type T (wire id taken from its [MessageId]). Throws if a handler is already registered.
-
void RegisterReusing<T>(Action<T, ISession> handler)Like Register``1 but decodes each frame into ONE reused T instance instead of allocating a fresh message per frame — for hot inbound paths (e.g. MoveInput at 20×/s per player). Contract: the handler must consume the message synchronously and NOT retain the reference (it is overwritten on the next frame); use only for read-and-discard handlers. The default implementation ignores the optimization and falls back to Register``1, so any router (and any test double) stays correct.
-
bool Route(ReadOnlyMemory<byte> frame, ISession sender)Decodes frame and dispatches to the registered handler. Returns false (without throwing) if the frame is malformed or no handler is registered.
IMetricsSnapshotSource
Exposes a readable MetricsSnapshot — implemented by accumulating sinks.
-
MetricsSnapshot Snapshot()Returns a stable copy of the current counters + gauges.
IModerationService
Admin/ops moderation actions, invoked out-of-band by a game's tooling (console, HTTP endpoint, …): kick an online player, ban/unban an account. Genre-neutral; the ban is enforced at login by the core login handler.
-
void Ban(long accountId)Bans the account (persisted) and kicks it if online.
-
bool Kick(long accountId)Disconnects the account's live session if online. Returns true if one was kicked.
-
bool Unban(long accountId)Lifts a ban. Returns true if the account was banned.
INotificationService
Sends coded server->client notifications. Handlers call this instead of sending raw strings, so all user-facing copy lives in the client catalog keyed by code.
-
void Send(ISession session, ushort code, params string[] args)Sends notification code with optional substitution args.
IOnlineDirectory
Resolves online players by display name and vends display names for sessions. This is the shared identity primitive the social pieces (Chat/Guild/Party/Friends) build on — each depends only on Core, never on one another. Names are unique among online sessions (last writer wins).
-
string GetDisplayName(ISession session)Returns a session's display name, or a stable fallback if none set.
-
IReadOnlyList<string> OnlineNames()A snapshot of the display names currently online. Single-node returns local names; a cluster provider (e.g. Redis) returns the roster across all nodes.
-
void SetName(ISession session, string name)Registers/replaces a session's display name (empty/whitespace clears it).
-
bool TryGetByName(string name, out ISession session)Finds the online session with the given display name (case-insensitive).
IRateLimiter
Per-session inbound rate limiter. The kernel consults it before routing each frame.
-
RateLimitResult Check(int connectionId, ushort messageId)Charges one message of type messageId against a session's budget (the global bucket, plus any per-message-type bucket) and returns the verdict.
-
void Forget(int connectionId)Drops a session's buckets (on disconnect).
IRoom
A named set of sessions with group-broadcast — the genre-neutral grouping primitive. A card table, a chat channel, a lobby, or a world instance are all rooms. Non-spatial games get full multiplayer from rooms alone, with no World piece.
-
bool Add(ISession session)Adds a session. Returns false if it was already a member.
-
void Broadcast<T>(T message, bool reliable = true, ISession except = null)Sends message to every member, optionally skipping one.
-
bool Contains(ISession session)True if the session is a member.
-
int Count { get; }Number of members.
-
string Id { get; }Stable room identifier.
-
IReadOnlyCollection<ISession> Members { get; }Snapshot of current members.
-
bool Remove(ISession session)Removes a session. Returns false if it was not a member.
IRoomRegistry
Owns the set of live rooms and supports removing a session from all of them.
-
IReadOnlyCollection<IRoom> All { get; }Snapshot of all live rooms.
-
IRoom GetOrCreate(string roomId)Returns the room with the given id, creating it if necessary.
-
bool Remove(string roomId)Removes (closes) a room. Returns false if it did not exist.
-
void RemoveFromAll(ISession session)Removes a session from every room (e.g. on disconnect).
-
bool TryGet(string roomId, out IRoom room)Looks up an existing room.
IServerBroadcast
Sends a message to every connected session — server-wide announcements / ops broadcasts.
-
void Announce(string text)Broadcasts a free-text announcement to all connected sessions.
IServerDrain
Graceful-drain lifecycle for a dedicated node — the seam an autoscaler (Kubernetes HPA, VM scale sets, GameLift) drives to retire a node without dropping players. Drain flips the node into draining: it refuses NEW connections/logins (existing sessions keep playing), readiness probes report 503 so the orchestrator stops routing new traffic here, and the cluster presence beacon / Director placement can read IsDraining to stop scheduling new work onto this node. The node signals shutdown once every session has left OR the configured grace period elapses (IsDrainComplete). Genre-neutral: no game concepts, only node lifecycle.
-
void Drain()Begins draining. Idempotent — a second call (e.g. SIGTERM after Ctrl+C) is a no-op. Safe to call from a signal-handler thread; observers on the poll thread see the flag on their next read.
-
event Action DrainingRaised exactly once, the first time Drain flips the node into draining — a hook for thread-safe listeners (e.g. deregistering from node presence, ops signals). Fires on the caller's (signal-handler) thread, so handlers must not touch poll-thread-only state such as sending frames.
-
bool IsDrainComplete { get; }True when draining AND either no sessions remain or the grace period has elapsed — the host loop watches this to stop the transport and exit. Always false before Drain is called.
-
bool IsDraining { get; }True once Drain has been called — the node is retiring and refuses new work.
IServerWorkQueue
Marshals work from background threads onto the server's main (poll) thread. Kernel services that offload CPU-heavy steps (e.g. Argon2id password hashing) post their completions here; Poll drains the queue on the poll thread, preserving the kernel's single-threaded execution model — services stay lock-free.
-
void Drain()Runs pending work on the caller's thread (the poll thread). A failing item is logged, not thrown.
-
void Post(Action work)Enqueues work to run on the next poll. Callable from any thread.
-
WaitHandle WorkAvailable { get; }Signaled while posted work is waiting — wakes the host loop alongside the transport's signal.
ISession
Per-connection server state. Genre-neutral: it carries identity + reconnect token and an extensible property bag (Items) so feature pieces attach their own per-session data without the kernel knowing about it (e.g. the Characters piece stores the selected character id). The kernel never adds game-specific fields here.
-
long? AccountId { get; set; }Authenticated account id, or null before login.
-
int ConnectionId { get; }Stable id for this connection, assigned by the transport.
-
void Disconnect()Closes this session's transport connection (kick / idle eviction / flood defence).
-
bool IsAuthenticated { get; }True once the session has authenticated (an account is bound).
-
IDictionary<string, object> Items { get; }Extensible per-session storage for feature pieces (piece-chosen keys).
-
string RemoteAddress { get; }The peer's remote address (IP) for throttling/moderation. May be empty.
-
void Send<T>(T message, bool reliable = true)Frames and sends message to this session.
-
void SendRaw(byte[] frame, bool reliable = true)Sends pre-framed bytes (an already-encoded [msgId][payload]) directly to the connection. Used by the cluster layer to deliver a message received from another node without re-framing.
-
void SendRaw(byte[] frame, int offset, int length, bool reliable = true)Sends a pre-framed slice of a shared buffer — the allocation-free hot path used with Crossplay.Core.Server.Protocol.FrameWriter (serialize once into a reused buffer, send the slice to many observers). The buffer may be reused immediately after this returns, so implementations must not retain it; the default copies the slice, keeping every existing implementation (fakes, cluster proxies) safe without changes.
-
string SessionToken { get; set; }Current reconnect/session token, or null.
ISessionBinder
Binds an authenticated account to a session, applying the account-session policy (e.g. single-session-per-account). Used by the login and resume handlers so the rule lives in one place.
-
void BindAccount(ISession session, long accountId)Binds accountId to session. If single-session is enabled and another live session already holds this account, that older session is notified and disconnected first.
ISessionRegistry
Tracks live sessions, indexed by connection id with a lookup by authenticated account. Genre-neutral: it knows nothing about characters or worlds.
-
void Add(ISession session)Registers (or replaces) a session by its connection id.
-
IReadOnlyCollection<ISession> All { get; }Snapshot of all live sessions.
-
int Count { get; }Number of live sessions.
-
bool Remove(int connectionId, out ISession removed)Removes the session for a connection id (e.g. on disconnect).
-
bool TryGet(int connectionId, out ISession session)Looks up a session by connection id.
-
bool TryGetByAccount(long accountId, out ISession session)Finds the live session bound to accountId, if any.
IWebhookNotifier
Server events → HTTP: batches JSON events and POSTs them to the configured URL — session lifecycle built in, anything else via Emit (the game's own vocabulary rides the same pipe). Same discipline as the OTLP exporter: the POST is fire-and-forget on the thread pool, failures never touch the poll thread and log once per outage, and the queue is bounded (oldest dropped, counted).
-
void Emit(string type, object payload = null)Queues one event; payload is serialized as the event body.
Configuration 17
Every tunable lives in an options object — there are no magic numbers to hunt for.
AccountLifecycleOptions
Account-lifecycle tuning (defaults here, never inline).
-
int MaxEmailLength { get; set; }Longest accepted email address.
-
int TokenBytes { get; set; }Random bytes per token (hex on the wire/mail).
-
double TokenTtlSeconds { get; set; }Verify/reset tokens die after this long.
AnalyticsOptions
Analytics tuning (defaults here, never inline).
-
string FilePath { get; set; }JSONL output path. Empty = analytics off (the null sink).
-
float FlushIntervalSeconds { get; set; }Seconds between buffer flushes.
-
int MaxBufferedEvents { get; set; }Buffered events that force an early flush (also the drop threshold ×4).
AnomalyOptions
Anomaly-detection tuning (defaults here, never inline). Ships INERT.
-
bool Enabled { get; set; }Master switch — off by default (a new layer must default inert).
-
Dictionary<ushort, int> InputRateCeilings { get; set; }Per-message-id ceilings per window (0/absent = unwatched). Message ids are numbers here — config can name any piece's or game's id without a reference.
-
double InputRateWindowSeconds { get; set; }The observation window for input-rate counting.
AuthOptions
Configurable authentication parameters. Defaults live here (the approved place for tunables); auth logic never inlines these constants.
-
bool AllowRegistration { get; set; }When true (default), the explicit RegisterRequest path may create accounts. Independent of AllowSignUpOnFirstLogin: auto-sign-up stays the zero-friction dev default, while explicit registration keeps working when a game turns auto-sign-up off for production. Username/password policy reuses MinUsernameLength/MaxUsernameLength/ MinPasswordLength.
-
bool AllowSignUpOnFirstLogin { get; set; }When true, a login for an unknown username silently creates the account. Defaults to false: a real game requires explicit registration (the RegisterRequest path, gated by AllowRegistration), and a login for an unknown username is REFUSED as invalid credentials — never silently signed up. Set to true only for a zero-friction / no-auth demo path (the Hub generator does this for the anonymous demo).
-
int Argon2DegreeOfParallelism { get; set; }Argon2 lanes / parallelism.
-
int Argon2Iterations { get; set; }Argon2 iteration (time) cost.
-
int Argon2MemorySizeKb { get; set; }Argon2 memory cost, in KiB.
-
bool DevExternalAuthEnabled { get; set; }When true, the built-in "dev" external-login provider accepts tickets of the form "{secret}:{username}" where the secret matches DevExternalAuthSecret — proving the platform-ticket path end-to-end without a third-party SDK. NEVER enable in production; real deployments register a real IExternalIdentityValidator (Steam/Google…).
-
string DevExternalAuthSecret { get; set; }Shared secret for the dev external provider. Empty (the default) refuses everything.
-
int ExpectedProtocolVersion { get; set; }Protocol version the server requires of a logging-in client. A mismatch is rejected up front with Auth.ProtocolVersionMismatch. Defaults to 0 (disabled) — the framework can't know a game's wire version; a game sets this to its client's version to enable the check.
-
int HashLengthBytes { get; set; }Digest length in bytes.
-
double LoginFailureWindowSeconds { get; set; }The sliding window (and block duration) for failed logins, in seconds.
-
bool LoginThrottleEnabled { get; set; }When true, repeated failed logins for a username are temporarily blocked.
-
int MaxConcurrentLogins { get; set; }Maximum Argon2id hashes (logins / sign-ups / password ops) run concurrently on worker threads, off the poll thread. Bounds hashing's CPU so a join wave cannot starve the poll/tick loop, while lifting login throughput from ~1/hash-time (the old inline path) to ~N/hash-time. Each hash itself uses up to Argon2DegreeOfParallelism lanes. 0 = hash inline on the poll thread (the legacy synchronous behavior; also useful on single-core boxes).
-
int MaxLoginFailures { get; set; }Failed attempts within the window before the username is blocked.
-
int MaxUsernameLength { get; set; }Maximum accepted username length.
-
int MinPasswordLength { get; set; }Minimum accepted password length.
-
int MinUsernameLength { get; set; }Minimum accepted username length.
-
int ResumeTokenLifetimeSeconds { get; set; }How long a reconnect token stays valid, in seconds (default 5 minutes). Each successful resume slides the window. Tune per game: longer eases flaky mobile networks, shorter narrows the replay window of a leaked token.
-
int SaltLengthBytes { get; set; }Salt length in bytes.
-
int SessionTokenBytes { get; set; }Session token length in bytes (hex-encoded on the wire).
-
bool SingleSessionPerAccount { get; set; }When true, a successful login/resume for an account already signed in elsewhere supersedes the older session (it is notified Session.LoggedInElsewhere and disconnected). One live session per account — the near-universal default.
BandwidthShapingOptions
Per-connection outbound bandwidth shaping (defaults here; never inlined in logic). A byte token-bucket per connection refills at BytesPerSecond up to BurstBytes; every send charges its frame size. Reliable sends always go (they carry protocol/state) and still charge the bucket; unreliable sends are admitted only while the bucket stays above their lane's reserve floor — so under congestion Bulk traffic sheds first, then Normal, while Critical flows longest.
-
double BulkReserveFraction { get; set; }Reserve floor for Bulk as a fraction of BurstBytes: a bulk unreliable send is dropped once the bucket would fall below this. Highest of the three so bulk sheds first (default 0.5 → bulk stops at half-empty).
-
int BurstBytes { get; set; }Bucket capacity in bytes — the largest burst a connection may send before the rate cap bites. Defaults to one second of budget; raise it to tolerate spikier traffic, lower it to shed sooner.
-
int BytesPerSecond { get; set; }Sustained per-connection outbound budget in bytes/second (the bucket refill rate). Size it to the thinnest client link you support; a value ≤ 0 disables shaping even when Enabled is set (unbounded).
-
double CriticalReserveFraction { get; set; }Reserve floor for Critical (default 0.0): critical unreliable traffic may draw the bucket all the way down before it sheds — it survives congestion longest.
-
bool Enabled { get; set; }Master switch — off by default so nothing changes until it is explicitly turned on (Crossplay:Shaping:Enabled / Crossplay__Shaping__Enabled=true, or AddCrossplayBandwidthShaping(o => o.Enabled = true)). Off = every send passes unshaped and the shaping decorator is not even installed on the connection.
-
Dictionary<ushort, BandwidthLane> MessageLanes { get; }Message-id → lane assignments. A msg id absent from the map rides Normal. Typical config: the movement EntityMove id → Bulk; combat state ids → Critical.
-
double NormalReserveFraction { get; set; }Reserve floor for Normal (default 0.15). Between bulk and critical.
CompressionOptions
Outbound frame-compression parameters (defaults here; never inlined in logic).
-
bool Enabled { get; set; }When true, large reliable frames are Deflate-compressed into a CompressedFrame envelope before they leave the server (rosters, leaderboards, replay history, batched spawns — payloads that repeat/compress well and fragment across packets). Off by default for wire compatibility: enable only once every connected client can inflate a CompressedFrame (a client that can't will drop it). Unreliable and small frames are never compressed.
-
int ThresholdBytes { get; set; }Only reliable frames of at least this many bytes are considered for compression — below it the envelope overhead and CPU are not worth it. A frame is sent compressed only if the compressed envelope is actually smaller than the original.
ConnectionThrottleOptions
Per-remote-address connection-rate control: caps how many new connections one IP may open within a sliding window, so a single source can't flood the accept loop. Genre-neutral kernel defence.
-
bool Enabled { get; set; }When false, all connections are allowed (throttle is a no-op).
-
int MaxConnectionsPerWindow { get; set; }Max new connections one address may open within WindowSeconds.
-
double WindowSeconds { get; set; }The sliding-window length in seconds.
DrainOptions
Tunables for the graceful-drain lifecycle (no hardcoded values — bound from Crossplay:Drain by the host composition).
-
double GraceSeconds { get; set; }Maximum seconds to keep serving after a drain begins, waiting for existing sessions to leave before the transport is stopped. The node stops earlier the moment it is empty. Set this at or below the orchestrator's termination grace (Kubernetes terminationGracePeriodSeconds) so the node exits cleanly on its own before it is force-killed.
HandlerTimingOptions
Tuning for handler timing capture (defaults here, never inline).
-
double SlowHandlerMs { get; set; }A handler slower than this (ms) logs a rate-limited warning. 0 disables the warning.
-
double SlowLogCooldownSeconds { get; set; }Minimum seconds between slow-handler warnings for the SAME message id (no log spam).
HeartbeatOptions
Application-level heartbeat / idle-session eviction. Transport-agnostic: a session that sends nothing (no gameplay, no Ping keepalive) for IdleTimeoutSeconds is disconnected, reclaiming server resources regardless of whether the transport has its own keepalive.
-
bool Enabled { get; set; }When false, idle sessions are never evicted.
-
double IdleTimeoutSeconds { get; set; }Seconds of inactivity (no inbound message) before a session is evicted.
-
double SweepIntervalSeconds { get; set; }How often the idle sweep runs (seconds); keeps the per-poll cost negligible.
LicenseOptions
Carries the signed license token a LICENSED Core build verifies at startup. The type always exists (so callers can supply a token regardless of build), but the verification that consumes it is only compiled into a -p:CrossplayLicensed=true build. The token is data (subscriber-supplied, but Hub-signed); the gate that checks it is inside the licensed, obfuscated assembly.
-
string HubUrl { get; set; }Base URL of the Hub the running server re-validates against; empty disables online checks.
-
double OfflineGraceHours { get; set; }How long the server keeps running while the license server is unreachable, before shutting down.
-
string Token { get; set; }The Hub-signed license token (e.g. from Crossplay:License in appsettings).
-
double ValidateIntervalMinutes { get; set; }How often the running server re-validates its license online.
MetricsEndpointOptions
Configuration for the HTTPS metrics endpoint (no hardcoded values in the server).
-
string AdminToken { get; set; }Single shared bearer token for /admin/* (recorded as actor "admin"). Empty + no Admins disables the admin surface. Sent over TLS as Authorization: Bearer <token>.
-
string AdminTokenPublicKeyBase64 { get; set; }Overrides the admin-token PUBLIC key (SubjectPublicKeyInfo, base64) used to verify Hub-minted admin tokens. Empty => use the key baked into a licensed Core build. Set this for tests or a self-hosted Hub whose admin signing key differs from the platform key. A public key is not secret, so it is safe in config.
-
List<AdminCredential> Admins { get; }Per-admin named tokens — each admin gets its own token and is named in the audit log.
-
double AgentHeartbeatSeconds { get; set; }Ping cadence (seconds) that keeps the outbound link + any NAT mapping warm and detects a dead peer.
-
string AgentHubUrl { get; set; }The Hub agent-relay endpoint the server dials OUT to so a browser can control this server even on localhost / behind NAT / with a self-signed cert — e.g. wss://api.crossplay.dev/agent (a Crossplay-generated server already has this set). Empty => the agent is off. Must be wss:// unless it is a loopback dev Hub.
-
int AgentMaxConcurrentRequests { get; set; }Max concurrent relayed commands the agent runs, so one link can't exhaust the server.
-
double AgentReconnectBaseSeconds { get; set; }Reconnect backoff floor (seconds) after the link drops.
-
double AgentReconnectMaxSeconds { get; set; }Reconnect backoff ceiling (seconds).
-
double AgentRequestTimeoutSeconds { get; set; }Per-relayed-command execution budget (seconds) before the agent returns a 408-style timeout.
-
string AgentToken { get; set; }The agent-scoped enrollment token that turns the phone-home agent on. Generate it on crossplay.dev (open the project → "Enable remote agent" → "Generate agent token"), paste it here, and restart the server; the site's "Agent connected" indicator then turns green. A SECRET — keep it in git-ignored config, never commit. Empty => the agent is off.
-
List<string> AllowedOrigins { get; }Browser origins allowed to call /admin/* and /api/* cross-origin (CORS). EMPTY by default — CORS is OFF, exactly as before, so a game server stays unreachable from any browser until an operator opts in by listing e.g. https://crossplay.dev. Exact-origin match; a single "*" entry allows any origin (the origin is reflected — safe here because the surface carries no cookies, only a bearer token). HARD RULE: configurable, never inlined.
-
string ApiToken { get; set; }Bearer token for the read-only /api/* companion-app gateway (null = gateway disabled). DELIBERATELY separate from the admin token — companion apps read, they never moderate.
-
string BindAddress { get; set; }Interface to listen on. The loopback default keeps metrics/admin private to the box; containers and pods need "0.0.0.0" (or "*") — Docker port maps and K8s probes reach the pod interface, never the container's loopback. The surface stays TLS + token-guarded either way.
-
string CertificatePassword { get; set; }Password for CertificatePath, if any.
-
string CertificatePath { get; set; }Path to a PKCS#12 (.pfx) certificate. When null, a self-signed dev cert is generated.
-
string Host { get; set; }Host name placed in the auto-generated dev certificate's subject/SAN.
-
string LivenessPath { get; set; }Liveness-probe path (Kubernetes livenessProbe): returns 200 whenever the process is up. Configurable so the probe path is never a hardcoded literal.
-
int Port { get; set; }TCP port to serve on.
-
string ProjectId { get; set; }This server's Hub project id. When set (together with an admin-token public key — embedded in a licensed Core build or supplied via AdminTokenPublicKeyBase64), the server ALSO accepts short-lived, project-scoped admin tokens the Hub mints: a developer generates one from the desktop app or crossplay.dev and controls THIS server for its lifetime. A token minted for a different project is refused. Empty => only the static AdminToken / named Admins tokens are accepted.
-
string ReadinessPath { get; set; }Readiness-probe path (Kubernetes readinessProbe): 200 when serving, 503 while draining or when a subsystem health check is down — so the orchestrator stops routing new traffic here. Configurable so the probe path is never a hardcoded literal.
OtlpOptions
OTLP export tuning (defaults here, never inline). Ships INERT (no endpoint).
-
string Endpoint { get; set; }The OTLP/HTTP metrics endpoint, e.g. http://localhost:4318/v1/metrics ("" = exporter off).
-
double IntervalSeconds { get; set; }Seconds between pushes.
-
string ServiceName { get; set; }The service.name resource attribute collectors group by.
-
double TimeoutSeconds { get; set; }HTTP timeout per push.
OtlpTracingOptions
OTLP trace export tuning (defaults here, never inline). Ships INERT (no endpoint).
-
string Endpoint { get; set; }OTLP/HTTP traces endpoint, e.g. http://localhost:4318/v1/traces ("" = tracing off).
-
double IntervalSeconds { get; set; }Seconds between export flushes.
-
int MaxBuffered { get; set; }Max spans buffered between flushes; excess is dropped (bounded memory).
-
int MaxPerFlush { get; set; }Max spans per export POST.
-
int SampleEvery { get; set; }Sample 1 in N dispatches (1 = every message). Raise it under heavy load to cap span volume.
-
string ServiceName { get; set; }The service.name resource attribute collectors group by.
-
double TimeoutSeconds { get; set; }HTTP timeout per export.
RateLimitOptions
Per-session inbound flood control (token bucket). Genre-neutral kernel defence; all values are configurable (no inline literals). Defaults are permissive enough for normal play (a 20 Hz movement client is well under 100 msg/s) yet cap abuse.
-
int BurstSize { get; set; }Maximum burst a session can spend at once (bucket capacity).
-
bool Enabled { get; set; }When false, all messages are allowed (limiter becomes a no-op).
-
int MaxViolationsBeforeDisconnect { get; set; }Consecutive throttled messages before the session is disconnected for flooding.
-
double MessagesPerSecond { get; set; }Sustained inbound messages allowed per second, per session (bucket refill rate).
-
Dictionary<ushort, MessageRateLimit> PerMessage { get; }Optional stricter limits for individual message types, keyed by wire id, layered on top of the global bucket. A configured type is dropped (throttled) when its own bucket empties even if the global budget has room — e.g. a low cap on login/chat while 20 Hz movement stays generous, or a movement cap that turns input-rate speed-hacking into a no-op. Empty = global limit only. Never escalates to disconnect on its own (the global bucket owns flood → disconnect).
ServerOptions
Genre-neutral server-wide limits. Like every tunable, defaults live here and are never inlined.
-
string AuditLogPath { get; set; }File path for the durable audit sink (append-only JSON lines). Empty = in-memory only (no persistence across restarts). Set via config Crossplay:Server:AuditLogPath or CROSSPLAY_AUDIT_LOG.
-
int AuditLogSize { get; set; }How many recent admin actions the audit ring keeps in memory (for /admin/audit).
-
int MaxMessageBytes { get; set; }Maximum accepted inbound frame size in bytes; a larger frame is dropped (and counted) before it reaches a handler — cheap protection against oversized/abusive payloads. 0 = unlimited.
-
int MaxSessions { get; set; }Maximum number of concurrent sessions. Beyond the cap a login waits in the queue. 0 = unlimited.
-
string Motd { get; set; }Message-of-the-day / status text advertised in ServerInfo on connect.
-
int QueueEtaSampleCount { get; set; }How many recent queue admissions feed the position/ETA estimate.
WebhookOptions
Webhook tuning (defaults here, never inline). Ships INERT (no URL).
-
double FlushSeconds { get; set; }Seconds between flushes (events batch up in between).
-
int MaxQueuedEvents { get; set; }Queue bound — beyond it the OLDEST events drop (counted, never silently).
-
bool SessionEvents { get; set; }Emit the built-in session.connected / session.disconnected events.
-
double TimeoutSeconds { get; set; }HTTP timeout per POST.
-
string Url { get; set; }Where event batches are POSTed as JSON ("" = webhooks off).
Wire messages 36
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
Ping Client -> server keepalive / latency probe. Sending it periodically keeps a session marked active (avoiding idle eviction) even with no gameplay traffic; the server echoes it as a Pong so the client can measure round-trip time.
Pong Server -> client reply to a Ping, echoing its nonce and carrying the server clock — so the heartbeat doubles as clock synchronization: the client estimates offset = ServerTimeMs + rtt/2 − localNow (smoothed over samples) and can then place server-timestamped state (e.g. movement snapshots) on a common timeline.
CompressedFrame A transport envelope: a Deflate-compressed inner frame. The server wraps a large reliable frame in one of these when compression is enabled and the compressed form is smaller; the client inflates it and dispatches the wrapped message exactly as if it had arrived uncompressed — transparent to every piece. The codec (Deflate/Inflate) is shared by the server decorator, the Unity client dispatcher, and the tests, so both tiers use byte-identical compression (Deflate is in the netstandard2.1 profile the Unity client builds against; Brotli is not).
LoginRequest Client -> server login attempt. The server's account policy (e.g. sign-up-on-first-login) is intentionally not expressed here; this contract only carries credentials + version.
LoginResponse Server -> client result of a LoginRequest.
ResumeSession Client -> server reconnect on a fresh connection, presenting the reconnect token issued by a prior LoginResponse. Lets a dropped client (mobile handover, backgrounding, wifi blip) re-authenticate without re-sending credentials.
ResumeResponse Server -> client result of a ResumeSession.
SetDisplayName Client -> server: sets this session's public display name in the online directory, so other players can be targeted by name (whisper, guild/party invite, friend request). Genre-neutral — every multiplayer game has player names; the base only stores and resolves them.
QueueStatus Server -> client: sent (and re-sent as the line moves) while a successfully-authenticated player waits for a session slot on a full server. When a slot frees, the server completes the login and sends the deferred LoginResponse (Success = true) — no re-login needed. Genre-neutral: every capacity-limited game can want a "you are #N in queue" wait instead of a hard rejection.
WhoIsOnlineRequest Client -> server: request the roster of online player display names. Genre-neutral — a lobby, a friends screen, or a global "who's online" list all want it. When the server is scaled out the roster spans every node.
OnlineRoster Server -> client: the current online-players roster (cluster-wide when scaled out).
ServerInfo Server -> client, sent immediately on connect (before login): lets a client show server status and check compatibility up front. Genre-neutral.
ChangePasswordRequest Client -> server: change the authenticated account's password.
ChangePasswordResponse Server -> client: result of a ChangePasswordRequest.
DeleteAccountRequest Client -> server: delete the authenticated account (requires the password).
DeleteAccountResponse Server -> client: result of a DeleteAccountRequest.
ServerAnnouncement Server -> client: a free-text announcement broadcast to all connected players (server restart warnings, events, ops messages). Distinct from coded notifications — the text is authored, not a code.
SessionKeyInit Client → server (transport-level, under the pre-shared-key layer): begin a session-key exchange. Both ends derive unique per-connection AES/HMAC keys from the PSK + both nonces, so no two sessions ever share packet keys. Handled INSIDE the transport (like the shard redirect) — the kernel never sees it, and peers that never send it stay on plain PSK keys (compatible).
SessionKeyAck Server → client: the server's contribution. Sent under the OLD keys; each side switches its outbound keys independently and accepts the previous keys inbound during a short window, so no packet ordering can wedge the exchange.
SetEmailRequest Client → server (authenticated): set/replace the recovery email.
SetEmailResponse Server → client: a verification mail went out (or why not).
ConfirmEmailRequest Client → server (authenticated): prove the mailed verification token.
ConfirmEmailResponse Server → client: the address is (or is not) verified now.
RequestPasswordReset Client → server (UNAUTHENTICATED): mail a reset token to a username's address.
PasswordResetRequested Server → client: ALWAYS accepted — the answer never reveals whether the account exists or has a verified email (no enumeration).
ConfirmPasswordReset Client → server (UNAUTHENTICATED): consume a reset token + set a new password.
PasswordResetResult Server → client: outcome of a password reset.
ExternalLoginRequest Client → server: log in with a platform ticket instead of a password. The provider string and ticket format are the game's ("steam" session ticket, "google"/"apple" id token…) — the server validates through its registered IExternalIdentityValidator and replies with the ordinary LoginResponse.
ServerCapabilities Server → client, sent on connect: the piece keys this server actually runs (e.g. "chat", "guild", "friends"). A modular client asset uses it to gracefully disable sub-features whose system the server doesn't have — the guild-chat tab greys out with "Guild system not installed" when "guild" is absent — instead of sending messages into the void. Keys are lowercase piece keys, matching the catalog.
RegisterRequest Client -> server (UNAUTHENTICATED): explicitly create an account. Distinct from sign-up-on-first-login (AuthOptions.AllowSignUpOnFirstLogin, the zero-friction dev default): explicit registration works regardless of that flag, so a game that disables auto-sign-up still has a create-account path. A successful register does NOT establish a session — the client logs in afterwards with the same credentials.
RegisterResponse Server -> client: result of a RegisterRequest.
LinkExternalIdentityRequest Client -> server (AUTHENTICATED): attach an external platform identity to the signed-in account, so a future ExternalLoginRequest with that same identity resolves to THIS account (keeping its progress) instead of auto-creating a fresh ext:{provider}:{id} one. The provider string and ticket format are the game's — validated through the same IExternalIdentityValidator the platform-login path uses, so linking proves you control the identity before it is recorded.
LinkExternalIdentityResponse Server -> client: result of a LinkExternalIdentityRequest.
UnlinkExternalIdentityRequest Client -> server (AUTHENTICATED): remove a previously linked external identity from the signed-in account. Symmetric with LinkExternalIdentityRequest — the ticket is re-validated, so a player proves they still control the identity before detaching it.
UnlinkExternalIdentityResponse Server -> client: result of an UnlinkExternalIdentityRequest.
SystemNotification Server -> client coded notification. The server never sends user-facing strings; it sends a Code plus substitution Args, which the client resolves against its notification catalog into a localized template and UI surface (toast/banner/error/chat).
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.
IAuthProvider unity SPI
SPI: a source of platform login tickets. Crossplay carries the ticket as an opaque string (ExternalLoginRequest.Ticket) and never interprets it — acquiring one is pure platform SDK work, so each platform is a drop-in implementation of this interface: Steam: a game-layer SteamAuthProvider wraps SteamUser.GetAuthSessionTicket and returns the hex-encoded session ticket; the server side registers an IExternalIdentityValidator that calls Steam's AuthenticateUserTicket web API. Google/Apple: a provider wraps the platform sign-in SDK and returns the id token; the server validator verifies the JWT. Dev: the built-in DevAuthProvider returns the ticket configured in CrossplayClientOptions (shared-secret, dev only). Providers register via the OG DI infra ([SingletonClass] bound to IAuthProvider); a login UI lists the available providers and, on pick, feeds the acquired ticket into ExternalLoginAsync with ProviderId. No Crossplay package references any platform SDK — that dependency lives entirely in the game's provider.
-
string ProviderIdThe wire provider key sent as ExternalLoginRequest.Provider — must match what the server-side validator answers for (e.g. AuthProviders.Dev, "steam", "google").
-
string DisplayNameHuman-readable name for login UI ("Sign in with …" buttons).
-
UniTask<string> AcquireTicketAsync(CancellationToken ct = default)Acquires a fresh login ticket from the platform (may prompt platform UI / go async to an SDK). Returns Empty when the provider is unavailable or the user cancelled — callers treat empty as "cannot log in via this provider", never as a ticket. Implementations must never log the ticket contents.
IClientTransport unity SPI
The client's wire seam — the mirror of the server's IServerTransport. Everything above (ServerConnection: typed sends, gap buffering, stats, resume) is transport-blind; an implementation only moves framed bytes. Events fire on the Unity main thread, during Pump. Kinds ship for UDP (LiteNetLib, the default) and WebSocket (TCP — browser-reachable; the reliable flag degrades to reliable-ordered).
-
bool IsConnectedTrue while the link is up.
-
int RoundTripMsTransport-level RTT in ms, or -1 when this transport doesn't measure one.
-
event Action ConnectedThe link came up.
-
event Action DisconnectedThe link went down (not raised for a deliberate Disconnect).
-
event Action<byte[]> FrameReceivedA framed message arrived (whole frame, id header included).
-
void Connect(CrossplayClientOptions options, string host, int port)Dials the given server. The endpoint is passed explicitly (it may be a node-hop override, not the options' default); every other knob (keys, paths, buffers) comes from the options, which are never mutated.
-
void Disconnect()Closes the link.
-
void Send(byte[] frame, bool reliable)Sends one framed message.
-
void Pump()Main-thread event drain — called every frame by the connection.
IConnectionStats unity SPI
Cumulative wire counters for the client connection — the raw feed for diagnostics overlays (rates are computed by the consumer over its own window). Counting costs four long increments per frame; nothing is allocated.
-
long MessagesInMessages received since startup.
-
long MessagesOutMessages sent since startup.
-
long BytesInPayload bytes received since startup (framed app bytes, not UDP headers).
-
long BytesOutPayload bytes sent since startup.
IServerClockSampler unity SPI
Write side of the clock — the heartbeat feeds one sample per pong.
-
void AddSample(long serverTimeMs, double rttMs)Adds a sync sample: the pong's server clock + the round trip it rode on.
IServerConnection unity SPI
Client half of the transport: a single UDP link to the Crossplay server. Mirrors the server's IServerTransport/IConnection but client-side (one peer, no sessions). Sends typed messages using the shared [ushort id][MemoryPack payload] framing and surfaces decoded inbound frames as FrameReceived (raised on the main thread during polling).
-
bool IsConnectedTrue once the handshake with the server has completed.
-
int TransportRoundTripMsThe transport-measured round-trip time to the server, in milliseconds (-1 when not connected). This is the real network RTT — near 0 on a local server — not the app-tick round-trip.
-
bool WasDisconnectRequestedTrue if the most recent teardown was a deliberate Disconnect (vs. an unexpected drop). Lets an auto-reconnect supervisor tell "the user/game closed it" from "the link died".
-
void BeginBuffering()Starts holding outbound reliable sends in a bounded buffer instead of dropping them (unreliable sends are still dropped as stale). Called on an unexpected drop so messages the game emits during the gap survive to be replayed on resume.
-
void FlushBuffered()Stops buffering and replays the held reliable messages, in order, over the connection.
-
void DiscardBuffered()Stops buffering and discards the held messages (the session could not be resumed).
-
event Action ConnectedRaised when the connection to the server is established.
-
event Action<string> ConnectFailedThe INITIAL connect gave up (timed out reaching the server). Reason is a user-facing message — subscribe to show an error on the "connecting" screen instead of hanging forever.
-
event Action DisconnectedRaised when the connection to the server is lost or closed.
-
event Action<ushort, ReadOnlyMemory<byte>> FrameReceivedRaised for each inbound frame: its wire id + MemoryPack payload slice.
-
void Connect()Begins connecting to the current endpoint (the configured host/port, or the last ConnectTo target after a hop). No-op if already started.
-
void ConnectTo(string host, int port)Sets the server endpoint and connects — the node-hop primitive (Director instance transfer, World node redirect). The override is STICKY: it becomes the connection's current endpoint, so every subsequent Connect — including the auto-reconnect supervisor redialing after a link drop — returns to THIS server, not the configured default. Returning to the hub is an explicit ConnectTo(hubHost, hubPort). An already-open link is torn down as a deliberate close first (same semantics as Disconnect + Connect, so WasDisconnectRequested keeps the reconnect supervisor quiet mid-hop). The configured client options are never mutated.
-
void Disconnect()Closes the connection and stops the transport.
-
void Send<T>(T message, bool reliable = true)Encodes and sends a message. reliable false = unreliable channel.
Unity services you inject 13
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IAccountClient
Authenticated account management: change password, delete account.
-
UniTask<ChangePasswordResponse> ChangePasswordAsync(string oldPassword, string newPassword, CancellationToken ct = default)Changes the signed-in account's password (verifies the old one server-side).
-
UniTask<DeleteAccountResponse> DeleteAccountAsync(string password, CancellationToken ct = default)Deletes the signed-in account (verifies the password server-side).
IAuthClient
Async login RPC over the reliable channel: sends a LoginRequest and completes when the matching LoginResponse arrives (or the configured timeout elapses). On success the returned token is stored in ICrossplayClientState.
-
event Action<QueueStatus> QueuedRaised while a login is waiting in the server's capacity queue, each time the position changes. A UI can show "You are #N of M in queue". The pending LoginAsync keeps waiting and completes on its own when the server admits the player (no re-login).
-
event Action LoggedInRaised after a successful login OR resume — the moment the session is authenticated. Drop-in panels/presenters refresh their server state on this instead of firing blind at Start (a request sent before login is dropped by the server).
-
UniTask<LoginResponse> LoginAsync(string username, string password, CancellationToken ct = default)Sends credentials and awaits the server's login result. If the server is at capacity the call does not time out while queued — it waits (bounded only by the connection staying up) and completes when admitted; subscribe to Queued for position updates.
-
UniTask<ResumeResponse> ResumeAsync(CancellationToken ct = default)Reconnects after a drop using the reconnect token stored from a prior login — no credentials. The caller must already have re-established the connection. On success the refreshed token replaces the stored one. Returns a failed response if no token is held or it was rejected.
-
UniTask<RegisterResponse> RegisterAsync(string username, string password, CancellationToken ct = default)Explicitly creates an account (distinct from sign-up-on-first-login). Success does NOT establish a session — call LoginAsync with the same credentials afterwards. A refusal carries a notification ReasonCode (username taken, policy, registration disabled…); a timeout returns a failed response with code 0.
-
UniTask<LoginResponse> ExternalLoginAsync(string provider, string ticket, CancellationToken ct = default)Logs in with a platform ticket instead of a password (see IAuthProvider for acquiring one). The server replies with the ordinary LoginResponse, so this behaves exactly like LoginAsync: capacity-queue aware (Queued), stores the session token, and raises LoggedIn on success.
-
UniTask<LinkExternalIdentityResponse> LinkExternalIdentityAsync(string provider, string ticket, CancellationToken ct = default)Attaches an external platform identity to the CURRENTLY signed-in account (authenticated RPC), so a future ExternalLoginAsync with that identity resolves to this account — a player who created a password account can later "link Steam/Google" and keep their progress. Acquire the ticket from the same IAuthProvider a login would use. A refusal carries a notification ReasonCode (notably Auth.IdentityAlreadyLinked when the identity already belongs to another account); a timeout returns a failed response with code 0.
-
UniTask<UnlinkExternalIdentityResponse> UnlinkExternalIdentityAsync(string provider, string ticket, CancellationToken ct = default)Detaches a previously linked external identity from the signed-in account (symmetric with LinkExternalIdentityAsync; the ticket is re-validated server-side). A refusal carries Auth.IdentityNotLinked when the identity was not linked to this account.
ICapabilities
Which Crossplay pieces are usable end-to-end in this session — the seam modular assets use to gracefully degrade. A "Chat Window" that offers a guild-chat tab checks Has("guild"): the client package must be installed (ClientHas) AND the server must run it (ServerHas, learned from the connect handshake). If either is missing, the sub-feature disables with an explanation instead of sending into the void.
-
bool ClientHas(string piece)The piece's client package (Crossplay.<Piece>.Client) is present in this build.
-
bool ServerHas(string piece)The connected server reported this piece in its capability handshake. Optimistically true until the handshake arrives (so nothing flickers off during the first frames of a connection).
-
bool Has(string piece)Usable end-to-end: the client package is installed AND the server runs the piece.
-
event Action ChangedRaised when the server capability handshake arrives/updates — rebind gated UI here.
IClientNotificationService
Client mirror of the server's notification system: turns inbound SystemNotification codes into resolved NotificationViews and raises them. The game's UI (Presenter) listens and routes by NotificationCategory. No hardcoded user-facing strings in Core.
-
event Action<NotificationView> ReceivedRaised (main thread) for each inbound server notification, already resolved.
ICrossplayClientState
Client-side local identity mirror of the server's ISession: the session token from a successful login plus an extensible property bag so higher pieces (Characters, World, …) can stash their own per-connection state without Core knowing about it.
-
string SessionTokenOpaque session token issued by the server on login; null until authenticated.
-
long AccountIdLocal mirror of the server's authenticated account id (0 until authenticated; account ids are 1-based). Genre-agnostic self-identity: the game reads this to tell which roster/scoreboard entry is itself (e.g. its own entry among a match's players) without matching on display name.
-
bool IsAuthenticatedTrue once a session token has been set.
-
IDictionary<string, object> ItemsPiece-defined per-connection state (e.g. Characters stores the selected id).
-
void Reset()Clears all local identity state (e.g. on disconnect / logout).
IHeartbeatClient
Exposes the measured round-trip time to the server.
-
float RttSecondsLast measured round-trip time in seconds (0 until the first pong).
-
event Action<float> RttUpdatedRaised each time a fresh RTT is measured.
IIdentityClient
Registers this client's public display name with the server's online directory, so social pieces (Chat/Guild/Party/Friends) can target this player by name. Call it once the player's name is known (e.g. after selecting a character).
-
string DisplayNameThe last display name registered this session, or null.
-
void SetDisplayName(string name)Sends the display name to the server's identity directory.
IMessageDispatcher
Client mirror of the server's IMessageRouter: typed inbound dispatch keyed by wire id. A piece registers a handler for each message type it cares about; the base never changes to add a message. One handler per message type (the owning service), matching the server router.
-
void Register<T>(Action<T> handler)Registers (or replaces) the handler invoked when a T arrives.
-
void RegisterReusing<T>(Action<T> handler)Like Register{T} but decodes every inbound T into ONE reused instance instead of allocating a fresh message per frame — the client twin of the server's IMessageRouter.RegisterReusing. Use for high-rate read-and-discard messages (e.g. EntityMove at ~moves/sec × remote entities). CONTRACT: the handler must consume the message synchronously and NOT retain the reference (it is overwritten on the next frame).
-
void Unregister<T>()Removes the handler for T, if any.
-
void Dispatch(ushort id, ReadOnlyMemory<byte> payload)Dispatches a frame — [ushort id][MemoryPack payload] already split — exactly as if it had arrived from the connection: the same typed handlers (and thus renderers) run. The normal source is the transport, but this lets an OFFLINE source drive dispatch — a replay player feeding recorded frames, or a test. Unknown ids are ignored (removable pieces), never an error.
INotificationTemplateSource
Contributes default notification templates for a range of codes (Core or one feature piece), so server codes render as readable text even without a game-authored CrossplayNotificationCatalog. The client notification service aggregates every registered source; a bound catalog still wins for overrides / localization. Each piece ships one of these, keeping its codes within its own package — Core never needs to know a piece's codes (layering preserved).
-
bool TryResolve(ushort code, string[] args, out NotificationView view)Resolves code to a view if this source owns it; false otherwise.
IPresenceClient
Queries the server's online-players roster (cluster-wide when the server is scaled out).
-
UniTask<OnlineRoster> WhoIsOnlineAsync(CancellationToken ct = default)Requests the current list of online player display names.
IReconnectEvents
What the resilience layer tells the game. Sessions resume transparently (auth restored, buffered reliable sends replayed) — but WORLD state does not survive a server-side eviction, so a game whose player was in-world re-enters on Resumed; and when the session itself is gone (token expired / server restarted), LoginRequired says "run your login flow again". Subscribing is optional — without it you still get transparent resume, just no re-entry.
-
event Action<int, float> ReconnectingA reconnect attempt is about to dial (attempt number, delay just waited).
-
event Action ResumedReconnected and the SESSION resumed (auth restored, gap-buffered sends replayed). Re-enter the world / re-join rooms here — server-side world state did not survive. "World state" means the World piece's spatial/interest state specifically (torn down on session eviction). Account-keyed, server-owned feature state — an in-progress Rounds flow, an open Vote — is connection-INDEPENDENT: it is not evicted by a link drop and re-syncs on each piece's own push cadence, so you do NOT re-create it here (rehydrate from the next fresh RoundState/VoteOpened push, never a pre-drop cache).
-
event Action<bool> LoginRequiredReconnected but the session is gone (or attempts ran out with gaveUp true): run the full login flow again.
IServerClock
The estimated server timeline, synchronized from heartbeat pongs (see ServerClockEstimator). Lets any client system place server-timestamped state (movement snapshots, events) on a common clock instead of guessing from arrival times.
-
bool IsSynchronizedTrue once at least one heartbeat sample has synchronized the clock.
-
double ServerNowMsEstimated current server time, in milliseconds (server's monotonic timeline).
-
double UnwrapServerTimeMs(uint wrapped)Reconstructs a full server time from a wrapped 32-bit millisecond stamp.
IServerInfoClient
Exposes the ServerInfo the server sends on connect (before login).
-
ServerInfo LatestThe most recent server info, or null until it arrives.
-
event Action<ServerInfo> ReceivedRaised when server info is received (on connect).
UI views you can replace 3
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.
ICrossplayAuthView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.
ICrossplayConnectionView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.
ICrossplayToastsView
Narrow contract the presenter drives. UI-only: render calls in. Row index 0 is the TOP (newest) row; the presenter owns ordering, expiry and fade timing — the view just paints.