A spider appears. The bot sees it, aims, and asks the host to move the knight closer. Yet the bot never moves the knight itself. The game decides whether the move and the attack succeed. Follow this one spider through each owner, and the source files start to look like one machine.
Starts with: Bot source or an uploaded program.
Ends with: A running VM and a game that advances one limited tick at a time.
Question: Who owns the bytes, observations, actions, and score?
Before the match: source, program, and upload
A player's source is readable code such as sense(); or step_toward(x, y);. The compiler turns it into an executable image: bytes that the VM can run. You write the source, but the server grades the image you upload.
The code calls the complete upload an Artifact. An optional header can select ABYSS; another part holds the bot's instructions. The runner reads the engine choice, then loads the instructions into VM memory. An upload without a valid header runs as CLASSIC, the default.
That means the upload can contain more than VM instructions. ABYSS also keeps a short fingerprint of the complete upload and the world seed. The VM runs only the instruction part.
The runner selects one economy mode per run. That choice is fixed until the run ends. The enum is small because there are only two options, though each has a large engine behind it:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EconomyMode {
#[default]
ClassicLegacy,
DeviceV4,
}The game stores this choice outside bot memory, so no bot variable can switch engines. CLASSIC measures VM behavior and fills power meters. ABYSS uses the Gate instead. To explain a score, then, you must know which bot and which engine produced it.
At the boundary: a copy is not the live world
The world holds the live knight: its position, health, and level. The bot lives in its own 64 KiB of guest memory and cannot reach the world's knight object directly. Instead, sense() asks the host to copy selected world data into guest memory.
That copy is a snapshot. Think of a screenshot, not a livestream. A screenshot is accurate when it is taken, but it can be out of date one move later.
One spider, two owners
The bot's copy and the world's live state can diverge between reads. That is why sensing time matters.
Accessors such as my_x() then read those saved bytes. They do not ask for a fresh position. So if the knight moves after sense(), the saved position shows where it was before. To get current data, the bot must call sense() again. Sometimes it may choose to keep using the old snapshot.
Here are the standard-library bridges for status, movement, and aim. The numeric identifiers select public host operations, while the addresses belong to guest memory. None of those addresses points to the host's knight object.
fn sense() { syscall(1, 0xC000); }
fn game_tick() { return *(0xC000); }
fn my_x() { return *(0xC004) & 0xFFFF; }
fn my_y() { return (*(0xC004) >> 16) & 0xFFFF; }
fn my_hp() { return *(0xC008) & 0xFFFF; }
fn my_level() { return *(0xC010) & 0xFFFF; }
fn my_xp() { return *(0xC014); }
fn my_reach() { return *(0xC020) & 0xFFFF; }
fn boss_alive() { return ((*(0xC030) >> 8) & 0xFF) == 1; }
fn move(dx, dy) { syscall(6, dx, dy); }
fn step_toward(x, y) { syscall(6, x - my_x(), y - my_y()); }
fn move_to(x, y) { step_toward(x, y); }
fn aim_at(x, y) { syscall(7, x, y); }Read step_toward from the outside in. Its caller supplies a target point. The helper subtracts the knight's last sensed coordinates, which turns that point into a direction. Only that direction goes to the host.
The host then limits the movement and checks for collisions, and it changes the knight's live position only if the move is possible. A distant target is a direction to walk in, not a command to teleport.
aim_at works differently. It only requests an attack direction and does not guarantee a hit. The world checks the cooldown, enemy positions, and attack shape later. Because aim and movement are separate requests, a useful bot can aim at one target while it moves toward another.
Follow one observation through one decision
Suppose a snapshot puts the knight at (1000,1000) and a spider at (1120,1000). The pilot calculates (dx,dy)=(120,0). That subtraction is guest arithmetic. It changes values inside the VM, not positions in the world.
Next, the pilot asks to aim at (1120,1000) and to step toward that point. The host can update the aim and spend part of this tick's movement allowance. Later in the tick, the world moves enemies and resolves combat. If the spider is still outside the attack area, the attack misses. By the next scan, both the knight and the spider may have moved.
This is the feedback loop. The bot cannot decide once and assume that the target stays still. Instead, it observes, calculates, requests actions, and then observes the changed world. Even a clever target formula depends on how recent and complete its data is.
Two loops run with different boundaries
A world tick is one step of the game. In each tick, the bot runs for a while; then the game moves enemies, resolves fights, and handles gems. The VM can run up to 10,000 instructions per world tick.
A call to main is one trip through the bot's function. The compiled program calls it again when it returns. But a function return is not a world tick, so returning from main does not make the spider move.
Try the small timeline below. The spider starts at x = 120 and moves to x = 124 when the world advances. These positions are only example values, but the call and tick order matches the implementation. The bot requests no movement here, so a second scan in the same tick sees the same world.
The compiler builds this loop into the uploaded program. CALL_ABS enters main, and POP discards its return value. JMP_ABS then goes back to offset zero to repeat the call:
let main_label = func_labels["main"];
e.push_u32(FRAME_BASE);
e.push_u32(FP_CELL);
e.op(op::STORE);
e.push_label(main_label);
e.op(op::CALL_ABS);
e.op(op::POP);
e.push_u32(0);
e.op(op::JMP_ABS);Separately, the VM counts the instructions it runs in each tick. When the allowance runs out, run_tick returns to the game, but the VM keeps its instruction pointer and stack. On the next tick, it can continue the same unfinished call:
let mut executed: u32 = 0;
loop {
if executed >= PER_TICK_INSTR_BUDGET {
self.last_inst_count = executed;
self.note_budget_resume();
return Stop::BudgetExhausted;
}
let ip_op = self.ip;
let opcode = self.mem.load_u8(ip_op);
self.ip = ip_op.wrapping_add(1);Here is the practical catch. A scan copies the world when the bot requests it. Suppose main reads x = 120, runs out of instructions, and resumes after the spider has moved. The saved scan still says 120, so the bot needs another scan to learn about 124. A long calculation can therefore act on old data.
Movement has a similar limit. All movement calls in one tick share one allowance. Calling main again and again cannot make the knight faster.
The driver connects the loops in a fixed order
One tick is a short assembly line, not one giant instant. First, the game resets this tick's limits and timers. Then the bot runs, sending scan, aim, and movement requests to the host during that run. Next, the selected engine sets the power that the world may use. Only then does the world move enemies, resolve attacks, and handle gems. If the runner records a replay, it copies the result last.
One game tick, five stops
Tap a stop. The blue lane is the bot and its VM; the green lane is the host's live arena. The knight and spider are game sprites, and this is a timing example, not a recorded fight.
The runner makes these calls in the same order. begin_tick runs earlier in the loop, so this excerpt starts at the VM step. In the code, bugs_on() selects CLASSIC's detector path, and device_on() selects ABYSS's device path:
vm.run_tick(&mut world);
if world.bugs_on() {
let latches = vm.pwn_latches();
world.apply_exploit_staging(&mut vm.mem, latches);
}
if world.device_on() {
world.device_resolve();
}
world.advance_tick();
if let Some(w) = writer.as_mut() {
w.push_frame(world.capture_frame());
}Why does this order matter? The host handles moves while vm.run_tick runs. A speed bonus set afterward cannot redo a move that has already happened. Attack and pickup bonuses can still matter, because world.advance_tick runs later. A replay frame comes last, so it shows the world after those events.
The world has its own internal order for enemy creation, enemy decisions, collision separation, attacks, projectiles, and gems. The world chapter explains it. For now, one point matters: world.advance_tick turns requests and power into results. A spider can move, an attack can hit, or a gem pickup can add EXP.
Where CLASSIC and ABYSS attach
Both engines share the same basic pilot loop. In CLASSIC, the engine also watches VM behavior. It recognizes specific behaviors and fills power meters for those that keep happening. The meter values become limited bonuses, which the world applies through its normal game rules.
ABYSS instead adds a second, smaller machine inside the game. That machine keeps unfinished work between requests. A scheduler decides which work may run, and an executor runs it for a limited number of steps. The host checks the result before it saves any proof.
Saved proof can permit limited attack, survival, and gem collection boosts. The same knight uses these boosts in the same world, against the same enemies and gems.
The two engines differ in how they earn power, but in both, the world owns the final EXP total. Keep the knight, enemies, and gems in view as you study each engine.
From one match to one leaderboard entry
Each run fixes one upload, one engine, and one seed. The seed starts the game's repeatable sequence of random values, and the bot's actions shape what happens from there. A match lasts at most 36,000 ticks: 600 seconds of game time at 60 ticks per second. The grader can simulate those ticks faster than real time.
The runner starts seven separate seed runs, each with its own VM and world. Then it sorts the complete results by EXP from high to low. It breaks ties by time from low to high, then by seed from low to high. The fourth result in that order is the median.
The EXP, time, level, kills, victory, and replay all come from that one run. The runner never mixes data from different seeds. Pairing one seed's score with another seed's faster time would describe a match that never happened.
The median is a real run
Choose a saved bot. The fourth sorted run gives the displayed EXP and all its other results.
The browser only displays the result; it does not decide collisions or create a trusted score. Replay capture is also only an observer: it must not draw extra random values or change the match it records. The repository tests that property.
A code map attached to its job
| Implementation area | Place in our example |
|---|---|
crates/compiler |
Turns scan, subtraction, aim, and movement into VM instructions |
crates/vm |
Executes those instructions and passes host requests onward |
crates/engine |
Owns knight and spider, resolves hits, creates gems, banks EXP |
crates/gate |
Supplies persistent ABYSS work and evidence machinery |
crates/runner |
Prepares artifacts, creates seed runs, selects the median run |
| Replay, API, coordinator, storage | Preserve and serve outcomes under competition rules |
| Browser client | Presents the arena, submission flow, leaderboard, and replay |
You now know what the pilot can see and ask for. The next chapter builds its decisions. After that, we look at the VM instructions that carry them out. A clever calculation helps only if the game accepts the request and the result improves the match.
