docs
Getting started
Run the engine locally, see a world tick, and decide which track you are entering.
Everything here runs on Bun 1.3 or newer. There is no database, no Docker, and no build step to watch a world tick.
git clone <repo-url> subpixel
cd subpixel
bun install
bun run dev # keyboard play page on http://localhost:3000
Controls on that page: arrow keys to move, X to jump, Z to run, C to spin jump, P to pause, . to step one frame, , to hold-rewind, R to reset. The same server serves /editor, a hand level editor that saves and loads level JSON.
The
subpixelCLI and the one-command starter repo ship with launch. Until then you work inside the monorepo, which is the same code the scoring worker runs.
The 60-second version
The whole engine is one class with one method that matters:
import { BTN_RIGHT, BTN_RUN, LEVELS, World } from '@subpixel/engine'
const world = World.create({ level: LEVELS.test!, seed: 1 })
for (let tick = 0; tick < 600; tick++) {
world.tick(BTN_RIGHT | BTN_RUN)
}
console.log(world.tickCount, world.player.xPx, world.hash().toString(16))
tick() takes one byte of input and advances the simulation exactly one frame. There is no wall clock, no floating point, and no Math.random anywhere inside: the state after 600 ticks is a pure function of the level, the seed, and the input bytes you fed it. Two machines that run those lines print the same three numbers.
That property is the whole competition. It is why a replay is a seed plus an input log rather than a video, why the worker can re-run your submission twice and compare, and why you can search over hypothetical futures at all.
The input byte
One InputFrame is a u8 bitmask. The names are semantic, not controller letters:
BTN_LEFT = 1 << 0
BTN_RIGHT = 1 << 1
BTN_UP = 1 << 2
BTN_DOWN = 1 << 3
BTN_JUMP = 1 << 4
BTN_SPIN = 1 << 5
BTN_RUN = 1 << 6
BTN_SELECT = 1 << 7
Two of those behave differently from the rest, and the difference has cost people whole afternoons — see your first agent.
Which track are you entering?
Agents play levels. You implement one method, act(), which receives an observation and returns an input byte; you get 40 ms of CPU per tick and a fork() of the live world to plan with. Start at your first agent, then search agents and fork().
Generators make levels. You implement one method, generate(seed, difficulty, constraints), which must be deterministic and must produce levels that can actually be finished. Start at writing a generator.
Both are ordinary TypeScript modules bundled to a single file under 2 MB, and both run in a sandbox where the only imports that resolve are @subpixel/engine and @subpixel/sdk.
What lives where
| Package | What it is |
|---|---|
@subpixel/engine |
The simulation. Zero dependencies, integer-only, World and everything it needs. |
@subpixel/sdk |
The author-facing surface: agent and generator contracts, the level linter, the run harness. |
@subpixel/agents |
Reference bots: a weighted A* solver that doubles as the level certifier, and a baseline jumper. Read this one — it is the best worked example of the fork-and-search loop that exists. |
@subpixel/gen-foundry |
The house generator. An ordinary LevelGenerator; it is on the generator leaderboard as the baseline to beat. |
@subpixel/renderer |
Canvas2D rendering, browser only. Nothing in the scoring path depends on it. |
Useful commands while you work:
bun test # the engine's parity suite and everything else
bun run ci # artifact guard, purity lint, provenance check, typecheck, tests
Determinism rules you have to respect
Your code runs in a sandbox that removes the ways of being non-deterministic. There is no Date, no Math.random, no network, no filesystem, and no dynamic import. The only randomness available is the seeded generator handed to you at run start.
This is enforced, but it is worth internalizing rather than discovering: official runs execute your submission twice on a sample of seeds and compare the input logs. Any divergence scores the submission zero. Anything that leaks entropy — iteration order over a Set keyed by object identity, a cache that survives between runs, a timing-dependent early exit — is a bug that the double-run will find.