Write your first ARC-AGI-3 agent in 40 lines of Python

A 40 line ARC-AGI-3 agent in Python that survives resets, converts action ids, handles clicks and scores locally. Every guard was paid for by a failure first.

0:00
Write your first ARC-AGI-3 agent in 40 lines of Python

The first working ARC-AGI-3 agent on a studio test machine was not the first one written. The first one written pressed keys at a dead game for 20,000 actions at 352,000 actions a second and reported nothing. The working one is about 40 lines, and every line that is not obvious is there because something broke without it.

Here it is, with the reasons.

What an ARC-AGI-3 agent has to do on every turn

Strip the benchmark down and an agent’s turn is four steps. Read the frame. Convert the legal action ids the frame reports into actions the engine accepts. Choose one. Send it, with coordinates if it is a click. Then check whether the game is still alive, because if it is not, nothing else you send will be heard.

Two of those steps are where every first agent breaks. The conversion breaks because GameAction(1) raises even though the frame hands you a 1. The liveness check breaks because after a game over the engine accepts every action and ignores it, silently, forever.

The 40 line ARC-AGI-3 agent, annotated

This is the loop that ran the random baseline across all 25 public games. The policy is random on purpose; the point is the loop.

import random, logging, arc_agi
from arcengine import GameAction, GameState
logging.disable(logging.INFO)

def choose(legal, frame):
    return random.choice(legal)          # replace with your model

def play(game_id, budget=60000, seed=1):
    random.seed(seed)
    arc = arc_agi.Arcade()
    env = arc.make(game_id)
    obs = env.step(GameAction.RESET)     # a game does nothing until reset
    n = deaths = 0
    first_clear = {}
    while n < budget:
        legal = [GameAction.from_id(i) for i in obs.available_actions]
        act = choose(legal, obs)
        if act.is_complex():             # ACTION6 needs a cell
            obs = env.step(act, data={"x": random.randrange(64),
                                      "y": random.randrange(64)})
        else:
            obs = env.step(act)
        n += 1
        if obs is None:                  # the game raised inside step()
            obs = env.step(GameAction.RESET); n += 1; continue
        lvl = obs.levels_completed
        if lvl and lvl not in first_clear:
            first_clear[lvl] = n
        if obs.state == GameState.GAME_OVER:
            deaths += 1
            obs = env.step(GameAction.RESET); n += 1
        elif obs.state == GameState.WIN:
            break
    return first_clear, deaths, n

if __name__ == "__main__":
    print(play("ls20"))

Line by line, the ones that matter.

env.step(GameAction.RESET) first. A freshly made game is in state NOT_PLAYED and has not built its first level. Our first agent skipped this and burned 20,000 actions on a game that had not started.

GameAction.from_id(i). The frame’s available_actions is a list of ints. The enum’s constructor rejects them; from_id is the lookup the library provides.

act.is_complex(). ACTION6 carries coordinates, and a game that receives it without them reads a key that is not there and raises inside its own step(). The wrapper turns that into a None return, which is why the obs is None guard exists two lines later. Six of the 25 public games offer nothing but this action.

obs.state == GameState.GAME_OVER. Check it every turn. Then reset, and count the reset as an action, because the scorer will.

GameState.WIN. Stop. After a win, as after a game over, the engine returns empty frames and the same state for every non reset action.

The state values are string enums from arcengine.enums, so obs.state == "GAME_OVER" also works, but comparing to the enum is what the engine does internally.

n += 1 after every reset. The budget is your own, but count the reset in it, because a reset is a decision the agent made and the scorer’s action total is what the level score divides by. An agent that resets freely and does not count it will be surprised by its own scorecard.

The budget itself is the last number to pick. Sixty thousand suits a random policy that costs nothing per action; it is absurd for a model that costs minutes. The median public game takes a first time human 638 actions end to end, so a model backed agent that is allowed a few thousand actions per game has already been given several complete human playthroughs’ worth of moves, and if it needs them all it is not going to score.

One more line worth having in a real run is arc.make(game_id, save_recording=True). On this machine that wrote one JSON line per action into recordings/<scorecard id>/<game>-<version>-<guid>.jsonl, 22 lines for a reset and twenty actions, each carrying the full frame, the action that produced it, the state and the level count, at about 12.8 KB a line. A run you can replay is a run you can debug.

Where to put the model in an ARC-AGI-3 agent

choose(legal, frame) is the only line the model touches, and keeping it that way is the most useful decision in the file. The loop above will run any policy: a random one at 60,000 actions in 14 seconds, or a local language model at three minutes an action. Swap the body of choose and nothing else changes.

What goes into choose is the real work. frame.frame is a list of 64 by 64 grids, one per rendered step of the last action, with cell values 0 to 15. A model can take the last grid as text, as a downsampled grid, as a colour summary, or as an image, and each of those costs a different amount of thinking. Whatever it returns, convert it with GameAction.from_name, which is case insensitive and raises cleanly on nonsense, and fall back to something legal when it raises.

Keep the history outside the model call. The loop can carry a list of what was pressed and what happened, and hand the last few entries to choose as context. The toolkit’s own ActionInput has a reasoning field for attaching the model’s explanation to the action, echoed back with the frame, which is a convenient place to keep it.

How to score the agent without the API

The toolkit computes a scorecard locally in every mode, so a run’s score does not need the server. The per level formula, from the methodology, is the human baseline divided by your action count, squared, and the baselines are in each game’s metadata.json as baseline_actions. For ls20 level one that is 22. An agent that clears it in 44 actions scores 25 on the level; the random loop above, which cleared it in 40,699, scores 0.000029.

first_clear in the return value is what you need for that arithmetic. Divide each baseline by the action count at which the level first completed and square it. Or ask the toolkit, arc.get_scorecard(), which does the same thing with the level weighting and the per game cap applied.

What to change once it runs

Three things, in order. First, replace the random choose with something that at least looks at the frame, and measure the death rate before and after; if it did not fall, the model is not reading the board. Second, treat ACTION6 as a space of 4,096 moves rather than one, because a quarter of the public set is click only and random coordinates are the worst possible policy for it. Third, record the run, arc.make(game_id, save_recording=True), so that a good game can be replayed and a bad one inspected.

None of that changes the loop. It was 40 lines when it was random and it is 40 lines with a model behind it, and every one of the guards in it was paid for in an afternoon of watching a counter climb at 352,000 actions a second towards nothing.

Share this