Latency and Location Targeting
Measure a player's latency to each PingCore location from the game client and send the map on tickets, quick-join, and the server list so matches and joins land where the player's connection is best.
Every PingCore location has a latency beacon. A game client measures its round trip to each beacon once, then sends the measurements as a latency map on a matchmaking ticket, a quick-join, or the public server list. The matchmaker uses the map to place the match at the location with the best worst-case latency across the party, and maxLatencyMs turns it into a hard limit. Nothing on this page needs a token: the location list is public and the measurement runs from the client.
What a location is
A location is one of the platform's deployment zones, named by an identifier such as us-east-nyc or eu-west-ams. Every fleet server advertises the identifier of the zone it runs in as meta.location, so the same string is what you filter on in an allocation, what arrives on a matched ticket as location, and what you key a latency map with. Heartbeat servers you host elsewhere can set meta.location themselves to a listed identifier and take part in latency sorting the same way.
An identifier names a latency zone rather than a city. The zone's beacon may sit in a different city from some of the servers that advertise it: servers in London and Amsterdam both advertise eu-west-ams and are measured through the Amsterdam beacon. A measurement is accurate to the zone, so a client measures each zone's beacon once and never probes individual servers.
Listing locations
GET /v1/locationsPublic, no token, rate limited per IP like the server list. It answers Access-Control-Allow-Origin: *, so a browser build can call it directly, and carries Cache-Control: public, max-age=300.
curl https://discovery.pingcore.io/v1/locationsYou should see:
{
"error": false,
"locations": [
{ "id": "eu-west-ams", "name": "Europe - Amsterdam", "pingUrl": "wss://ams-latency.pingplayers.com/ws/", "enabled": true },
{ "id": "us-east-nyc", "name": "US - East - New York", "pingUrl": "wss://nyc-latency.pingplayers.com/ws/", "enabled": true }
],
"returned": 2
}id: the identifier, byte-identical to the
meta.locationvalue fleet servers advertise. Sorted ascending.name: a display name for a region picker.
pingUrl: the WebSocket beacon to measure against, or
nullwhen the zone has no beacon. A location withpingUrl: nullis still listed so your client recognises the id on a running server, but it cannot be measured.enabled: whether the zone is currently taking new deployments. A disabled zone can still be running servers, so keep resolving its id; hide it from a "preferred region" picker if you have one.
The list is platform-wide and identical for every app. It answers 503 Discovery has not loaded its locations yet. Retry shortly. until the service has pulled the locations from the platform for the first time.
Measuring latency
The beacon is a WebSocket echo. Send the text frame ping and it answers the text frame pong; the round trip is your latency to that zone. The procedure:
GET /v1/locations; skip entries withpingUrl: null.For each location in parallel: open the socket, send
pingonce as a warm-up and discard the reply, then sendping5 times in sequence, each after the previouspong, with a 2 second timeout per sample. Take the median of the 5 samples, rounded to an integer. Close the socket.A location that fails to connect or times out is omitted from the map. Never send it as
0.Cache the map for the play session; refresh it every 5 minutes or when the network changes.
Send the map as
latencyon tickets and quick-join, and aslatency.<id>=query parameters on the server list.
Browser and WebGL builds use the browser's WebSocket. Unity desktop and console builds use System.Net.WebSockets.ClientWebSocket; a Unity WebGL build has no ClientWebSocket and routes through the browser socket via a .jslib plugin, using the JavaScript below. UDP is not required.
// Measure every measurable location in parallel. Returns { [locationId]: medianMs }.
// Works in a browser (including Unity WebGL through a .jslib plugin) and in Node 22+.
const LOCATIONS_URL = 'https://discovery.pingcore.io/v1/locations';
const SAMPLES = 5;
const SAMPLE_TIMEOUT_MS = 2000;
async function measureLatencyMap() {
const { locations } = await (await fetch(LOCATIONS_URL)).json();
const entries = await Promise.all(
locations
.filter((location) => location.pingUrl !== null)
.map(async (location) => [location.id, await measureBeacon(location.pingUrl)]),
);
// Omit locations that failed to connect or timed out; never send 0.
return Object.fromEntries(entries.filter(([, ms]) => ms !== null));
}
function measureBeacon(pingUrl) {
return new Promise((resolve) => {
const socket = new WebSocket(pingUrl);
const samples = [];
let sentAt = 0;
let timer = null;
let warmedUp = false;
const finish = (value) => {
clearTimeout(timer);
socket.close();
resolve(value);
};
const sendPing = () => {
sentAt = performance.now();
timer = setTimeout(() => finish(null), SAMPLE_TIMEOUT_MS);
socket.send('ping');
};
socket.onerror = () => finish(null);
socket.onopen = sendPing;
socket.onmessage = (event) => {
if (event.data !== 'pong') return;
clearTimeout(timer);
if (!warmedUp) {
warmedUp = true; // discard the warm-up sample
} else {
samples.push(performance.now() - sentAt);
}
if (samples.length === SAMPLES) {
samples.sort((a, b) => a - b);
finish(Math.round(samples[Math.floor(SAMPLES / 2)])); // median
} else {
sendPing();
}
};
});
}// Unity (desktop, console) or any .NET client. WebGL builds cannot use
// ClientWebSocket: bridge to the browser WebSocket with a .jslib plugin instead.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public static class LatencyProbe
{
private static readonly HttpClient Http = new HttpClient();
private const string LocationsUrl = "https://discovery.pingcore.io/v1/locations";
private const int Samples = 5;
private static readonly TimeSpan SampleTimeout = TimeSpan.FromSeconds(2);
// Returns { locationId => median round trip in ms }. Unreachable beacons are omitted.
public static async Task<Dictionary<string, int>> MeasureAsync(CancellationToken ct)
{
var json = await Http.GetStringAsync(LocationsUrl);
var locations = JsonDocument.Parse(json).RootElement.GetProperty("locations");
var tasks = new List<Task<(string Id, int? Ms)>>();
foreach (var location in locations.EnumerateArray())
{
var pingUrl = location.GetProperty("pingUrl");
if (pingUrl.ValueKind == JsonValueKind.Null) continue; // cannot be measured
var id = location.GetProperty("id").GetString()!;
tasks.Add(MeasureBeaconAsync(id, pingUrl.GetString()!, ct));
}
var map = new Dictionary<string, int>();
foreach (var (id, ms) in await Task.WhenAll(tasks))
{
if (ms.HasValue) map[id] = ms.Value; // never send 0 for a failed probe
}
return map;
}
private static async Task<(string, int?)> MeasureBeaconAsync(string id, string pingUrl, CancellationToken ct)
{
try
{
using var socket = new ClientWebSocket();
await socket.ConnectAsync(new Uri(pingUrl), ct);
await RoundTripAsync(socket, ct); // warm-up, discarded
var samples = new List<double>(Samples);
for (var i = 0; i < Samples; i++)
{
samples.Add(await RoundTripAsync(socket, ct));
}
samples.Sort();
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", ct);
return (id, (int)Math.Round(samples[Samples / 2])); // median
}
catch (Exception)
{
return (id, null); // connect failure or timeout: omit from the map
}
}
private static async Task<double> RoundTripAsync(ClientWebSocket socket, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(SampleTimeout);
var ping = Encoding.UTF8.GetBytes("ping");
var buffer = new byte[64];
var watch = Stopwatch.StartNew();
await socket.SendAsync(ping, WebSocketMessageType.Text, true, timeout.Token);
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(buffer, timeout.Token);
} while (Encoding.UTF8.GetString(buffer, 0, result.Count) != "pong");
return watch.Elapsed.TotalMilliseconds;
}
}You should see: a map such as { "eu-west-ams": 24, "eu-west-fra": 31, "us-east-nyc": 96 }, with a lower number for the zone nearest the player and no entry for a beacon that could not be reached.
Sending the map
The same map goes to three places. Up to 32 entries by default; values are integers from 0 to 10000 milliseconds; maxLatencyMs is an integer from 1 to 10000 and needs a map to apply to. Keys the service does not recognise (a location your client knows that this deployment does not yet) are ignored, never rejected.
Tickets
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"queue":"ranked","sessionSize":8,"latency":{"eu-west-ams":24,"eu-west-fra":31,"us-east-nyc":96},"maxLatencyMs":80}'// Send the measured map on a ticket. Works with an allocate token from a backend
// or a player token from the client; the latency fields are the same for both.
var latency = await LatencyProbe.MeasureAsync(ct);
var body = JsonSerializer.Serialize(new
{
queue = "ranked",
sessionSize = 8,
latency, // { "eu-west-ams": 24, ... }
maxLatencyMs = 80, // omit to make the map a preference instead of a limit
});
using var request = new HttpRequestMessage(HttpMethod.Post,
"https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request, ct);
// 200 => queued. 400 no_location_within_ceiling => every measurement is above maxLatencyMs.You should see: 200 with status: "queued", and once matched the ticket's location names one of the zones under your ceiling. The full ticket contract is on Matchmaking Integration.
Quick-join
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/quick-join \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"idempotencyKey":"join-77","seats":2,"latency":{"eu-west-ams":24,"eu-west-fra":31,"us-east-nyc":96},"maxLatencyMs":80}'// Quick-join ranked by latency. Candidates are ordered in 25 ms buckets, then by
// player count, so a lively server within 25 ms of the nearest one still wins.
var latency = await LatencyProbe.MeasureAsync(ct);
var body = JsonSerializer.Serialize(new
{
idempotencyKey = Guid.NewGuid().ToString("N"),
seats = 2,
latency,
maxLatencyMs = 80,
});
using var request = new HttpRequestMessage(HttpMethod.Post,
"https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/quick-join");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request, ct);
// 200 => a reservation on the chosen server (ip, port, reservationId, expiresIn).
// 409 no_seats => no visible server under the ceiling has room for the party.You should see: a reservation whose server advertises a meta.location within your ceiling, ranked as described on Reservations and Quick-Join.
The server list
On the public list the map arrives as flat query parameters, one per location, alongside an optional maxLatencyMs and the latency sort:
curl "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?latency.eu-west-ams=24&latency.eu-west-fra=31&latency.us-east-nyc=96&maxLatencyMs=80&sort=latency"latency.<id>=<ms>: one parameter per measured location. Values that are not integers within range are ignored.maxLatencyMs: with a map present, keeps only servers whosemeta.locationyou measured at or below the ceiling. Servers with no location, or a location you did not measure, are excluded.sort=latency(or-latency): orders by your measurement for each server's location, exact milliseconds ascending, then by players descending, then by server id. Servers with no location or no measurement sort last in both directions.sort=latencywith no map falls back to the default sort.
You should see: with sort=latency the first server's meta.location is the location with your lowest measurement.
How placement chooses
For a ticket match, the matchmaker builds a candidate set of locations and ranks it:
With a ceiling (any member sent
maxLatencyMs): the candidates are the locations every ceiling-bearing member accepts, intersected with the locations a live server of your app advertises this tick. An empty intersection means zero attempts and the tickets stay queued; the matcher never places a match at a location the player ruled out. Tickets that would have no acceptable location at all are refused at submit instead (below).Without a ceiling (members sent maps only): the candidates are every location any member measured, intersected with what live servers advertise, tried as a preference. If none of them has capacity the match is placed on any ready server, so a map alone never leaves a match queued.
No maps: one attempt with the tickets' filters, as before latency existed.
Ranking is minimax. For each candidate, take the highest latency across the members that sent a map (a member with no measurement for that location counts as the worst case); the location with the lowest such maximum wins. Ties fall to the lower mean latency, then to the identifier alphabetically. Up to 3 ranked locations are tried per match, in order, before the tickets are left for the next tick. The chosen identifier is delivered to the game server as location in the match context and appears on the matched ticket as location.
Quick-join uses the same map differently: it ranks visible servers in 25 ms buckets of your measurement to their location, ascending, then by player count descending, so servers with equivalent latency still favour the livelier one. Servers with no measurement rank last.
maxLatencyMs is a hard filter
Once set, a location the player did not measure is not acceptable, and a location measured above the ceiling is not acceptable. Consequences:
A ticket with
maxLatencyMsbut nolatencymap is refused with 400max_latency_without_map:maxLatencyMs needs a latency map to apply to. Measure the locations from GET /v1/locations and send them as latency.A ticket whose every measurement is above its own ceiling is refused with 400
no_location_within_ceiling:No measured location is within maxLatencyMs; raise the ceiling or measure more locations.The refusal happens at submit because such a ticket could never be placed; queuing it until it expired would only hide the problem. Quick-join has no equivalent refusal: with no visible server under the ceiling it answers 409no_seats.A ticket that is accepted but whose acceptable zones have no ready server stays queued, with no
no_capacityerror, until a server in one of them is ready or the ticket expires. The workspace's Matchmaking card shows queued tickets per location so you can see which zone is short (see Fleet Observability).A location that has no beacon (
pingUrl: null) can never appear in a map, so under a ceiling it is never chosen.
Omit maxLatencyMs when you would rather play far than wait: the map still steers placement toward the nearest zones with capacity.
Checkpoints
You should see:
GET /v1/locationsreturns the platform's location ids, including the ones your fleet servers advertise asmeta.location, each with apingUrl(ornull).The measurement code produces a map with one integer per reachable beacon and no entry for an unreachable one.
A ticket with
maxLatencyMsbelow every measurement is refused at submit with 400no_location_within_ceilingrather than queued.A ticket with a ceiling that only one zone satisfies is placed at that zone (
locationon the ticket and in the delivered context) or stays queued while that zone has no ready server; the Matchmaking card counts it under that zone.
Troubleshooting
The beacon cannot be reached from a browser build. Beacons are
wss://only. Use thepingUrlexactly as listed: a browser refuses an insecurews://socket from anhttps://page as mixed content, and the beacon does not listen onws://anyway. A corporate proxy that blocks WebSockets shows up the same way: the probe times out, the zone is omitted from the map, and placement falls back to the zones that were measured.400
max_latency_without_map.maxLatencyMswas sent with nolatencymap (or an empty one). Measure first, or omit the ceiling.400
no_location_within_ceiling. Every measured location is above the ceiling. RaisemaxLatencyMs, measure more locations, or omit the ceiling so the map becomes a preference.400
too_many_latency_entries. The map has more entries than the limit (32 by default):latency has 40 entries; the limit is 32.Send only the zones your fleet deploys to.A location shows
pingUrl: null. The zone has no beacon and cannot be measured. Leave it out of the map; servers there are still listed and still allocatable for tickets without a ceiling.503 from
GET /v1/locations. The service has not loaded its locations yet (Discovery has not loaded its locations yet. Retry shortly.). Retry shortly; nothing else is affected.