The Client SDK
Use DiscoveryClient in a Unity game client to get a player token, find a game server, hold a seat or queue for a match, and connect with a join ticket.
Use the client SDK in the build your players run. It finds a game server through Discovery (quick join, a seat on a game server the player picked, or a matchmaking ticket), then connects to it with a join ticket. Your assembly definition references PingCore.Discovery.Client and PingCore.Core, plus Unity.Netcode.Runtime for the Netcode for GameObjects examples. The examples are adapted from Beacon Rush, and How Beacon Rush Works shows where it makes each call.
Create a client
using PingCore.Discovery.Client;
using UnityEngine;
public sealed class PingCoreClient : MonoBehaviour
{
[SerializeField] private PingCoreClientSettings settings;
public DiscoveryClient Fleet { get; private set; }
private void Awake()
{
if (DiscoveryClient.IsAppPublicId(settings.FleetAppPublicId))
{
Fleet = DiscoveryClient.Create(settings, settings.FleetAppPublicId);
}
}
private void OnDestroy() => Fleet?.Dispose();
}The PingCoreClientSettings asset holds your fleet's Discovery app id. Connect in the PingCore window writes it when you pick the fleet, so you do not edit it (see The PingCore Window). Create one client per Discovery app, because player tokens are per app, and dispose it when the game quits.
CreatethrowsArgumentExceptionwhen the app id is empty or is not adscp_id. Check it withIsAppPublicIdfirst, as above.
Tell the player what is missing
using PingCore.Discovery.Client;
// settings, client and banner (a UI label) come from your own scene; ct is a CancellationToken.
InfrastructureReport report = await InfrastructureCheck.RunAsync(settings.FleetAppPublicId, client, null, ct);
if (!report.IsOk)
{
banner.text = report.ToString();
}Run it at launch and again after a join fails. report.State is Ok, NoAppId, AppUnknown, NoGameServers, Unreachable or DiscoveryError, and report.ToString() is the text to show. In the Editor a second line adds the exact reason from your workspace, such as "Fleet Beacon Rush has no deployment." Every message, and what to do about it, is on Troubleshooting.
RunAsyncthrowsArgumentNullExceptionwhen the app id is set and the client is null. Pass a null client only when there is no app id.
Check what a call returned
using PingCore.Core;
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
using PingCore.Discovery.Client.Wire;
DiscoveryResult<ReservationResponse> held = await client.QuickJoinAsync(new QuickJoinOptions(), ct);
if (held.IsOk)
{
// held.Value is the answer.
}
else if (held.Reason == DiscoveryReason.NoSeats)
{
// A refusal Discovery named. Branch on Reason, never on Message.
}No call throws for an HTTP or network failure. Each returns a DiscoveryResult<T> with IsOk and Value, an Outcome such as Conflict, RateLimited or Unreachable, the DiscoveryReason Discovery gave in Reason, and the HTTP Status. IsLocalRefusal is true when the SDK refused the request itself and sent nothing. By the time you see a result, the client has already retried a short rate limit, a 503 and a lost answer, up to three attempts in all.
Get the player id
using PingCore.Core;
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
DiscoveryResult<PlayerToken> token = await client.Tokens.GetAsync(ct);
if (token.IsOk)
{
string playerId = token.Value.PlayerId; // anon:<uuid> for an anonymous token
}The client gets a player token by itself on the first call that needs one. Ask for it yourself when you need the player id, for example for a join ticket. By default it is an anonymous token, which Discovery issues only when Anonymous player tokens is on under Player Access on the app's page (see Choosing How Players Connect). It lasts 6 hours and is kept in PlayerPrefs, so a restart keeps the same player id.
ForbiddenwithDiscoveryReason.AnonymousTokensDisabled: the switch is off for the app. Turn it on, and allow up to 60 seconds for it to apply.RateLimited: Discovery issues at most 10 tokens a minute per address for each app.RetryAftersays when to try again.
Use your own signed player tokens
using PingCore.Discovery.Client;
// myLoginService is your own code: it returns a compact JWT, or null when it has none.
client.Tokens.SetSignedTokenProvider(ct => myLoginService.GetDiscoveryTokenAsync(ct));A game with its own accounts signs a player token on its login service, and the client uses it instead of an anonymous one. The client calls the provider when it needs a token, when the one in hand is about to expire, and after Discovery rejects one. How to sign the token is on Choosing How Players Connect.
ForbiddenwithDiscoveryReason.SignedTokensDisabled: Studio-signed player tokens is off for the app.Unauthorizedwith a token reason such asDiscoveryReason.UnknownKidorTokenExpired: Discovery could not verify the token. Check the signing key and the claims.
Run two players on one PC
using PingCore.Discovery.Client;
DiscoveryClient client = DiscoveryClient.Create(new DiscoveryClientOptions
{
BaseUrl = settings.DiscoveryBaseUrl,
AppPublicId = settings.FleetAppPublicId,
Profile = "player2",
});Two copies of the game on one PC share PlayerPrefs, so by default they share one anonymous token and one player id, and the game server refuses the second one as duplicate_player. Give each copy its own Profile. Beacon Rush does this for each Multiplayer Play Mode player.
CreatethrowsArgumentExceptionfor a profile longer than 32 characters or with characters other than letters, digits,_and-.
Measure latency
using System.Collections.Generic;
using PingCore.Discovery.Client;
LatencyResult latency = await client.MeasureLatencyAsync(new LatencyProbeOptions(), ct);
IReadOnlyDictionary<string, int> medians = latency.IsOk && latency.Medians.Count > 0 ? latency.Medians : null;Medians holds the median round trip to each Discovery location, by location id. Pass it to quick join, the server list and tickets so players land near their game server (see Latency and Location Targeting). A successful result is reused for 10 minutes.
Unreachablewhen every location failed, orUnsupportedwhere the platform cannot measure. Leave latency out: every call works without it.
Quick join
using PingCore.Core;
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
using PingCore.Discovery.Client.Wire;
var options = new QuickJoinOptions { Latency = medians };
DiscoveryResult<ReservationResponse> held = await client.QuickJoinAsync(options, ct);
if (held.IsOk && held.Value.Port.HasValue)
{
// Connect to held.Value.Ip and held.Value.Port.Value with a reservation join ticket.
}
else if (held.Reason == DiscoveryReason.NoSeats)
{
// No game server has room. Beacon Rush queues for a match instead.
}Quick join picks a game server with room and holds a seat on it, for a Play button. The hold lasts 60 seconds, so connect straight away (see Connect with a join ticket). A retry after a lost answer never holds two seats. Ranking is on Reservations and Quick-Join.
ConflictwithDiscoveryReason.NoSeats: no game server the player can see has room. Try later, or find a match.ConflictwithDiscoveryReason.TooManyReservations: the player already holds as many reservations as a player may (2 by default). Release one.
List game servers
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
using PingCore.Discovery.Client.Wire;
using UnityEngine;
ServerListQuery query = new ServerListQuery()
.HasSlots(true)
.Meta("proto", 2) // only game servers on your network protocol
.SortBy(ServerSort.Players, true) // most players first
.Page(50);
DiscoveryResult<ServerPage> page = await client.ListServersAsync(query, ct);
if (page.IsOk)
{
foreach (PublicServer server in page.Value.Servers)
{
Debug.Log($"{server.Name} {server.Players}/{server.MaxPlayers}");
}
}The server list needs no player token. When page.Value.HasMore is true, pass page.Value.Next to ListServersAsync for the next page. The query options match the parameters on The Public Server List.
NotFound: Discovery does not know the app id. Pick the fleet again in Window > PingCore.
Reserve a seat on a chosen game server
using PingCore.Core;
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
using PingCore.Discovery.Client.Wire;
DiscoveryResult<ReservationResponse> held = await client.ReserveAsync(server.ServerId, new ReserveOptions(), ct);
if (!held.IsOk && held.Reason == DiscoveryReason.NoSeats)
{
status.text = $"That game server is full ({held.Error?.Available} seats free).";
}When the player picked a game server from your list, reserve a seat on it and connect as for quick join. To give a seat back early, call ReleaseReservationAsync(reservationId, ct); releasing a hold that is already gone also succeeds.
ConflictwithDiscoveryReason.NoSeats: the party does not fit. A hold is all or nothing, andError.Availablesays how many seats are free.NotFound: the game server is gone or no longer listed. Refresh the list.
Find a match
using PingCore.Core.Discovery;
using PingCore.Core.Handshake;
using PingCore.Discovery.Client;
DiscoveryResult<TicketHandle> submitted = await client.SubmitTicketAsync(new TicketOptions
{
Queue = "rush-p2", // one queue per network protocol version
SessionSize = 4,
MinSessionSize = 2, // or 2 players after 10 seconds
RelaxAfterSeconds = 10,
Latency = medians,
}, ct);
if (!submitted.IsOk)
{
return;
}
TicketHandle ticket = submitted.Value;
await ticket.WaitAsync(ct);
if (ticket.State == TicketState.Matched)
{
JoinTicket joinTicket = ticket.CreateJoinTicket(2, "Bob");
// Connect to ticket.Match.Ip and ticket.Match.Port with joinTicket.
}SubmitTicketAsync queues the player with Discovery's matchmaker. WaitAsync polls every 2 seconds until the ticket is Matched, Expired, Cancelled or Failed (ticket.LastError says why). A player's ticket must ask for at least 2 players, so a lone player stays queued until a second one joins. How queues and relaxation work is on Matchmaking Integration.
The join ticket carries the ticket id, which admits the player to the matched game server. Never log JoinTicket.TicketId or the connection payload. Log ticket.TicketRef instead.
ConflictwithDiscoveryReason.TooManyTickets: the player already has a ticket queued. Cancel it first.InvalidRequestwithIsLocalRefusaltrue, such asDiscoveryReason.SessionTooSmallForPlayer: the options break a limit, and nothing was sent. Ask for a session of at least 2.
Cancel a ticket
using PingCore.Core.Discovery;
using PingCore.Discovery.Client;
using PingCore.Discovery.Client.Wire;
DiscoveryResult<CancelTicketResponse> cancelled = await ticket.CancelAsync(ct);
if (ticket.State == TicketState.Matched)
{
// The match was made before the cancel arrived. Join it, or leave it.
}Cancelling the token you passed to WaitAsync only stops the wait, and the ticket stays queued. To leave the queue, call CancelAsync, or dispose the TicketHandle.
Connect with a join ticket
using PingCore.Core.Handshake;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
// held: the quick join or reserve result. playerId: the player token's player id.
JoinTicket joinTicket = JoinTicket.ForReservation(held.Value.ReservationId, playerId, 2, "Bob");
if (!JoinTicketCodec.TryEncode(joinTicket, out byte[] payload, out string problem))
{
status.text = "The join ticket could not be written: " + problem;
return;
}
NetworkManager manager = NetworkManager.Singleton;
manager.GetComponent<UnityTransport>().SetConnectionData(held.Value.Ip, (ushort)held.Value.Port.Value);
manager.NetworkConfig.ConnectionData = payload;
manager.NetworkConfig.ClientConnectionBufferTimeout = 15;
manager.StartClient();Build the join ticket from Discovery's answer: JoinTicket.ForReservation after quick join or reserve, and ticket.CreateJoinTicket after a match. The number (2 here) is your game's own network protocol version, the same number your game server checks. Keep ClientConnectionBufferTimeout above the game server's 10-second decision deadline. A LAN game uses JoinTicket.ForLan (see Self-Hosted Game Servers and Listen Hosts).
JoinTicket.ForReservation,ForLanandCreateJoinTicketthrowArgumentExceptionwhen a field breaks the format, for example a display name over 32 characters.CreateJoinTicketthrowsInvalidOperationExceptionbefore a match.TryEncodereturns false with aproblemwhen the encoded ticket is over 1024 bytes or would be refused.The game server refuses
protocol_mismatchwhen its version differs from yours: the player has another version of the game.
Show why the game server refused
using PingCore.Core.Handshake;
using Unity.Netcode;
manager.OnClientDisconnectCallback += clientId =>
{
if (JoinRejectReasons.TryParse(manager.DisconnectReason, out JoinRejectReason reason))
{
ShowRefusal(reason); // for example JoinRejectReason.ServerFull
}
};A refused client gets the reason as its disconnect reason. Turn it into a sentence for the player. Every reason is on Admitting Players.
TryParsereturns false for a dropped connection, which has no reject reason.After a refused quick join (
server_full,not_in_sessionorrefused_by_game), release the hold withReleaseReservationAsyncand quick join again. Retryreservation_unverifiableandapproval_timeoutas they are.
Less common options
DiscoveryClientOptions:TokenStore(MemoryTokenStorekeeps nothing between runs, or implementIPlayerTokenStore),Log,CallTimeout(10 s),MaxAttempts(3) andMaxRetryDelay(10 s).client.Tokens:Current, theChangedevent,Invalidate()andSetSignedToken(jwt)for a single token without a provider.QuickJoinOptionsandReserveOptions:SeatsandPlayerIdsfor a party, andContextfor the game server. Quick join also hasFiltersandMaxLatencyMs. Reserve hasTtlSeconds(5 to 300) andReservationId.TicketOptions:PartySize,JoinInProgressfor a place in a running match,MaxLatencyMs,FiltersandContext.GetReservationAsync,GetLocationsAsync, and the query methodsMetaWhere,SortByMeta,SearchandWithLatency.