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/locations

Public, 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/locations

You 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.location value fleet servers advertise. Sorted ascending.

  • name: a display name for a region picker.

  • pingUrl: the WebSocket beacon to measure against, or null when the zone has no beacon. A location with pingUrl: null is 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:

  1. GET /v1/locations; skip entries with pingUrl: null.

  2. For each location in parallel: open the socket, send ping once as a warm-up and discard the reply, then send ping 5 times in sequence, each after the previous pong, with a 2 second timeout per sample. Take the median of the 5 samples, rounded to an integer. Close the socket.

  3. A location that fails to connect or times out is omitted from the map. Never send it as 0.

  4. Cache the map for the play session; refresh it every 5 minutes or when the network changes.

  5. Send the map as latency on tickets and quick-join, and as latency.<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();
      }
    };
  });
}

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}'

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}'

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 whose meta.location you 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=latency with 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 maxLatencyMs but no latency map is refused with 400 max_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 409 no_seats.

  • A ticket that is accepted but whose acceptable zones have no ready server stays queued, with no no_capacity error, 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/locations returns the platform's location ids, including the ones your fleet servers advertise as meta.location, each with a pingUrl (or null).

  • The measurement code produces a map with one integer per reachable beacon and no entry for an unreachable one.

  • A ticket with maxLatencyMs below every measurement is refused at submit with 400 no_location_within_ceiling rather than queued.

  • A ticket with a ceiling that only one zone satisfies is placed at that zone (location on 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 the pingUrl exactly as listed: a browser refuses an insecure ws:// socket from an https:// page as mixed content, and the beacon does not listen on ws:// 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. maxLatencyMs was sent with no latency map (or an empty one). Measure first, or omit the ceiling.

  • 400 no_location_within_ceiling. Every measured location is above the ceiling. Raise maxLatencyMs, 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.