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}/serversThe 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:
truekeeps only servers withplayers < maxPlayers;falsekeeps 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, ormeta.<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]=coastlineandmeta.map=coastlineare the same filter.ne: not equal. Numeric-aware: when both sides parse as numbers they compare numerically, so
10and10.0count 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.
eqcompares string forms (the bare form shipped that way, and[eq]stays its explicit spelling), whilenecompares numerically when it can. A server withwave: 10fails bothmeta.wave[eq]=10.0(the string forms differ) andmeta.wave[ne]=10.0(the numbers are equal). When you filter witheq, 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 (
9sorts before10, 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 whosemeta.locationyou measured at or below this value (1 to 10000). Servers with nometa.location, and servers whose location has no matchinglatency.entry, are excluded. Without alatency.parameter it has no effect.sort=latency (or
-latency): orders by your measurement for each server'smeta.location, in exact milliseconds, then byplayersdescending, then by server id. Servers with nometa.locationor no matchinglatency.entry sort last in both directions.sort=latencywith nolatency.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 };
}// Public endpoint, no token. Call it straight from the client.
static readonly HttpClient Http = new HttpClient();
var url = "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers"
+ "?limit=50&offset=0&hasSlots=true&sort=-players";
using var response = await Http.GetAsync(url);
var json = await response.Content.ReadAsStringAsync();
// {
// "servers": [ { "serverId", "name", "ip", "port", "players", "maxPlayers", "version", "meta" } ],
// "totalServers": 1284, // matches after filtering, before paging: use for the page count
// "returned": 50, // servers in THIS response
// "limit": 50,
// "offset": 0
// }// Public endpoint, no token (libcurl).
CURL* curl = curl_easy_init();
curl_easy_setopt(curl, CURLOPT_URL,
"https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers?limit=50&offset=0&sort=-players");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteToStringCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseBody);
curl_easy_perform(curl);
curl_easy_cleanup(curl);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
tcporudp-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=latencyormaxLatencyMschanges nothing, ormaxLatencyMsreturns an empty list. The parameters only act on servers whosemeta.locationmatches one of yourlatency.<locationIdentifier>keys. Check that you sent at least onelatency.parameter with an integer value in range, that the keys are the identifiers fromGET /v1/locationsrather than display names, and that the servers you expect actually advertisemeta.location(fleet servers always do; heartbeat servers only if they set it). With no matching entry a server sorts last undersort=latencyand is excluded undermaxLatencyMs, so a map that covers none of your zones filters everything out.