Skip to content

Strategy (FSM)

The brain of the AI is a finite state machine (FSM): on every loop tick, the current state produces one (or several) command(s), and the FSM decides whether to switch to another state. Survival always takes priority over everything else.

This page describes the controller (ai/src/strategy/fsm.py), the dotted multi-step command convention, the optimistic local state updates, then the real behavior of each state. It finally covers the two support modules: incantation.py (requirements) and pathfinding.py (toric geometry).

The FSM controller

On construction, the FSM eagerly instantiates all 7 states and gives each a back-reference to itself (ai/src/strategy/fsm.py:47):

self._states: dict[str, State] = {
    "survive": SurviveState(drone, client),
    "rally": RallyState(drone, client),
    "collect": CollectState(drone, client),
    "explore": ExploreState(drone, client),
    "fork": ForkState(drone, client),
    "incant_prep": IncantPrepState(drone, client),
    "incant": IncantState(drone, client),
}

The initial state is survive: main.run() calls fsm.transition_to("survive") right after creating the drone (ai/src/main.py:125).

transition_to and the explore special case

transition_to(name) (ai/src/strategy/fsm.py:62) validates the name, logs the transition, then calls the new state's on_enter(). It treats explore specially: before entering it, it derives which resource to look for and which state to return to via _resource_for_return() (ai/src/strategy/fsm.py:84), then configures the ExploreState instance with set_target(resource, return_state):

  • current state survive → look for "food", return to survive;
  • current state collect → look for the target stone, return to collect;
  • otherwise → ("food", "survive") by default.

This is necessary because ExploreState is a parameterized state reused by several callers: on its own, it does not know what it is searching for.

decide_next_command: the core driver

decide_next_command() (ai/src/strategy/fsm.py:101) is called in a loop as long as the command queue can accept sends. Its flow:

  1. ask the current state should_transition(); if a name is returned, transition immediately (ai/src/strategy/fsm.py:105-108);
  2. ask for the next command via next_command(). If empty, return False (nothing to send this tick);
  3. split the command on the dot . and send each sub-command.
flowchart TD
    A[decide_next_command] --> B{should_transition?}
    B -- yes --> C[transition_to]
    B -- no --> D[next_command]
    C --> D
    D --> E{empty command?}
    E -- yes --> F[return False]
    E -- no --> G["split on '.'"]
    G --> H[For each sub-command:<br/>optimistic update + send_command]
    H --> I[return True]

The dotted command convention

A state never returns just one atomic action: it may return a dotted sequence "cmd1.cmd2.cmd3". For example, to head East while facing North, RallyState returns Right.Forward (ai/src/strategy/states/rally.py:184). The FSM does command.split(".") (ai/src/strategy/fsm.py:115) and sends each piece as a separate server command.

Why chain several commands?

The server tolerates up to 10 in-flight requests (see Network & command queue). Emitting Right.Forward in one shot fills the pipeline and avoids one loop round-trip per micro-action, which speeds up the drone's responsiveness.

Optimistic local state updates

Before the server confirms anything, the FSM anticipates the effect of each sub-command on the drone's local state (ai/src/strategy/fsm.py:121-167). This is the match subcommand:

Sub-command Optimistic effect applied immediately
Right clockwise rotation of drone.direction in ORIENTATIONS = ["N","E","S","W"]
Left counter-clockwise rotation of drone.direction
Forward advances drone.pos on the torus (E → x+1, W → x−1, S → y+1, N → y−1), modulo width/height
Set <r> decrements inventory[r] (clamped at 0) and drops the resource on the mental map at the current position

Why anticipate?

The drone must decide the next command (e.g. a Look) with the correct position/direction, without waiting for the network round-trip. The local state acts as a predictive model; the server response remains the source of truth and corrects any divergence. This matches the "server is authoritative" principle (see Overview).

The per-command callback: re-injecting pos/direction

For each sub-command, the FSM registers a callback capturing a snapshot of the position and direction at send time (ai/src/strategy/fsm.py:169-182):

def make_callback(pos, direction):
    def callback(line):
        msg = parse_server_line(line, self._client)
        if isinstance(msg, LookResult):
            msg = LookResult(tiles=msg.tiles, pos=pos, direction=direction)
        if self._state:
            self._state.on_response(msg)
    return callback

When the response arrives, the callback re-parses the raw line then, if it is a LookResult, re-injects the captured pos/direction from when the Look was sent. This is essential: between sending the Look and its response, several optimistic Forward/Right may have moved drone.pos. Without this snapshot, the vision would be merged into the map at the wrong place.

Incantation case and periodic refresh

  • After an Incantation, the FSM adds an extra callback via add_callback (ai/src/strategy/fsm.py:184-190) to handle the unsolicited follow-up message (Current level: k).
  • Every INVENTORY_UPDATE_TU (= 10, ai/src/config.py:98) sub-commands, an Inventory is injected automatically (ai/src/strategy/fsm.py:192-201) to resynchronize the food stock and stones before a resource respawns.

The states, one by one

survive — eat above all

SurviveState (ai/src/strategy/states/survive.py) is the default state and the absolute priority. It looks for the nearest known food (mental map + vision via nearest_food), Take food when on top of it, and delegates movement to rally by setting a destination (ai/src/strategy/states/survive.py:154-159). If no food is known, it switches to explore. The exit transition (should_transition, ai/src/strategy/states/survive.py:138) targets collect once fed.

Likely unit bug in the food threshold

should_transition compares drone.food (a count of units) against FOOD_SURVIVAL_THRESHOLD * LIFE_PER_FOOD (= 5 × 126 = 630, time units). Since the drone never holds 630 food units, the exit from survive is in practice never reached. The same count/time mix appears in collect, incant_prep and incant. See Known limitations.

rally — the reusable movement engine

RallyState (ai/src/strategy/states/rally.py) is the generic navigation building block. A caller arms it via set_destination(target, return_state, on_arrived, take_resource) then requests the rally transition. Every tick it computes the optimal signed delta with toric_signed_delta (ai/src/strategy/states/rally.py:137), then _navigate(dx, dy) (ai/src/strategy/states/rally.py:170) translates that delta + the current direction into a dotted sequence of Left/Right/Forward. On reaching the target, it emits Take + Inventory (if take_resource is set), invokes on_arrived, then transitions to return_state.

Axis priority

_navigate handles the X axis (East/West) first, then the Y axis (North/South): the drone aligns horizontally before going up/down. Simple and deterministic, but not optimal in number of turns.

collect — target a missing stone

CollectState (ai/src/strategy/states/collect.py) computes the stones missing for the next incantation (missing_stones), picks a target resource, looks up its nearest known position and delegates to rally. If the stone is unknown → explore; if nothing is missing → incant_prep; if food drops → survive.

Leftover debug logs

collect.py contains forgotten print() calls (ai/src/strategy/states/collect.py:50 and :95), as does incant_prep.py:77. See Known limitations.

explore — map-building square spiral

ExploreState (ai/src/strategy/states/explore.py) sweeps the world in a square spiral: legs of Forward, a Right at the end of each leg, and the leg length grows every 2 turns (ai/src/strategy/states/explore.py:122-130). A Look is interleaved every _LOOK_EVERY = 3 steps (ai/src/strategy/states/explore.py:22), merged into the mental map. As soon as the target resource becomes known, the state returns to _return_state (should_transition, ai/src/strategy/states/explore.py:132).

incant_prep — prepare the ritual tile

IncantPrepState (ai/src/strategy/states/incant_prep.py) sets the required stones on enter (requirements_for), then next_command drops (Set) the missing stones one at a time as long as it has them in inventory, otherwise it emits a verification Look (ai/src/strategy/states/incant_prep.py:56-64). It moves to incant as soon as can_incant(...) is satisfied. Deliberately simple implementation (no multi-player coordination here).

incant — trigger the incantation

IncantState (ai/src/strategy/states/incant.py) sends Incantation once (incant_send flag, ai/src/strategy/states/incant.py:57-59), waits for the server messages (Elevation underway then Current level: k or ko), updates drone.level on success, then transitions: explore on success, incant_prep on failure.

No multi-player coordination

incant sends the incantation with no leader election nor synchronization of the number of players present on the tile. See Known limitations.

fork — a full stub (never entered)

ForkState (ai/src/strategy/states/fork.py) is a full stub: all four methods raise NotImplementedError (ai/src/strategy/states/fork.py:27,33,37,41). It is registered in the FSM but no transition leads to it; entering it would crash the drone. Reproduction (Fork / Connect_nbr) is therefore not implemented.

Reproduction not implemented

ForkState is inert: no state calls transition_to("fork"). See Known limitations.

State diagram (implemented transitions)

stateDiagram-v2
    [*] --> survive
    survive --> explore: no known food
    survive --> rally: food spotted (go to it)
    survive --> collect: fed (threshold, cf. unit bug)

    collect --> rally: stone spotted
    collect --> explore: stone unknown
    collect --> incant_prep: nothing missing
    collect --> survive: low food

    explore --> survive: food found
    explore --> collect: stone found

    rally --> survive: arrived (return to caller)
    rally --> collect: arrived (return to caller)

    incant_prep --> incant: conditions met
    incant_prep --> survive: low food

    incant --> explore: success
    incant --> incant_prep: failure
    incant --> survive: low food

    fork: fork (unreachable STUB)

rally is a transit state

rally is never a final destination: it always returns to the return_state passed by the caller (survive or collect).

incantation.py module — requirements

ai/src/strategy/incantation.py contains only pure functions over the INCANTATION_REQUIREMENTS table (ai/src/config.py:73):

  • requirements_for(level)(player_count, {stone: quantity}) (ai/src/strategy/incantation.py:15);
  • can_incant(tile_objects, players_on_tile, level) → checks players and stones on the ground (ai/src/strategy/incantation.py:19);
  • missing_stones(inventory_on_tile, level) → stones still missing (ai/src/strategy/incantation.py:34).

Level 8 not covered

The table stops at level 7; requirements_for(8) raises KeyError. No guard prevents this call. See Known limitations.

pathfinding.py module — toric geometry

ai/src/strategy/pathfinding.py, pure functions on a toric world:

  • toric_distance(p1, p2, w, h) (ai/src/strategy/pathfinding.py:14) returns a tuple (min(dx, w−dx), min(dy, h−dy)), not a scalar. Used to compare distances (nearest_food/nearest_resource), never to decide a direction.
  • toric_signed_delta(p1, p2, w, h) (ai/src/strategy/pathfinding.py:31) is the real navigation primitive: it returns the shortest signed per-axis delta (dx>0 = East, dy>0 = South). This is what RallyState uses.

Implemented but unused code

bfs_toric (4-neighbour toric BFS, ai/src/strategy/pathfinding.py:73) and direction_from_sound (ai/src/strategy/pathfinding.py:121) are fully implemented but have no caller. Real navigation uses toric_signed_delta, and broadcast listening (which would feed direction_from_sound) is not wired up. See Known limitations.

Going further