Skip to content

Latest commit

 

History

History
514 lines (421 loc) · 23.5 KB

File metadata and controls

514 lines (421 loc) · 23.5 KB

Skybloom Agent API

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.

Why

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.

Fairness profiles

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 values

actionHz 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.

Quickstart

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

API surface at a glance

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.

Observation reference

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 } ]
  }
}

Conventions

  • World units for all positions, distances, heights, radii. The y axis is up.
  • Radians for all angles; units/second for all speeds.
  • bearing is the signed angle from your current heading to the target direction, normalized to [-π, π]. Negative = target is to your LEFT. A bot can steer with turn = k * bearing and 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

Top level

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)

self — your carpet

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

limits — the physics caps (identical for humans and agents)

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

terrain — ground probes

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

racenull when no course is loaded

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)

race.nextGates[i]

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

nearby — fog-radius entities only

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"

Action reference

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). Calling act() 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: true is 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. Send cast: true only on the tick you want to cast, then return to false.
  • 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 tagged mixed.

Racing

Course seeds

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)

Gate flow

  1. 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 (elapsedMs is 0). nextGates[0] is always the next ring you must fly through, including the start ring during the run-up.
  2. Start ring. Crossing it starts the timer.
  3. Splits. Each gate crossed appends your elapsedMs at that moment to race.splits. Pass within radius of the gate's center; gates must be taken in order (gateIndex tells you which is next).
  4. Finish. Crossing the final gate freezes elapsedMs, sets race.state = "finished", and saves a replay.

Replays

const all  = agentAPI.listReplays();        // every stored replay
const best = agentAPI.getBestReplay(1337);  // fastest run on seed 1337, or null

A 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));

Ghosts

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 it

Comparing human vs agent times

The honest protocol:

  1. Pick a seed. Tell everyone the seed.
  2. Human races it (no agent act() calls during the run → replay tagged human).
  3. Agent races it under the strict profile (no human input → tagged agent).
  4. 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.

postMessage bridge

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>

Writing your own bot

Anatomy of SimpleBot

src/agents/SimpleBot.js is deliberately small. Three parts:

  1. A loop. setInterval at 100 ms — matching the 10 Hz action tick, so every decision lands in its own tick window.
  2. Lifecycle. No race → startRace() once. state === "finished"release() (and stop, if configured to run once). Anything else → fly.
  3. 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 − 12climb = 1 Not hitting mountains beats hitting gates
ceiling clamp altitude > ceiling − 50climb = 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.

Three upgrades worth trying

  1. 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) with w rising as dist0 shrinks — and you will cut inside, carrying speed through the exit.
  2. 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 raise Kp and corner harder. (Store prevBearing between ticks; this is the one place your bot wants memory.)
  3. 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 (toward limits.boostedMaxSpeed). When nextGates[0].elevation is strongly negative, dive steeper than the direct line, bank the surplus speed, and shallow out through the gate. Watch altitudeAboveTerrain while you do it — the terrain override exists for a reason.

Streaming transport (WebSocket) — bots in any language

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 DevTools
http://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.

Limits and roadmap

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.