Live · concept
The service plane
The gate — sign in, characters, the catalog check, and the one call that turns "I want to play" into an endpoint and a signed ticket.
Edit this page on GitHubDocuments
- GateService
- GateEndpoints
- GateOptions
- GateHost
- GateLog
- GateToken
- GateTokenSigner
- TokenStatus
- GateAnswer`1
- IAccountAuthority
- AuthorityResult
- DevelopmentAuthority
- IFleetDirectory
- ClusterFleetDirectory
- ServicePlane
- IGateSubscriber
- WebSocketSubscriber
- SignInRequest
- SignInResponse
- CharacterSummary
- CharacterList
- CreateCharacterRequest
- CatalogResponse
- PlayRequest
- PlayResponse
- PlayStatus
- GateProblem
- GateEvent
- GateJson
- RealmVersionJsonConverter
- GateClient
- GateOutcome`1
- GateConnection
- IGateSocket
- WebSocketGateSocket
What it is
One of the three planes doc 27 separates. The client holds two connections in steady state: a UDP session to the realm simulating it, and an HTTPS/WSS connection to the gate. The gate is the second one.
It does five things: says what the fleet is running, turns a credential into a session, lists and makes characters, and — the one the milestone exists for — answers "put me somewhere" with an endpoint and a signed ticket.
Both halves are here. Vixen.Live.Gate is the ASP.NET side; Vixen.Live.Client is the typed client
side, and it is the only assembly of this milestone a game client links (ADR-017).
What it is for
Nothing on this plane is on a frame path. A hundred milliseconds is fine here, which is exactly why the things that need it live here rather than on the realm: login, the character list, the catalog, and anything whose recipient may be on another realm, offline, or on another continent.
The routing question doc 27 answers at length is why there is no gateway on the hot path. This is the other half of that answer: real-time traffic goes straight from the client to its realm, and everything that does not need to be fast comes here instead. What the client learns from the gate is an endpoint and a ticket, and from then on the packet path is doc 16's.
Using it
builder.Services.AddVixenGate(gate => { gate.Version = new("0.1.0", catalog.BuildHash); gate.Content = "https://content.example/catalog"; gate.Region = "eu"; gate.Maps.Add("maps/queensdale");});app.UseWebSockets();app.MapVixenGate();⚠ AddVixenGate deliberately registers almost nothing. IPersistence, IFleetDirectory, the two
signers and every IAccountAuthority are the caller's, because each is a decision only a deployment
can make: which database, which cluster, where the secrets live, and who is allowed to say who
somebody is. A gate assembled with none of them fails at construction rather than at the first
sign-in.
Examples
The client's whole sequence, in four calls. Vixen.Live.Client is the typed half, and it is the
one assembly of this milestone a game client links:
var gate = new GateClient(new HttpClient { BaseAddress = new("https://gate.example/v1/") });var catalog = await gate.CatalogAsync(cancellation); // before signing in: a launcher can ask tooawait gate.SignInAsync("steam", sessionTicket, cancellation); // the token is held, never written to diskvar characters = await gate.CharactersAsync(cancellation);var play = await gate.EnterAsync( new PlayRequest(characters.Value!.Characters[0].Character, "maps/queensdale", catalog.Value!.Version, "en-GB", default, default), attempts: 5, cancellation);⚠ Nothing on GateClient throws for a refusal, and GateOutcome.Unreachable is separate from
one: "the gate said no" is a sentence to show and "the gate did not answer" is a spinner and a
retry, and a client that showed the first for the second sends people to a support forum over dropped
Wi-Fi.
⚠ EnterAsync waits out Starting and hands UpdateRequired straight back. A shard coming up
needs nothing from the game but patience; fetching a catalog is the asset system doing work it must
decide to do, on a connection the player may be paying for.
And then the four answers it has to be able to render:
switch (play.Value!.Status) { case PlayStatus.Placed: await session.ConnectAsync(play.Value.Endpoint, play.Value.Ticket); // doc 16's handshake, carrying the ticket break; case PlayStatus.Starting: await Task.Delay(play.Value.RetryAfter); // a wait, NOT a failure goto retry; case PlayStatus.UpdateRequired: await assets.UpdateAsync(catalog.Value.Content); // ADR-022's routing decision goto retry; case PlayStatus.Refused: Show(play.Value.Reason); // the map's own sentence break;}⚠ Starting is not an error and UpdateRequired is not a rejection. A client that renders
Starting as a failure turns an elastic fleet's ordinary behaviour into a support ticket; one that
renders UpdateRequired as a failure turns a rolling upgrade back into a maintenance window. Both are
the two easiest cases to get wrong, and both are why the enum has four values instead of two.
Signing in without an identity provider, for a laptop and for tests:
builder.Services.AddSingleton<IAccountAuthority, DevelopmentAuthority>();⚠ DevelopmentAuthority trusts whatever it is told — the credential is the handle, so anyone
who can reach the gate can sign in as anybody. It is not registered by default, and that is the
point: a gate with no authority refuses every sign-in, which is loud, rather than accepting every
sign-in, which is not.
The socket
ServicePlane is where the gate pushes: a catalog that has been published, a shard about to drain,
guild and whisper chat, a party invite.
await plane.TellEveryoneAsync(new("catalog", version.ToString(), DateTimeOffset.UtcNow));The client's side is GateConnection, which reconnects by itself and says nothing about it:
await using var stream = new GateConnection(new("wss://gate.example/v1/stream"), gate);await foreach (var message in stream.ListenAsync(cancellation)) { switch (message.Kind) { case "catalog": await assets.UpdateAsync(); break; case "draining": await Replace(await gate.PlayAsync(request, cancellation)); break; }}⚠ ListenAsync never completes on its own — it ends when the caller cancels. A socket closing is
a reconnect rather than an end, so a loop that stopped when the enumeration did would stop the first
time a train went into a tunnel. Nothing is replayed across a reconnect and nothing needs to be, and
an unreadable frame is skipped so that a newer gate saying something newer is not a client update.
⚠ This socket is allowed to be down and every message on it is allowed to be lost. Nothing a player is waiting on travels here — that is the data plane — and anything that would be wrong to lose is a request the client makes rather than a push it receives. A push is a hint to go and ask. Anything a client sends is treated as a ping, because a socket that also carried commands would need its own authorisation, rate limiting and closed-set deserialization: the whole security surface doc 16 built once already.
It is authenticated by the Authorization header and never by a query string — a token in a URL is
written to every access log and proxy cache between the gate and the player.
Two token types, and why they are not one
| Admits | Checked by | Lives for | |
|---|---|---|---|
GateToken | one account to the gate | the gate | hours |
TransferTicket | one character to one shard | a realm | a minute or two |
Sharing a key would let a realm mint gate sessions. Making them one type would let a realm be handed something that authorises reading an account's character list. They are separate for the same reason ADR-017 splits the assemblies.
⚠ A GateToken is stateless, so it cannot be revoked before it expires — its lifetime is the whole
of its bound, which is why it is hours rather than weeks. Suspension is checked against the account
on every request that matters, so a banned account stops being able to play at once even though its
token still parses.
See also
- Durable state and the ledger — the accounts and characters behind it.
- Transfer tickets — what
POST /v1/playmints, and what a realm does with it. - Placing players — the score that decides which shard the answer names.
- docs/plan/27 § The routing question — why there is no gateway on the hot path.