The Game Server SDK

Use the fleet SDK and PingCoreConnectionApproval in a Unity dedicated game server on a PingCore fleet: ready, player counts, matches, sessions, backfill and who may connect.

Use the game server SDK in the dedicated game server build that runs on a PingCore fleet. It tells PingCore when the game server is ready and how many players it has, hands you each match, ends sessions, and decides who may connect through Netcode for GameObjects (NGO) connection approval. Your assembly definition references PingCore.Fleet, PingCore.Core, PingCore.Netcode.NGO and Unity.Netcode.Runtime, plus PingCore.Discovery.Host when one build also self-hosts. The same build runs anywhere, because off a fleet the fleet SDK does nothing. How Beacon Rush Works shows how Beacon Rush puts these calls together.

Start the fleet SDK

using PingCore.Fleet;
using UnityEngine;

IFleetSdk fleet = FleetSdk.Create();
fleet.AllocationReceived += OnAllocationReceived;
fleet.AllocationCleared += OnAllocationCleared;
Application.quitting += fleet.NotifyProcessStopping;

if (fleet.IsHosted && !await fleet.StartAsync(ct))
{
    Application.Quit(1);   // hosted, but the local SDK endpoint never answered
}

Create it once per process, subscribe to its events, then start it. IsHosted is true only on a game server PingCore runs, where your game talks to the local SDK endpoint in its container. StartAsync only reads, so it changes nothing players see.

  • StartAsync returns false after 30 failed tries, one second apart. Quit rather than run as another kind of game server. Beacon Rush quits with exit code 1.

  • IsHosted is false: this is not a fleet game server, and every call returns at once with an inert result. Run as a self-hosted game server or listen host instead (see Self-Hosted Game Servers and Listen Hosts).

Tell PingCore the game server is ready

using PingCore.Fleet;
using UnityEngine;

await fleet.SetCounterAsync("players", 0, ct);   // the first write: hidden from players until ReadyAsync
// Install the connection approval and start listening (below).
FleetCallResult ready = await fleet.ReadyAsync(ct);
if (!ready.IsOk)
{
    Debug.LogWarning("Ready failed: " + ready + " " + ready.Message);
}

Your game's first write to the fleet SDK tells PingCore the game uses it, and from then on the game server is hidden from players until you call ReadyAsync. So write the players counter before you listen, then call ReadyAsync as soon as the game accepts connections. After a successful ReadyAsync the SDK sends health pings every 2 seconds by itself.

  • ReadyAsync fails: the game server stays hidden and is never given a match. Outcome, Status and Message say why.

Count players

using PingCore.Fleet;

CounterResult result = await fleet.SetCounterAsync("players", connectedPlayers, ct);
if (!result.IsOk)
{
    // Send the latest count again shortly.
}

Write the players counter on every join and leave. The public server list and the matchmaker read it, and result.Capacity echoes the most players the game server takes. How counters drive matches is on Making Your Game Fleet-Ready.

  • Write players only, never sessions. PingCore counts sessions itself, and a value you write freezes the count the matchmaker reads.

  • A failed write leaves the old count in place. Beacon Rush retries until the latest count lands.

Admit players

using PingCore.Core.Handshake;
using PingCore.Fleet.Sessions;
using PingCore.Netcode.NGO;
using Unity.Netcode;
using UnityEngine;

var backfills = new BackfillWatcher(fleet);
var approval = new PingCoreConnectionApproval(
    NetworkManager.Singleton,
    new ApprovalOptions { ProtocolVersion = 2, Mode = HostingMode.Hosted },
    new HostedAdmissionEvidence(fleet, backfills),
    gate);

approval.Decided += decision => Debug.Log(decision);   // safe to log: it never shows the ticket id
approval.Install();
Application.quitting += approval.NotifyStopping;
NetworkManager.Singleton.StartServer();

Every client sends a join ticket when it connects. The approval checks it against what PingCore delivered to this game server (the player's reservation, the match roster or a backfill), then asks your gate (below). A refused client gets a reject reason as its disconnect reason. ProtocolVersion is your game's own network protocol version, the same number your client puts in its join ticket (see The Client SDK). The rules and every reject reason are on Admitting Players.

Install turns on connection approval and raises NGO's pending-connection timeout to at least 15 seconds, so call it before StartServer. NotifyStopping refuses new clients as stopping while the game server shuts down.

  • Install throws InvalidOperationException when another ConnectionApprovalCallback is already set on the NetworkManager.

  • Every client is refused protocol_mismatch: the client and the game server use different protocol versions.

Pick the evidence for the hosting mode

using PingCore.Core.Handshake;
using PingCore.Fleet.Sessions;
using PingCore.Netcode.NGO;

IAdmissionEvidence evidence =
      hosted  ? new HostedAdmissionEvidence(fleet, backfills)   // the same watcher as above
    : lanOnly ? new LanAdmissionEvidence()
    :           new HeartbeatAdmissionEvidence(heartbeat);

One build can run on a fleet, on your own machines or inside a player's game. Decide which at startup, from what the process finds, never from anything a client sends. Then set ApprovalOptions.Mode to match:

  • On a fleet: HostingMode.Hosted with HostedAdmissionEvidence.

  • Self-hosted, or a listen host that is online: HostingMode.SelfHosted or HostingMode.Listen with HeartbeatAdmissionEvidence and your started HeartbeatReporter.

  • A listen host on a LAN only: HostingMode.Listen with LanOnly = true and LanAdmissionEvidence.

The last two are on Self-Hosted Game Servers and Listen Hosts.

  • Evidence of the wrong kind for the mode refuses every reservation as reservation_unverifiable.

Write your admission gate

using System;
using PingCore.Core.Handshake;

public sealed class MyAdmissionGate : IAdmissionGate
{
    private readonly Func<int> seatsTaken;
    private readonly Func<bool> sessionOpen;

    public MyAdmissionGate(Func<int> seatsTaken, Func<bool> sessionOpen)
    {
        this.seatsTaken = seatsTaken;
        this.sessionOpen = sessionOpen;
    }

    public AdmissionGateResult CanAdmit(JoinTicket ticket, AdmissionFacts facts)
    {
        if (!sessionOpen() && !JoinAdmission.NeedsSessionClaim(ticket, facts))
        {
            return AdmissionGateResult.Reject(JoinRejectReason.NotInSession, "no open session");
        }

        if (seatsTaken() >= 8)
        {
            return AdmissionGateResult.Reject(JoinRejectReason.ServerFull);
        }

        return AdmissionGateResult.Admit;
    }
}

The approval checks the ticket first. Your gate then answers what only the game knows: is there a session to join, and a free seat in it. CanAdmit runs on the main thread, only for a ticket that passed. approval.Ledger.Count is the number of seats held by admitted players. Pass new AdmitAllGate() to admit whatever the approval accepts. Beacon Rush's gate is described on How Beacon Rush Works.

  • Reject only with JoinRejectReason.NotInSession, ServerFull or RefusedByGame. Any other reason reaches the client as refused_by_game.

  • The detail string is for your log and never reaches the client.

Start a session when a match arrives

using PingCore.Fleet;
using PingCore.Fleet.Sessions;

private void OnAllocationReceived(AllocationInfo allocation)
{
    MatchContext match = MatchContext.Parse(allocation);
    currentAllocationId = allocation.AllocationId;
    OpenLobby(waitFor: match.RosterPlayers);
}

When the matchmaker or your backend gives this game server a match, AllocationReceived fires once with it. Open the session then, before anyone connects, and wait in a lobby for RosterPlayers (the players of every matched party). Keep AllocationId, because you end the session with it. match.Queue, SessionSize and Roster describe the match, and Raw holds the whole context for your own keys.

  • A lobby nobody joins must still end. Give it a timeout.

  • Each RosterEntry.TicketId admits a player, so never log it. Log TicketRef.

End a session

using PingCore.Fleet;

FleetCallResult ended = await fleet.EndSessionAsync(currentAllocationId, ct);

Call it with the id you kept when the match is over, because fleet.CurrentAllocation can already be null by then. The game server returns to ready for its next match, and AllocationCleared fires with AllocationClearedReason.EndedByGame. For a fresh process after every match, call ShutdownAsync(ct) instead. PingCore restarts the game process in place (see Making Your Game Fleet-Ready).

  • A session your game never ends keeps the game server out of play until it restarts. Every path that opens a session needs one that ends it.

Close a session PingCore ended

using PingCore.Fleet;

private void OnAllocationCleared(AllocationCleared cleared)
{
    if (cleared.Reason == AllocationClearedReason.ClearedByPlatform)
    {
        CloseSession(cleared.AllocationId);
    }
}

AllocationCleared fires whenever a match leaves the game server. ClearedByPlatform means PingCore ended it, not your game, so close the session and send its players back.

  • An EndSessionAsync that PingCore refused also clears as ClearedByPlatform later.

Fill open seats in a running match

using PingCore.Fleet.Sessions;

JoinableSessionKeeper keeper = JoinableSessionKeeper.Start(fleet, allocationId, match.Queue, sessionSize: 8, maxPlayers: 8);
backfills.BackfillReceived += backfill =>
{
    expectedJoiners += backfill.RosterPlayers;
    keeper.Update(connected, expectedJoiners);
};
keeper.Update(connected, expectedJoiners);   // again on every join and leave; keeper.Stop() when the match closes

The keeper offers maxPlayers - connected - expectedJoiners seats, never more than the players counter has free, and republishes every ttlSeconds / 2 (30 s TTL by default). At 0 seats it withdraws the offer. Use the queue from the match, because a game server never names a queue of its own. Players the matchmaker places here (tickets sent with JoinInProgress) connect with a backfill join ticket, which the approval admits once their backfill arrives, so give the same BackfillWatcher to HostedAdmissionEvidence. How joinable sessions work is on Matchmaking Integration.

  • A successful publish only means the game server sent the offer. Discovery can still refuse it, and the game is not told: an accepted offer is listed under Joinable sessions in the Matchmaking card on the Discovery app's page.

Let one player start a game on an idle game server

using PingCore.Core.Handshake;

var options = new ApprovalOptions
{
    ProtocolVersion = 2,
    Mode = HostingMode.Hosted,
    ClaimIdleSessions = true,
};

Quick join can hold a seat on an idle game server, but a hold alone does not stop the matchmaker giving that game server to a match. With ClaimIdleSessions on, the approval claims the game server for a session of its own before it lets the player in. AllocationReceived then fires with IsSelfAllocated true and no roster. Your gate admits that join while no session is open, which is what JoinAdmission.NeedsSessionClaim in the gate above checks. Without the option, answer not_in_session while no session is open, so idle game servers stay free for the matchmaker.

  • Your game must end every claimed session, including one nobody joined, because a claim can land for a player who then timed out. Until it ends, the game server never gets a match. Beacon Rush gives such a lobby 10 seconds.

  • A match that arrives during the claim asks your gate again, with facts.SessionClaim set to SessionClaimOutcome.AllocatedMeanwhile. Decide whether the player may join that match.

Less common options

  • FleetSdkOptions: HealthInterval (2 s), CallTimeout (5 s), ReservationWait (2 s), ReservationPoll (250 ms), SelfAllocationWait (2 s), SelfAllocationGrace (10 s) and Log.

  • ApprovalOptions: Deadline (10 s), BackfillWait (5 s), BackfillPoll (500 ms), MaxPlayers, CreatePlayerObject, and AllowSelfAllocatedJoins (local testing only).

  • IFleetSdk: State and StateChanged, Current and GameServerChanged, GetCounterAsync, GetReservationAsync, ListReservationsAsync, GetBackfillsAsync, PublishJoinableAsync, WithdrawJoinableAsync and AllocateSelfAsync.

  • AdmissionPipeline in PingCore.Core.Handshake, for a netcode library other than NGO. PingCoreConnectionApproval is a thin wrapper around it.