The Public Server List

Read the live server list from your game client: paging, filtering, sorting, and the compatibility promise.

Every Discovery app has one public server list endpoint. It is unauthenticated and rate limited per IP, so it is safe to call straight from a shipped game client. It answers Access-Control-Allow-Origin: *, so a browser build on any domain can read its own server list too.

GET /v1/apps/{publicId}/servers

The publicId is the dscp_ identifier on your Discovery app page (the page shows the full URL with a copy button). It is public by design; shipping it inside your client is the intended use.

The response

{
  "error": false,
  "servers": [
    {
      "serverId": "203.0.113.10:27015",
      "name": "EU West #1",
      "ip": "203.0.113.10",
      "port": 27015,
      "players": 4,
      "maxPlayers": 32,
      "version": "1.4.2",
      "meta": { "map": "coastline" }
    }
  ],
  "totalServers": 1284,
  "returned": 100,
  "limit": 100,
  "offset": 0
}
  • totalServers counts everything matching the filters before paging. Use it to compute the page count.

  • returned is how many servers came back in this response.

Responses carry a short Cache-Control header (5 seconds by default), matching the service's internal cache window, so an intermediary can never serve staler data than the service itself would.

Query parameters

The list is paged and filtered server-side, so a client never has to download the full server set.

  • limit: page size. Default 100, capped at 500.

  • offset: rows to skip. Page N is offset = (N - 1) * limit.

  • search: case-insensitive substring match on the server name.

  • version: exact match on the version string. An old client can hide servers it cannot join.

  • hasSlots: true keeps only servers with players < maxPlayers; false keeps only full ones.

  • meta.<key>: case-insensitive exact match on one of your own meta fields, e.g. meta.map=coastline. Values are compared as strings, so numeric and boolean meta values match too (meta.pvp=true). A meta value that is a nested object or array never matches.

  • meta.<key>[op]: a typed comparison on a meta field, e.g. meta.xp[gt]=1.0. See "Meta filter operators" below.

  • latency.<locationIdentifier> and maxLatencyMs: the client's measured round trip per location, and an optional ceiling. See "Filtering and sorting by latency" below.

  • sort: players, name, updatedAt, latency, or meta.<key>. A leading - sorts descending. The default is -players, which puts the servers with the most players first. Anything unrecognised falls back to the default. Meta sorts are numeric-aware; see "Sorting by a meta field" below.

Example: page 2 of joinable "coastline" servers on version 1.4.2, busiest first:

curl "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?limit=50&offset=50&hasSlots=true&version=1.4.2&meta.map=coastline&sort=-players"

Meta filter operators

A bracketed operator after the key turns a meta filter into a typed comparison:

# Servers past wave 5 but not yet at wave 10:
curl "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?meta.wave[ge]=5&meta.wave[lt]=10"

# Servers on either of two maps:
curl "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?meta.map[in]=DustII,Airport"
  • eq: case-insensitive string equality. This is the explicit spelling of the bare form; meta.map[eq]=coastline and meta.map=coastline are the same filter.

  • ne: not equal. Numeric-aware: when both sides parse as numbers they compare numerically, so 10 and 10.0 count as equal. When either side is not a number, it falls back to case-insensitive string inequality.

  • gt, ge, lt, le: greater than, greater or equal, less than, less or equal. Numeric only: both the meta value and your value must parse as finite numbers, otherwise the server simply does not match. A value like "high" is never coerced into an ordering.

  • contains: case-insensitive substring match.

  • in: case-insensitive match against a comma-separated set, e.g. meta.tier[in]=gold,diamond.

One key can carry several conditions at once, and a server must satisfy all of them. That is how you express a range (meta.wave[ge]=5&meta.wave[lt]=10), and a bare exact match combines with bracketed operators on the same key in the same way.

Two behaviours to plan around:

  • eq and ne are not exact complements on numbers. eq compares string forms (the bare form shipped that way, and [eq] stays its explicit spelling), while ne compares numerically when it can. A server with wave: 10 fails both meta.wave[eq]=10.0 (the string forms differ) and meta.wave[ne]=10.0 (the numbers are equal). When you filter with eq, send the same lexical form your servers heartbeat.

  • An unrecognised operator drops the whole parameter instead of being treated as an exact match on a strange key. An unknown operator is most likely a newer client build talking to an older deployment, and degrading to "no filter" keeps that client's list working.

Sorting by a meta field

sort=meta.<key> orders the list by one of your own meta fields, with the usual - prefix for descending. The comparison is numeric-aware:

  • Two values that both parse as numbers compare numerically (9 sorts before 10, not after it).

  • Two non-numeric values compare as strings.

  • When a key mixes numbers and text across servers, numeric values order before text ascending (and after it descending).

  • Servers missing the key, or holding an object or array under it, sort last in both directions.

Ties break on the server id, so the ordering is stable and paging never repeats or skips a server.

Filtering and sorting by latency

Fleet servers advertise the zone they run in as meta.location, using the platform's location identifier (eu-west-ams, us-east-nyc, and so on). A client that has measured its round trip to each zone's beacon can send those measurements with the list request and have the list filtered and ordered by them. How to list the zones and measure them is on Latency and Location Targeting; this section covers only the query parameters.

  • latency.<locationIdentifier>=<ms>: one parameter per measured zone, e.g. latency.eu-west-ams=24. Values must be integers from 0 to 10000 milliseconds; anything else is ignored. Up to 32 entries by default; extras are ignored in key order. An identifier the service does not know is ignored too.

  • maxLatencyMs: with at least one latency. parameter present, keeps only servers whose meta.location you measured at or below this value (1 to 10000). Servers with no meta.location, and servers whose location has no matching latency. entry, are excluded. Without a latency. parameter it has no effect.

  • sort=latency (or -latency): orders by your measurement for each server's meta.location, in exact milliseconds, then by players descending, then by server id. Servers with no meta.location or no matching latency. entry sort last in both directions. sort=latency with no latency. parameters falls back to the default sort.

As with every other parameter, a deployment that predates these ignores them rather than rejecting the request, so a shipped client can send them unconditionally.

# Servers within 80 ms of the player, nearest zone first, busiest within a zone first:
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"

You should see: with sort=latency the first server's meta.location is the location with your lowest measurement (eu-west-ams here), no server from us-east-nyc appears because 96 is above the 80 ms ceiling, and totalServers counts only the servers under the ceiling.

Compatibility

Unknown query parameters are ignored rather than rejected. Your shipped game build cannot be patched in lockstep with the service, so a client built against a newer version of this contract keeps working against an older deployment: its extra parameters degrade to "no filter" instead of a 400. Response fields are additive as well; fields are only ever added, never removed or renamed.

Paging from a client

async function fetchPage({ page = 1, limit = 100, filters = {} } = {}) {
  const query = new URLSearchParams({
    limit: String(limit),
    offset: String((page - 1) * limit),
    sort: '-players',
    ...filters, // e.g. { search: 'EU', version: '1.4.2', 'meta.map': 'coastline' }
  });

  const url = `https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?${query}`;
  const response = await fetch(url);
  const { servers, totalServers, returned, limit: pageSize, offset } = await response.json();

  return { servers, totalPages: Math.ceil(totalServers / pageSize), returned, offset };
}

What appears in the list

  • Servers appear while they heartbeat and disappear 90 seconds after their last beat (or immediately on a clean delist).

  • When the app's verification mode is tcp or udp-echo, unverified servers are hidden from the list even though they keep heartbeating. See Reachability Verification.

  • An unknown public ID and a disabled app both return the same 404. The endpoint never confirms that a disabled app exists.

  • A 503 means the service is starting up and has not loaded its registry yet. Retry shortly.

The per-IP rate limit is 120 requests per minute by default, which is far above what a server browser UI needs. Poll on user action (opening or refreshing the browser screen), not on a timer.

Troubleshooting

  • sort=latency or maxLatencyMs changes nothing, or maxLatencyMs returns an empty list. The parameters only act on servers whose meta.location matches one of your latency.<locationIdentifier> keys. Check that you sent at least one latency. parameter with an integer value in range, that the keys are the identifiers from GET /v1/locations rather than display names, and that the servers you expect actually advertise meta.location (fleet servers always do; heartbeat servers only if they set it). With no matching entry a server sorts last under sort=latency and is excluded under maxLatencyMs, so a map that covers none of your zones filters everything out.