The ARC-AGI-3 toolkit hands your agent a list of legal moves as integers, [1, 2, 3, 4]. The obvious next line, GameAction(1), raises ValueError: 1 is not a valid GameAction. Print GameAction.ACTION1.value and it says 1.
This cost about ten minutes on a studio test machine before the cause was clear, and it will cost everyone who writes a first agent the same ten minutes, so here is the whole of it.
What GameAction(1) returns, and what it raises
Nothing returns. The call goes straight to Python’s enum lookup, which checks a table of known values, does not find 1, and raises. Meanwhile every member reports an integer value:
>>> from arcengine import GameAction
>>> GameAction.ACTION1.value
1
>>> GameAction(1)
ValueError: 1 is not a valid GameActionThe available actions that come back from a game frame are plain ints, produced from the game’s own declaration, which for ls20 is available_actions=[1, 2, 3, 4]. So the natural loop, take the int, convert it to the enum, send it back, fails on its second line.
Why the GameAction enum rejects its own value
The answer is in arcengine/enums.py, and it is not a bug in the usual sense. The members are declared as tuples:
class GameAction(Enum):
RESET = (0, SimpleAction)
ACTION1 = (1, SimpleAction)
...
ACTION6 = (6, ComplexAction)
ACTION7 = (7, SimpleAction)
def __init__(self, action_id, action_type):
self._value_ = action_id
self.action_type = action_type
self.action_data = action_type()Each member carries two things, an id and the pydantic model that validates its payload. To make .value read as the plain id, the class overwrites _value_ inside __init__.
That works for .value. It does not work for lookup, because Python builds the enum’s value to member table when the class is created, from the original tuple values, and __init__ runs after that table exists. The Python documentation defines _value_ as the value of the member, which “can be set in __new__()“, and that is the hook that runs before the table is built. arcengine sets it in __init__ instead, so the table still expects the tuple, and an integer never matches.
Checking the table directly confirms it. GameAction._value2member_map_ has tuples as keys, not integers.
How to look up a GameAction by id or name safely
The library knows about this and ships two class methods for it:
GameAction.from_id(1) # -> GameAction.ACTION1
GameAction.from_name("action1") # -> GameAction.ACTION1, case insensitivefrom_id loops over the members comparing .value, which is the rewritten integer, so it finds what the constructor cannot. from_name upper cases the string and indexes the class by member name. Both raise ValueError with a clear message on a miss. The same class also carries validate_data and set_data, which run an incoming payload through the member’s pydantic model, so the enum is doing three jobs at once: naming the action, typing its data, and validating it.
Building a lookup dictionary yourself, {m.value: m for m in GameAction}, is equally fine and is what our first random agent did after the constructor failed. The only thing that does not work is the one thing that looks like it should.
The safe loop, then, looks like this:
o = env.step(GameAction.RESET)
while o.state == GameState.NOT_FINISHED:
legal = [GameAction.from_id(i) for i in o.available_actions]
act = choose(legal)
o = env.step(act, data=coords) if act.is_complex() else env.step(act)Three details carry the weight. available_actions is a list of ints, so every element goes through from_id. The state check is against GameState.NOT_FINISHED, a string enum from the same module, and it has to run on every turn rather than once. And the branch on is_complex() is not optional, for the reason the next section gives. choose(legal) is where your model goes, and what to do when the model returns nothing at all is a separate problem from this one.
What ACTION6 needs that the other GameAction members do not
The tuple exists because the actions are not all the same shape. Six of them are SimpleAction, a model with nothing but a game_id. ACTION6 is ComplexAction, which adds x and y, each validated as an integer between 0 and 63, the coordinates of a cell on the 64 by 64 board. That matches the technical report’s description of the action space: five key actions, an undo, and “one action to select (e.g. click on) a cell from the 64×64 grid by specifying its coordinates.”
Sending ACTION6 without coordinates is the second trap. The engine does not reject it at the API boundary. It hands the action to the game, and the game reads x out of a dictionary that does not have it. Our 25 game survey script hit exactly this on its second game, bp35, with a KeyError: 'x' from inside the game’s own step(), and the wrapper returned None instead of a frame. A loop that assumes every step returns a frame then dies one line later on None.levels_completed.
The wrapper’s step() takes the data as a second argument, env.step(GameAction.ACTION6, data={"x": 12, "y": 40}), and the pydantic model rejects anything outside the grid: set_data({"x": 64, "y": 0}) raises a validation error naming the field. Check action.is_complex() before you send, and always send coordinates when it is true.
The seventh action, ACTION7, is simple like the first five. The report calls it undo, “reverting to the previous state”, and whether a game offers it at all is the game’s decision. ls20 declares only actions 1 to 4, so ACTION5, ACTION6 and ACTION7 never appear in its legal list. The engine’s perform_action does not check an incoming id against that list; whatever the game’s step() does with an unexpected one is up to the game.
When to use from_id, from_name, or the member itself
For an agent, the rule is short. The frame gives you ints, so convert with from_id. The model gives you text, so convert with from_name, which also absorbs the case differences a language model produces. Refer to fixed actions like RESET by member, GameAction.RESET, since you are not converting anything. Never call the constructor.
Two more things about RESET that the enum does not tell you and the engine does. RESET is action id 0 and it is always legal, but it is not always the same reset. The base game checks its action count: at zero actions, or after a win, RESET is a full reset that reloads every level from a clean copy. After any other action it is a level reset, which reloads only the current level and keeps your progress. There is an environment variable, ONLY_RESET_LEVELS=true, that forces the level behaviour. If your agent counts levels completed and sees the number vanish after a reset, it was the first kind.
The other is that after GAME_OVER or WIN the engine ignores every action except RESET. It returns a frame with an empty grid and the same state, forever, without an error. That is the silent failure that made our first random loop report 352,000 actions per second and zero levels: it was pressing keys at a finished game. Check state after every step, and when it is GAME_OVER, the only move that does anything is the one whose id you cannot construct from its own value.
