Every ARC-AGI-3 game on your disk is a subclass of one 593 line file. arcengine/base_game.py defines ARCBaseGame, and the game you download only fills in the gaps it leaves: what the sprites are, what the levels contain, and what happens in step(). Everything else, the action loop, the reset rules, the frame rendering, the score, is decided here.
Reading it took under an hour on a studio test machine and explained three behaviours that had cost far longer to find by experiment.
What ARCBaseGame does on every action
The entry point is perform_action, and the class marks it @final with a docstring that says, in capitals, do not override this, put your logic in step(). It runs the same sequence for every action the engine receives:
1. If the action is RESET, run handle_reset() and continue.
2. Else if the state is GAME_OVER or WIN, return a frame with an empty grid
and the same state. Nothing else happens.
3. Store the action. Loop: if a level change is pending, apply it;
otherwise call step(). Render a frame. Repeat until the action is complete.
4. Return the frames, the state, levels_completed, and available_actions.Point two is the one that matters most to anyone writing an agent, and it is the silent failure our first random loop fell into. After a game over, every action except RESET is accepted, answered with a frame whose grid is empty and whose state is still GAME_OVER, and ignored. No error, no exception. The engine will do this forever at whatever speed you call it, which is how a loop can report 352,000 actions a second and zero progress.
Point three explains something else visible in the frame data: frame is a list, not a single grid. One action can produce several frames, because the loop renders once per step() call until the game says the action is done. The technical report calls these frame sequences and uses them for “non-interactive animations (e.g., an object moving across the screen) between player turns.” There is a hard ceiling: MAX_FRAME_PER_ACTION is 1,000, and an action that is still not complete after that raises ValueError("Action took too many frames").
How step() and complete_action() control the frame loop
The contract for a game author is two methods. step() is called repeatedly for one action and is where the game logic lives. complete_action() is what the game calls when the action has finished, and the base class docstring is explicit that it “does not need to be called every step, but once the action is complete.”
The default step() in the base class is one line: it calls complete_action(). A game that does not override it accepts every action instantly and does nothing. ls20 overrides it, along with __init__, render_interface and on_set_level, and those four are exactly the names that survive the obfuscation in the downloaded source, because the engine finds them by name.
For movement the base class supplies try_move(sprite_name, dx, dy) and try_move_sprite. Both attempt a move, check for collisions against the level, and return the list of sprites hit; if the list is not empty, the sprite does not move. Collision itself is a property of the sprite: BlockingMode is NOT_BLOCKED, BOUNDING_BOX or PIXEL_PERFECT, and the default for a new Sprite is pixel perfect.
What win(), lose() and next_level() change in the state
Three one line methods, all @final, are the only ways a game changes its own outcome. win() sets the state to WIN. lose() sets it to GAME_OVER. next_level() increments the internal score by one and either flags a level change for the next loop iteration or, if this was the last level, calls win().
That internal score is what the toolkit exposes as levels_completed, and it is the number the RHAE scorer reads. It is simply a count of next_level() calls since the last full reset. There is no partial credit inside a level and no notion of points; a level is done when the game says so, and the only thing the engine records about how is the action count.
The available actions come from the same place. A game declares them once in its constructor, available_actions=[1, 2, 3, 4] for ls20, and the base class hands that list back with every frame. There is a separate, internal _get_valid_actions that expands ACTION6 into the specific cells that can be clicked or placed on, but its docstring says it is “never exposed via the API or to Users/Agents.” What an agent sees is the declared list, and whether an out of list id does anything is left to the game’s own step().
Why RESET behaves two different ways in ARCBaseGame
handle_reset() is the source of a behaviour that looks like a bug until you read it. On the very first action of a game, or at any point after a WIN, RESET performs a full reset: every level is rebuilt from a clean copy, the score returns to zero, the action count returns to zero, and the game starts at level one. At any other time, RESET performs a level reset: only the current level is rebuilt, and the score is kept.
So an agent that dies on level three and resets is back at the start of level three with levels_completed still reading two. An agent that resets before making its first move, or after winning, starts over. There is one override: with ONLY_RESET_LEVELS=true in the environment, every reset is a level reset unless the game has been won, which is the behaviour competition mode requires when it says “Only Level Resets are permitted”.
The clean copies come from _clean_levels, a list of level clones made at construction. full_reset clones all of them again; level_reset clones one. Nothing a game does to a level during play survives a reset of that level, which is the guarantee that makes the human baseline repeatable.
How the camera turns a 16 by 16 level into a 64 by 64 frame
The last piece is the Camera. ls20 constructs one 16 pixels wide and 16 high, with a background colour, a letterbox colour and a list of user interface elements to draw on top. The camera’s render docstring states the rule: “The rendered output is always 64×64 pixels.” A smaller viewport “will be scaled up uniformly (maintaining aspect ratio) to fit within 64×64, and the remaining space will be filled with the letter_box color”, which is why the frame printed in our first run reads in runs of four identical cells. The engine also supports interfaces drawn over the game, which is how ls20 shows its step counter without it being part of the level.
This is also the layer the toolkit documentation means when it lists editing existing games and creating new ones as features: a new game is a subclass, a camera, some sprites and a step().
Levels are plain containers. A Level holds sprites, an optional grid size, a data dictionary the game reads with get_data, a name, and a list of placeable areas for games that let the player drop things. Sprites carry their pixels, a position, a layer, a scale, a rotation, mirroring flags, a blocking mode, an interaction mode that can make them intangible or invisible, and tags. Everything on screen is one of these, and everything the engine knows about the world is the list of them.
One field on the incoming action is worth knowing before you build a harness. ActionInput has an optional reasoning field described in the source as an “opaque client-supplied blob; stored & echoed back verbatim”, guarded at 16 KB. You can attach why your agent chose a move, and the engine will hand it back with the frame. It is the only place in the loop where an agent’s explanation has somewhere to go.
