5Stack is a platform for organizing and managing competitive CS2 matches and tournaments.
The demo parser is a small HTTP + CLI service that wraps markus-wa/demoinfocs-golang to extract playback metadata, events, and player stats from a CS2 .dem file.
Please visit 5Stack for more documentation.
| Endpoint | Body | Answers |
|---|---|---|
POST /parse |
{"demo_url": "..."} |
parsed Result JSON |
POST /parse-file |
multipart, demo part |
parsed Result JSON |
POST /smoke-volume |
{"map","x","y","z"} |
the bloom at a point |
POST /sightlines |
map + smoke(s) + eye-position pairs | what the smoke blocks |
POST /oneway |
map + smoke(s) + player-position pairs | asymmetric visibility |
POST /drift |
map + two mesh revisions + lineups | which lineups a map update moved |
The last four answer questions about a map, not a demo, so they work for a
lineup nobody has ever thrown. They need the map's collision mesh and return
404 when none is published for it. Coordinates are raw CS2 source units.
Map meshes come from https://demo-dl.5stack.gg/maps/: latest.json names the
newest build's manifest (cached 10 minutes, refreshed in the background, and
possibly a revision such as <build>/manifest.r2.json), and the manifest names
each map's tri (collision hull) and grenadeclip files. A map the manifest
does not list uses build 24957633's flat <map>.tri.gz with no grenade clips,
and if latest.json cannot be read — or is not version 1 — everything falls
back to that build. A file the manifest lists but the CDN refuses is an error
and is retried, never remembered as "no mesh". Setting MAP_MESH_CDN to a base
URL skips all of that and reads <base>/<map>.tri.gz directly; setting it empty
disables geometry.
Line of sight, smoke flood fill, /sightlines and /oneway use the hull only.
Grenade clips block nothing but grenades, so only the /drift flight
simulation collides with them, and a build that published none is flown
against the hull alone.
/smoke-volume returns the same voxel grid the playback blob carries
(ox/oy/oz, vs, dx/dy/dz, den), so one decoder serves both, plus cells
and radius. A point that resolves inside geometry returns 422.
/sightlines takes at (a detonation point), smoke, or smokes — a cloud is
either {"at": {...}} or {"volume": {...}}, where the volume is one
/smoke-volume handed straight back to skip the flood. Each pair returns
blocked, blocked_by (world / smoke), depth (optical depth in cell
widths of full density), transmittance (e^-depth) and world_blocked.
threshold defaults to 3.0 — about 5% of the target's contrast surviving —
and is a request field because it is a judgement, not a measurement.
/oneway takes player positions (feet by default, "positions": "eyes"
otherwise) and tests both standing and crouched eye heights in both directions,
treating each player as a body rather than a point. It reports one_way,
favors, cause, confidence, contested, the best stance pairing and all
four pairings, along with caveats describing what the model does not know.
When Valve ships a map update, every stored lineup is re-flown against the old collision mesh and the new one, and the two endpoints are compared.
The output is a differential and nothing else. The grenade simulator behind
it is deterministic and self-consistent but not fitted to CS2: an absolute
landing point out of it is wrong by an unknown amount. It is sound here only
because the same model error appears on both sides and cancels. The position
field is called comparison_point for that reason, and no coordinate from
this endpoint may ever be shown to a player as where their nade lands.
A mesh revision is a CS2 build id (25537370, read through that build's
first manifest <build>/manifest.json, or <build>/<map>.tri.gz for builds
published before manifests) or an http(s) base holding <map>.tri.gz directly. from and to
come back resolved, so a blank one is legible in the report later. When only
one side published grenade clips, caveats says so: a lineup that touches one
reads as moved even where the map did not change.
Each lineup gets one of four verdicts:
| verdict | meaning |
|---|---|
unchanged |
both meshes end the flight in the same place, within unchanged_radius |
moved |
both resolve, and the endpoint shifted — severity is minor or major |
broken |
it resolved on the old mesh and does not on the new one: inside_geometry, start_sealed, out_of_world, or never comes to rest |
unsimulatable |
no recorded seed, an unknown grenade, or a flight that fails on both meshes — which says nothing about the map |
{
"map": "de_mirage",
"from": "…", "to": "…",
"constants": { … }, // echoed, so a report can be reproduced
"thresholds": {"unchanged": 8, "major": 64},
"summary": {"lineups": 900, "unchanged": 848, "moved": 9, "broken": 2, "unsimulatable": 41, "max_distance": 213.4},
"results": [
{
"index": 0, // request order, for matching when ids repeat
"id": "1f4c…",
"verdict": "moved",
"severity": "minor",
"reason": "landing moved 21.7 units",
"from": {"comparison_point": {"x":…,"y":…,"z":…}, "resolved": true, "stop": "rest",
"bounces": 3, "flight_seconds": 3.61, "steps": 462},
"to": { … },
"distance": 21.7, "distance_xy": 21.6, "distance_z": 1.9 // absent unless BOTH sides resolved
}
],
"caveats": ["…"] // ships with the payload; surface it wherever the numbers are shown
}"stream": true returns application/x-ndjson: one {"type":"header"} line,
one {"type":"result"} line per lineup in request order, then
{"type":"summary"}. The status code is sent before the first result, so a run
that fails part way through ends with a {"type":"error"} line — a consumer
that does not check for one will read a truncated run as a clean one.
Thresholds are judgements, not measurements. unchanged is 8 units: two
re-exports of an unchanged map still round vertices differently, and a bounce
grazing that seam lands a few units off. major is 64 units — a smoke's radius
is 144 and a player is 32 wide, so past 64 a cloud no longer covers the same
gap. Both are request fields.
Batch sizing. A lineup costs two flights: ~2 ms on one core, ~0.4 ms across
eight (measured on de_mirage, 138k triangles). 500-1000 per request is the
comfortable size; 2000 is the cap for a single JSON body and 10000 the hard cap
with stream. Drift is the only endpoint that holds two meshes at once —
21 MiB for the de_mirage pair, 136 MiB for de_anubis — so it is serialized to
one request at a time. Raise DRIFT_CONCURRENCY only on a pod dedicated to it,
and DRIFT_WORKERS (default min(NumCPU, 8)) to change per-request
parallelism; neither changes the answer.
Mesh loading is process-wide, concurrency-safe and LRU-bounded; raise
MAP_MESH_CACHE (default 2) on a deployment serving lineups across many maps.
Drift needs at least 2 or it refetches a mesh per request, and 4 is a better
number on a pod that also serves the other endpoints.
POST /drift { "map": "de_mirage", "from": "24957633", // CS2 build before the patch "to": "25537370", // after it; "" means the build latest.json names "lineups": [ { "id": "1f4c…", "nade_type": "Smoke", // Smoke | HE | Flash | Molotov | Decoy "initial_position": {"x": -2300, "y": 0, "z": -64}, // both optional; a lineup "initial_velocity": {"x": 500, "y": -80, "z": 200} // missing either is unsimulatable } ], "stream": false, // true → NDJSON, required above 2000 lineups "unchanged_radius": 8, // optional threshold overrides "major_radius": 64, "constants": {"gravity": 320} // optional physics overrides, applied to BOTH sides }