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 tosurvive; - current state
collect→ look for the target stone, return tocollect; - 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:
- ask the current state
should_transition(); if a name is returned, transition immediately (ai/src/strategy/fsm.py:105-108); - ask for the next command via
next_command(). If empty, returnFalse(nothing to send this tick); - 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 viaadd_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, anInventoryis injected automatically (ai/src/strategy/fsm.py:192-201) to resynchronize thefoodstock 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 whatRallyStateuses.
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¶
- Decision loop — how the FSM fits into the end-to-end network loop.
- Internal state —
DroneState, toric map, vision. - Team coordination — encrypted inter-drone layer (not wired up).