Talos raw query API — v1.0.0
Frozen. This document is the contract packages/observe (step 06) and the critic (steps 07–08) are written against.
Frozen. This document is the contract packages/observe (step 06) and the critic (steps 07–08) are written against. It is complete and exact on purpose: an agent should be able to read it cold and call every endpoint correctly without reading any Rust.
Served by crates/app from crates/query:
| HTTP | http://127.0.0.1:7700 |
| WebSocket | ws://127.0.0.1:7701/stream |
Both ports are configurable (argus_query::Config). /stream is also mounted on the HTTP port, so a client that only knows one address always finds it. Everything binds loopback only.
Those are the standalone defaults. A worker started for a workspace session binds ephemeral ports and reports them on its ready line; a client resolves it by session id and never guesses a port. See Managed sessions.
Versioning rule
Additions are fine; changes need a reason in the commit.
- Additive, no version bump: a new endpoint, a new field on a response, a new optional query parameter, a new variant of a tagged enum (
type,kind,status), a new response header. Clients must ignore fields and variants they do not recognize. - Breaking, needs a version bump and a reason in the commit message: removing or renaming a field, changing a field's type, changing a status code, changing the meaning of an existing value, making an optional request field required.
API_VERSION is in crates/query/src/lib.rs, on GET /, in the x-argus-api header of every response, and in the hello message on /stream.
Conventions
These hold on every endpoint, and crates/query/tests/json_shape.rs enforces them against real responses rather than trusting the types.
- Positions are arrays.
[x, y, z]in metres, never{"x":…}. Rotations are quaternions,[x, y, z, w]. Axis values are[x, y]. - Entities are named by
idandname.idis the stable scene id and is the key forGET /entity/{id}.nameis the human label and is not unique. No ECS handle, slot index or generation counter ever crosses this boundary, so an id that worked at frame 100 still works at frame 900. - No NaN, no infinity. A non-finite float is written as
0and counted inGET /perf→nonfinite_scrubbed. A non-zero count means some subsystem produced a NaN. - No null where a number belongs. Optional fields are omitted, never
null. There are exactly two whole-body nulls in the API, both deliberate: aPOST /raycastmiss and aPOST /pathwith no route. One field is also deliberately nullable: a HUD field a behavior cleared withctx.hud.set(key, null)is published asnullrather than dropped, because the key is still declared and a restore that dropped it could not put the cleared state back. SeeGET /snapshot. - Every response is frame-stamped.
x-argus-framecarries the frame the response describes, so a capture, an event list and a trace can be correlated without guessing. - Every response is state-stamped.
x-argus-stampcarries the committed state, which is a different question from the frame and the one to compare when correlating. See State stamps. - Frames and time.
frameis the fixed-tick counter, 60 Hz, starting at 0.timeis simulated seconds since the scene loaded.
Response headers
| Header | On | Meaning |
|---|---|---|
x-argus-frame | every response | the frame this response describes |
x-argus-stamp | every response | the committed state this response describes, artifact.frame.revision.structure |
x-argus-api | every response | the API version, 1.0.0 |
x-argus-trace-range | GET /trace | first-last frame indices in this page |
x-argus-trace-truncated | GET /trace | frames dropped from the oldest end to fit the budget |
x-argus-trace-deltas | GET /trace | transform deltas in this page |
x-argus-total | GET /entities, GET /feel | how many entities (for /feel, animators) matched, before the page cut them |
x-argus-next-cursor | GET /entities, GET /feel | the cursor for the next page; absent on the last |
Request headers
| Header | On | Meaning |
|---|---|---|
x-argus-session | any request | the session id the request is meant for |
x-argus-nonce | any request | the serving instance's nonce, from its ready line or the workspace service |
Both are optional against a standalone server, and checked whenever they are sent: an identity that is not the server's own is refused on any route. See Managed sessions.
Errors
Every non-2xx response has the same body.
{ "error": "not_found", "message": "no entity with id \"nope\"; GET /entities lists what the scene has" }
error | Status | Means |
|---|---|---|
bad_request | 400 | the request parsed but a value is wrong: a non-finite float, a zero ray direction, a malformed filter, or (on GET /events, /entities and /feel) a query parameter the route does not take, which is refused rather than ignored so a misspelt filter cannot read as one that matched everything. The message names the field or the unknown parameter and the ones the route takes. |
not_found | 404 | no such entity, schema, or route |
unsupported | 501 | this build has not wired up the subsystem behind this endpoint. The message names the endpoint. |
timeout | 503 | the simulation did not answer a queued command before the deadline: it is paused under a debugger, blocked on a load, or shut down |
unavailable | 503 | the command queue is full, or the simulation dropped the command |
at_capacity | 503 | a bounded resource is full and the server declined the work rather than queueing it: too many live WS /stream readers, or too many exact operations in flight. GET /state reports both counts and both ceilings. |
stale_stamp | 409 | an exact request named a committed state this server cannot answer for: already passed, not yet reached, or belonging to a scene since reloaded. wanted and current carry both stamps. |
internal | 500 | the source failed while answering |
session_mismatch | 409 | the request presented an identity that is not this server's: another session's id ("mismatch": "session"), or a nonce another server instance issued ("mismatch": "nonce"), usually a process since replaced on the same port |
capability_denied | 403 | the request is this session's, but the session does not grant what the endpoint needs. capability names it. |
session_required | 428 | a managed session refused a request that presented no identity. capability names what the endpoint needs. |
host_rejected | 403 | the Host header is not 127.0.0.1:<port> or localhost:<port> for one of this server's two ports: a web page that re-pointed its own name at 127.0.0.1 (DNS rebinding), or a client that set Host itself. Every route, GET /session and WS /stream included. |
origin_rejected | 403 | the request carries an Origin that is not http://127.0.0.1:<port> or http://localhost:<port>: a web page on another site. Programs send no Origin. This covers the WS /stream handshake, which browsers send cross-site without CORS. |
unsupported_media_type | 415 | a POST body without Content-Type: application/json. |
capability and mismatch appear only on those three, and wanted and current only on stale_stamp; every other error body is exactly error and message.
State stamps
x-argus-frame answers which tick. For a world that only changes by ticking that is the whole story, and for this engine it is not:
- A paused world still changes. A restore, a scene patch and an editor edit all mutate it without advancing the tick counter, so two responses can carry the same
frameand describe different states. - A restart puts the tick counter back to 0, so
frameis not even monotonic. - An entity replaced at the same count changes every published identity while
frameadvances exactly as it would have anyway. - A rendered frame is not a state change at all. A world drawn at 1 kHz and simulated at 60 Hz commits 60 states a second and draws a thousand pictures of them.
So every response also carries x-argus-stamp, which names the committed state itself:
x-argus-stamp: 1.130.457.3
Four counters, artifact.frame.revision.structure:
| Changes when | What a client does about it | |
|---|---|---|
artifact | the applied scene changes: load, restart, hot reload, patch | drop every cached id, path and capture |
frame | the simulation commits a tick | the same number as x-argus-frame |
revision | any committed state change, ticked or not | compare this to ask "is this the state I already have" |
structure | the published identity set may differ: spawn, despawn, replacement, restore | refetch GET /entities rather than reusing identities |
revision is monotonic for the life of the session and does not reset on a restart, so a client that reconnects across one can still order what it saw. Two responses with the same artifact and revision describe the same committed state.
The same stamp appears as an object in JSON, on GET /state and in the hello message on /stream:
{ "artifact": 1, "frame": 130, "revision": 457, "structure": 3 }
A server whose source does not stamp its publishes reports 0.{frame}.0.0, which reads as "tick frame of something that does not name states". revision: 0 is the marker.
Exact reads: ?at=
Eight routes take an optional at parameter naming the committed state to answer from: GET /entities, GET /entity/{id}, GET /contacts, GET /perf, GET /snapshot, GET /ui, GET /ui/screens, and POST /capture.
GET /entities?at=1.130.457.3
Absent, the read answers from whatever is current, which is what every read did before this existed and what most reads still want. Present, there are exactly three outcomes, and none of them is a different state returned as though it were the one asked for:
| the simulation is at that state, or reaches it before the deadline | 200, and x-argus-stamp equals what was asked for |
| the state has been passed, or belongs to a scene since reloaded | 409 stale_stamp, carrying wanted and current |
| the deadline passes with the state still ahead | 503 timeout |
The typical sequence is to step and then read the state the step reached:
POST /time {"paused": true, "step": 1} -> x-argus-stamp: 1.131.462.3
GET /entities?at=1.131.462.3 -> the entities as of that tick, or an error
This is what makes an exact read exact across a throttled or fast render loop, across several ticks per rendered frame, and across a reconnect: the answer is correlated to the state that was asked for rather than to whenever the request happened to arrive.
An at value that is not a stamp is 400, not ignored — ignoring it would answer an exact request approximately.
Ceilings
The server bounds what it will take on, and refuses past it with 503 at_capacity rather than accepting work that would degrade every reader:
| Default ceiling | Refused with | |
|---|---|---|
live WS /stream readers | 32 | at_capacity on the upgrade, before the handshake completes |
| exact operations in flight | 8 | at_capacity on GET /snapshot, POST /snapshot, POST /capture, and any read carrying ?at= |
Read the numbers, do not hard-code them. They are configurable (argus_query::Limits, set through LiveSource::with_limits), and GET /state reports what this server is actually holding itself to alongside how much of each is in use. A client that reads it first decides whether to open another socket, rather than discovering the answer as a 503.
Two further bounds have no dial on the wire because a client never has to think about them: the entity-buffer pool is capped by bytes as well as by count, and a pooled buffer a reader is still holding is released rather than occupying a slot nothing can reuse.
Two more bounds apply to readers and need nothing from the client. perf and frame messages are samples, so a socket that falls behind is sent only the newest of each rather than a backlog of superseded ones; events are never coalesced, because each is a thing that happened and seq is gapless by contract. And a socket whose send cannot complete within two seconds is closed: at that point nobody is reading it, and a reader nobody reads still holds a receiver.
Entity-addressed requests
Three requests name an entity: GET /entity/{id}, POST /capture with {"kind":"views"}, and POST /input for an action or axis that carries an entity. All three answer an id the scene does not have the same way, with the same message, so the shape learned from one reads correctly on the others:
{ "error": "not_found", "message": "no entity with id \"ghost\"; GET /entities lists what the scene has" }
An id that GET /entity/{id} refuses is refused by the other two as well, because all three are answered from the same published snapshot. The refusal does not wait on the simulation.
An input action with no entity targets the scene's player spawn and is not checked, since there is no id to check.
501 is the one worth handling specially. The engine is built in separate pieces, and an endpoint whose subsystem has not landed answers 501 with its own name rather than returning an empty object that looks like an answer. Treat it as "not yet", not as "broken".
GET /entities
Every entity passing the filter, in stable entity order.
| Parameter | Shape | Meaning |
|---|---|---|
id | a,b,coin#* | the entity's id is one of these; an item with * or ? is a pattern |
name | pattern | the entity's name matches (* any run of characters, ? exactly one) |
tag | string | the entity carries this tag exactly |
component | string | the entity carries a component of this type |
near | x,y,z,radius | the entity's position is within radius metres of the point |
inside | minx,miny,minz,maxx,maxy,maxz | the entity's position is inside this box, bounds included |
fields | transform,anim | keep only these top-level fields of each entity; id is always kept |
limit | integer | return at most this many |
cursor | the last page's x-argus-next-cursor | start the page there |
Clauses are ANDed. No parameters means every entity. A malformed near or inside is a 400 naming the parameter and the shape it wanted.
Narrowing a large answer
On a scene of a few hundred entities the whole answer is megabytes, far more than an agent can read, so it narrows three ways, which combine:
- Which entities.
id=takes exact ids and patterns (id=door,coin#*is the door and every copy spawned fromcoin);name=is a pattern over names;tag=,component=,near=andinside=as above. - Which fields.
fields=names top-level fields:id,name,tags,position,rotation,scale,parent,static,velocity,components,overrides,script,anim,vehicle,sprite,frame, andtransformfor the three transform fields.idis always kept. An unknown field is a400listing every one. - A page at a time.
limit=cuts the answer;x-argus-totalsays how many matched andx-argus-next-cursoris thecursor=for the next page, absent on the last. A cursor is a position in entity order, so an entity spawned or despawned between pages can shift a page by one; pin the pages to one state with?at=when that matters.
The body keeps its shape: a narrowed answer is still a bare array of entities, each missing only the fields it was not asked for. GET /feel takes the same words (below).
GET /entities?id=crab*&fields=transform,anim&limit=2
[
{ "id": "crab-1", "position": [4.0, 0.0, -2.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0],
"anim": { "state": "walk", "clip": "walk", "time": 0.4, "speed": 1.0, "looping": true, "params": {}, "phase": 0.3 } },
{ "id": "crab-2", "position": [6.5, 0.0, -1.0], "rotation": [0.0, 0.0, 0.0, 1.0], "scale": [1.0, 1.0, 1.0] }
]
with x-argus-total: 7 and x-argus-next-cursor: 2.
Component type values come from the scene contract: mesh, collider, rigidbody, light, camera, script, spawn, goal, trigger, navarea, navagent, animator, audio, particles, label, hud, controller.
GET /entities?tag=moving&limit=1
[
{
"id": "e0",
"name": "entity-0",
"tags": ["enemy", "moving", "player"],
"position": [-7.166, 1.9591131, -7.3306265],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
"static": false,
"velocity": {
"linear": [-2.8848248, -0.821622, 2.857725],
"angular": [0.0, 0.004341185, 0.0]
},
"components": [
{ "type": "mesh", "props": { "asset": "cube", "material": "grey" } },
{ "type": "controller", "props": { "speed": 5.0 } }
],
"overrides": {
"light": { "on": false }
},
"script": {
"module": "behaviors/patrol.ts",
"state": { "waypoint": 0, "alerted": false },
"enabled": true
},
"anim": {
"state": "walk",
"clip": "walk-loop",
"time": 0.42,
"speed": 1.0,
"looping": true,
"phase": 0.42,
"params": { "speed": 1.6 },
"weights": [
{ "clip": "walk-loop", "weight": 0.75 },
{ "clip": "idle-loop", "weight": 0.25 }
]
},
"frame": 130
}
]
parent (a string id) is present when the entity has one. velocity is present only for entities with a rigid body, script only with a script component, anim only with an animator, and overrides only when a behavior has written a component on the entity, vehicle only on a running vehicle, and sprite only with a sprite component.
sprite is what the entity's sprite shows (ctx.sprite): animation (absent while a still frame shows), frame, frameIndex (its entry in the animation), playing, speed, view (the view the last drawn frame showed: 0 the front, counter-clockwise seen from above) and views (how many the frame has), visible, opacity, and bounds, the world box [min, max] of the picture showing, standing on its pivot: for a sprite that turns to the camera, the box it sweeps as it turns, so it does not depend on where the camera is. Like script and anim it is published once something reads entities. ?fields=sprite keeps just it. See docs/API.md, "Sprites".
vehicle is what a tester driving the car through POST /input steers by: input (who owns the movement: auto, the engine from the actions; script, a behaviour), actions (each role's { action, kind } where the scene's gameplay context binds it: throttle, brake, steer +1 right, handbrake), speed (m/s along forward, negative reversing), forward (its nose, the entity's -Z), steerAngle (the wheels' angle now, radians, positive right like the action), maxSteer, wheelbase, minTurnRadius (at its speed now), brakeDecel (m/s² at full brake), topSpeed, the throttle and brake in force, gear (1 or -1) and wheelsOnGround. A vehicle with a drift block also has drift: active, angle (radians, positive right), charge (seconds), tier and boostTime. ?fields=vehicle keeps just it. See docs/VEHICLES.md §7.
anim is what ctx.animator sees: state is ctx.animator.state, clip the clip playing (the heaviest one on a blend tree), time its playhead in seconds. params holds every declared parameter (absent when there are none), after any damp has eased it. phase is where the playing state is in its cycle, 0..1 of its length (a blend tree's blended length, which every clip of the tree shares). weights is every clip in the pose with its share, summing to 1: a blend tree's points at their weights, heaviest first, then — during a crossfade — the clips being blended out of, each scaled by its state's share; a clip in both is listed once. once appears while a one-shot plays, as { "name", "next", "remaining" }. script and anim are filled from the first entity read on, so a server nobody queries never pays for them.
A full unfiltered dump of a 10,000-entity scene is about 3.4 MB. Filter when you can.
components and overrides: the authored truth and the runtime one
components is the scene as authored, contract-typed: every field in it is a field the scene schema has, and nothing at run time changes it. overrides is what a behavior has written with entity.set, keyed by component type, and holds only the fields that were written:
{
"overrides": {
"light": { "on": false },
"material": { "baseColor": [1.0, 0.2, 0.1, 1.0] }
}
}
They are two fields rather than one merged view because two of the three writable component types have fields the contract does not have — light.on and mesh.visible exist only at run time — and material is not a scene component at all: the contract's material is an asset kind and mesh.material names a file. Merging would make this endpoint report a mesh component carrying a visible field, which no scene could author and a reader validating against the contract would be right to reject.
So: read components for what the level says, overrides for what the game has done to it since. A light bar that is flashing has an unchanged light component and an overrides.light.on that alternates. The key is omitted entirely for an entity nothing has written to, which is almost all of them, and {} is never emitted — an absent overrides and an empty one would mean the same thing, so only one of them is written.
The same map rides on every entity in GET /snapshot and is accepted back by POST /snapshot, so a restored checkpoint puts the light bar back as it was rather than as the scene authored it. An entity in a posted snapshot with no overrides key has its overrides cleared, not left alone: "nothing is written here" is a state a checkpoint has to be able to restore.
script, and why the first read is the slow one
script is the same ctx.state GET /snapshot carries, plus enabled: it is the behavior's own working memory — which waypoint it is heading for, whether it has seen the player, what phase of a run it thinks it is in. Reading it is how you debug a behavior that is running and doing the wrong thing, as against one that is not running at all.
enabled is false once the behavior has thrown or blown its wall-clock budget and the engine has stopped calling it. Its state is still reported, frozen at the tick it died on, which is usually the thing you want to look at. An entity whose behavior is disabled therefore still has a script object; it is the entity with no script component that has no script field.
Filling script costs a walk of every behavior and a serialisation of each one's state out of the JS runtime, so the engine does not do it until something asks. Nothing asks until the first GET /entities or GET /entity/{id}; that request asks the simulation to publish again before it answers, so it costs up to one tick and returns the current state, not an empty one. If nothing is draining the command queue at all it waits out the command deadline instead, once, and then answers from the snapshot without script rather than failing. Every read after the first is served from the published snapshot at no extra cost.
The 404 guard is not affected: an id the snapshot does not have is refused straight out of the snapshot, so a typo on POST /capture or POST /input still comes back immediately. The same deferral covers GET /contacts — see GET /state.
GET /entity/{id}
One entity, by its stable scene id. 404 when there is no such id.
GET /entity/e11
{
"id": "e11",
"name": "entity-11",
"tags": ["prop"],
"position": [0.9861875, 0.743595, -5.1129594],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
"static": true,
"components": [
{ "type": "mesh", "props": { "asset": "cube", "material": "grey" } },
{ "type": "goal", "props": { "index": 11, "shape": "box" } }
],
"script": {
"module": "behaviors/patrol.ts",
"state": { "waypoint": 3, "alerted": false },
"enabled": true
},
"frame": 130
}
GET /events
Frame-stamped events, oldest first. The last 1024 are retained.
| Parameter | Shape | Meaning |
|---|---|---|
since | integer | only events with frame >= since. Default 0. |
after_seq | integer | only events with seq > after_seq. The precise polling cursor. |
filter | string | match type or the custom/trigger event name. Comma-separated for alternatives: filter=trigger,door-opened. |
entity | string | only events naming this entity id |
limit | integer | at most this many, keeping the most recent |
Use after_seq, not since, to poll. seq increases by one per event and never repeats, so after_seq gives you exactly what you missed even when several events share a frame. since is for "what happened around frame N", where a duplicate does not matter.
[
{
"seq": 2,
"frame": 130,
"time": 2.1666666666666665,
"type": "trigger",
"volume": "door-volume",
"other": "player",
"event": "door-opened",
"entering": true
},
{
"seq": 3,
"frame": 130,
"time": 2.1666666666666665,
"type": "collision",
"a": "player",
"b": "floor-tile-7",
"point": [2.5, 0.0, -1.0],
"normal": [0.0, 1.0, 0.0],
"impulse": 12.5,
"entering": true
},
{
"seq": 4,
"frame": 130,
"time": 2.1666666666666665,
"type": "custom",
"name": "score-changed",
"data": { "score": 30 },
"by": "scorekeeper"
},
{
"seq": 5,
"frame": 130,
"time": 2.1666666666666665,
"type": "script_error",
"entity": "e3",
"module": "behaviors/patrol.ts",
"phase": "update",
"message": "cannot read property 'x' of undefined"
},
{
"seq": 6,
"frame": 130,
"time": 2.1666666666666665,
"type": "log",
"entity": "e3",
"message": "waypoint reached",
"data": { "index": 2 }
}
]
type is one of collision, trigger, custom, script_error, script_warning, log, ui. The payload is flattened into the record, so there is no nested object to unwrap.
A ui event's what is pushed, popped, replaced, focus, activated, emitted, submitted, scrolled, context, diagnostic or refused. A popped screen the player's back input dismissed (Escape, the pad's B) carries message: "uiBack"; one the game popped carries none.
collision.entering and trigger.entering are the same field: true when the pair starts touching or the volume is entered, false when it stops or is left. Read both the same way. A collision with entering: false reports the pair separating and carries impulse: 0, because there is no contact left to have applied one. Without the flag a reader cannot tell two bodies still resting on each other from two that have just come apart.
script_error.phase is one of init, update, dispose, onCollision, onTrigger, onEvent, listener, budget. A budget error also carries ms, the cost of the offending tick. A behavior is disabled after it errors, which shows up as script.enabled: false on the entity.
script_warning carries entity, module, code and message, and disables nothing. Its one code today is null-handle: a behavior read ctx.nav, ctx.input, ctx.controller or ctx.animator on an entity without that component (once per behavior and handle).
GET /contacts
Contact points from the most recent physics step.
[
{
"a": "e0",
"b": "e1",
"point": [-6.070686, 1.114969, -7.263609],
"normal": [0.0, 1.0, 0.0],
"depth": 0.01,
"impulse": 4.5,
"frame": 130
}
]
normal points from a towards b. depth is penetration in metres, positive when the shapes overlap. impulse is the normal impulse the solver applied, in newton-seconds.
GET /perf
Cost of the most recent frame. Every field is always present; a counter the host cannot measure reports 0, never null.
{
"frame": 130,
"frame_ms": 4.2,
"physics_ms": 1.1,
"script_ms": 0.7,
"render_ms": 2.0,
"p95_frame_ms": 5.0,
"draw_calls": 12,
"instances": 40,
"triangles": 480,
"heap_mb": 48.0,
"entities": 40,
"bodies": 10,
"agents": 0,
"time_scale": 1.0,
"paused": false,
"nonfinite_scrubbed": 0,
"unwired": {
"audio_play": 0,
"particles_emit": 0,
"hud_set": 0,
"nav_speed_override": 0,
"audio_bus": 0,
"audio_snapshot": 0,
"audio_music": 0,
"body_push": 0,
"joint_ignored": 0,
"vehicle_ignored": 0
},
"ui_ms": 0.21,
"ui_draw_calls": 7,
"terrain_load": {
"prewarm_ms": 0.4,
"decode_ms": 3.1,
"cache_read_ms": 6.2,
"evaluate_ms": 9.8,
"mesh_ms": 21.5,
"upload_ms": 30.2,
"collider_ms": 12.7,
"total_ms": 85.0,
"disk_hits": 64,
"cells_evaluated": 0
}
}
terrain_load is where the scene load's terrain time went, by stage, measured once when the terrain was attached and the same in every sample after: decode_ms (the terrain file, and the layers' textures when drawn), cache_read_ms (the project's terrain cache), evaluate_ms (what the cache did not have, conforming to roads, the water data, the collision query), mesh_ms (the render meshes on the CPU), upload_ms (layers, cell maps and meshes to the renderer), collider_ms (cell entities, colliders, ground materials) and prewarm_ms (the early pipeline start). The stages are disjoint; total_ms is the whole attach. disk_hits is 0 on a cold load and cells_evaluated is 0 on a warm one. All-zero with no terrain; mesh_ms and upload_ms are 0 headless.
p95_frame_ms is over the last 120 frames, so a spike can be told from a regression without polling at 60 Hz. nonfinite_scrubbed counts non-finite floats replaced by 0 on the way out since process start; non-zero means something produced a NaN.
ui_ms is the game UI's share of the frame on the CPU (its tick and the mapping of its draw list onto render instances) and ui_draw_calls its share of draw_calls; both are 0 with no UI, and ui_draw_calls is 0 headless. GET /ui/stats breaks them down by screen.
unwired
Behavior calls the engine recorded but could not act on, cumulative since the scene loaded. All-zero is the healthy answer and is what a scene with no behaviors reports; a non-zero counter is a call that named something the scene does not have, and the call did nothing.
| Counter | What it counts |
|---|---|
audio_play | ctx.audio.play(asset) for an asset with no registered sound |
particles_emit | ctx.particles.emit(preset) outside the eight presets the contract defines |
hud_set | ctx.hud.set(key, …) for a field nothing draws. 0 with the stock HUD, which draws every field (meta.hud, else stacked top-left); counts every call while a project hud screen has replaced the stock one (docs/UI.md, "The HUD") |
nav_speed_override | ctx.nav.goTo(target, speed) with a speed — the crowd has no per-agent speed setter |
audio_bus | a bus meta.audio does not declare (the cue still plays, on the default bus) |
audio_snapshot | ctx.audio.snapshot(name) with no such snapshot in meta.audio.snapshots |
audio_music | ctx.audio.music.play(layer) with no such layer in meta.audio.music.layers |
body_push | a ctx.body force, torque or impulse on a kinematic or static body, or through a handle whose body has gone (docs/API.md, "Forces, torques and impulses") |
joint_ignored | a ctx.joint setMotor, stopMotor or setLimits on a fixed joint (no free axis), or through a handle whose joint has gone (docs/API.md, "Joints") |
vehicle_ignored | a ctx.vehicle.drive on a vehicle the engine drives from the input actions (input: "auto"), or a ctx.vehicle call through a handle whose vehicle has gone (docs/VEHICLES.md) |
This is the assertion a scripted run makes after a scene has been driven:
curl -s localhost:7700/perf | jq '.unwired | add == 0'
None of these is an error and none stops the run — that is exactly why they need a number. A behavior that misspells a preset name, or writes a HUD field the scene's meta.hud has no place for, otherwise fails in complete silence and the only symptom is something missing from the picture. The counters are cumulative, so poll once before a section and once after to attribute a rise to that section rather than to the whole run.
heap_mb is the resident set size of the whole engine process, not the Rust heap: the JS runtime, the physics world, the entity store and the graphics driver's allocations included, because those are what grow when a session leaks and a Rust-allocator figure would miss all four. Sampled from /proc/self/statm on Linux and GetProcessMemoryInfo on Windows; a platform with neither reports 0, which is the "a counter the host cannot measure reports 0" rule above. It is the same number crates/jobs reports as a worker's rss_bytes, so a soak can compare the two.
GET /trace
The ring buffer of per-frame deltas: 300 frames, keyframe every 60.
| Parameter | Shape | Meaning |
|---|---|---|
frames | integer | how many frames to return, newest last. Default 60, maximum 300. |
before | integer | only frames strictly older than this, for paging backwards |
A keyframe carries every non-static entity; an ordinary frame carries only what moved.
position is always present: it is why the entity is in the list. rotation and scale appear only when they changed, and always on a keyframe — most things that move do not rotate, and 60 frames of 1,000 bodies is 1.8 MB of identical [0,0,0,1] otherwise.
Values are absolute world-space, not offsets. To reconstruct a full transform: start at the newest keyframe at or before the frame you want, which carries every field for every non-static entity, and apply the deltas forward. Any 60-frame window contains exactly one keyframe, so the default page is always self-contained.
One response carries at most 60,000 transform deltas. The ring holds 300 frames, which at 1,000 moving bodies is 300,000 deltas and about 26 MB of JSON — more than any endpoint answers in 30 ms. A response therefore keeps the newest frames that fit and reports the rest in x-argus-trace-truncated. Page backwards by passing the oldest frame you have as before:
{ "first_call": "/trace?frames=60", "then": "/trace?frames=60&before=341", "then_again": "/trace?frames=60&before=281" }
GET /trace?frames=2
[
{
"frame": 129,
"keyframe": false,
"transforms": [
{ "id": "e0", "position": [-7.166, 1.9542632, -7.3306265] },
{ "id": "e10", "position": [-0.6533455, 1.9972527, -4.857719] }
],
"events": []
},
{
"frame": 120,
"keyframe": true,
"transforms": [
{ "id": "e0", "position": [-7.166, 1.9591131, -7.3306265], "rotation": [0.0, 0.0, 0.0, 1.0] },
{ "id": "e10", "position": [-0.6533455, 1.9983487, -4.857719], "rotation": [0.0, 0.7071, 0.0, 0.7071] }
],
"events": []
}
]
events on a frame holds the same records GET /events serves.
POST /raycast
Cast against the live physics broad phase.
{ "from": [0, 50, 0], "dir": [0, -1, 0], "max": 100 }
dir need not be normalized but must not be the zero vector. max defaults to 1000 metres.
The names other APIs use are accepted for the three: origin for from, direction for dir, maxDistance (or max_distance) for max, and excludeTags for exclude_tags, as ctx.world.raycast spells it. So { "origin": [0, 50, 0], "direction": [0, -1, 0], "maxDistance": 100 } is the ray above. Giving both spellings of one field is a 400 ("duplicate field").
| field | default | |
|---|---|---|
exclude | none | an entity id, or a list of them, the ray passes straight through. An id the scene does not have is a 404 |
exclude_tags | none | a tag, or a list; every entity carrying one is passed through too |
triggers | true | whether trigger volumes count as hits, as ctx.world.raycast has it |
A ray cast down from above the player hits the player first: it starts outside the capsule, so the capsule is the nearest thing under it. To find the ground beneath a character, exclude it:
{ "from": [0, 10, 0], "dir": [0, -1, 0], "exclude": "player", "triggers": false }
Several rays at once go under rays, up to 256, and are all cast on the same frame. The answer is a list with one entry per ray, in order, each a hit or null:
{ "rays": [
{ "from": [0, 10, 0], "dir": [0, -1, 0], "exclude": "player" },
{ "from": [2, 10, 0], "dir": [0, -1, 0], "exclude_tags": ["pickup"] }
] }
A body of neither shape is a 400 whose message quotes both.
A hit:
{
"entity": "floor-tile-7",
"point": [0.0, 0.5, 0.0],
"normal": [0.0, 1.0, 0.0],
"distance": 49.5,
"frame": 130
}
A miss is the whole body, and is not an error:
null
POST /path
Query the navmesh.
{ "from": [0, 0, 0], "to": [4, 0, 4] }
The response is the waypoint list when a complete route exists, and null otherwise — the SPEC's Option<Vec<Vec3>>:
[[0.0, 0.0, 0.0], [2.0, 0.0, 2.0], [4.0, 0.0, 4.0]]
POST /path?detail=1 returns the full result instead. Use it when you need to tell "no route exists" from "one end is not on the navmesh" — which is exactly what a stuck-agent diagnosis needs:
{
"status": "complete",
"waypoints": [[0.0, 0.0, 0.0], [2.0, 0.0, 2.0], [4.0, 0.0, 4.0]],
"length": 5.656854,
"frame": 130
}
status is one of complete, partial, start_off_mesh, goal_off_mesh, failed. waypoints is empty for the last three.
A route that crosses a navigation link — a scene's navlink component, or one meta.nav.autoLinks generated — says so in links, one entry per crossing, in path order. A crossing runs from waypoints[waypoint] to waypoints[waypoint + 1]; link indexes GET /navmesh's links; action is what a player presses at the start while moving to the end (jump for a jump link unless the component names another; absent for a drop, which is crossed by walking off); hold is how many seconds to hold it down (the component's hold, 0.25 when it names none; absent with no action), because a game with variable jump height cuts a tapped jump short; entity is the declaring entity, absent for a generated link. links is omitted when the route walks the whole way, so a scene with no links answers exactly as before:
{
"status": "complete",
"waypoints": [[-6.0, 0.0, 0.0], [2.2, 0.0, 0.0], [4.1, 1.5, 0.0], [5.0, 1.5, 0.0]],
"length": 12.183,
"frame": 212,
"links": [{ "waypoint": 1, "link": 0, "kind": "jump", "action": "jump", "hold": 0.25, "entity": "ledge-link" }]
}
GET /navmesh
The built navmesh, as plain data.
Query parameters, both optional:
bounds=minX,minY,minZ,maxX,maxY,maxZ— world metres; only the polygons whose own box touches this box are returned.area,boundsandlinksstill describe the whole mesh (linksis never filtered, becausePOST /path'slinks[].linkindexes it).limit=N— lowers the polygon cap (at most 20,000, the default).
A response is never truncated. A request with no bounds on a mesh of more polygons than the cap is refused 400 bad_request with a message beginning nav-bounds-required:; one whose bounds still hold more than the cap, with nav-bounds-too-large:. Both say how many polygons there are.
{
"polys": [
{
"vertices": [
[-9.316954, 0.0, -9.3306265],
[7.3998413, 0.0, -9.3306265],
[7.3998413, 0.0, 5.281846],
[-9.316954, 0.0, 5.281846]
],
"area": 63,
"flags": 1,
"areaM2": 244.27371
}
],
"area": 244.27371,
"bounds": [[-9.316954, 0.0, -9.3306265], [7.3998413, 0.0, 5.281846]]
}
polys[].vertices are counter-clockwise world-space corners. polys[].area is the Recast area id, an integer; polys[].areaM2 is that polygon's surface in square metres. Top-level area is the total walkable surface. bounds is [min, max].
links lists the off-mesh links baked into the mesh, with their ends snapped onto it; it is omitted when there are none. Each has start, end, kind (jump, drop, generic), bidirectional, auto (generated by meta.nav.autoLinks rather than declared), and, when there is one, the action to press, the seconds to hold it and the declaring entity. A link's Detour polygon carries area 61 (jump), 62 (drop) or 60 (generic).
{
"polys": [],
"area": 0.0,
"bounds": [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
"links": [
{ "start": [2.2, 0.0, 0.0], "end": [4.1, 1.5, 0.0], "kind": "jump", "bidirectional": false, "action": "jump", "hold": 0.25, "entity": "ledge-link", "auto": false }
]
}
GET /frame
The current frame index. The one endpoint that returns a JSON scalar rather than an object, because the SPEC's table says u64:
130
Its schema is named FrameIndex and describes a non-negative integer. The name exists so that GET /schema covers every served endpoint; the body on the wire is the bare number above, not an object wrapping one.
GET /frame.png
The last rendered frame, as image/png bytes. 404 until something has rendered, with a message naming both ways to get one.
| Parameter | Shape | Meaning |
|---|---|---|
fresh | 1, true, yes | render one now instead of serving the cached image |
It is stale, and it says which frame it is
The cached image is refreshed every 30 rendered frames and encoded off the simulation loop, so it lags the world by up to half a second at 60 Hz — and does not move at all while the simulation is paused. That is the trade the endpoint exists for: a picture, cheaply, as often as you like.
So the two stamps on the response answer two different questions, and reading the wrong one is how an agent ends up explaining a collision with a picture taken before it:
| Header | What it means here |
|---|---|
x-argus-frame | the frame the image shows — not the frame the server is on |
x-argus-stamp | the committed state the world is in right now, as on every other response |
Every other endpoint's x-argus-frame is the frame of the body it is attached to, and this is the same rule; it is only worth spelling out because here the body is a picture and the gap is visible. GET /frame is the live frame in plain form, if you want the two numbers side by side.
?fresh=1
GET /frame.png?fresh=1
Renders on demand and comes back stamped with the frame it drew. It goes through the same path as POST /capture, so it costs a round trip to the simulation (up to one tick) and holds an exact-job slot — see Ceilings. Use it when the picture has to be this moment; use the plain form when roughly current is enough, which is most of the time.
Anything other than 1, true or yes means no, and serves the cached image: ?fresh=0, ?fresh= and a typo all take the cheap path rather than a 400. A picture is not worth an error.
Not in the SPEC's endpoint table: this is the phase-0 endpoint, kept because the CLI and the tracer gate use it. POST /capture is still the full capture path — it takes overlays, sizes, an arbitrary eye point, clips, and can read back the depth and entity-id buffers. ?fresh=1 is the one-line form of its simplest case.
GET /state
Which committed state this server is serving, and what it will take on. The body form of x-argus-stamp, for a client that reads JSON more easily than headers.
{
"stamp": { "artifact": 1, "frame": 130, "revision": 457, "structure": 3 },
"contacts_stamp": { "artifact": 1, "frame": 130, "revision": 457, "structure": 3 },
"readers": 2,
"max_readers": 32,
"exact_jobs": 0,
"max_exact_jobs": 8
}
contacts_stamp is behind stamp when the engine has deferred the contact copy because nothing has read GET /contacts yet. The first read of that endpoint asks the simulation to materialise one and waits for it, so a first reader sees current state rather than an empty list; after that the two stamps track each other. GET /contacts always reports the state its list actually describes.
GET /snapshot
Everything needed to restore the world to this frame: transforms, velocities, script state, animator playheads, HUD, goals, and the RNG seed. Entities are in ascending entity-slot order, so two captures of the same state are byte-identical.
{
"frame": 130,
"time": 2.1666666666666665,
"rng_seed": 24301,
"entities": [
{
"id": "e0",
"name": "entity-0",
"tags": ["enemy", "moving", "player"],
"position": [-7.166, 1.9591131, -7.3306265],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
"velocity": {
"linear": [-2.8848248, -0.821622, 2.857725],
"angular": [0.0, 0.004341185, 0.0]
},
"script": { "module": "behaviors/patrol.ts", "state": { "waypoint": 0, "alerted": false } },
"anim": { "state": "idle", "time": 0.0 }
}
],
"hud": { "score": 0 },
"goals": [
{ "id": "reach-exit", "labelKey": "goal.reach-exit", "order": 0, "reached": false }
],
"opaque": { "backend": "rapier3d", "version": 1, "data": "" }
}
velocity, script and anim are absent when the entity has none — an entity with no rigid body is not the same as one at rest. goals[].at_frame appears once a goal is reached.
hud is the exception to the no-null rule: a key whose behavior cleared it with ctx.hud.set(key, null) is published as "key": null, not dropped. It has to be. The body is the input to POST /snapshot, and a restore rebuilds the HUD from exactly these keys — so a cleared field that came back omitted would restore as whatever it held before the clear, which is the one state the capture was not taken in. A field the scene never set is genuinely absent; a field set to null is a field that is currently showing nothing. Readers that treat "missing" and "null" the same are safe; readers that want to know which keys the run has touched read the nulls too.
opaque is solver state the physics backend cannot rebuild from transforms and velocities alone: contact islands, sleeping flags, warm-start impulses. It is opaque on purpose and the only thing to do with it is hand it back. A snapshot taken without it may not restore bit-exactly.
anim carries an animator's gameplay state as well as its playhead: once (the one-shot playing), layers ([{ name, state, once }]), speed (the playback rate when it is not 1; 0 is a hit-stop) and windows (the event windows open now, an attack's hit window). Those are for a reader. What a restore uses is runtime, { "backend": "argus-animator", "version", "data" }, the animator's whole state between ticks (tracks, fades, parameters, layers, the root motion banked for the controller), so a world restored mid-attack, here or in a fresh world as a late joiner's is, goes on exactly as the run that never restored. Like opaque it is only to be handed back. A snapshot without it (an older build's), or with another version, restores the animator by state and time alone, and the restore's report counts it in anim_fallbacks (exact ones in anim_runtimes). So does a runtime from a scene edited since, which names a state, one-shot or clip the animator no longer has. Two things are not in the runtime: spring bones (hair, cloth) and a face's idle motion start again at rest after a restore, as they do after a teleport. Neither changes what the game does, only how it looks for a moment.
spawned lists the entities a behaviour created at run time (ctx.world.spawn) that are alive, ascending by id: [{ "id": "kart-1", "def": { ...the entity as a scene file holds it... } }]. It is absent when nothing was spawned, so a world that never spawned snapshots as it always did, and a body without it restores as before. A restore recreates an entity it has lost from the scene's own entry for the id, then from the run's own spawns, then from this def; that last one is what brings back entities spawned before a loadScene or in another process. A def that is not a valid entity of its id is ignored, and that entity counts in skipped.
road_progress lists every entity ctx.world.road(id).progress(entity) is tracking. It goes road by road in id order, and each road's entities in the order their tracking started, which is the standings' last tie-break: [{ "road": "circuit", "entity": "kart", "position": [x, y, z], "s": 41.2, "offset": 0.3, "from_line": 0.3, "along": 341.2, "checkpoint": 5, "backward": 0, "reached": 140, "best": 341.2 }]. These are the engine's whole track:
position,s,offsetandfrom_line: where the entity was at its last step, in the road's own frame.along: its unwrapped distance past the start line.checkpoint: the last checkpoint crossed in order, counted across laps.backward: the metres gone the wrong way.reachedandbest: the tick and distance of its best.
A world restored from them counts the lap and orders the standings exactly as the world it came from, here or in a fresh one. The field is absent when nothing is tracked, so such a world snapshots as it always did. A body without it restores with nothing tracked, and tracking starts again at the next progress call. A track whose road or entity the world does not have is dropped. So is one with a number no race could make; POST /snapshot refuses a non-finite one.
The entity's numeric identity is deliberately not here. argus_state tracks an {index, generation} internally because restore needs exact object identity across a respawn; nothing but the stable scene id crosses this boundary, as everywhere else in this API.
POST /snapshot
Put the world back into a state a previous GET /snapshot returned. The body is exactly that response, unmodified. This is the playtester's checkpoint restore: capture before a risky attempt, restore and retry when it fails.
{
"frame": 130,
"time": 2.1666666666666665,
"rng_seed": 24301,
"entities": [
{
"id": "e0",
"name": "entity-0",
"tags": ["enemy", "moving", "player"],
"position": [-7.166, 1.9591131, -7.3306265],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
"velocity": {
"linear": [-2.8848248, -0.821622, 2.857725],
"angular": [0.0, 0.004341185, 0.0]
}
}
],
"hud": { "score": 0 },
"goals": [],
"opaque": { "backend": "rapier3d", "version": 1, "data": "" }
}
{ "restored": true, "frame": 130 }
restored is always true in a 200; a restore that could not be applied is a 4xx with the usual error body. The world is at frame afterwards, which is the frame the submitted state carried.
An entity in the state that the loaded scene no longer has is skipped and counted, so a restore against a scene edited since the capture is visibly partial rather than silently so:
{ "restored": true, "frame": 130, "skipped": 3 }
skipped is absent when every entity was applied. A state in which no entity matches is a 400, because that is a snapshot from a different scene rather than a partial restore.
A capture taken while ctx.session.pause() holds the world carries session_paused: true, and the restore puts the hold back. A held tick counts a frame without advancing time, so after a pause time is behind frame × the timestep; the restore keeps a time that is behind, and recomputes any other from frame. A pausesWorld screen is not captured: the UI stack is not part of the state.
Include opaque if the capture had it. Without it the restore still places every transform and velocity, but the physics solver starts from a clean slate, so restore then stepping will not match continuous stepping bit for bit.
Size and cost. This is the only request in the API measured in megabytes: a 10,000-entity state is about 1.8 MB. The route accepts bodies up to 64 MiB, where every other endpoint accepts far less, and refuses more than 200,000 entities with a reason.
It is also the only endpoint not held to the 30 ms query budget, because it is a write of the whole world rather than a question about it. It measures about 26 ms on the 10,000-entity fixture and is budgeted at 100 ms.
Implementers: index by id before applying. Matching each submitted entity against the live table by scanning it is O(n²) — a hundred million string comparisons on 10,000 entities, which measured 193 ms. Building a map of id to slot first brings the same restore to 26 ms.
POST /capture
Render and read back. The kind field picks the variant. The SPEC writes this as FrameOpts | ClipOpts | AtOpts; an untagged union of three structs with overlapping optional fields cannot be parsed unambiguously or errored on usefully, so the frozen form is tagged.
Shared fields on every kind: overlays (default []), buffers (default {"depth":false,"ids":false}), width and height (default: the scene's configured size, max 4096).
{"kind":"frame"} — one frame from the scene's active camera.
{ "kind": "frame", "overlays": [{ "type": "colliders" }], "buffers": { "depth": true, "ids": true } }
When a script's third-person or orbit rig is the view, a frame capture's camera also carries arm: what the spring arm was doing — the length asked for, the length it had, whether a wall collapsed it (collapsed: under 0.5 m, the eye in or against the character) and what wall (blocker, an entity id, absent when nothing blocks), the character fade (0..1; faded pixels are dropped by a dither, so they are missing from the depth and id buffers too), and the probeRadius the sweep used, widened so the near plane never enters geometry. On a clip, each frame's own. Absent on at and views frames and whenever no arm rig is active. It is ctx.camera.arm on the wire.
{
"kind": "frame",
"frames": [
{ "frame_index": 212, "width": 640, "height": 360, "png": "iVBORw0KGgo…",
"camera": { "eye": [0.4, 1.9, -0.4], "target": [0.7, 1.6, -0.7], "up": [0.0, 1.0, 0.0],
"fovY": 1.5707964, "near": 0.1, "far": 500.0,
"arm": { "desired": 4.0, "length": 0.12, "collapsed": true, "blocked": true,
"blocker": "side-wall", "fade": 1.0, "probeRadius": 0.29 } },
"perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
"draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } }
],
"elapsed_ms": 1.0
}
The UI in an ordinary capture, and ?no_ui=1
An ordinary POST /capture (any kind) draws the UI a player would see over the world, laid out at the size captured, from the screens on the stack at that moment. (Before the k5 fix a render-on-request engine, which never draws a frame of its own, showed no UI at all until a ?ui_only=1 capture had left one on the renderer, and then that stale picture of it.) ?no_ui=1 leaves the UI out: the world alone, for any kind, a clip included.
?ui_only=1 and ?node=<id> — the UI screenshots
Two query parameters, so the body is unchanged and a client that knows POST /capture keeps writing it. Both apply to {"kind":"frame"} and {"kind":"at"} only; a clip or a views capture with either is 400.
?ui_only=1 renders the UI pass alone — no meshes, no world labels, no particles, no overlays and not the scene's sky — over a flat constant background, so two captures of the same screen at the same size are the same bytes whatever the game is doing behind them. 1, true, yes, on and a bare ?ui_only all mean yes.
?node=<id> crops to that node's painted rectangle in device pixels: the bounds.device GET /ui reports, intersected with bounds.clip. Takes the qualified id or the bare one. A node that is not on screen, is under display: none, or is clipped away entirely is 400 with the reason — never a 0×0 image. The two combine: ?ui_only=1&node=panel:start is a picture of one component and nothing else.
The crop is taken from the rendered frame rather than by re-rendering at the node's size. Re-rendering would lay the screen out into a different viewport and the node would not be the node any more; and a shadow or a glow that reaches outside the rectangle is part of what the node looks like, so the crop keeps whatever the frame put there.
Neither returns a depth or an id buffer: a UI-only frame has no entities and no useful depth, and a buffer whose pixels did not line up with the image beside it would be worse than none.
Screenshots are evidence, not assertions. Pixel-diffing whole screens produces snapshot churn and flaky failures. The machine-checkable claims about a screen belong in the critic's UI checks, which assert over the computed tree.
{"kind":"clip"} — a run of frames. seconds in (0, 60], fps in [1, 120], default 15. The first frame is the world as it stands; between frames the world runs 1 / fps simulated seconds in whole ticks (at least one), through the same hooks a POST /time step takes, so inputs held with POST /input act during the clip and a read afterwards sees the world where the clip left it. Each frame's camera.arm is the arm at that frame.
{ "kind": "clip", "seconds": 5.0, "fps": 15 }
A clip renders every frame on the simulation thread before it answers. The simulation must take any command within the command deadline (2 s); once taken, a command has 60 s (COMMAND_WORK_LIMIT), and a capture has the larger of that and the command deadline plus 10 s per frame requested (CAPTURE_FRAME_ALLOWANCE; a views capture counts 4, a frame, at or material 1). An HD 1080p clip at ~0.3 s a frame used to answer 503 timeout at 2 s while the engine was still drawing it. The simulation answers nothing else while a clip renders, and every frame comes back base64 in one JSON body, so for anything long, large or slow — a trailer, a replay, HD on a software adapter — use argus run <project> --record <dir> instead (docs/API.md, "Recording frames offline"): no HTTP, no deadline, one PNG per frame on disk. One thing differs: a clip's frames are drawn through argus_capture, which does not re-upload the UI, labels, particles or skinning between the ticks it steps, where --record draws each frame the way the game does.
{"kind":"at"} — one frame from an arbitrary eye point. pos and look must differ.
{ "kind": "at", "pos": [0, 10, 0], "look": [0, 0, 0], "overlays": [{ "type": "findings", "positions": [[1, 2, 3]] }] }
{"kind":"views"} — the four canonical views of one entity: front, side, top, iso. This is the asset stage's four-view vision check. entity is the stable scene id, not a number.
{ "kind": "views", "entity": "player" }
It takes the same overlays, width and height as every other kind:
{ "kind": "views", "entity": "player", "width": 320, "height": 240, "overlays": [{ "type": "bounds" }] }
It always answers with JSON, never raw PNG, because there are four images. Each is the same frame shape the other kinds return, with a view field naming which one it is, always in the order front, side, top, iso:
{
"kind": "views",
"frames": [
{ "view": "front", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
"camera": { "eye": [0.0, 1.0, 6.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
"fovY": 1.0471976, "near": 0.1, "far": 500.0 },
"perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
"draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
{ "view": "side", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
"camera": { "eye": [6.0, 1.0, 0.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
"fovY": 1.0471976, "near": 0.1, "far": 500.0 },
"perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
"draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
{ "view": "top", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
"camera": { "eye": [0.0, 7.0, 0.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 0.0, -1.0],
"fovY": 1.0471976, "near": 0.1, "far": 500.0 },
"perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
"draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } },
{ "view": "iso", "frame_index": 131, "width": 320, "height": 240, "png": "iVBORw0KGgo…",
"camera": { "eye": [4.0, 4.0, 4.0], "target": [0.0, 1.0, 0.0], "up": [0.0, 1.0, 0.0],
"fovY": 1.0471976, "near": 0.1, "far": 500.0 },
"perf": { "render_ms": 0.6, "readback_ms": 0.3, "encode_ms": 0.1, "total_ms": 1.0,
"draw_calls": 12, "instances": 40, "triangles": 480, "overlay_vertices": 0 } }
],
"elapsed_ms": 4.0
}
Overlays
Wireframe passes drawn over the colour buffer, each tagged by type: colliders, navmesh, triggers, rays, bounds, and two that carry data:
[
{ "type": "colliders" },
{ "type": "navmesh" },
{ "type": "findings", "positions": [[1, 2, 3], [4, 5, 6]] },
{ "type": "paths", "paths": [[[0, 0, 0], [1, 0, 1], [2, 0, 2]]] }
]
{"kind":"material"} — one material on a preview shape, drawn alone at the origin under the scene's sky, sun, fog and meta.render (point lights and the scene's own entities are left out), from a fixed camera a little above the shape. material is a scene asset id of kind material, or a material file's JSON as an object (docs/MATERIALS.md; texture references are texture asset ids or paths from the scene's folder). shape is sphere (the default), cube or plane; the size defaults to 256x256. It takes no overlays or buffers. It answers like a frame: raw PNG, or JSON with ?format=json.
{ "kind": "material", "material": "brick", "shape": "cube" }
{ "kind": "material", "material": { "shading": "vertex", "filter": "nearest", "baseColor": [0.8, 0.4, 0.2] } }
A material asset that is not in the scene or is not a material, or JSON the material contract refuses, is a 400 whose message says what was wrong (the contract's first violation).
Response
A frame or at capture answers with raw image/png bytes. A clip or views capture answers with JSON. Add ?format=json to get JSON for any kind — that is how you reach the depth and id buffers, which are base64 PNGs alongside the colour one.
{
"kind": "frame",
"frames": [
{
"frame_index": 131,
"width": 16,
"height": 16,
"png": "iVBORw0KGgoAAAANSUhEUg…",
"depth": "AAAAAJqZmT0zMzM+…",
"ids": "AAAAAAEAAAACAAAA…",
"camera": {
"eye": [0.0, 8.0, 12.0],
"target": [0.0, 0.0, 0.0],
"up": [0.0, 1.0, 0.0],
"fovY": 1.0471976,
"near": 0.1,
"far": 500.0
},
"perf": {
"render_ms": 0.6,
"readback_ms": 0.3,
"encode_ms": 0.1,
"total_ms": 1.0,
"draw_calls": 12,
"instances": 40,
"triangles": 480,
"overlay_vertices": 0
}
}
],
"elapsed_ms": 1.0
}
png is base64, standard alphabet, padded. A views capture adds view, one of front, side, top, iso. elapsed_ms is measured on the simulation thread, and perf is per frame and capture-specific — readback_ms and encode_ms exist nowhere else, and they are usually where a capture that misses its budget loses the time.
camera is the camera the frame was taken through, so a caller can project a world position into the image afterwards. That is what turning a critic finding into "here it is, circled" needs.
depth and ids are not images
They appear only when the matching buffers flag was set, and they are base64 of the raw little-endian pixel arrays, width * height values in row-major order — f32 for depth (0 at the near plane, 1 at the far plane), u32 for ids.
Encoding them as PNGs would mean quantizing depth to 16 bits and packing 32-bit entity ids across RGBA channels, and every client would then need a PNG decoder to read one number back out. Raw is lossless, smaller than JSON numbers, and one DataView away:
{ "pick": "ids[y * width + x], where 0 means no entity covered that pixel" }
const buf = Uint8Array.from(atob(frame.ids), c => c.charCodeAt(0));
const ids = new Uint32Array(buf.buffer);
const entityAt = (x, y) => ids[y * frame.width + x] || null;
POST /time
Change the clock. Every field is optional; absent means "leave it alone", so {} reports the current state without changing it.
{ "scale": 0.5, "paused": true, "step": 1 }
scale is in [0.1, 10.0]. step advances exactly that many fixed ticks whether or not the sim is paused, at most 3600 (one minute at 60 Hz). It is applied after scale and paused, so {"paused": true, "step": 1} is the debugger gesture: pause, then advance one tick.
{ "scale": 0.5, "paused": true, "frame": 131, "fixed_dt": 0.016666668, "stepped": 1 }
POST /input
Synthetic input for the playtester. Applied in order, taking effect on the next tick.
{
"actions": [
{ "type": "action", "name": "jump", "held": true, "frames": 2 },
{ "type": "axis", "name": "move", "value": [1.0, 0.0], "frames": 10 }
]
}
name comes from the scene's input map. entity targets one entity's controller; absent means the scene's player spawn. frames is how many ticks the input stays applied before releasing, in [1, 600], default 1 — so "held": true with no frames is a tap. An axis value is [x, y], each component in [-1, 1]. At most 64 inputs per request.
The body is a list under actions, and every event needs its "type" (action, axis, steer, mouse, pointer or key); this is not the MCP play.input shape, which takes one event with frames or held at the top level. An action presses only with "held": true (for frames ticks); false, or no held, releases it. An action that asks for frames above 1 without held is refused with a 400 rather than accepted as a release that does nothing, and a body the endpoint cannot read is answered with an example body.
An action or axis is player input: it reaches ctx.input and the controller exactly as a key or a stick does, so a move of [0, 1] walks away from the camera for a controller with the default inputBasis: "camera", and moves nothing on an input: "script" controller whose behaviour ignores it (docs/API.md, "Who drives the controller").
A steer is world-frame steering for a tool walking a route. dir is a world direction [x, z] (each component in [-1, 1], [0, -1] is -Z), speed is m/s and defaults to the controller component's speed. It reaches the game as the player's move stick in ctx.input, turned into the controller's inputBasis frame at the length that asks for that speed, so a behaviour cannot tell it from a person. An input: "auto" controller is also walked along dir by the engine; for the ticks it is held that wins over the stick and over a behaviour's move, and it is what an animator's inputSpeed reads. An input: "script" controller is walked only by its behaviour, from the stick: one that ignores the stick or has been disabled stands still, and a steer placed while none of its entity's behaviours runs comes back in unbound (docs/API.md, "POST /input: a player's hands, and steer for tools"):
{
"actions": [
{ "type": "steer", "dir": [0.0, -1.0], "speed": 3.0, "frames": 30 }
]
}
{ "accepted": 2, "frame": 132 }
unbound lists names no entity in the scene consumes, so a typo shows up as data rather than as nothing happening:
{ "accepted": 1, "frame": 132, "unbound": ["jmup"] }
A mouse
A mouse is a person's mouse: delta pixels (screen axes, +y down, each in [-2000, 2000]) on each of frames ticks, fed in before the action map exactly where the window's mouse goes. Every action bound to the mouse's delta reads it through its own sensitivity (degrees per pixel, 0.1 when absent), so a probe measures the turn a player gets. It is always the player's (there is one mouse), and it is unbound when no action in the map reads the mouse's delta:
{
"actions": [
{ "type": "mouse", "delta": [240.0, 0.0], "frames": 10 }
]
}
A pointer
A pointer says where the mouse points: pixel (x, y) of view view's cell (view 1 to 4, default 1; x, y in [-100000, 100000]), the space ctx.input.pointer reports and ctx.camera.pick(x, y, { view }) takes. It is a level, not a span: it holds until another pointer moves it or one with "off": true takes it away, so a caller driving the clock with POST /time keeps it across any number of steps. It is the window cursor's own state (there is one mouse), recorded by a replay, and applies at the next tick. A view the layout does not have comes back in unbound.
{
"actions": [
{ "type": "pointer", "view": 1, "x": 640.0, "y": 360.0 }
]
}
A key, and an axis held until it is released
A key is one keyboard key going down or up, the way a window's keyboard sends it. It is offered to the screen stack first (a key a menu or a focused field takes is not the game's), then goes into the action map of the live input context, which is what turns it into an action: W is move while the play context is live and nothing while a menu is. It stays down until a down: false for the same key, so a caller that drives the clock with POST /time can hold a key across as many steps as it likes. code is a scene's input-map spelling (W, Space, LShift, Return) or winit's (KeyW, ArrowUp, ShiftLeft, Enter); a name nothing answers to comes back in unbound. There is one keyboard, so a key is always the player's and takes no entity.
{
"actions": [
{ "type": "key", "code": "W", "down": true }
]
}
An axis with "sustain": true is held until the same axis is placed again or released (a value of [0, 0] replaces it), whatever frames says. That is how a caller that steps the clock itself keeps a stick over for longer than the 600-tick ceiling of a frames hold:
{
"actions": [
{ "type": "axis", "name": "move", "value": [0.0, 1.0], "sustain": true }
]
}
GET /input
Where a press goes: the action map of the scene that is running now (after a POST /scene or a ctx.session.loadScene that is not the scene the caller opened), the live input context, and the entity player input reaches. A caller builds its action-to-key table from this, and reads it again after a press to learn whether anything read it. Needs observe.
{
"scene": "scenes/main.scene.json",
"input": { "active": "play", "contexts": { "play": { "move": { "kind": "axis2", "keys": ["W", "A", "S", "D"] } } } },
"context": "play",
"frame": 130,
"receiver": { "entity": "player", "scripts": ["behaviors/walker"], "native": true },
"unrouted": []
}
input is absent when the scene has no meta.input and the built-in map is the one running. receiver is absent when the scene has no player. unrouted lists entities whose behaviours read input while nothing routes it to them (the runtime's input-read-without-routing warning).
POST /scene
Switch the running world to another scene of the project: ctx.session.loadScene from outside, with the same rules. The path is project-relative with / separators, resolved inside the session's content root, and may not leave it. A refusal changes nothing and keeps the runtime's own code in its message: 404 with scene-missing, 400 with scene-path-refused, scene-invalid, scene-no-root or scene-load-failed. Needs reload, the capability for changing what the running world is made of without restarting the process; a paused world stays paused.
{ "scene": "scenes/track-meadow.scene.json" }
{ "scene": "scenes/track-meadow.scene.json", "frame": 0, "entities": 1756 }
POST /step
Inputs, ticks and a read in one call: POST /input, then POST /time {step}, then GET /entity/{id} for each id asked, applied in that order. A client that steps a paused world (the playtester does, every few ticks) used to make those three requests each time; each is a command the simulation answers in turn, so this is one round trip and one wake of its loop instead of three.
{
"input": [{ "type": "axis", "name": "steer", "value": [1.0, 0.0], "frames": 6 }],
"step": 6,
"entities": ["player"]
}
input is POST /input's events (the same types, the same validation, the same refusal of an action that asks for frames with no held), at most 64, and may be absent. step is whole ticks, at most 3600, one when absent, run whether or not the world is paused, exactly as POST /time {step} runs them. entities are ids read after the ticks, at most 64, answered as GET /entity/{id} answers them, from the state the ticks committed, so each carries the frame the response names. It needs both capabilities, time and input (a holder of one cannot do the other's work through it); with a managed session a caller without either is refused as for POST /time or POST /input. The route changes neither the scale nor the pause state; use POST /time for those.
{
"frame": 137,
"paused": true,
"stepped": 6,
"input": { "accepted": 1, "frame": 131 },
"entities": []
}
input is POST /input's answer for the events placed (unbound names included), absent when there were none. Nothing is placed and no tick runs when a request is refused: an unknown entity to read or to target is a 404, a bad event or too many ticks a 400.
POST /entity/{id}/teleport
Put one entity somewhere else outright: the move a behaviour's ctx.entity.teleport(position, yaw) makes. The transform, the physics body and the controller move together, the controller's velocity is cleared, and the next frame is drawn where it landed rather than interpolated there. position is the entity's own, local to its parent if it has one; yaw (radians about +Y, 0 facing -Z) turns it and is optional. A yaw also turns the camera rig filming the character; with no yaw the facing and the view heading are both left exactly as they were. Needs snapshot-restore.
{ "position": [2.0, 0.0, -7.0], "yaw": 3.14159 }
{ "entity": "player", "position": [2.0, 0.0, -7.0], "frame": 412 }
position in the response is where it landed: a character put inside geometry is pushed out of it. It is answered after the move is published, so a read that follows sees it. An entity the scene does not have is 404; a position or yaw that is not finite, a position outside meta.bounds, or one inside geometry with no way out inside the bounds is 400 and moves nothing.
Nothing else is rewound, which is what makes this different from POST /snapshot with one position changed: the frame, the other entities and every behaviour's own state are left alone. So the game is told. At the top of the next tick, before the controllers step and before behaviours run, the engine raises a bus event teleported (by null) with the entity, where it landed, where it was and the yaw it was given:
{ "seq": 90, "frame": 413, "time": 6.883333, "type": "custom", "name": "teleported", "data": { "entity": "player", "position": [2.0, 0.0, -7.0], "from": [0.0, 0.0, 0.0], "yaw": 3.14159 } }
A behaviour that keeps its own velocity or state machine resets it on that event (ctx.events.on("teleported", …) or onEvent); GET /events?filter=teleported lists it.
Two ticks after a teleport with a yaw, the engine checks that the view took it. When a behaviour wrote the old heading back, it raises the teleport-heading-ignored script warning (GET /events?filter=script_warning).
Two ticks after any teleport of 0.25 m or more, the engine checks that the move stuck. When a behaviour wrote the entity's old position back (it keeps the position in its own variables and never adopts teleported), it raises the teleport-position-ignored script warning, naming that behaviour as the module, once per entity per run.
GET /environment
The sky as it actually resolved, which is not always what the scene document asked for: a scene whose panorama is missing reports the default sky it fell back to and says why in findings.
{
"kind": "procedural",
"preset": "clear-noon",
"intensity": 1.0,
"rotation": 0.0,
"sun_direction": [-0.35, -0.87, -0.35],
"sun_color": [1.0, 0.96, 0.9],
"sun_intensity": 1.0,
"sun_from_sky": false,
"persisted": false
}
kind is procedural, hdr, color or atmosphere. preset is present only for a procedural sky (its preset) or an atmosphere sky (sky.atmosphere.preset, default earth-clear). asset and asset_path name the panorama an hdr sky loaded. sun_direction is the direction the sunlight travels (sun towards scene) and sun_intensity is 0 when the sun is below the horizon, so sun_intensity > 0 is the test for "it is daytime". persisted is always false: this endpoint reports the live sky, and a live sky is only on disk when someone edited the scene file.
POST /environment
Swap the sky live. The body is a partial sky merged onto whatever is running, so a preset swap is one field and does not reset the rotation someone spent a minute getting right.
{ "sky": { "preset": "dusk", "rotation": 1.25 } }
Every field is optional, sky included, so {} reports the current environment without changing it. kind is procedural, hdr, color or atmosphere (an atmosphere sky's own settings are written in the scene file); a preset with no kind means procedural and an asset with no kind means hdr, because that is the only thing either can mean. asset must already be in scene.assets as an envmap: this endpoint swaps skies, it does not add assets. intensity must not be negative, turbidity is in [1.8, 10], and the merged environment then goes through the same contract rules a scene file does — so a swap can never produce a scene a file could not.
{
"kind": "procedural",
"preset": "dusk",
"intensity": 1.0,
"rotation": 1.25,
"sun_from_sky": false,
"persisted": false,
"frame": 132,
"changed": ["sky"]
}
changed names what actually moved, so a caller sees when a field it sent was a no-op. Nothing is written to disk. The next hot reload takes the document's sky back, which is what makes trying ten skies in a second safe. To keep a sky, edit meta.environment.sky in the scene file.
docs/SKY.md is the field reference and the list of presets.
GET /save, POST /save and GET /roads
What a caller outside the game needs to reach a state in it, with no test hook in the game's code.
GET /save answers { "values": {key: json}, "persistent": bool, "path": string|null }: every key of the running game's ctx.save store (persistent is false for the in-memory store a bare scene, a test world and the editor's Play get). POST /save { "set": {key: json}, "remove": [key] } changes it (sets first, then removes; a key that is not there is fine) and answers the store after. A key is 1 to 64 characters of A-Z a-z 0-9 . _ - and the whole store may be 256 KiB: a refusal (save-bad-key, save-full) is 400 and changes nothing of that write's key. The write is flushed at once, because a paused world has no tick to do it. It needs snapshot-restore, as a teleport does: it is a state write outside the game's rules.
GET /roads?id=&step_m= answers { "roads": [ ... ] }, one per road entity (the .road.json file the scene's road component names): id, length, closed, space: "world", stepM (the spacing reported), samples (each {s, pos, tangent, halfWidth, curvature, bankRad}, position and tangent in WORLD space: the road entity's transform is applied, unlike ctx.world.road, which reads the entity's own frame; halfWidth is half the driving surface's width there), gates ({id, s, position} in lap order, gate 0 the start line), kerbWidthM and barrier. At most 400 samples a road unless step_m asks for a spacing; id names one road (an unknown id is 400 listing the scene's roads). Other spline assets (terrain splines, paths) are not served here: this engine has the road as its one path asset a game reads at run time.
GET /npc/log
GET /npc/log?id=<entity id> answers { "id": "g1", "entries": [ {tick, action, rule, target}, ... ] }: the last decisions of one NPC, oldest first, at most 16. action is what it chose, rule the name of the rule that chose it and target the entity id (the ECS entity index) it aimed at. 400 without id (or with a parameter the route does not take); 404 (not_found) when id is not an NPC of the running scene. It needs observe, as /entities does.
GET /camera/project and GET /camera/ray
Read-only (Observe, like GET /state), per split-screen view: where a world point lands on screen, and which world ray a screen pixel is. Both use the matrices the running game draws each view with, the same per-view frames a script's ctx.camera.worldToScreen and screenToRay read (so the answer is as of the top of the tick, which the body stamps), and the same pixel space: the pixels of the view's own cell, top-left 0,0, in the viewport the sim holds (ctx.camera.viewSize's pixelWidth x pixelHeight, divided among the cells of the layout ctx.views set). Every answer says which cell with "cell": { "w", "h" }, in pixels, so a caller never guesses the size to scale against.
GET /camera/project?x=&y=&z=[&view=1] answers { "view", "x", "y", "cell", "tick" }. GET /camera/project?entity=<id> projects that entity's world position at the answered tick instead of x, y, z. A point at or behind the eye has "x": null, "y": null; a point in front of the camera but off screen answers pixels outside the cell (negative, or beyond cell.w / cell.h), not null.
GET /camera/ray?x=&y=[&view=1] answers { "view", "origin": [x, y, z], "dir": [x, y, z], "cell", "tick" }: the ray through that pixel of the view's cell, origin on the near plane and dir a unit vector. A pixel outside the cell extrapolates. GET /camera/project of a point on the ray answers the pixel back (within half a pixel).
A view with no camera answers 200 with null fields (x, y, origin, dir, cell), so a caller polling during a load does not branch on errors. Refusals are 400 with the usual body whose error is a rule code:
| code | when |
|---|---|
camera-view-unknown | view is not a number, is 0, or is beyond the layout's live views (the message says how many) |
camera-point-invalid | x, y or z is missing or not a finite number; entity names no entity in the scene; or entity is combined with x, y or z (the message names the problem) |
GET /feel
The numbers behind "does this game feel right", for a caller that cannot look at the character or listen to it. It answers for the tick it is served on; the playtester's feel probes read it every few ticks while they drive a scripted sequence (packages/critic, critic.play with feel).
With no parameters it answers whole, as below. A rig with sixty joints and a dozen clips makes that tens of kilobytes per animator, so it narrows with the words GET /entities uses:
| Parameter | Shape | Meaning |
|---|---|---|
section | camera,animators | keep only these top-level sections: player, camera, animators, audio, particles, haptics, animator_misses, and clips (below); frame is always kept |
id (or entity) | hero,crab* | only the animators, animator misses, emitters and the burst and rumble records of these entities (a record with no entity goes with an unfiltered answer only); patterns as on /entities |
name | pattern | of entities whose name matches |
tag | string | of entities carrying this tag |
fields | states,clips | keep only these fields of each animator: position, states, joints, bind_deviation, pose, feet, foot_joints, feet_from, feet_method, feet_confidence, feet_reason, feet_unknown, rig_joints, clips, loop_seams, loop_ranges, pops, or the groups gait (feet, foot_joints, feet_from, feet_unknown) and rig (joints, rig_joints, pose, bind_deviation); entity is always kept |
limit, cursor | integer, the last page's x-argus-next-cursor | page the animators; x-argus-total counts them |
The entity filters narrow the animators and the misses, never the player or the camera. GET /feel?section=animators&id=hero&fields=states,gait is one character's states and feet. section=clips is not a field of the whole answer: it is one entry per animator (paged and filtered as the animators are) with only its entity, clips, loop_seams and loop_ranges, the clip facts the critic's feel/anim-pop and feel/anim-loop-seam findings point at. GET /feel?section=clips&id=hero is one character's clips, whether each is loopable, its seam_deg, and the ranges it loops. An unknown section or field is a 400 listing every one.
{
"frame": 240,
"player": { "id": "hero", "position": [0.0, 0.0, -3.2], "velocity": [0.0, 0.0, -4.0], "grounded": true, "controller": true, "blocked": false },
"camera": {
"kind": "third-person", "source": "rig", "position": [0.0, 2.35, 0.8], "forward": [0.0, -0.17, -0.985], "yaw": 0.0,
"fov_y": 60.0, "near": 0.1, "near_radius": 0.2, "near_clear": true, "clearance": 1.0, "pitch": -0.17, "follow": "hero",
"to_player": 4.0, "player_visible": true,
"arm": { "desired": 4.0, "length": 4.0, "collapsed": false, "blocked": false, "fade": 0.0, "probeRadius": 0.2 }
},
"animators": [
{
"entity": "hero", "position": [0.0, 0.0, -3.2], "states": ["idle", "jump", "walk"],
"joints": 3, "bind_deviation": 0.12,
"pose": [[0.0, 1.0, 0.0], [0.1, 0.5, 0.05], [0.1, 0.05, 0.1]],
"feet": [[0.1, 0.05, -3.1], [-0.1, 0.12, -3.5]], "foot_joints": ["LeftToeBase", "RightToeBase"],
"feet_from": "auto", "feet_method": "names", "feet_confidence": 0.95,
"feet_reason": "named foot, toe or ankle: LeftToeBase, RightToeBase; clip \"Walk\" plants them too",
"feet_unknown": [],
"rig_joints": ["Hips", "LeftToeBase", "RightToeBase"],
"clips": [
{ "name": "Walk", "duration": 1.0, "loopable": true, "seam_deg": 0.0, "max_step_deg": 3.4,
"ground_speed": 1.5, "speed_from": "feet", "in_place": true, "root_travel": 0.0 },
{ "name": "LookLeft", "duration": 0.97, "loopable": false, "seam_deg": 49.1, "seam_joint": "Head",
"max_step_deg": 4.4, "ground_speed": 0.0, "speed_from": "none", "in_place": false, "root_travel": 0.0 }
],
"strides": [{ "state": "walk", "clip": "Walk", "at": 1.5, "measured": 1.5, "rate": 1.0 }],
"loop_seams": [{ "state": "idle", "clip": "LookLeft", "next": "LookLeft", "seam_deg": 49.1, "joint": "Head" }],
"pops": {
"count": 3,
"worst": { "deg": 49.1, "joint": "Head", "clip": "LookLeft", "cause": "wrap", "ticks_ago": 12 },
"recent": [{ "deg": 49.1, "joint": "Head", "clip": "LookLeft", "cause": "wrap", "ticks_ago": 12 }]
}
}
],
"audio": {
"started": 2, "music": 0, "unplayable": 0, "recent": ["sfx-jump", "sfx-land"],
"output": "silent", "playing": 1, "peak_voices": 2,
"voices": [{ "voice": 2, "asset": "sfx-land", "bus": "sfx", "gain": 0.8, "looped": false, "spatial": false, "distance": 0.0, "pitch": 1.0, "seconds": 0.05 }],
"sounds": [
{ "asset": "sfx-jump", "plays": 1, "peak_gain": 0.8, "max_overlap": 1, "max_stacked_loops": 0, "rms": 0.21 },
{ "asset": "sfx-land", "plays": 1, "peak_gain": 0.8, "max_overlap": 1, "max_stacked_loops": 0, "rms": 0.18 }
],
"stacked": [],
"level": { "rms": 0.14, "peak": 0.62, "max_rms": 0.2, "max_peak": 0.7 },
"buses": [
{ "name": "master", "volume": 1.0, "muted": false },
{ "name": "music", "volume": 0.6, "muted": false },
{ "name": "sfx", "volume": 0.8, "muted": false }
]
},
"particles": {
"bursts": 3, "burst_particles": 60,
"recent": [{ "seq": 3, "frame": 238, "entity": "hero", "preset": "sparks", "count": 24, "position": [0.0, 1.0, -3.2] }],
"emitters": [{ "entity": "torch", "preset": "fire", "emitted": 410, "live": 38 }]
},
"haptics": {
"total": 1, "played": 0,
"recent": [{ "seq": 1, "frame": 238, "source": "hero", "strength": 0.6, "high": 0.3, "seconds": 0.2 }]
},
"animator_misses": [{ "entity": "hero", "call": "set", "name": "Run", "count": 12, "states": ["idle", "jump", "walk"] }]
}
particlescounts what the world emitted, free when unread (a counter per emitter, a ring of 64 records). Eachctx.particles.emitis a burst with a sessionseqfrom 1: keep the lastseqseen and take therecententries after it; a gap before the oldest means the ring overflowed.entityis the emitting behaviour's entity,countcapped at the preset's pool. Component emitters are listed inemitterswith a monotonicemittedandlive; diff two reads.hapticsis everyctx.input.rumblecall, clamped, whether or not a pad took it (playedcounts those, 0 headless);totalis the newestseq;recentreads asparticles.recentdoes.camera.pitchis the vertical angle offorward, radians, positive looking up, the same pitchctx.camera.settakes.look_atis the world point a fixed rig looks at, when one was set.targetis the entity a lock-on rig is framing; absent while no rig sets one.playeris the entity player input drives (the scene'sspawn { for: "player" }, else the first character controller);groundedandblocked(its last move was stopped by something) are the controller's.velocityis the controller's, or for a player without one (a vehicle) its rigid body's linear velocity;[0, 0, 0]when it has neither.camera.kindisscene(the scene's camera entity),fixed,third-person,orbitorfirst-person(a rig a script drives), oroverride(the editor's view).yawis the heading offorwardabout +Y, the same yawctx.camera.settakes: 0 looks down −Z.sourcesays who places the view:rig(the engine's rig, configured by a behaviour throughctx.camera— the game's own or a built-in such asplayer, whichever called it last),entity(the scene's primary camera entity, named inentity, wherever its transform is: a camera component'skind,targetanddistancedo not move it, a rig or a behaviour does), oreditor(the override). Nothing here, and nothing in the feel probes, positions the camera: this is the view the frame is drawn from, the same onePOST /capture {"kind":"frame"}renders.near_clearis false when a sphere ofnear_radiusaround the eye — big enough to hold the whole near plane at the widest aspect the engine draws — overlaps a solid collider: part of the frame is the inside ofnear_hit. Triggers and the followed entity do not count.clearanceis how far the nearest solid is from the eye, measured up to 1 m.player_visiblecasts a ray from the eye to the player's chest;occluderis what it hit first when it is false.to_playeris that distance.- An animator's playing state, clip, playhead and parameters are the entity's
animonGET /entities; this reports what that does not.bind_deviationis how far the pose carries any joint from its bind position, in metres, so near zero is a T-pose however the state reads (it measures a point 10 cm from each joint as well as the joint, so a clip that only turns joints counts).poseis up to 32 such points in the rig's own frame, the same joints on every read: diff two reads to see whether the pose moved.feetare, world space and at most four, named infoot_joints: the joints the animator'sfeetnames (feet_from: "animator"), or else the ones the engine found when it built the animator (feet_from: "auto").feet_methodsays how:namesfor joints with a word starting foot, toe or ankle (not IK targets or controls);motionfor the leaf joints a looping clip puts on the ground and lifts off it in turn;bindfor the lowest end of each limb near the ground at bind (within a tenth of the skeleton's height, 3 cm at least);animator; ornone. Each rule keeps the lowest end of each limb, such as toe ends on a humanoid.feet_confidence(0..1) is how sure the engine is, andfeet_reasongives the evidence in one sentence. Below 0.5, name the feet inanimator.feet.feetis empty when fewer than two qualify.feet_unknownlists names inanimator.feetthe rig does not have; with fewer than two found, the engine finds the feet. One foot is planted at any moment of a walk, so the slowest foot's horizontal travel between ticks while the body moves is foot sliding. rig_jointsnames every joint of the rig, in the rig's joint order (parents first): the namesanimator.feet, an IK chain, a look-at and a retarget map are written with.clipsis what each clip the animator can play says about itself, measured once when the animator is built:durationin seconds;loopable, false when its last frame is far from its first (seam_deg, the largest joint turn from the last frame to the first, onseam_joint, more than 2° and more than three timesmax_step_deg, the largest turn between two neighbouring frames), so looped it jumps at the wrap; andground_speed, the speed in m/s at playback rate 1 that the character must move at for its feet not to slide — how fast the planted foot moves back under the body along the way the clip goes (speed_from: "feet"), or failing two feet the root's travel ("root"), or 0 in place ("none"), in the world at the entity's scale.root_travelis the root's planar travel over one cycle, whichrootMotionmoves the character by each loop.in_placeis true for a clip that walks or runs in place: its feet cycle while its root does not travel, the usual way an imported rig authors a gait. Only the stance counts — the samples with a foot on the floor — so a run's flight phase does not pull its pace down. A blend point'satbelongs near its clip'sground_speed, andstrideMatchplays against it.strideslists every point of a stride-matched blend tree:state,clip,at,measured(its clip'sground_speed, over its loop range when it has one),ref_speed(the point'srefSpeed, which replacesmeasured; absent when the scene gives none) andrate, the playback rate at its ownat(atover the speed used, 1 in place). Arateover 3 or under a third is said once on stderr asanimator-stride-rate: the point sits at a speed its clip was not authored for, or its stride was misread and wants arefSpeed. Absent when there are none.loop_seamslists each looping state whose clip does not meet itself (nextis the same clip), or — in a state that playsclips— each hand-over from one clip to the next that jumps; the engine says each once on stderr asanimator-loop-seam. For a clip with a loop range (loopStart/loopEnd) the seam is the range's, from its end back to its start, andloop_rangegives[loopStart, loopEnd].loop_rangeslists every clip a state or a blend point loops part of:state,point(the blend point's index, absent for the state's own clip),clip,loop_startandloop_end, thenloopable,seam_deg,seam_joint,max_step_degandground_speed, each as forclipsbut measured over the range. Absent when there are none.popscounts the ticks on which a joint turned at least 15° and four times as far as on the ticks either side of it: a pose that jumps rather than moves.causeiswrap(the clip looped on that tick),cut(a state change with no blend time),blend(a blend tree's weights moved by more than a quarter in one tick: its parameter stepped) orother;recentholds the latest eight,ticks_agocounts back from this read. Absent when there have been none.animator_missescounts everyctx.animator.setorctx.animator.playthat named a state or clip the animator does not have.playthrows for one;setdoes not, and says so once per entity and name on stderr, so this list is where a misspelt state shows up. Actx.animatorread that found no animator at all is not here: it is ascript_warningevent with codenull-handleonGET /events.camera.armis the follow rig's spring arm, exactly asPOST /captureframes report it (camera.armthere): present only while a third-person or orbit arm is the view.audio.startedcounts voices:ctx.audio.playcues andaudiocomponents that play at load.audio.musiccounts the music layers that started, andunplayablethectx.audio.playcalls whose asset had no sound behind it.- The rest of
audiois the mixer's own account, and it is the same with or without a device: the mixer computes every gain, pan and fade itself and a device only plays what it is handed, so a headless run (ARGUS_NO_AUDIO=1, every agent's) is heard by reading this.outputsays where the mix goes:device(output_notenames it),silent(this world never asked for one: headless, a worker, a preview,--no-audio,ARGUS_NO_AUDIO, a window serving the query API without--audio) orunavailable(asked, and the platform refused;output_notesays why).voicesis every voice sounding now, loudest first: its asset, bus, finalgain(its volume, every bus, the distance rolloff and any fade), whether it loops and isspatial(placed withat), itsdistancefrom the listener,pitchandsecondsplayed;playingcounts them andpeak_voicesis the most there ever were.soundslists every asset that has played since load:plays, the loudest it was mixed at (peak_gain), the most of it at once (max_overlap), the most non-spatial loops of it at once (max_stacked_loops), and its ownrmslevel measured at load (absent for a streamed sound).stackedis every asset with three or more identical non-spatial loops sounding now, with their summedgain: in the centre they add up to one sound up to 20·log10(n) dB louder that masks the rest, and the engine says so once asaudio-loops-stacked.levelestimates the mix from each voice's gain and its sound's level:rmsadds the voices as unrelated signals,peakadds their peaks as if they lined up (the worst case, so over 1 can clip), each now and as the loudest since load (max_rms,max_peak).buseslists every mixer bus in declaration order (master,music,sfxand anymeta.audiodeclares) with its own linearvolume(0..1, ramps included, parents not multiplied in) and whether it ismuted; it is absent when the mixer has no buses.GET /feel?section=audiois all of this on its own;play.queryreaches it from an agent.
GET /profile and the flight recorder
The engine keeps an always-on ring of the last few seconds of timed spans: each sim phase, each script behaviour call, the render stages. These endpoints read it. It lives on the simulation thread, so each answer is made between frames like GET /feel. GET /perf is unchanged. A source with no recorder answers {"enabled": false, "why": ...}, which is also what an engine started with ARGUS_PROFILE=off (or an export built without default features) says.
| Route | Needs | Answers |
|---|---|---|
GET /profile | observe | enabled, ring {capacity_spans, spans_written, span_bytes}, frames_kept, retention_s, spikes_seen, spikes_suppressed, capture_dir, auto_capture, captures |
GET /profile/frames?last= | observe | frames: one row per frame, oldest first (last 1-1200, default 120): frame, cost_ms, ticks [from, to), spans, script_ms, physics_ms, render_ms, top {name, ms} (the biggest sim phase), top_behaviour {name, entity_index, ms} (the biggest behaviour call), spike; and median_ms, spike_line_ms. spike is the detector's own line: over 2.5x the median and over it by 2 ms |
GET /profile/trace?frames= or ?capture=<id> | observe | Chrome trace JSON (opens in ui.perfetto.dev) of the live ring or of a saved capture |
POST /profile/capture {reason?} | capture | saves the ring now: id, path, frames, spans, writing: true. The file is written on a worker thread |
GET /profile/captures | observe | dir, and captures newest first: id, bytes, modified_unix_s, trigger, scene |
GET /profile/capture/{id} | observe | one capture's header, frames, spans, names, first_frame, last_frame, duration_ms, trace |
GET /profile/blame?by=&limit=&metric= | observe | who paid: by is script (per module.method: calls, total_ms, per_tick_ms, mean_us, p95_us, max_us, top_entities, plus api_calls when detail is on), phase (per sim phase), entity (metric=script_ms|triangles|contacts), render (draw groups), physics (bodies, awake_dynamic, contacts, top_entities), asset (bytes per texture and mesh against resident_bytes, with what no row names as unattributed_bytes) or load (each phase of the scene load with its share). limit 1-200. Every answer says how its numbers were obtained (measured, estimate, counted while detail was on) |
POST /profile/detail {on, seconds?} | capture | turns on the counters that cost something in a hot loop: script API calls per behaviour (world.find, world.raycast, world.spawn, entity.despawn, nav.goTo; each behaviour's total of every engine API call is kept apart as totals[].total_api_calls, never as a row beside them) and which entities are in which draw group. Off by default; it turns itself off after seconds of sim time (30 by default). Turning it on starts the counts from zero |
GET /profile/explain?frames=&capture=&finding=&top= | observe | what the window is bound by and a ranked list of fixes. bound_by: side (cpu, gpu or present when vsync paces the frame), the dominant track, share_of_frame, cpu_ms_p50, gpu_ms_p50 (null when the window has no GPU spans: unknown, not zero), frame_cost_ms_p50 and p95. findings (top 5, ?top= up to 20, ?finding=<id> for one in full): rank, id (script-hot, script-many, physics-heavy, draw-bound, render-cpu, gpu-pass-hot, spike, periodic, unexplained), confidence, title, evidence (metric, target, value, unit, method), fix, saving_ms {low, high, capped_by} (null when there is no honest number) and confirm. A saving obeys frame cost = max(CPU, GPU): a CPU fix on a GPU-bound frame is 0, with capped_by saying why. not_bottlenecks says what was ruled out and why. ?capture=<id> explains a saved capture with the context saved in it |
GET /profile/diff?a=&b=&frames=&allow_diverged= | observe | capture a against capture b, a finished re-run (b is its job id: the runs are pooled, the whole-frame path is the sim tick because a headless replay has no render, replay.run_spread_ms is the spread between runs, and a re-run whose counters diverged from the capture is refused with 400 unless allow_diverged=true; findings about render or the GPU are inconclusive there), or against the live ring when b is absent: frame_cost_ms {a_p50, b_p50, delta, significant}, noise_ms, bound_by {a, b}, spans (the significant per-path deltas: a delta is significant only outside the two windows' spread), and findings, each of a's findings with predicted_saving_ms, actual_saving_ms and a verdict: confirmed, partial, not-confirmed, regressed or inconclusive. replay.available is false when b is a capture or the live ring: the second window is whatever ran, not a replay |
POST /profile/rerun {a, runs?} | input | replays capture a's recorded run against the project as it is on disk now: starts runs (1-5, default 3) child argus processes, each run <scene> --replay <input file> --headless --frames <tick>, and answers with a job id at once. Needs a capture with a replay recipe (replay.available), which a run has when it started with ARGUS_PROFILE_DIR=<folder> or --save-replay <file>; 400 says how to get one otherwise. A scene edited since the recording is refused by the replay itself (replay-scene-mismatch, the job failed). Needs the input capability, which a person's human-play session does not grant an API caller |
GET /profile/rerun/{id} | observe | state (running, done, failed with error), runs_done, and when done the fidelity check (ok, mismatches: the entities, bodies and contacts of tick T in the re-run against the capture's) and the captures written |
Captures are .argusprof files in ARGUS_PROFILE_DIR, else <scene base directory>/.argus/profile; the newest 8 are kept. A capture id is the file stem (manual-f318, absolute-f251): letters, digits, -, _ and ., never a path (400); an id that is not there is 404. With the recorder off POST /profile/capture is 503, and so is a capture asked for before any frame has been recorded. Frame numbers are the recorder's own, counting frames it closed, and are not x-argus-frame ticks.
WS /stream
Events as they happen, perf every 60 frames. ws://127.0.0.1:7701/stream.
| Parameter | Shape | Meaning |
|---|---|---|
filter | string | same matching as GET /events?filter=. Applies to event messages only. |
entity | string | only events naming this entity id |
frames | bool | also send a frame message every tick. Off by default: 60 messages a second for something GET /frame answers on demand. |
no_perf | bool | suppress perf messages |
Messages are JSON text, each tagged by type. Read type first and ignore what you do not recognize — new variants are an additive change.
The socket opens with exactly one hello, before anything else:
{ "type": "hello", "version": "1.0.0", "frame": 130, "last_seq": 6 }
last_seq is the seq of the last event published before you attached. Fetch the backlog with GET /events?after_seq=6 and you get every earlier event with no gap and no duplicate, because the socket picks up strictly after it.
{ "type": "event", "event": { "seq": 7, "frame": 131, "time": 2.183, "type": "trigger", "volume": "door-volume", "other": "player", "event": "door-opened", "entering": true } }
{ "type": "perf", "perf": { "frame": 180, "frame_ms": 4.2, "physics_ms": 1.1, "script_ms": 0.7, "render_ms": 2.0, "p95_frame_ms": 5.0, "draw_calls": 12, "instances": 40, "triangles": 480, "heap_mb": 48.0, "entities": 40, "bodies": 10, "agents": 0, "time_scale": 1.0, "paused": false, "nonfinite_scrubbed": 0, "unwired": { "audio_play": 0, "particles_emit": 0, "hud_set": 0, "nav_speed_override": 0, "audio_bus": 0, "audio_snapshot": 0, "audio_music": 0, "body_push": 0, "joint_ignored": 0 } } }
{ "type": "frame", "frame": 131 }
A client that cannot keep up is never allowed to slow the publisher down. Its oldest messages are dropped and it is told how many:
{ "type": "lagged", "dropped": 42 }
Recover by refetching from the last seq you actually received: GET /events?after_seq=….
Latency. A trigger firing on frame N reaches the socket by frame N+2. There is no polling interval and no batching between publish_event and the wire.
GET /session
The identity handshake: which process is serving, which session it is, and what a caller may do there. Open to every caller.
Test workers, the human's Play session and previews serve this API side by side on ports the operating system chose, so a port is not an identity: a process started later on the same port answers it just as well. A client that controls a session learns the session id and the serving instance's nonce from whoever started the process (its ready line, or the workspace service), checks them here, and sends both on every request afterwards.
From the session's holder, with x-argus-session: ses_0123456789abcdef0123456789abcdef and x-argus-nonce: 9f86d081884c7d659a2feaa0c55ad015:
{
"managed": true,
"session_id": "ses_0123456789abcdef0123456789abcdef",
"kind": "test",
"project_id": "6f1c2a9e-3b7d-4c1e-9a2f-5d8e7b6c4a31",
"artifact_id": "art_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"job_id": "job_0123456789abcdef0123456789abcdef",
"capabilities": ["capture", "input", "observe", "snapshot-read", "snapshot-restore", "time"],
"anonymous_capabilities": ["observe"],
"pid": 48213,
"nonce": "9f86d081884c7d659a2feaa0c55ad015",
"endpoints": { "http": "http://127.0.0.1:41873", "ws": "ws://127.0.0.1:41874/stream" },
"devices": { "window": false, "gamepads": false, "audio_output": false },
"version": "1.0.0"
}
| Field | Meaning |
|---|---|
managed | whether this server enforces a session's capabilities |
session_id, kind | the workspace contract's SessionId and SessionKind (preview, human-play, test). Absent on a standalone server. |
project_id, artifact_id, job_id | what the launcher declared. The engine echoes them and computes none. A test session always has job_id. |
capabilities | what a request presenting this server's nonce may do, sorted |
anonymous_capabilities | what a request presenting no identity may do, sorted |
pid | the serving process. Diagnostic only: operating systems reuse process ids. |
nonce | 32 lowercase hex, fresh for every server instance. Echoed only to a request that presented it, so reading this endpoint never hands the nonce to anyone who did not already hold it. |
endpoints | http and ws, the addresses this instance actually bound |
devices | what the process declares it opens: window, gamepads, audio_output. A test worker declares all three false. Declared by the process (Config::with_devices), not observed; absent when it declares nothing |
version | the API version |
A standalone server (argus run --serve with no session) answers managed: false, every capability, and no session fields. Anonymously, with no nonce:
{
"managed": false,
"capabilities": ["camera", "capture", "input", "observe", "reload", "snapshot-read", "snapshot-restore", "stop", "time"],
"anonymous_capabilities": ["camera", "capture", "input", "observe", "reload", "snapshot-read", "snapshot-restore", "stop", "time"],
"pid": 48213,
"endpoints": { "http": "http://127.0.0.1:7700", "ws": "ws://127.0.0.1:7701/stream" },
"version": "1.0.0"
}
A request presenting an identity that is not this server's gets 409 session_mismatch here as everywhere, so a client verifies with one call: a 200 whose session_id and nonce are the ones it holds.
GET / and GET /schema
GET / lists every endpoint and the API version, for an agent that arrives without this document.
GET /schema lists the name of every request and response schema. GET /schema/{name} returns one as JSON Schema draft 2020-12 — EntitySnapshot, EventRecord, ContactRecord, PerfSample, FrameDelta, RaycastRequest, RaycastBatch, HitView, PathRequest, PathResult, NavMeshView, FrameIndex, StateStamp, StateStampView, WorldStateView, RestoreResponse, CaptureRequest, CaptureResponse, FrameOpts, ClipOpts, AtOpts, ViewsOpts, MaterialOpts, TimeRequest, TimeResponse, StepRequest, StepResponse, InputRequest, InputResponse, InputStateView, SceneRequest, SceneResponse, TeleportRequest, TeleportResponse, UiTreeView, UiScreensView, UiStatsView, UiSchemaView, UiActionRequest, UiActionResponse, EnvironmentView, EnvironmentRequest, EnvironmentResponse, FeelView, StreamMessage, SessionHandshake, ErrorBody.
Generate TypeScript types from these rather than transcribing this document by hand.
GET /ui
The live UI tree, in paint order across the whole screen stack.
| Query | Type | Selects |
|---|---|---|
screen | string | only nodes of this screen |
id | string | this node and its subtree. Takes the qualified id this endpoint reports (pause:resume) or the bare author id (resume) when exactly one screen answers to it; an ambiguous bare id selects nothing rather than guessing |
depth | integer | at most this many levels below the root, or below id when one is given. 0 is that node alone |
text | string | only nodes whose resolved text or string key contains this, case-insensitively |
at | stamp | answer at this committed state, or fail. See Exact reads |
Each node carries its stable id, the screen it belongs to, its role and the short tag its derived id uses, its classes and data attributes, its bounds, visible, clipped, focusable, focused, focus_visible, disabled, selected, checked, hovered, pressed, hittable, its paint order z and depth, its parent and children, its resolved text with the meta.strings key it came from, and the appearance-affecting half of its resolved style.
Bounds are reported in both pixel spaces. logical is what layout and pointer coordinates are in; device is what a screenshot is in; layout is the flexbox rectangle before any transform, equal to logical for anything untransformed. Reporting both is deliberate: an agent that inferred the ratio would get it wrong at 1.25 and 1.5, the two common Windows display settings, which are exactly the ratios where it matters.
total counts every node on the stack before the filter, whatever the filter kept, so a caller can tell "nothing matched" from "nothing is on screen". An id nothing answers to is an empty result rather than a 404 — another screen may have held it a tick ago, and a 404 would make a polling agent fall over on a menu that closed.
GET /ui/screens
The screen stack, bottom first. Each entry carries its name, modal, dismissible, click_through, the input context it asks for, whether it wants a cursor, the node holding its focus, how many nodes it holds, and the props it was last pushed with.
Beside the stack: every registered screen whether or not it is up, the wanted_context the topmost input-taking screen asks for, the active_context the game is actually in, whether a cursor should be visible, and the root UI scale.
wanted_context and active_context differ exactly when the scene's meta.input declares no context by that name. The engine leaves the game in the context it had rather than switching into one with nothing bound — which would look to a player exactly like the controls having died — and reporting both is how an agent sees that happened.
GET /ui/stats
What the game UI cost on the latest frame, and, per screen on the stack, how many layout passes it has run and what dirtied layout for the last one. It answers "is the HUD laying out every frame, and why?" with two reads instead of a bisect.
{
"frame": 4210,
"ui_ms": 0.21,
"draw_calls": 7,
"batches": 2,
"quads": 118,
"texts": 31,
"screens": [
{ "name": "hud", "nodes": 64, "layout_passes": 12, "last_layout_cause": "text", "last_layout_node": "hud:place" },
{ "name": "raceinfo", "nodes": 70, "layout_passes": 3, "last_layout_cause": "styles" }
]
}
draw_callsare the UI pass's quad rounds plus its text rounds. A new quad round (batches) starts only where a quad covers a text run below it, so adraw_callsabove2means something is painted over a label.layout_passescounts from the screen's push. Read twice a second apart: a steady screen gives the same number; one climbing by the frame rate is laying out every frame.last_layout_causeisstyles(a full cascade: a class or a sheet changed),binding(a bound value a selector reads),text(a run was re-measured),transition(a size or position is animating),direction, orother.last_layout_nodenames the node when one node caused it.- Not an exact read: like
GET /perfit describes the latest frame, so it takes no?at=. The first read asks the engine to start publishing it; until then nothing is built, and that first read answers zeroes.
With the flight recorder on, the same readout lands in the trace: a ui span over each UI tick (its argument the layout passes it ran) and a ui.layout:<screen>:<cause> marker inside it for each screen that laid out (its argument the node count). With the recorder off none of it is recorded or built.
GET /ui/schema
The artefact that stops agents guessing. One machine-readable document naming everything this engine's UI supports and everything it refuses: every style property with its syntax, initial value and whether it inherits and animates; every unit, function, selector form, pseudo-class, at-rule and easing; every role; every design token with its value; the ctx.ui.set binding vocabulary; the three hit-test modes; the screen-level flags; and every refusal code the UI surface can answer with.
It is generated from the parser's own dispatch tables, not written by hand, so it cannot drift from what the engine actually parses — a property added to the parser and missing here is a failing test (crates/app's every_property_the_parser_applies_is_in_the_document).
Not an exact read, and it takes no ?at=. It describes what the build supports, which is a property of the build and the loaded stylesheets rather than of a committed state.
| Key | What |
|---|---|
format | the style table's own version; goes up when a key changes meaning, not when one is added |
version | the wrapper's version, same rule |
source | engine, mock, or none for a source with no UI runtime |
properties, refusedProperties | what the parser applies, and what it refuses with the nearest alternative |
units, functions, selectors, pseudoClasses, atRules, easings | the rest of the style subset |
roles, sizeClasses, transitionProperties, diagnostics | the runtime's own vocabularies |
tokens | the project's token table, primitive and semantic, at the active theme |
themes, theme, locales, locale | what POST /ui/theme and POST /ui/locale will accept, and what is in force |
bindings | what ctx.ui.set writes to, what each takes, and whether it restyles |
hitModes | auto, always, none, how they inherit, and §2.9's click-through rule |
components | the component library's published manifest; absent when the project ships none |
refusals | every ui.* code this surface raises, with what it means |
A document missing one of format, version, source, properties, selectors, pseudoClasses, roles, tokens, bindings or hitModes is refused as 500 rather than served short. An agent that reads a support table with no properties key concludes the engine supports no properties, and a wrong answer about what is supported is worse than none.
POST /ui/activate, /ui/focus, /ui/input, /ui/theme, /ui/scale, /ui/locale, /ui/push, /ui/pop
Driving the live UI. All six answer the same body, and all six drive the same events a player does — activation is a synthesised pointer press and release at the node's own visible centre, focus-by-id walks Tab, a direction is the pad's. There is no back door: a route that reached past the hit test could report a press on a button no player could click, which is the one failure a UI test surface must not have.
Refusals are 200, not errors
§3: "Refuses, with a reason, when hidden, disabled, clipped or not hit-testable — the refusal is itself the test signal."
"That button is disabled" is a fact about the screen, not a mistake by the caller, so it comes back as ok: false with a coded refused object rather than as an HTTP error — the same shape of answer POST /input gives with its unbound list. A malformed request is still 400: an empty id, both id and direction on /ui/focus, more than 64 events, a non-finite coordinate, a scale outside 0.5..=4.0.
{ "ok": false, "op": "activate", "frame": 412,
"refused": { "code": "ui.activate.disabled",
"message": "pause:resume is disabled, and a disabled node refuses activation",
"next": "clear it with ctx.ui.set(id, \"disabled\", false) and press it again" } }
The body
| Field | What |
|---|---|
ok | false when the UI refused |
op | which route answered |
frame | the tick it was applied on |
refused | { code, message, next } when ok is false |
node, screen | what the call resolved to |
focus | { screen, node?, visible, moved } — where focus is now |
activated, popped | what the call caused, in order |
diagnostics | { code, severity, message, node?, hint? } raised while it was handled |
theme, locale, scale | what is in force, from the route that set it |
stack | the screen stack after the call, bottom first. From /ui/push and /ui/pop |
handled, consumed | events routed, and how many the UI took. An event the UI did not take reaches the game |
POST /ui/activate — { "id": "pause:resume" }
Takes the qualified id or the bare one when exactly one screen answers to it. Refuses with:
| Code | When |
|---|---|
ui.activate.no-such-node | no screen on the stack holds that id, or a bare id is ambiguous |
ui.activate.hidden | the node is under display: none |
ui.activate.disabled | the node is disabled |
ui.activate.clipped | a clipping ancestor removes every pixel of it |
ui.activate.not-hittable | it answers no hit test — on a click-through screen a node must say hit="always" |
ui.activate.not-interactive | neither it nor any ancestor takes activation |
ui.activate.occluded | another node is painted over the point a click would land on; the message names it |
ui.activate.not-on-top | its screen is not the topmost one taking input |
POST /ui/focus — { "id": … } or { "direction": "up"\|"down"\|"left"\|"right" }
Exactly one of the two. By id it walks Tab until focus arrives, bounded by the tree's size, so a focusable node Tab never reaches refuses with ui.focus.unreachable — which is the same signal the critic's unreachable-focus check reports, produced by the same walk. By direction it is one Nav, so gamepad navigation is testable without a gamepad. The answer's focus says what received it.
Also refuses ui.focus.no-such-node and ui.focus.not-focusable.
POST /ui/input — { "events": [ … ] }
Up to 64 events, applied in order on one tick, in UI logical pixels — the space bounds.logical is in, so a caller that read the tree can click a node's centre with no arithmetic.
type | Fields |
|---|---|
pointer_move, pointer_down, pointer_up, click | pos: [x, y], button: "primary"|"secondary"|"middle" |
key | key, repeat. See the names below |
nav | direction |
activate, back | no fields |
text | text — typed characters, into whichever text-field holds focus |
composition | text, cursor?: [from, to], commit? — an IME preedit, or its commit |
click is a down and an up at the same point, which is what a person means by one. Complements POST /input, which sends named game actions; this one sends UI events.
Key names: tab, shift+tab, escape, enter, space, backspace, delete, left, right, up, down, home, end, page-up, page-down, copy, cut, paste, select-all, and scancode:<n> for anything else.
home, end and the two page keys drive a scroll container when no text field has focus, and home and end move a focused field's caret instead — which is what every text editor does with them. An agent scrolling a list without a pointer sends these.
A modifier is a prefix on the name, which is the grammar shift+tab already established: shift+left extends a selection, ctrl+left and alt+left move by word, cmd+a selects all, and ctrl+c, ctrl+x and ctrl+v are the clipboard. There is no separate modifier field, so nothing a caller already writes changed shape. ctrl and cmd both mean the platform accelerator, so one body works on every platform.
Typing needs a field focused. A text or composition event with none is refused with ui.input.no-field and a next saying to POST /ui/focus first; a key with none is not, because Escape and Tab belong to the screen stack and the focus ring whatever holds focus. handled counts the events the caller sent, not the runtime events each becomes.
submitted lists the fields that submitted while the call was handled, beside activated: Enter in a single-line field submits rather than activating. GET /ui then reports the value on the node's field view, which is how an agent types and reads back.
POST /ui/theme, /ui/scale, /ui/locale
The screenshot matrix. theme takes a name the project's stylesheets declare (ui.theme.unknown otherwise, listing the ones there are). scale takes the player's UI-scale multiplier in 0.5..=4.0 (ui.scale.out-of-range). locale takes a locale the scene's string table resolves (ui.locale.unknown).
A locale is a prefix on a string key. meta.strings["menu.resume"] is the base locale's and meta.strings["de.menu.resume"] is German's; resolution tries the prefixed key and falls back to the unprefixed one, so a project with one language writes what it writes today and a half-translated one renders the half it has. GET /ui/schema lists the locales the table actually holds. The active theme and locale are written onto every screen root as data-theme and data-locale, so a stylesheet can select on either. A node whose text a behaviour set through ctx.ui.set keeps that value across a switch; the localisable surface is the one the author wrote as a key, and GET /ui keeps reporting that key after the switch so the critic can name the string that overflowed rather than the English it happened to render as.
Full localisation — font fallback chains, plural forms, RTL — is U4.
POST /ui/push — { "screen": …, "props"?: …, "replace"?: false } — and POST /ui/pop
The route form of ctx.ui.push, ctx.ui.replace and ctx.ui.pop, refusing with the same codes (ui.screen.unknown, ui.screen.duplicate, ui.stack.empty). Without them an agent could read the stack and press what was on it but never open anything, so the first thing anyone does with a new screen — open it and look at it — needed a behaviour written first. /ui/pop takes no body. Both answer with the resulting stack.
Managed sessions
A server started for one workspace session (argus_query::serve_managed) enforces that session here, on every route, before any handler runs. A standalone server (argus run --serve with no session, argus serve-mock) behaves as it always has, except that it too refuses an identity that is not its own.
These checks stop accidental cross-session control: a stale port, a replaced process, an agent's client pointed at the human's session. They are not a security boundary against code running as the same user.
Capabilities
Each route needs one capability, named exactly as in the workspace contract (docs/WORKSPACE-CONTRACT.md §7):
| Capability | Routes |
|---|---|
observe | GET /, /schema, /schema/{name}, /entities, /entity/{id}, /events, /contacts, /perf, /trace, /navmesh, /frame, /input, /state, /ui, /ui/screens, /ui/schema, /ui/stats, /environment, /feel, /save, /roads, /npc/log, /npc/pending, /camera/project, /camera/ray, /profile, /profile/frames, /profile/trace, /profile/captures, /profile/capture/{id}, /profile/blame, /profile/explain, /profile/diff, /profile/rerun/{id}; POST /raycast, /path; WS /stream |
capture | POST /capture, GET /frame.png, GET /profile/image, POST /ui/theme, /ui/scale, /ui/locale, /ui/viewport, /profile/capture, /profile/detail |
snapshot-read | GET /snapshot |
snapshot-restore | POST /snapshot, POST /entity/{id}/teleport, POST /save |
input | POST /input, /ui/activate, /ui/focus, /ui/input, /ui/push, /ui/pop, /profile/rerun |
time | POST /time (pause, step, scale); POST /step as well, which also needs input |
reload | POST /environment, POST /scene — changing what the running scene is made of without restarting it |
camera, stop | no endpoint in this version; the managed runtime enforces them itself |
GET /session needs none. HEAD needs what GET needs.
The rules
On every request, in order:
- An identity the request presents must be this server's. An
x-argus-sessionnaming another session (or any session, on a standalone server), or anx-argus-noncethis server instance did not issue, is409 session_mismatch, reads included. A header sent twice never matches. - A standalone server allows everything else.
- A managed server, nonce presented: the route's capability must be in
capabilities, or403 capability_denied. - A managed server, no nonce: the route's capability must be in
anonymous_capabilities, or428 session_required.
A session id without the nonce counts as no identity. The session id is public, on GET /session; the nonce is known only to the launcher and whoever it hands the session to.
WS /stream also reads ?session= and ?nonce=, because a browser websocket cannot set headers. A header beats the query parameter of the same name, and a parameter sent twice never matches, whichever value comes first. A refused upgrade is the same error body, before any socket opens.
A nonce that is not this server's, whatever the route:
{ "error": "session_mismatch", "mismatch": "nonce", "message": "the x-argus-nonce on this request was not issued by this server: the process it came from has been replaced, or the request reached a different server. Resolve the session again and repeat the handshake" }
The session's own holder, asking for a control the session does not grant:
{ "error": "capability_denied", "capability": "time", "message": "session ses_0123456789abcdef0123456789abcdef (preview) does not grant `time`, which POST /time needs; it grants input, observe" }
A plain client, reaching for a managed session's controls:
{ "error": "session_required", "capability": "input", "message": "this server is managed session ses_0123456789abcdef0123456789abcdef (human-play); POST /input needs `input`, which a request without the session's x-argus-session and x-argus-nonce headers does not hold (it holds observe). Connect through a session-aware client" }
Refusals and request bodies
A refused request is answered only once its body has arrived, so the refusal reaches the client rather than being lost to a connection reset. The server reads and discards the body first, within two bounds:
- Size. The route's own body limit: 64 MiB for
POST /snapshot, 2 MiB for every other route. A request whoseContent-Lengthdeclares more is answered at once, and nothing is read. - Time. Two seconds for the whole body. A client that stops sending partway through gets its refusal two seconds later.
Past either bound the refusal is still sent, and the connection then closes with the rest of the body unread, as it does when an endpoint refuses an over-limit body. A client sending Expect: 100-continue gets 100 Continue, sends its body, then gets the refusal. The same applies to every other answer no endpoint handler gives: GET /session, and a 404 or 405 for a route this API does not serve.
The cost: a caller the session refuses can still upload up to the route's limit before it hears so.
Launch flags
A worker is started for a session with these argus run flags. Ids use the workspace contract's formats, and serve_managed refuses one that does not parse. With no --session-id the engine serves standalone.
| Flag | Meaning |
|---|---|
--session-id ses_… | the session this process serves |
--session-kind preview|human-play|test | its kind |
--job-id job_… | the owning job; required for test |
--project-id <uuid> | the project |
--artifact-id art_… | the build artifact this process runs, echoed for the launcher to check |
--capabilities a,b,c | what the holder of the nonce may do |
--anonymous-capabilities a,b | what a caller with no identity may do; observe when absent, nothing when empty |
--seed <u64> | the seed the session's simulation starts from; not part of the handshake |
--port 0 --ws-port 0 | bind ephemeral ports and report them on the ready line |
The nonce is never a flag: the engine generates it, so a replacement process can never reuse one.
What the runtime declares
A managed runtime caps the capabilities it was launched with by the session's kind. That covers argus run with the launch flags (built with the app's managed feature), an editor Play session, and an editor test worker:
| Kind | capabilities (holder) | anonymous_capabilities |
|---|---|---|
preview | as launched, capped at observe, capture, snapshot-read | the same cap |
human-play | as launched, capped at observe, capture, snapshot-read | the same cap |
test | as launched, plus observe | observe |
So no API caller can drive the human's session. With the session's identity, POST /input, POST /time and POST /snapshot are 403 capability_denied; without it they are 428 session_required. The human's pause, step, stop and reload are in-process controls in the editor, not API capabilities. A runtime declares the devices it opens in devices, and a test worker declares none. It prints its ready line once both listeners are bound and the first snapshot is published. docs/EDITOR.md covers the editor's side.
Connecting to a session
- Resolve the session by id through whoever owns it: the workspace service, or the launcher that read the ready line. Take its
endpoints.httpand its nonce. Never guess or reuse a port. GET /sessionwith both headers. Continue only on a200carrying thesession_idandnonceyou hold, and theartifact_idorpidyou expect when you know them.- Send both headers on every later request.
409 session_mismatchmeans the address now answers for something else. Resolve the session again and repeat step 2. Never re-send the same request with an identity copied from the server.
packages/observe's connectSession does exactly this, and never sends a request other than the handshake before step 2 passes.
The ready line
A serving process, managed or standalone, prints exactly one line on stdout, and flushes it, once both listeners are bound:
argus-ready {"managed":true,"session_id":"ses_0123456789abcdef0123456789abcdef","kind":"test",...,"nonce":"9f86d081884c7d659a2feaa0c55ad015","endpoints":{"http":"http://127.0.0.1:41873","ws":"ws://127.0.0.1:41874/stream"},"version":"1.0.0"}
The JSON is a SessionHandshake, nonce included. The process that started the server reads it from the pipe. That is how a launcher learns the ports a worker bound with --port 0 --ws-port 0, with no pick-a-free-port-then-close race, and the nonce it verifies with.
Compatibility
- Additive under the versioning rule: one endpoint, two request headers, three error codes with two optional
ErrorBodyfields, one schema (with an optionaldevicesobject).API_VERSIONstays1.0.0. - A client that sends no identity to a standalone server sees no change at all.
- A plain client pointed at a managed session can still observe it, with the default
anonymous_capabilitiesof["observe"], and gets428 session_requiredrather than silence for anything else. 409,403and428occur only when a request presents an identity or the server is managed.
Implementers
let session = ManagedSession::new(session_id, SessionKind::Test, [Capability::Observe, Capability::Input, Capability::Time])
.with_job(job_id)
.with_artifact(artifact_id);
let config = Config::on_ports(0, 0)
.with_devices(SessionDevices { window: false, gamepads: false, audio_output: false });
let server = argus_query::serve_managed(source, config, session)?;
println!("{}", server.ready_line());
serve_managed refuses a declaration the workspace contract would refuse: a malformed id, a test session with no job, a capability listed twice. Controls with no raw endpoint check server.session(), as in session.allows(Capability::Reload). argus_query::serve_mock_session is the mock behind the same guard, and cargo run -p argus-query --example session_mock runs it as a process.
The QuerySource contract
This half is for crates/app, not for API clients.
crates/query knows nothing about World, EntityStore, Rapier or Recast. It talks to one trait, and the app implements it — or, far more likely, uses the implementation this crate already ships.
pub trait QuerySource: Send + Sync + 'static {
// Served from the published snapshot. Must not block the simulation.
fn frame(&self) -> u64;
fn entities(&self, sel: &EntitySelector) -> Vec<EntitySnapshot>;
fn entity(&self, id: &str) -> Option<EntitySnapshot>;
fn events(&self, filter: &EventFilter) -> Vec<EventRecord>;
fn contacts(&self) -> Vec<ContactRecord>;
fn perf(&self) -> PerfSample;
fn trace(&self, q: &TraceQuery) -> TracePage;
fn subscribe(&self) -> StreamSubscription;
fn frame_png(&self) -> Option<Arc<Vec<u8>>> { None }
// Answered by the simulation thread. Each defaults to QueryError::Unsupported → 501.
fn navmesh(&self) -> Result<NavMeshView, QueryError>;
fn world_state(&self) -> Result<WorldStateView, QueryError>;
fn restore(&self, state: WorldStateView) -> Result<RestoreResponse, QueryError>;
fn raycast(&self, req: &RaycastRequest) -> Result<Option<HitView>, QueryError>;
fn raycast_batch(&self, reqs: &[RaycastRequest]) -> Result<Vec<Option<HitView>>, QueryError>; // default: one raycast per ray
fn path(&self, req: &PathRequest) -> Result<PathResult, QueryError>;
fn capture(&self, req: &CaptureRequest) -> Result<CaptureResponse, QueryError>;
fn set_time(&self, req: &TimeRequest) -> Result<TimeResponse, QueryError>;
fn send_input(&self, req: &InputRequest) -> Result<InputResponse, QueryError>;
}
Return QueryError::no_such_entity(id) from capture and send_input when the world has no such entity, rather than mapping the failure to QueryError::Internal. Internal is a 500 and tells the caller the engine broke; this is a 404 and tells them they asked for the wrong thing. The server already refuses an id missing from the published snapshot, so the trait only has to cover an entity despawned between that check and the command being drained.
The rule
The simulation never waits on the query server, and the query server never touches the world.
Two mechanisms carry everything across.
A published snapshot, for reads. Once per rendered frame the app fills the entity buffer the publisher lends it and publishes a WorldSnapshot. On the 10,000-entity fixture that is zero allocations and 0.09 ms, against a simulation frame of 0.57 ms.
Publish per rendered frame, not per tick. At a time scale of 10 the simulation runs ten fixed ticks per frame, and publishing each would do ten times the work for a picture nobody looked at. A snapshot at most one rendered frame old is no staler than the up-to-one-tick latency the command queue already costs, and x-argus-frame says exactly which tick it describes.
Events and the trace ring are the exception and stay per tick. An event published per frame is an event dropped, and a trace delta is per tick by definition. Both have their own path and neither goes through the snapshot.
A command queue, for everything else. POST /raycast, /path, /capture, /time, /input and GET /snapshot need the live world. The handler pushes a Command carrying a one-shot reply channel and waits on a blocking thread; the app drains the queue once per tick and replies. A live op therefore costs up to one tick of latency, 16.7 ms worst case at 60 Hz, which is inside the 30 ms budget and is the price of never blocking the simulation. If nothing drains the queue the caller gets 503 after Config::command_timeout rather than hanging.
Identity and state
Publishing is cheap because EntitySnapshot's fields split in two.
Identity is id, name, tags, components, parent and static. It changes only when the scene's structure does. Every one is a shared handle: IStr wraps Arc<str>, tags and components wrap Arc<[T]>. Build them when a scene loads and keep them beside your ECS columns.
State is position, rotation, scale, velocity, script, anim and frame. It changes every frame, and every one is Copy or an Arc you swap.
So a frame's publish overwrites state and never touches identity. Two things make that add up to zero allocations:
IStr::from(&str)interns. A table lookup under a read lock, no allocation on a hit. Use it for the bounded vocabularies that come out of scene data: ids, names, tags, component type names.IStr::from(String)deliberately does not intern — it takes the allocation you already made — so arbitrary runtime text cannot grow the table without bound.- The publisher lends you the buffer.
entity_bufferhands back the one from an earlier publish, still holding its contents, uniquely owned. You overwrite and hand it back.
Measured on the 10,000-entity fixture, per frame:
| shape | allocations | bytes |
|---|---|---|
| lend the buffer, overwrite state | 0 | 0 |
| rebuild the table from cached handles | 2 | 4,800,016 |
rebuild from String columns, uninterned | 150,002 | 21,489,936 |
Wiring it up
let (publisher, mut commands, source) = argus_query::channel();
let server = argus_query::serve(Arc::new(source), Config::default())?;
loop {
let ticks = world.advance(real_dt); // one or more fixed ticks
// Per tick: events and the trace ring. Neither may be skipped.
for _ in 0..ticks {
for ev in world.take_events() {
publisher.publish_event(world.frame(), world.time(), to_event_kind(ev));
}
let trace = publisher.trace();
let deltas = if trace.is_keyframe(world.frame()) { all_movable(&world) } else { dirty(&world) };
trace.record_frame(world.frame(), deltas, std::iter::empty());
}
world.render(world.alpha());
// Answer whatever the agent asked for while the frame was running.
commands.drain(|cmd| match cmd {
Command::Raycast(rays, reply) => reply.respond(rays.iter().map(|r| cast(&world, r)).collect()),
Command::Time(req, reply) => reply.respond(apply_time(&mut world, &req)),
other => other.reject_unsupported(), // 501, not a timeout
});
// Per rendered frame: the snapshot. Zero allocations in the steady state.
let mut buf = publisher.entity_buffer(world.len(), || build_table(&world));
for (i, e) in buf.entities_mut().iter_mut().enumerate() {
e.set_state(world.frame(), position(&world, i), rotation(&world, i), velocity(&world, i));
}
publisher.publish(WorldSnapshot {
frame: world.frame(),
time: world.time(),
perf: perf_window.sample(),
entities: buf.into_entities(),
..publisher.latest() // contacts, navmesh, png reused: one refcount bump each
});
if world.frame() % 60 == 0 {
publisher.publish_perf(perf_window.sample());
}
}
build_table runs only when a buffer has to be created: the first publish, a change in entity count, or every pooled buffer still held by an in-flight reader. EntityBuffer::was_rebuilt says which happened, so an app that wants to know it is allocating every frame can count it. Build it from your cached identity handles, not from your String columns.
Command::reject_unsupported() is the important one during the build: it answers a command for a subsystem that does not exist yet with 501 naming the endpoint, immediately, instead of leaving the caller to wait out the timeout.
Developing against it with no engine
MockSource implements the whole trait over a generated world. It is in the default build on purpose:
let (server, source) = argus_query::serve_mock(10_000)?;
Ten thousand entities with tags, components, velocities, script state, animator state and a navmesh, a deterministic generator, working /raycast and /path, real PNG bytes from /capture, and a /stream that delivers. packages/observe and the critic can be written and tested against it with no renderer, no GPU and no scene load.
What this API is not
These are primitives. /contacts answers "what is touching at frame 412". It does not answer "the player fell through the floor" — that is composed in packages/observe, in TypeScript, on top of these calls, and it iterates in a second because nothing here has to recompile for it.