Protocol reference for window.agentAPI — the interface that lets a program fly the
carpet. Everything an agent can know or do goes through this one object; no knowledge
of the game's source is required. Reference bot: src/agents/SimpleBot.js.
Skybloom treats agents as first-class players, not as scripted NPCs or debug tools. The design goal is a game where a human and a bot line up on the same seeded gate course, fly the same carpet under the same rules, and post times that are honestly comparable. Fair competition is the starting constraint, not a feature bolted on later — which is why the API is organized around four parities:
- Information parity.
observe()returns approximately what the human screen shows and nothing more: only the next 3 gates (the HUD shows the same), only mana nodes and landmarks inside the visibility fog radius, and 5 coarse terrain probes along your current heading. There is no full course dump, no world map, no positions of things a human couldn't see. - Action parity.
act()writes the same virtual control axes that keyboard and touch input write: throttle, brake, turn, climb, spell selection, cast. Those axes feed the same input ramps and the same physics integration as human input. An agent cannot teleport, set its velocity, or skip the carpet's inertia. - Tempo parity. Agent actions latch at a fixed action tick (10 Hz by default) and observations refresh at 20 Hz, optionally delayed by a configurable latency. Humans get smooth 60 fps visuals but human reaction times; the latency knob lets an agent run under comparable reaction constraints.
- Embodiment parity. Agents fly the same carpet with the same caps —
maxSpeed,turnRate,ceiling— and the same spell costs and cooldowns. There is no agent-only vehicle and no hidden stat differences.
Two recommended configurations, set via setConfig:
| Profile | actionHz |
observationLatencyMs |
Intended use |
|---|---|---|---|
| strict | 10 | 150 | Human-comparable runs. Models human reaction delay; use this when posting times you want taken seriously. |
| open | 20 | 0 | Research and maximum machine performance. Times are interesting but not human-comparable. |
agentAPI.setConfig({ actionHz: 10, observationLatencyMs: 150 }); // strict
agentAPI.setConfig({ actionHz: 20, observationLatencyMs: 0 }); // open
agentAPI.getConfig(); // read back current valuesactionHz accepts 1–20 and observationLatencyMs accepts 0–1000; values outside
those ranges are clamped.
Every replay records who was flying: pilot: "human" | "agent" | "mixed"
(mixed means control changed hands during the run). So leaderboard entries are
honest about their origin even when humans and agents share a session.
Trust model: all of this is enforced client-side and cooperatively. A modified client can lie about its config or its pilot tag. That is fine for local play, self-experiments, and honest comparisons between friends. Ranked competition requires replay re-simulation on a trusted machine — see Limits and roadmap.
With the game running, paste into the DevTools console:
const { SimpleBot } = await import('/src/agents/SimpleBot.js');
const bot = new SimpleBot(); // wraps window.agentAPI
bot.start(); // starts a seeded race and flies it
// ...watch it fly...
bot.stop(); // control snaps back to you, mid-air| Method | Purpose |
|---|---|
observe() |
Latest observation snapshot (see Observation reference) |
act(action) |
Latch control inputs for the next action tick (see Action reference) |
release() |
Drop virtual control back to the human instantly |
startRace(courseSeed?) |
Load and start a race; omit the seed for a random course |
abortRace() |
Abandon the current run |
listReplays() |
All stored replays |
getLatestReplay() |
Most recent stored replay |
getBestReplay(seed) |
Fastest stored replay for a course seed |
loadGhost(replay) / clearGhost() |
Show / hide a translucent ghost flying a replay |
exportResult(replay?) |
Benchmark JSON for the latest or supplied replay |
getConfig() / setConfig({...}) |
Read / set actionHz and observationLatencyMs |
window.agentAPI is frozen (Object.freeze) — you cannot monkey-patch it, and you
should not need to.
observe() can be called at any time and returns the most recent snapshot. The
snapshot refreshes at observationHz (20 Hz) and, if observationLatencyMs is set,
describes the world as it was that many milliseconds ago. Polling faster than 20 Hz
returns repeats; it does not reveal anything new.
A representative snapshot (values illustrative):
{
version: "1.0.0", // API contract version — check it, log it with results
t: 412.85, // game-clock time of this snapshot, seconds
tick: 8257, // monotonic observation counter
self: {
pos: [120.4, 86.2, -310.7],
vel: [38.1, 1.2, -11.5],
speed: 39.8, heading: 1.32, pitch: -0.05, bank: 0.21,
throttle: 1.0, altitude: 86.2, altitudeAboveTerrain: 41.7,
mana: 35, totalMana: 410, currentSpell: 0, effects: []
},
limits: {
maxSpeed: 60, boostedMaxSpeed: 84, ceiling: 500,
turnRate: 1.6, actionHz: 10, observationHz: 20
},
terrain: {
below: 44.5,
ahead: [
{ dist: 100, height: 42.1 }, { dist: 300, height: 55.0 },
{ dist: 500, height: 71.3 }, { dist: 700, height: 64.9 },
{ dist: 900, height: 48.2 }
]
},
race: {
state: "racing", courseSeed: 1337, gateIndex: 4, gateCount: 12,
elapsedMs: 31250, splits: [6210, 13480, 21900, 29800],
nextGates: [
{ pos: [260.0, 92.0, -355.0], dist: 142.5, bearing: -0.18, elevation: 5.8, radius: 18 },
{ pos: [410.0, 70.0, -300.0], dist: 295.4, bearing: 0.31, elevation: -16.2, radius: 18 },
{ pos: [520.0, 65.0, -180.0], dist: 421.0, bearing: 0.64, elevation: -21.2, radius: 18 }
]
},
nearby: {
manaNodes: [ { pos: [180.0, 80.0, -290.0], dist: 63.0, bearing: 0.45 } ],
landmarks: [ { type: "ruins", pos: [340.0, 60.0, -510.0], dist: 297.0, bearing: -0.72 } ]
}
}- World units for all positions, distances, heights, radii. The y axis is up.
- Radians for all angles; units/second for all speeds.
bearingis the signed angle from your current heading to the target direction, normalized to [-π, π]. Negative = target is to your LEFT. A bot can steer withturn = k * bearingand never think about absolute heading:
0 (dead ahead)
|
-pi/2 ------ you ------ +pi/2
(hard left) | (hard right)
|
-pi / +pi (directly behind)
bearing < 0 → target left → steer with turn < 0
bearing > 0 → target right → steer with turn > 0
| Field | Type | Description |
|---|---|---|
version |
string | Contract version of the observation/action schema |
t |
number | Game-clock timestamp of this snapshot, seconds |
tick |
int | Monotonic observation counter (increments at observationHz) |
| Field | Type / units | Description |
|---|---|---|
pos |
[x, y, z], world units |
Carpet position; pos[1] is height (y-up) |
vel |
[x, y, z], units/s |
Velocity vector |
speed |
units/s | Magnitude of vel |
heading |
radians | Absolute yaw in the world frame. Prefer the relative bearing fields — they are self-contained |
pitch |
radians | Nose attitude; positive = nose down (diving), negative = nose up — this engine's Euler convention is inverted from aviation |
bank |
radians | Roll; positive = banked right |
throttle |
0..1 | Throttle currently applied (yours or the human's) |
altitude |
world units | World-frame height; equals pos[1] |
altitudeAboveTerrain |
world units | altitude - terrain.below; your real safety margin |
mana |
number | Spendable mana (spells draw from this) |
totalMana |
number | Lifetime mana collected (score, not spendable) |
currentSpell |
int 0–3 | Selected spell slot |
effects |
string[] | Active status effects, e.g. ["boost"]; empty when none |
| Field | Type / units | Description |
|---|---|---|
maxSpeed |
units/s | Level-flight speed cap |
boostedMaxSpeed |
units/s | Absolute cap with boost effects or a full dive (≈ 1.4 × maxSpeed) |
ceiling |
world units | Maximum altitude; climb input is ineffective above it |
turnRate |
radians/s | Maximum yaw rate at full turn input |
actionHz |
Hz | Current action latch rate (see Fairness profiles) |
observationHz |
Hz | Observation refresh rate |
| Field | Type / units | Description |
|---|---|---|
below |
world units | Terrain surface height directly under the carpet |
ahead |
array of 5 { dist, height } |
Probes at dist 100, 300, 500, 700, 900 along your current heading; height is the terrain surface height there, in the same frame as self.altitude — so height > altitude means that ground is above you |
| Field | Type / units | Description |
|---|---|---|
state |
string | Race lifecycle. "idle" = loaded but not started; "finished" = run complete. Treat any other value (run-up, racing, ...) as in-progress — new intermediate states may appear |
courseSeed |
number | Seed that generated this course; same seed = same gates, for everyone |
gateIndex |
int | Index of the next gate to fly (0-based) |
gateCount |
int | Total gates in the course |
elapsedMs |
ms | Time since crossing the start ring; frozen at the finish |
splits |
ms[] | elapsedMs recorded at each gate already crossed |
nextGates |
array ≤ 3 | The upcoming gates, nearest first — never more than 3 (information parity with the HUD) |
| Field | Type / units | Description |
|---|---|---|
pos |
[x, y, z], world units |
Gate center |
dist |
world units | Straight-line distance from you to the gate center |
bearing |
radians [-π, π] | Direction to the gate relative to your heading; negative = left |
elevation |
world units | Vertical offset, gate center minus your altitude; positive = gate is above you |
radius |
world units | Pass within this distance of the center to clear the gate |
| Field | Type | Description |
|---|---|---|
manaNodes |
array of { pos, dist, bearing } |
Collectible mana within visibility range |
landmarks |
array of { type, pos, dist, bearing } |
Points of interest within visibility range; type is a string such as "ruins" |
agentAPI.act({
throttle: 1, // 0..1
brake: 0, // 0..1
turn: -0.4, // -1..1, positive = right
climb: 0.25, // -1..1, positive = up
selectSpell: 2, // 0..3, optional
cast: false // boolean, optional
});| Field | Range | Description |
|---|---|---|
throttle |
0..1 | Forward acceleration request, through the same ramp as the human throttle key |
brake |
0..1 | Deceleration request; may be combined with throttle (e.g. dragging the speed down into a corner) |
turn |
-1..1 | Yaw input; positive = turn right, scaled by limits.turnRate |
climb |
-1..1 | Vertical input; positive = up |
selectSpell |
0..3 (int) | Switch the active spell slot |
cast |
boolean | Request a cast of the current spell this tick |
Semantics:
- Clamping. Out-of-range values are clamped, never rejected.
act()does not throw for bad numbers. - The action tick. Inputs do not apply instantly: they latch at the next action
tick (
actionHz, 10 Hz by default — every 100 ms). Callingact()faster than the tick rate is allowed but pointless: last write wins within a tick window. - Hold-last. Latched axes hold between ticks and across ticks until you change
them. If you send
{ throttle: 1 }once and go silent, the carpet keeps flying at full throttle. Always send a complete action object every tick — relying on stale axes you set seconds ago is how bots fly into mountains. - Spell parity.
cast: trueis a request, not a command. The game enforces mana costs and cooldowns exactly as it does for a human pilot; a cast you cannot afford simply does nothing. Sendcast: trueonly on the tick you want to cast, then return tofalse. - Coexistence with the human. While an agent holds virtual control, human input is
superseded.
release()returns control instantly; the human can also reclaim it from the game's side. If both happen in one run, the replay is taggedmixed.
Courses are generated deterministically from courseSeed. The same seed yields the
same gate layout for every player, human or agent. Comparisons are only meaningful
on the same seed — always record the seed alongside a time.
agentAPI.startRace(1337); // everyone who races seed 1337 flies this exact course
agentAPI.startRace(); // random seed; read it back from observe().race.courseSeed
agentAPI.abortRace(); // abandon the run (no replay is saved for aborted runs)- Run-up.
startRace(seed)loads the course. You get a free run-up to the start ring — position, line up, build speed. The clock is not running yet (elapsedMsis 0).nextGates[0]is always the next ring you must fly through, including the start ring during the run-up. - Start ring. Crossing it starts the timer.
- Splits. Each gate crossed appends your
elapsedMsat that moment torace.splits. Pass withinradiusof the gate's center; gates must be taken in order (gateIndextells you which is next). - Finish. Crossing the final gate freezes
elapsedMs, setsrace.state = "finished", and saves a replay.
const all = agentAPI.listReplays(); // every stored replay
const best = agentAPI.getBestReplay(1337); // fastest run on seed 1337, or nullA replay is a recording of a run (not a verified re-simulation — see Limits and roadmap). New runs use Replay v2 groundwork: ghost path samples are still present, and Agent API action metadata is recorded when available.
{
"version": 2,
"schemaVersion": 2,
"courseSeed": 1337,
"worldSeed": 0,
"pilot": "agent",
"finalTimeMs": 84210,
"splits": [6210, 13480, 21900, 29800, 38100, 47000, 55900, 63200, 70800, 77400, 81000, 84210],
"date": "2026-06-12T09:30:00Z",
"samples": [
[0, 12.1, 86.0, -240.3, 1.57],
[100, 15.9, 86.1, -239.0, 1.55]
],
"actionLog": [
{ "tMs": 100, "source": "agentAPI", "action": { "throttle": 1, "turn": 0.2 } }
],
"fairnessConfig": { "profile": "strict", "actionHz": 10, "observationLatencyMs": 150 },
"verificationStatus": "action-log-present"
}samples are compact ghost path samples: [tMs, x, y, z, heading].
verificationStatus is currently one of:
ghost-only: path replay exists, no action stream.action-log-present: path replay plus Agent API action metadata.verified: reserved for future deterministic re-simulation; current builds do not emit it.
Export the latest result:
const result = agentAPI.exportResult();
console.log(JSON.stringify(result, null, 2));A ghost renders a translucent carpet flying a replay in real time alongside you — the classic time-trial pace car:
const best = agentAPI.getBestReplay(1337);
if (best) agentAPI.loadGhost(best); // race your (or your bot's) best self
agentAPI.startRace(1337);
agentAPI.clearGhost(); // remove itThe honest protocol:
- Pick a seed. Tell everyone the seed.
- Human races it (no agent
act()calls during the run → replay taggedhuman). - Agent races it under the strict profile (no human input → tagged
agent). - Compare
timeMs. Load each other's replays as ghosts for the rematch.
Runs where control changed hands are tagged mixed and should not be compared
against pure runs.
For drivers that cannot call window.agentAPI directly — the game in an iframe, or
an external process speaking through a thin web page — the same API is exposed over
postMessage:
| Direction | Message | Effect / reply |
|---|---|---|
| you → game | { type: "agentapi:observe" } |
Game replies { type: "agentapi:observation", payload: <observation> } |
| you → game | { type: "agentapi:act", payload: <action> } |
Same as act(payload) |
| you → game | { type: "agentapi:start-race", seed: <number> } |
Same as startRace(seed) |
Fairness configuration applies unchanged; the bridge only adds its own messaging latency on top. A minimal external driver — a page embedding the game in an iframe:
<iframe id="game" src="http://localhost:5173/"></iframe>
<script>
const game = document.getElementById('game').contentWindow;
const clamp = (v) => Math.max(-1, Math.min(1, v));
window.addEventListener('message', (e) => {
if (e.data?.type !== 'agentapi:observation') return;
const gate = e.data.payload.race?.nextGates?.[0];
if (!gate) return;
game.postMessage({ type: 'agentapi:act', payload: {
throttle: 1, brake: 0,
turn: clamp(gate.bearing * 2.2),
climb: clamp(gate.elevation / 40),
} }, '*');
});
setTimeout(() => game.postMessage({ type: 'agentapi:start-race', seed: 42 }, '*'), 2000);
setInterval(() => game.postMessage({ type: 'agentapi:observe' }, '*'), 100);
</script>src/agents/SimpleBot.js is deliberately small. Three parts:
- A loop.
setIntervalat 100 ms — matching the 10 Hz action tick, so every decision lands in its own tick window. - Lifecycle. No race →
startRace()once.state === "finished"→release()(and stop, if configured to run once). Anything else → fly. - A pure control law,
steer(observation) → action:
| Output | Law | Why |
|---|---|---|
turn |
clamp(bearing × 2.2, -1, 1) |
Proportional steering on the next gate |
climb |
clamp(elevation / 40, -1, 1) |
Full climb/dive when 40+ units off the gate's height |
throttle |
1 |
Always; the carpet's cap does the moderating |
brake |
0.5 if ` |
bearing |
| terrain override | any ahead probe with dist ≤ 300 and height > altitude − 12 → climb = 1 |
Not hitting mountains beats hitting gates |
| ceiling clamp | altitude > ceiling − 50 → climb = min(climb, 0) |
Climb input is wasted above the ceiling |
Pure means: observation in, action out, no hidden state. You can unit-test it with a hand-written observation object and no game running.
- Look ahead to
nextGates[1]for racing lines. SimpleBot aims at gate centers, so it exits every gate pointed the wrong way for the next one. Blend the two bearings as you approach — e.g.aim = bearing0 + w(dist0) * (bearing1 - bearing0)withwrising asdist0shrinks — and you will cut inside, carrying speed through the exit. - PD control on bearing. Pure proportional steering oscillates at high speed:
the bot crosses the line to the gate, overshoots, recrosses. Add a derivative
term —
turn = Kp * bearing + Kd * (bearing - prevBearing) / dt— to damp the weave. With damping you can raiseKpand corner harder. (StoreprevBearingbetween ticks; this is the one place your bot wants memory.) - Exploit dive energy on descending gates. The carpet's speed cap is
pitch-aware: nose-down flight stretches the effective cap by up to about 1.4 ×
(boost and dive stacking is bounded by an absolute 420 u/s ceiling)
maxSpeed(towardlimits.boostedMaxSpeed). WhennextGates[0].elevationis strongly negative, dive steeper than the direct line, bank the surplus speed, and shallow out through the gate. WatchaltitudeAboveTerrainwhile you do it — the terrain override exists for a reason.
External agents (a local SLM, a cloud-API middleman, any process) don't poll — the game streams to them. Architecture: browsers can't listen on sockets, so the GAME is the WebSocket client and your agent process runs a tiny WS server. Connect either way:
agentAPI.connectAgent('ws://localhost:8765') // from DevToolshttp://localhost:5173/?agent=ws://localhost:8765 // auto-connect at boot
Once connected the game pushes one message per observation tick (20 Hz, the latency buffer is applied — fairness travels with the data):
{ "type": "observation", "payload": { ...same schema as observe()... } }Your process answers whenever it wants (messages are applied through the same
10 Hz action ticks as act()):
| You send | Effect |
|---|---|
{"type": "act", "payload": {throttle, brake, turn, climb, ...}} |
queued for the next action tick |
{"type": "start-race", "seed": 123} |
starts a race |
{"type": "release"} |
hands controls back to the human |
{"type": "config", "payload": {...}} |
setConfig (tighten-only) |
A {"type": "hello"} frame with the current config arrives on connect.
Safety invariant: if the socket drops, the game immediately releases the
virtual pad — a dead agent can never leave the carpet pinned. Reconnects are
attempted 3 times, then it stays with the human. agentAPI.transportStatus()
reports connecting | connected | reconnecting | disconnected.
A complete reference pilot lives at examples/agent_pilot.py
(pip install websockets, run it, open the game with ?agent=...). It also
shows the two-tier pattern for language-model pilots: a reflex loop answers
every observation with simple math at full rate, while the slow model (Ollama,
llama.cpp, a cloud API) runs beside it as a planner, re-reading the latest
observation every few seconds and adjusting the reflex layer's goals. Model
latency then costs strategy freshness — never control of the carpet.
Current, honestly stated:
- No determinism guarantee. Replays are recordings of what happened, not input logs that re-simulate bit-identically. Do not build verification or training pipelines that assume replaying inputs reproduces a run.
- Cooperative enforcement. Rate limits, latency, and pilot tags are applied client-side. A modified client can cheat. Local and friendly competition only.
Roadmap, in rough order:
- Replay re-simulation — deterministic input logs re-run by a verifier, making pilot tags and times trustworthy.
- Server-verified leaderboards — ranked seeds where only verified runs count.
Check observe().version and include it when you report results or file issues —
the contract above is versioned, and this document describes the 1.x surface.