FinalFactory.Rendezvous.Client
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
The package targets netstandard2.1 and uses a caller-owned HttpClient.
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using HttpClient http = new()
{
BaseAddress = new Uri("https://rendezvous.example/"),
};
string publisherCredential = Environment.GetEnvironmentVariable(
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
?? throw new InvalidOperationException("Publisher credential is not configured.");
CancellationToken cancellationToken = default;
RendezvousPublisherClient publisher = new(http);
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
new RegisterSessionRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "My server",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.40",
Port = 7777,
},
},
publisherCredential,
cancellationToken);
if (!registered.IsSuccess || registered.Value is null)
{
throw new InvalidOperationException(
$"Registration failed: {registered.Error} ({registered.Message})");
}
Load publisherCredential from the game's deployment secret boundary; never
embed it in a client build or source control. A successful registration returns a
PublishedSession containing the lease and host-presence capabilities.
Send a periodic presence request from the host's gameplay NetManager using the
server-controlled refresh interval and the fixed-size native token:
string presenceToken = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
session.HostPresenceHandle,
session.HostPresenceCapability);
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
For direct connections, let the SDK drive those tokens from the same caller-owned LiteNetLib socket that carries gameplay. Ask the routing listener to create the bound manager, then configure and start that caller-owned manager yourself. The factory does not open a socket, and synchronized events must remain enabled:
RendezvousNetListener networkEvents = new();
NetManager gameplayNetManager = networkEvents.CreateManager();
if (!gameplayNetManager.Start(0))
{
throw new InvalidOperationException("The gameplay UDP socket could not start.");
}
The host polls join invitations asynchronously; that method only queues a
snapshot and never calls the manager. Poll() is the sole SDK path that invokes
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
frame on the thread that owns the manager:
RendezvousJoinClient joins = new(http);
using RendezvousHostCoordinator host = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
session,
joins);
// Run periodically from the game's normal async scheduling path.
await host.RefreshJoinAttemptsAsync(cancellationToken);
// Godot _Process, Update, or the equivalent main-thread frame callback.
host.Poll();
Do not also call gameplayNetManager.PollEvents() or
gameplayNetManager.NatPunchModule.PollEvents() when a coordinator owns polling.
The host coordinator refreshes host presence, punches for queued invitations,
validates the introduction ticket, and accepts the direct request. Subscribe to
AttemptCompleted; a Connected result is raised only after LiteNetLib reports
the accepted peer as connected. Register ordinary gameplay callbacks on
networkEvents.GameplayEvents; the routing listener reserves Rendezvous direct
requests for ticket validation and forwards every other callback normally.
The joining game first requests an attempt through the typed start API. It returns exactly one issued attempt or one terminal service outcome, so service authority is not confused with a later locally observed traversal failure:
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
createJoinRequest,
cancellationToken: cancellationToken);
if (start.Outcome is { } serviceOutcome)
{
ShowConnectionFailure(serviceOutcome.Kind, serviceOutcome.Category);
return;
}
CreateJoinAttemptResponse attempt = start.Attempt
?? throw new InvalidOperationException("The typed start result was invalid.");
using RendezvousClientCoordinator client = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
attempt);
// Godot _Process, Update, or the equivalent main-thread frame callback.
client.Poll();
NAT introduction changes the client state to Connecting; it is not success.
Only a Connected outcome supplies Peer. Completion exposes a stable kind,
source, category, phase, and elapsed duration. The default HTTP silence, punch,
and direct-connect budgets are five, ten, and five seconds respectively; configure
them through RendezvousClientOptions and RendezvousCoordinatorOptions when a
game has measured reasons to do so. The signed attempt expiry is always the
absolute upper bound.
Call Cancel() and then Poll() for local cancellation, or
CancelAsync(joins, cancellationToken) to also revoke the service attempt.
Terminal client paths complete exactly once and release all event subscriptions,
so late packets and callbacks are inert. Disposing a coordinator never stops or
disposes the caller-owned manager and does not touch an in-flight peer; call
Cancel() followed by Poll() first when that peer must also be disconnected.
After terminal completion, reporting is explicit and safe to retry. It sends only the authenticated outcome enum and a coarse elapsed bucket—never the endpoint, exact duration, diagnostic text, metadata, or player identity:
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
await client.ReportOutcomeAsync(joins, cancellationToken);
An optional DedicatedFallback is copied from the authoritative listing into the
issued attempt and terminal outcome. A local deployment may replace it with
RendezvousCoordinatorOptions.DedicatedFallbackOverride. The SDK only returns
the endpoint; it never connects automatically. The game must explicitly decide
whether to use it and then connect and authenticate through its own gameplay
transport. If the outcome has no fallback, v1 offers no relay.
Lease renewal is explicit and caller-controlled:
PublishedSession session = registered.Value;
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
publisherCredential);
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
Creating the maintainer does not start background work. Await its run and dispose
it when hosting stops. Use IRendezvousPublisherClient and
IRendezvousSessionBrowserClient as injection seams in game tests. The SDK disposes
the requests and responses it creates but never disposes the supplied HttpClient.
The host-side ConnectionTicketValidator is a bounded, thread-safe one-time gate.
Authorize only tickets delivered by the authenticated Rendezvous introduction,
then consume the exact ticket presented by the direct LiteNetLib connection:
using ConnectionTicketValidator tickets = new();
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
ConnectionTicketConsumptionResult admission = tickets.Consume(
attemptId,
presentedTicket);
An Accepted ticket authorizes only this connection attempt. The game must still
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
the attempt on cancellation and dispose the validator during host shutdown so its
keyed ticket digests are zeroed.
See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for join-capability and connection-ticket security semantics, and ADR 0010 for typed outcomes, deadlines, reporting, and caller-owned fallback.