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.

  • Create throws ArgumentException when the app id is empty or is not a dscp_ id. Check it with IsAppPublicId first, 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.

  • RunAsync throws ArgumentNullException when 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.

  • Forbidden with DiscoveryReason.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. RetryAfter says 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.

  • Forbidden with DiscoveryReason.SignedTokensDisabled: Studio-signed player tokens is off for the app.

  • Unauthorized with a token reason such as DiscoveryReason.UnknownKid or TokenExpired: 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.

  • Create throws ArgumentException for 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.

  • Unreachable when every location failed, or Unsupported where 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.

  • Conflict with DiscoveryReason.NoSeats: no game server the player can see has room. Try later, or find a match.

  • Conflict with DiscoveryReason.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.

  • Conflict with DiscoveryReason.NoSeats: the party does not fit. A hold is all or nothing, and Error.Available says 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.

  • Conflict with DiscoveryReason.TooManyTickets: the player already has a ticket queued. Cancel it first.

  • InvalidRequest with IsLocalRefusal true, such as DiscoveryReason.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, ForLan and CreateJoinTicket throw ArgumentException when a field breaks the format, for example a display name over 32 characters. CreateJoinTicket throws InvalidOperationException before a match.

  • TryEncode returns false with a problem when the encoded ticket is over 1024 bytes or would be refused.

  • The game server refuses protocol_mismatch when 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.

  • TryParse returns false for a dropped connection, which has no reject reason.

  • After a refused quick join (server_full, not_in_session or refused_by_game), release the hold with ReleaseReservationAsync and quick join again. Retry reservation_unverifiable and approval_timeout as they are.

Less common options

  • DiscoveryClientOptions: TokenStore (MemoryTokenStore keeps nothing between runs, or implement IPlayerTokenStore), Log, CallTimeout (10 s), MaxAttempts (3) and MaxRetryDelay (10 s).

  • client.Tokens: Current, the Changed event, Invalidate() and SetSignedToken(jwt) for a single token without a provider.

  • QuickJoinOptions and ReserveOptions: Seats and PlayerIds for a party, and Context for the game server. Quick join also has Filters and MaxLatencyMs. Reserve has TtlSeconds (5 to 300) and ReservationId.

  • TicketOptions: PartySize, JoinInProgress for a place in a running match, MaxLatencyMs, Filters and Context.

  • GetReservationAsync, GetLocationsAsync, and the query methods MetaWhere, SortByMeta, Search and WithLatency.