API
The game list's data as JSON, for building a server browser, a bot or your own stats. These endpoints are Delta's parts on this site: Delta List (live games, listing), Delta Stats (finished games, players) and Delta Link (connecting a game).
Basics
- Base URL:
https://halo.milenko.org. Every endpoint is under/v1/. - No account or key needed to read anything.
- Limit: 60 calls a minute per IP address. Past that you get
429 slow down. - No CORS headers, so a web page on another site can't call it from the browser. Call it from a server, a bot or a game.
- Errors are plain text with a status code, such as
404 not found. GET /v1/healthanswersokwhile the server is up.
Game types
Games and reports give the game type as a number in engine:
| engine | Game type |
|---|---|
1 | Capture the Flag |
2 | Slayer |
3 | Oddball |
4 | King of the Hill |
5 | Race |
Map names
map is either a short name (bloodgulch) or the full tag path
(levels\test\bloodgulch\bloodgulch). Take the part after the last backslash to get the short name.
A Halo PC (Custom Edition) map, which Chupathingy plays from the player's maps_ce folder, ends in
@ce (bloodgulch@ce), a different map from the Xbox map of the same name. Its players need
maps_ce/bloodgulch.map to join.
Co-op games
A game on a campaign level is a co-op game (OpenCE's networked co-op): a10 The Pillar of Autumn,
a30 Halo, a50 The Truth and Reconciliation, b30 The Silent Cartographer,
b40 Assault on the Control Room, c10 343 Guilty Spark, c20 The Library,
c40 Two Betrayals, d20 Keyes and d40 The Maw. Live games say so in
mode. A co-op game's engine and gametype are whatever its host's menus
last had for multiplayer, not what is played: show it as co-op. Co-op games aren't recorded in the multiplayer
stats, so they have no carnage reports.
Live games Delta List
The games being hosted right now, most players first; co-op games after the multiplayer games.
{"games": [{
"invite": "54d85a1abd0d9a48…", // 64 hex digits (44 from older builds)
"name": "Walter937",
"map": "bloodgulch",
"engine": 1,
"players": 25,
"maximum_players": 128,
"open": 1, // 1 if it takes new players
"version": 10, // the host's network version
"score_limit": 0,
"teams": 1,
"roster": [{"team": 0, "name": "Walter937"}, …],
"region": "NA-US", // where the host is: continent and country ("" if unknown)
"verified": true, // false: the list couldn't reach it lately
"counted": true, // false: left out of the totals (below)
"age": 57, // seconds since the host last announced it
"listed_by": "walter937", // only for games listed from the site
"source": "announced", // where the listing came from (below)
"mode": "multiplayer", // or "coop": a campaign level (above)
"platform": "pc_linux", // the host's platform ("" if it didn't say; below)
"hosting": "player", // "official", "dedicated", "player" or ""
"protocol": "delta", // "delta", "opence" or ""
"family": "xbox", // the map's: "xbox", "ce", "pc" or "md"
"machines": {"pc_linux": 2, "xbox": 1}, // a Delta host's machines by platform, else {}
"arch": "x64", // a ChupathingyCE host's build, else ""
"build": "0.7.1b"
}]}
source is announced (the host's copy announces it here), broker (an
OpenCE public game, below), relayed (shared on Discord and listed by a relay),
hosted (listed by a player from the site) or unverified (a relay's invite the list
couldn't reach yet). OpenCE public games also have gametype (the game variant's name) and
in_progress (1 once the match has started; open then says whether it still takes
players).
roster is missing from games listed through the site, and from older builds.
Hosts and kinds of game
A ChupathingyCE host (0.7.1b and later) says what it runs on when it announces its game (below). The rest is worked out from where the listing came from. Each field is empty when unknown, as it is for older builds and for games listed by their invite.
platform: the host machine's platform, from its Delta platform key:pc_windows,pc_macos,pc_linux,android,steam_deck,xbox,xbox360,wiiu,switchorunknown(a platform this list doesn't know yet).hosting:official(one of ChupathingyCE's own dedicated servers),dedicated(anyone's dedicated server),player(a player hosting from their game).protocol:delta(hosted with Delta: OpenCE players join it too) oropence(OpenCE's protocol alone: every OpenCE public game, and a ChupathingyCE host with Delta off). The site shows these as "OpenCE". Nothing about an OpenCE host is known beyond its listing.family: the map's family, from its name: an Xbox map, or<file>@ce(Halo PC Custom Edition),<file>@pc(Halo PC retail) or<file>@md(HaloMD).machines: how many machines in the game are on each platform, as the host's Delta Peer knows them (legacyis a machine without Delta,unknownone that doesn't share its platform). Counts only, with no player named. A dedicated server leaves itself out.
Filters take comma-separated values (a game matches any value in a filter, and must match every filter): ?platform= (a platform, or
pc, console, handheld, dedicated, or empty for "not
reported"), ?family=, ?mode=, ?hosting=, ?protocol=. For
example /v1/games?platform=xbox,steam_deck&protocol=delta. An unknown value returns
400.
region is the host's continent (AF, AN, AS, EU,
NA, OC, SA) and country (ISO 3166-1), joined by a dash. It comes from the
address the host announces from, or the one the list's probe reached it at; addresses themselves are never
shown. It is empty when the list can't tell, for example for a game shared on Discord that the probe can't reach.
Regions: IP Geolocation by DB-IP, licensed under
CC BY 4.0.
counted is false for a listing the list hasn't heard from lately (a game listed from the site
that the probe hasn't reached in the last few minutes; it has probably ended, and is removed once the probe has
missed it three times), and for a host's older listing once it lists its game again under a new invite. Add up the
players of the counted games only, as the site does for players online and Activity.
The same list as plain text: one game a line, fields split by tabs, in this order. Easier to read from C than JSON.
invite name map engine players maximum_players open version age score_limit teams roster
The roster is team:name pairs joined by |, with team -1 in games without teams.
Newer fields are included only when you ask for them with ?fields= and the number of fields you
read, because the game's own browser treats anything after the twelfth field as part of the roster.
/v1/games.txt?fields=13 adds region (as in the JSON) after the roster;
?fields=17 adds platform, hosting, protocol and
machines after it, the machines as pc_linux:2,xbox:1.
Joining a game
The join link is halo://join/<invite>. Opening it starts the game and joins. A client can also hand
the invite straight to its own join code.
Games of every network version are listed, each with its version (0 when the list hasn't reached it
yet). Only show the ones your build can join.
A game only appears here once the list's probe has reached it, and it's checked again every two minutes, so a listed game was really there moments ago.
OpenCE public games
Games hosted as PUBLIC in OpenCE are listed in its in-game server browser through public MQTT brokers, and the
list shows them too, with source broker. Each listing is signed by its host's key,
and the invite is made from that key, so no one else can list a game under that invite or change its details.
The list checks each signature the way the game does, and drops a game as soon as its host ends it, or about 90
seconds after it stops hearing from it. These games have no roster, and usually no region.
Activity Delta List
The list as recorded every minute, over the last n days (1 to 30, default 7). Refreshed once a minute.
{"days": 7, "bucket": 600, "minutes": 10079, "since": 1790390040,
"samples": [[1790389800, 43, 5], …], // [time, most players, most games] each 10 minutes
"now": {"time": 1790994600, "players": 32, "games": 4},
"peak": {"time": 1790950200, "players": 63}, // the last 24 hours
"maps": [{"map": "bloodgulch", "minutes": 9800, "player_minutes": 91980}, …],
"servers": [{"name": "[D] Bloodgulch", "minutes": 9776, "player_minutes": 67680,
"most_players": 29, "last_seen": 1790994600}, …]}
minutes is how many minutes were recorded in the range, so a server's uptime is its
minutes divided by that. Servers are grouped by game name.
Each minute counts the games that were counted then (see Live games):
a game that has ended isn't counted while it waits to go off the list, and a host that hosts again is
counted once.
Finished games Delta Stats
A game that ends with at least two players, after at least a minute, is kept as a carnage report. Games that crash or get quit early aren't recorded.
Every report has a tier: host (sent by the game's host), verified (the host
didn't report it, but two or more players at different addresses sent the same scores of a game the list saw), or
player (sent by one player, or by players who disagree). A player game counts only on the
line of the player who sent it ("reported": true) and for no leaderboard or record. It appears only on
that player's own record (by player ID), in no list of games, and shows only the numbers of its reporters' own
lines (the other lines have just name, team, place and
"unconfirmed": true).
A page of recent games, newest first, without the players.
| Query | Meaning |
|---|---|
limit | How many, 1 to 50 (default 10) |
before | Only games with an ID below this, for the next page |
map | Only this map (as the reports name it) |
engine | Only this game type |
{"reports": [{"id": 79, "time": 1790993717, "map": "levels\\test\\ratrace\\ratrace",
"engine": 2, "teams": 0, "host": "[D] Slayer", "winner": "B", "player_count": 6,
"tier": "host"}],
"total": 21, // games matching the filters
"maps": ["levels\\test\\bloodgulch\\bloodgulch", …]} // every map played
One full carnage report.
The last n full reports, newest first (default 100, at most 1000). This is what the leaderboards are
built from.
A full report
{"id": 79, "time": 1790993717, // Unix time it ended
"map": "levels\\test\\ratrace\\ratrace", "engine": 2, "teams": 0,
"score_limit": 15, "duration": 492, // seconds
"team_scores": [0, 0], "host": "[D] Slayer", "winner": "B",
"players": [{
"name": "B", "team": 0, "place": 1, "color": 3,
"score": 15, "kills": 15, "assists": 4, "deaths": 13,
"betrayals": 0, "suicides": 0, "shots_fired": 0, "shots_hit": 0, "multikills": 1,
"flag_grabs": 0, "flag_returns": 0, "flag_scores": 0,
"ball_time": 0, "ball_carrier_kills": 0, "hill_time": 0, "laps": 0,
"medals": {"killing_spree": 1},
"weapons": {"weapons\\assault rifle\\bullet": 9, …}, // kills by the killing damage's tag
"best_spree": 7, "joined": 0, // seconds into the game they joined
"player_id": "0f3c…" // only on confirmed lines
}, …],
"tier": "host", // host, verified or player
"stats": 1, // from builds that record every kill
"sources": {"statistics": "host", "medals": "host", "weapons": "host", …},
"departed": [{"name": "C", "kills": 2, "deaths": 4, "left": 210, …}], // left early
"reporters": 2} // players who sent the same scores
color, the objective stats and medals only appear in reports from newer builds;
weapons, best_spree, joined, departed, stats and
sources from newer still. sources says how each part was known: host (the
host's own numbers), synced (the host's numbers, as every player's game is sent them) or
feed (worked out from the kills a player's game saw). Where a player's game didn't see all of
someone's kills, that line has "feed_incomplete": true and no medals, weapons or spree. In reports
with stats, multikills counts every multikill. Older reports have
multikills_chain instead: the game's own counter when the game ended, which is only the multikill
under way then, not a count.
Players Delta Stats
A confirmed player has a player_id (32 hex digits) that follows them whatever name they use.
Anyone else is only a name, and two people can share one. The leaderboards count confirmed players only.
The full reports of every game that player played, newest first. A name matches any line with that name, in any
case. A player ID matches only that player's confirmed lines, and adds their public profile (or
null).
{"reports": [ … ], "profile": {"handle": "milenko", "display": "Milenko", "bio": "…"}}
A player's public profile, or 404 if they haven't made one, with the badges it shows.
{"handle": "milenko", "display": "Milenko", "bio": "…",
"badges": [{"id": "developer", "name": "Developer", "description": "…", "icon": "code", "kind": "granted",
"since": 1791331200}]}
Every badge in use and which profiles show which, to mark names in a list without asking for each profile.
kind is granted (given by the site) or automatic (worked out from a
player's confirmed games, the platforms their own game says they play on, and when they made their profile). A
profile's owner may hide its automatic badges; hidden ones aren't listed.
{"badges": [{"id": "played_xbox", "name": "Played on Xbox", "description": "…", "icon": "xbox", "kind": "automatic"}, …],
"players": {"0123456789abcdef0123456789abcdef": ["developer", "played_xbox"], …}}
Matches, maps and records Delta Stats events
Hosts that share stats (our dedicated servers, and community servers and ChupathingyCE hosts unless they turn it off) send
each game's event log when it ends (ChupathingyCE 0.7.1b and later). These endpoints return what the site keeps from those logs, as JSON cached for a minute. No
answer holds an address or a hardware ID. Players who hide their stats are left out of the boards and their own pages
answer {"hidden": true}. What we collect.
A game's batch, from its host (JSON, gzip or not; schema 1, in the game's docs/delta.md). Our servers send
Authorization: Bearer <token>; any other host's game must be listed from the same address. Each server may
send one batch every 20 seconds and 60 an hour, at most 2 MiB each.
{"ok": true, "match": 123, "tier": "trusted", "duplicate": false, "ignored": []}
Totals: matches, seconds played, maps, servers, kills, players, matches this week, games under way.
Recorded matches, newest first (limit up to 50; before a match ID, for the next page).
{"matches": [{"id": 123, "server": 1, "server_name": "[D] Slayer", "tier": "trusted", "started": 1791234567,
"ended": 1791235467, "duration": 900, "map": "levels\test\bloodgulch\bloodgulch", "map_key": "bloodgulch",
"engine": 2, "gametype": "Team Slayer", "teams": 1, "team_scores": [50, 41], "players": 12, "ranked": true,
"final": true, "report": 456, "leader": "Walter", "kills": 91}]}
One match: the same, with players (each line's score, kills, deaths, assists, accuracy, best spree,
medals, weapons by tag with shots, hits, kills and headshots), its timeline (kills as
[t, killer, victim, weapon, damage, flags, killer_pos, victim_pos, killer_vehicle], flags 1 headshot,
2 betrayal, 4 suicide, 8 stuck, 16 victim riding; medals, objectives, pickups, rides, sessions, and the game's ping a
minute), and its replay (each player's sampled positions in tenths: {"scale": 10, "players":
{"0": [[t, x, y, z], …]}}, kept 30 days; null after). Players are their index in
players; -1 is no one (the world).
The maps with recorded matches; one map's numbers over the last days (1 to 365, 90 by default):
matches, average length, game types, weapons by kills, accuracy, hot spots, longest kills, top players and its
latest matches. A map's key is its file (bloodgulch), ce/<file> for Halo PC maps or
md/<file> for HaloMD maps.
A map's squares, counted: kind is kills (where killers stood), deaths,
positions, spawns or spawn_deaths (spawns their player died within 10 seconds
of); weapon a damage tag; player a confirmed player's ID for their own kills or deaths. Each square [gx, gy, count] covers gx * cell to
(gx + 1) * cell world units. /static/map_bounds.json has each map's world bounds over its
top-down picture, /art/overhead/<map>.png.
{"map": "bloodgulch", "kind": "kills", "days": 30, "cell": 0.5, "matches": 40,
"cells": [[120, -240, 17], …], "total": 1290, "weapons": [["weapons\sniper rifle\sniper bullet", 310], …]}
A confirmed player's service record: totals (with accuracy and their longest kill), armor color, game types,
playlists, maps, weeks (trend), favorites, weapons, vehicles, medals, pickups, their games in order
(series), best sprees, the maps they have a heatmap on, and their profile's badges; and their game
history, 25 a page (before: the last page's oldest). A name answers {"name_only": true}
(a name alone can be anyone's); a player who hides their record {"hidden": true}.
Confirmed players whose name has the text; every medal with how many were earned over the last days
(0: all time) and its top earners; the site's totals.
The dedicated servers' playlists; one playlist's games, population by day, maps and game types, top players and latest games.
The recorded match of a carnage report, {"match": n}, or 404.
Leaderboards (kills, K/D, accuracy, wins, win rate, headshots, sprees, assists, damage, flags, ball and hill time)
and single-game records (most kills, longest spree, biggest multikill, best accuracy, longest kill, …) over the last
days, 0 for all time; the leaderboards of one game type (engine and
teams), map or playlist. Confirmed players only. Ranked matches only: two or more players, no bots, at
least a minute long, and not ended by a server stopping.
The ratios need minimum_games games, and accuracy needs minimum_shots shots.
The servers that send events; one server's activity by day and by hour (UTC), players and health, map rotation, game lengths, top players and latest matches.
Signed in (the page's own request: X-Halo: 1), {} reads your setting and
{"hide_stats": true, "hide_heatmap": false} sets it.
Connecting a game Delta Link
Link Profile links a game to a player's profile without a web browser on the game's side (a Steam Deck, Game Mode, a console). The game asks for a code and shows it. The player enters it at /connect, signed in on any device. The game then asks its player to confirm, so a code seen on a stream or in a screenshot can't be used to take over the game. Each step times out after 2 minutes. ChupathingyCE builds do this for you. The game's calls go over HTTPS only, since they carry the player key.
JSON {"key": "<player key, 64 hex digits>"}, and optionally "name", the player's
name in the game. The answer is one line of text:
ok K7M-2QX 120 3f9c… // the code, how many seconds it's good for, a poll token (64 hex digits)
The code is 7 characters, XXX-XXX, from 23456789ABCDEFGHJKMNPQRSTUVWXYZ (no 0, O, 1, I or
L). It works once. Each key and each address may request 10 codes an hour. Past that, 429. A bad key is
400 bad key.
JSON {"token": "<poll token>"}. Ask every 3 seconds or so while the code is on screen. One line:
| Answer | Meaning |
|---|---|
pending | Nobody has typed the code in yet |
confirm <profile> | Typed in by that profile: ask the player to confirm (2 minutes) |
confirm <profile> <previous> | The same, and the game's games currently count for
previous. Confirming moves them |
connected <profile> | Confirmed and linked |
declined | The player said no |
expired | The code or the claim lapsed (or the server doesn't know the token) |
JSON {"token": "<poll token>", "accept": true} (or false), once the player chose. One
line: connected <profile> (linked now), declined (nothing changed; the code and token
are done), or expired (no claim waiting, or it waited more than 2 minutes). A token may try 5 times, and
an address 30 times, in 10 minutes. Past that, 429.
The /connect page's own call, signed in: JSON {"code"}, in any case, with the
dash or without. It uses up the code and waits for the game, answering 202:
{"waiting": true, "handle": "milenko", "name": "Milenko",
"previous": null, // the profile the game's games count for now, if another
"confirm_seconds": 120}
A session, and an address, may type 10 wrong codes in 10 minutes.
Also the page's own call: the state of this session's latest claim. The page asks every 2 seconds.
{"state": "connected", // or "waiting", "declined", "expired"
"handle": "milenko", // the profile the game's games count toward
"name": "Milenko", // the game's player name, if known
"games": 12, // games that game played already
"same": false, // true if the game already played as this profile
"other": null, // the profile the game had of its own, if any: it stays, its games count here
"before": null, // the profile the game was connected to before, if any
"previous": null, "confirm_seconds": 0}
Finished games don't change. Their lines keep the game's player ID, which every report and the leaderboards show as the profile's.
Listing a game Delta List
ChupathingyCE builds do this for you. This part is for people adding the game list to another build. To list a game by hand, use your profile instead.
Form encoded (application/x-www-form-urlencoded). Send it again at least every 75 seconds while the game
runs, or it drops off the list. The reply is ok <seconds until it expires>.
| Field | Meaning |
|---|---|
invite | The game's invite, 64 hex digits |
name | The game's name |
map | The map (required) |
engine | The game type number |
players, maximum_players | Player counts |
open | 1 if it takes new players |
version | Your build's network version |
score_limit, teams | The game's score limit, and 1 for a team game |
roster | team:name pairs joined by |; send it last |
Optional fields about the host (ChupathingyCE 0.7.1b and later). Each is checked on its own: a field the list can't read is dropped, never the whole announcement. Older versions of the list ignore them.
| Field | Meaning |
|---|---|
delta | The Delta major the game is hosted with (1), or 0
for a game hosted on OpenCE's protocol alone |
platform_key | The host machine's Delta platform key, 16 hex digits (the 8 bytes of
the game's docs/delta.md: platform, key version, flags, limits, memory class) |
arch | x64, x86, arm64 or arm |
build | The build's version, such as 0.7.1b |
capabilities | The Delta capabilities the host offers, 8 hex digits |
machines | The game's machines by platform, pc_linux:2,xbox:1,legacy:1:
counts only |
A new game isn't listed right away. The list's probe first checks that the invite leads to a running game, which
takes a few seconds. If it can't reach the game, announcements of that invite are refused with 403 for
two minutes. Once listed, the game is probed again every two minutes and dropped after three misses in a row.
An address may list 4 games at a time, and an invite can only be announced from the address that first listed it.
Form field invite. Takes your game off the list now instead of waiting for it to expire.
A finished game's carnage report, as JSON in the shape above plus its invite. It's only accepted from
the address that lists that game, at most once a minute. Games with fewer than two players, shorter than a
minute, or on a campaign level (co-op), are refused with 422.
A player's report of a game they joined, sent by ChupathingyCE when the game ends, whoever hosted it: JSON in the
shape above, plus invite, key (the player key, which identifies the reporter; only the
player ID derived from it is kept, as with confirmed lines), reporter (the reporting player's name,
one of the lines), and the game's map, engine, score_limit and
host (its name). Reports with the same invite and map that end within a minute and a half of each other, and
are about the same length, are treated as one game. A host's report takes precedence. Answers ok <id>
<tier>; 422 for a game too short or small, a co-op game, or one with impossible numbers (more kills than
deaths, more than a kill a second); 429 for a second report from one player within 30 seconds.
Delta's legacy table Delta List
The signed table that tells ChupathingyCE builds which OpenCE network versions they can announce and join
(Delta). Builds from 0.7.0b fetch it at startup and every four hours, and pass it to each other
over Delta Peer. CI signs each table and commits it to the game repository's delta-table branch; this
site pulls it from there every five minutes, takes only a newer serial with a valid signature, and serves the two
files as they are. A reader checks the signature itself, so it doesn't need to trust this site.
The document: JSON (application/json), at most 16384 bytes. Its exact bytes are what is signed, so
keep them as they come.
{
"delta_legacy": 1, // the document's format
"serial": 42, // higher for each table published
"issued": 1791331200, // when it was made (Unix time), optional
"wires": { // one row per wire: the game protocol a build speaks
"chupa-20a": {"announce": 22, "minimum": 11, "maximum": 22,
"follows": "build-145"} // the OpenCE build the cross-play test proved it with, optional
},
"disabled_capabilities": [], // Delta Peer capabilities switched off
"platform_policy": {} // reserved
}
The document's signature: Ed25519 (RFC 8032) over the document's exact bytes, as 128 hex digits and a line
feed (text/plain).
Checking it
- Check the signature against the public keys in the game's port/linux/src/delta_key.h (also listed in docs/delta.md): a primary and a recovery key. A table signed with either is valid. Check it before reading the document.
- Take a table only if its
serialis higher than the one you have, and read only the row of your own wire. A build takes a row only if it widens its built-in numbers, never if it narrows them. - The game's
tools/delta_table.py verify legacy.jsonchecks a downloaded pair (withlegacy.json.sigbeside it) against those keys.
Caching
Both answers have Cache-Control: public, max-age=300. Fetch the two together, and not more than
every few hours: the game fetches at startup and every four hours (thirty minutes after a failure).
None yet
Both answer 404 no legacy table until a table is published. Builds then play with the numbers they
shipped with. The same two files are also on GitHub, on the delta-table branch of
ChupathingyCE/chupathingyce
(legacy.json and legacy.json.sig), which the game reads when this site can't be reached
or has no valid table.
Building on Delta
Delta is open: anyone may build a client, server or tool that speaks it. The endpoints on this page are its HTTP parts. Delta Peer, the channel between ChupathingyCE machines in a game, is UDP: its wire format (the header, the handshake and every message), the capability registry, platform keys and the legacy table's rules are in docs/delta.md in ChupathingyCE/chupathingyce.