‹ XENON 2AutopilotAutopilot decision cost
Xenon 2 · autoplay.py

Where the per-frame decision goes

One canonical Atari frame (~4 VBLs, ~80ms of game time) must produce one joystick decision. Right now that decision costs 60–100ms of wall time on a modern desktop — already past budget before Level 3–5 content adds more hazards. This maps every layer between ReactiveController.choose() and the injected input, with the actual multipliers measured from a full-campaign-flow-field-1 offline profile (frames 0–3260, Level 1 only).

Dominant cost — candidate scoring
9 candidates × ≤56 horizon frames × ~13 hazards = up to 6,552 checks / decision
Re-run every canonical frame — roughly once every 80 ms of game time. _score alone is 63–66% of total decision time.
01

The per-frame pipeline

Every stage runs once per frame except the two shaded ones — those are the multipliers. Score candidates always runs at 9×horizon×hazards; the beam search only triggers during an active escape, but is a second, independent multiplication on top of it.

flowchart TD
    A["Canonical Atari frame\nxenon_client.py"] --> B["Rebuild WorldState + TrackedObjects\nworld_state.py"]
    B --> C["Update PersistentWorldMap + A* route\nworld_map.py _ensure_navigation_grid"]
    C --> D["Select tactic by precedence\n30+ tactic rows, first match wins"]
    D --> E["Rank target, compute aim / intercept"]
    E --> F["_relevant_scoring_hazards()\ncull off-screen formation + swarm bodies"]
    F --> G["score all 9 joystick candidates\n_score() per candidate"]
    G --> H["tactic proposes region\n+ reaction band + route tube"]
    H --> I["survival arbitration\ncollision time / exposure / route progress"]
    I --> J{"worm / swarm needs\nmulti-frame escape?"}
    J -- no --> K["lowest-score legal action"]
    J -- yes --> L["_formation_escape_decision()\nbeam search, width 24-48, depth <=48"]
    L --> K
    K --> M["add alternating fire bit, inject, step frame"]

    style G fill:#c96a1c22,stroke:#c96a1c,stroke-width:2px
    style L fill:#c4324a22,stroke:#c4324a,stroke-width:2px
    style C fill:#0f8f8222,stroke:#0f8f82,stroke-width:1.5px
source: xenondoc/AUTOPILOT.MD § Decision cycle and movement ownership, cross-checked against autoplay.py ReactiveController.choose()
02

The three cost centers

Measured with validate_campaign.py profile ... --cprofile over 3,260 Level 1 frames (2,318 of them real decisions). 807M total function calls in that window — roughly 247,000 per frame.

hot

_score() — candidate scoring

for candidate in CANDIDATES (9, fixed):
  for horizon in 1..prediction_limit (8 base, up to 56):
    for hazard in _scoring_hazards (~13 kept, Lvl 1):
      distance / contact / clearance checks

Called 20,862 times over the window — exactly 9× every decision frame. prediction_limit is a single shared value: if any hazard that frame is a swarm member, the horizon for the whole call jumps to 56, even for hazards that would only need 8.

Hazard geometry itself is already cached once per (frame, horizon) — _scoring_hazard_sweeps. What still runs 9× is the distance/contact test between that cached geometry and each candidate's own predicted position.

autoplay.py:24818 _score autoplay.py:25496 horizon loop autoplay.py:19093 _relevant_scoring_hazards
hot, conditional

_formation_escape_decision() — beam search

for frame in 1..horizon (≤48):
  for node in beam (width 24–48):
    for candidate in CANDIDATES (9):
      expand, score, re-sort, keep top beam_width

Every call is a two-tier gate: a cheap revalidation of an already-retained plan runs first, and most calls exit there. When it falls through to a fresh search, the node count is horizon × beam_width × 9 — up to ~20,700 node expansions for one decision, each doing its own hull/collision math.

This is a second, independent multiplication layered on top of candidate scoring — not shared work with it — and only triggers during active worm/swarm escape, so its cost is bursty rather than constant.

autoplay.py:12878 _formation_escape_decision autoplay.py:14058 beam_width autoplay.py:15702 node expansion loop
warm

_ensure_navigation_grid() — route / A*

per newly-discovered map row:
  rebuild 4px configuration-space grid cells
  for node in grid: 8-connected A*, clearance bias

Scales with discovered map area, not with hazard count or candidate count — a different axis entirely from the two above. Flagged here because it was the third-largest single contributor in the same profiling window, not because it shares the candidate×horizon×hazard shape.

world_map.py:2138 _ensure_navigation_grid
Total window
227.9s
Decision frames
2,318
Total calls
807M
Calls / frame
~247,000
_score calls
20,862
avg hazards kept
13.3
03

How cost grows with each parameter

The candidate count is fixed; everything else is a multiplier the current architecture pays repeatedly rather than once.

ParameterCurrent rangeWhere it multipliesGrowth
CANDIDATES 9 (fixed) Outer loop of _score AND the branching factor of every beam node linear
prediction_limit 8 base → 56 Inner horizon loop in _score; shared across all 9 candidates and ALL hazards that frame, even ones that don't need it multiplies scoring
hazard count ~13 kept / ~24 raw Innermost loop of _score; re-walked once per candidate per horizon frame multiplies scoring
beam_width 24 – 48 Nodes retained per beam-search frame; each expands into 9 children multiplies beam
beam horizon ≤ 48 frames Outer loop of the beam search, compounding with beam_width × 9 compounds beam
discovered map area grows with progress _ensure_navigation_grid rebuild cost, independent axis linear

What this rules in for the re-engineering conversation