docs
Your first agent
The agent contract, a working baseline, and the two mistakes that cost everyone an afternoon.
An agent is one object with one method that matters. You get an observation of the world every tick and you return one byte of input.
export interface Agent {
/** Called once per run, before tick 0. Deterministic setup only. */
init(ctx: RunContext): void
/** Called every tick. Must return within the budget. */
act(obs: Observation, tools: AgentTools): InputFrame
/** Optional: called after the run with the final result. */
end?(result: RunResult): void
}
What you are handed:
export interface RunContext {
levelInfo: { widthTiles: number; theme: Theme; timeLimit: number; difficulty: number }
rng: Rng // seeded per run; the ONLY randomness allowed
engineVersion: string
}
export interface Observation {
tick: number
player: PlayerState // position and velocity in subpixels, powerup, pMeter,
// cape state, onGround, blocked flags, carrying, mount…
sprites: SpriteState[] // every live sprite: slot, id, position, velocity, state, hitbox
tiles: TileView // tiles.at(tx, ty) — the whole map, not a window
camera: CameraState
meters: { coins; score; timer; starTimer; pSwitchTimer }
}
export interface AgentTools {
/** The forward model: a clone of the live world you can step with hypothetical inputs. */
fork(): SimHandle
debug: DebugSink
}
Three things about that shape are deliberate:
- You see everything. The whole tile map, every live sprite. The 2009 competition gave agents a 22×22 window and made perception part of the challenge; here the challenge is control. What is hidden is the seed set, not the level in front of you.
fork()is the point. It is the API the entire competition is built around, and it is why search agents are the natural design rather than an exotic one.rngis the only entropy. NoDate, noMath.random, nothing from outside. Official runs execute your agent twice and compare input logs; anything non-deterministic scores zero.
Status: the agent-side SDK (
Agent,Observation,AgentTools, and the run harness) is being frozen in the current phase. The contract above is the shape it lands in. The engine-level loop shown below already works today and is what the harness wraps.
A baseline that works
The simplest agent that finishes anything is a jumper: hold right, hold run, and jump when something is in the way or the ground runs out. Here is that idea against the raw engine, which is exactly what the SDK harness will be calling for you:
import {
BTN_JUMP,
BTN_RIGHT,
BTN_RUN,
INPUT_NONE,
LEVELS,
World,
type InputFrame,
} from '@subpixel/engine'
const JUMP_FRAMES = 12
const world = World.create({ level: LEVELS.test!, seed: 1 })
let prev: InputFrame = INPUT_NONE
let jumpHeld = 0
while (world.tickCount < 3600 && world.player.dead === 0) {
let input: InputFrame = BTN_RIGHT | BTN_RUN
// Probe: does holding right for 8 frames end badly? If so, jump instead.
if (jumpHeld === 0 && probeIsBad(world)) jumpHeld = JUMP_FRAMES
if (jumpHeld > 0) {
input |= BTN_JUMP
// Trap 2, below: on the first frame of a new jump the bit has to be clear if it was
// held last frame, or the press is not a fresh edge and nothing happens.
if (jumpHeld === JUMP_FRAMES && (prev & BTN_JUMP) !== 0) input &= ~BTN_JUMP
jumpHeld--
}
world.tick(input)
prev = input
}
// `doomed()` is trap 1, defined below.
function probeIsBad(world: World): boolean {
const sim = world.clone()
for (let i = 0; i < 8; i++) sim.tick(BTN_RIGHT | BTN_RUN)
return sim.player.dead !== 0 || doomed(sim) || sim.player.xPx <= world.player.xPx
}
world.clone() is the engine-level forward model. The SDK's fork() is the same operation with the budget accounting attached. A cloned world is fully independent: step it as far as your CPU budget allows, read whatever you like out of it, then throw it away.
@subpixel/agents ships runForwardJumper(), a tidied version of exactly this baseline. It is worth reading before you write your own, and it is worth beating — it clears difficulty-1 levels and dies on most of the rest.
Trap 1: the death plane is 32 px below the floor
player.dead is not set the moment you fall past the bottom row of tiles. The engine follows the original's off-screen check: death is flagged when
player.yPx > level.heightTiles * 16 + 32
Between the bottom of the level and that plane, the simulation keeps running. The player keeps accelerating rightward through where geometry would have been, and xPx keeps increasing. To a search that scores states by how far right they got, a pit fall looks like excellent progress — better than the careful jump that would have cleared it, because nothing is slowing it down.
The fix is to treat "below the floor and still falling" as terminal yourself, a tile above where the engine gives up:
function doomed(world: World): boolean {
return world.player.yPx >= world.level.heightTiles * 16 - 16
}
The reference solver calls this its single largest reach improvement. Without the prune, the frontier fills with states that can never come back; with it, the search spends its budget on futures that exist.
Trap 2: jump and spin are edge-triggered
BTN_LEFT, BTN_RIGHT, BTN_RUN and friends are level-triggered: holding the bit does what you expect for as long as you hold it. BTN_JUMP and BTN_SPIN are not. The engine compares them against the previous frame's read, exactly as the original hardware routine does, so a jump happens on the rising edge of the bit.
The consequence: an agent that returns BTN_RIGHT | BTN_JUMP every single tick jumps exactly once, at tick 0, and then walks into the first obstacle forever. It looks like the jump is broken. It is not — you have been holding the button since the title screen.
To press again, the bit has to be clear for at least one frame first:
// tick n: BTN_RIGHT | BTN_JUMP → jumps
// tick n+1: BTN_RIGHT | BTN_JUMP → still the same jump (holding extends its height)
// tick n+2: BTN_RIGHT → release
// tick n+3: BTN_RIGHT | BTN_JUMP → jumps again
Holding the bit is not useless — jump height is set by how long it stays held during the ascent, which is why the reference solver carries three different running-jump lengths as separate actions. You just cannot start a second jump without releasing.
@subpixel/agents exposes the helper it uses for this: NEW_PRESS_BUTTONS (the mask of edge-triggered bits) and macroFrame(macro, frame, prevInput), which clears a stale press on the first frame of an action so the next frame is a genuine new press.
What to do next
- Watch your agent instead of guessing: everything you emit through
tools.debug— lines, boxes, values, search trees — is recorded into the replay and rendered by the replay viewer with per-channel toggles. - Move from reflexes to search. A probe-and-react agent tops out quickly; the search agents and fork() page covers the action set, state keying, and budget arithmetic that make planning affordable.
- Check the physics reference when something surprises you. Most surprises are the reference game being weird, faithfully, on purpose.