Systems Lifecycle¶
This page provides a deep dive into the full lifecycle of systems executed per step()
, why the order matters, what each stage consumes/produces, and how to safely add your own systems. It also covers per-substep vs per-step timing, the role of prev_position
and trail
, and practical diagnostics.
Contents
- Big picture: one step, many transforms
- Master order (annotated)
- Data dependencies and invariants
- Per-substep vs per-step systems
- Roles of
prev_position
andtrail
- Adding your own systems (where to hook)
- Timing examples (timeline snippets)
- Design guidelines (idempotence, purity, determinism)
- Common patterns and pitfalls
- Diagnostics and testing
Big picture: one step, many transforms¶
step(state, action, agent_id)
is the single orchestrator that:
-
Prepares the
State
for this turn (snapshot, autonomous movements, timers). -
Applies the agent’s action, possibly executing multiple submoves if
Speed
is active. -
Interleaves critical interactions (push, movement, portals, damage, tile rewards) after each submove to allow immediate consequences.
-
Wraps up by cleaning up expired effects, charging tile costs, checking terminal conditions, advancing time, and garbage-collecting unreachable entities.
This fixed order keeps behavior predictable and deterministic, ensuring consistent outcomes across runs given the same seed and actions.
Master order (annotated)¶
The exact execution order in grid_universe.step.step
is:
-
Early checks
-
No agent found → raise or no-op depending on context.
-
If agent in
dead
orstate
already terminal (win
/lose
) → return currentstate
.
-
-
Pre-action systems (world updates and bookkeeping)
1)
position_system
- Snapshot all current positions into `prev_position`. - This “freezes” last turn’s locations for downstream systems that need to know “where entities came from.”
2)
moving_system
- Move autonomous entities that have `Moving` (axis, direction, speed). - Handles bouncing or stopping on blockage, up to `Moving.speed` micro-steps.
3)
pathfinding_system
- Move entities with `Pathfinding` one step toward targets. - `PATH` uses A* on non-blocking tiles; `STRAIGHT_LINE` is greedy.
4)
status_tick_system
- Decrement `TimeLimit` for all effects referenced by `Status` across all entities.
5) Trail updates
- Trail entries are recorded incrementally while movers advance and after each substep.
-
Action handling
-
If action in
MOVE_ACTIONS
:-
Determine
move_count
(Speed effect may multiply). -
For each submove (
1..move_count
):-
For each proposed
Position
bymove_fn
(someMoveFn
can propose multiple positions per action):-
push_system
: try to pushPushable
at the target; may move both pusher and pushable if destination is free. -
movement_system
: if push did not move, attempt to move agent one cell. -
Per-substep suite (in this exact order):
-
Record trail for the agent’s current position.
-
portal_system
: teleport collidable entrants to paired portals; usesprev_position
andtrail
to detect entrants. -
damage_system
: applyDamage
/LethalDamage
considering co-location and cross-path;Immunity
/Phasing
can negate. -
tile_reward_system
: add score for non-collectibleRewardable
tiles the agent is on. -
position_system
: snapshot positions again for downstream checks. -
win_system
,lose_system
: evaluate terminal conditions after the substep.
-
-
If the move was blocked or
state
became terminal (win
/lose
/dead
), break early.
-
-
-
-
Else if action ==
USE_KEY
:-
unlock_system
: unlock adjacent doors (Locked
) if the matching key is in the agent’s inventory. -
Then run the per-substep suite once:
trail record → portal_system → damage_system → tile_reward_system → position_system → win_system → lose_system
-
-
Else if action ==
PICK_UP
:-
collectible_system
: pick up items/effects on the agent’s tile; may add toInventory
/Status
and grantRewardable
. -
Then run the per-substep suite once:
trail record → portal_system → damage_system → tile_reward_system → position_system → win_system → lose_system
-
-
Else if action ==
WAIT
:- No movement or world change; the per‑substep suite runs once (trail record, portal, damage, tile reward, position snapshot, win/lose).
-
-
Post-step systems (exactly once per step)
1)
status_gc_system
- Remove expired effects from `Status` and delete orphan/expired effect entities.
2)
tile_cost_system
- Subtract `Cost` for the agent’s current tile (only once per action, not per submove).
3) turn increment
- `turn += 1`
4)
run_garbage_collector
- Prune entities not reachable via any live maps (positions, inventory sets, status sets, etc.).
Data dependencies and invariants¶
Many systems consume artifacts produced by earlier systems:
-
position_system
must run before anything else that compares “before vs after” positions. -
moving_system
andpathfinding_system
occur before the agent acts to “advance the world” around the agent first. -
Trail is updated during autonomous movement and after each substep;
portal
anddamage
consulttrail
(andprev_position
) to detect entrants, swaps, and cross‑through collisions. -
portal_system
uses bothtrail
andprev_position
to detect entrants and avoid re-teleport loops in the same turn. -
damage_system
consultstrail
(for cross paths), andprev_position
(for swap collisions). -
tile_cost_system
runs post-step to avoid multi-charging withSpeed
.
Invariants you should preserve:
-
Positions remain in-bounds.
-
movement_system
does not allow walking intoBlocking
unlessPhasing
is active (and then consumes it via usage limit, if present). -
GC removes entities only if they are not found in any live structure (including nested references).
Per-substep vs per-step systems¶
Per-substep (executed after each micro‑move or attempted push, and once for non‑move actions):
-
Record trail for the acting agent’s current tile
-
portal_system
-
damage_system
-
tile_reward_system
-
position_system
(snapshot after substep) -
win_system
,lose_system
Per-step (executed once, after action handling):
-
status_gc_system
-
tile_cost_system
-
turn increment
-
run_garbage_collector
Why this matters:
-
Immediate effects (teleport, damage, immediate rewards) should react to each micro-move, enabling realistic motion–interaction coupling.
-
Persistent bookkeeping (costs, GC, victory/defeat) should occur once to keep semantics and scoring fair and deterministic.
Roles of prev_position
and trail
¶
-
prev_position
-
Snapshot from the start of the step. Used to compare where an entity was vs where it is, which is vital for:
-
Detecting “entrants” into portals (avoid double teleporting stationary entities).
-
Detecting cross damage (two entities moving through each other’s tiles).
-
-
-
trail
-
A map of
Position → set[EntityID]
collected during the step showing all tiles traversed between prev and current positions (Manhattan path, x then y). -
Used by:
-
portal_system
to confirm entries. -
damage_system
to handle both co-location and cross paths (swap/cross-through collisions).
-
-
Adding your own systems (where to hook)¶
Where a new system should be placed depends on its semantics:
-
If it depends on the agent’s micro-movement and should apply immediately (e.g., a bounce pad, poison tile tick):
- Place it in the per-substep suite. For example, after
tile_reward_system
if you want rewards to apply before your effect.
- Place it in the per-substep suite. For example, after
-
If it modifies the world generally each turn (autonomous changes, environmental decay):
- Place it in pre-action updates (after
status_tick_system
). Trail is recorded incrementally as movers/pathfinders advance.
- Place it in pre-action updates (after
-
If it’s a “once-per-action” effect (e.g., banking points, draining resources post-action):
- Place it in the post-step list, possibly before or after
tile_cost_system
.
- Place it in the post-step list, possibly before or after
Typical extension placements:
-
Per-substep (after movement):
portal → damage → tile_reward → your_system
(e.g., bounce_pad, poison, conveyor belt).
-
Pre-action:
- After moving/pathfinding, before
trail
if your system moves entities that should contribute to thetrail
; otherwise aftertrail
if you only inspect current locations.
- After moving/pathfinding, before
-
Post-step:
- Before/after
tile_cost_system
depending on whether your cost interacts with tile costs.
- Before/after
Timing examples (timeline snippets)¶
Consider a turn where:
-
A moving box moves horizontally.
-
A pathfinding enemy takes one step toward the agent.
-
The agent has
Speed ×2
and takesAction.RIGHT
. -
There’s a portal to the right, and a spike (damage) beyond it.
Timeline:
-
Pre-action:
-
position_system
: snapshot positions. -
moving_system
: box moves 1 step; may bounce. -
pathfinding_system
: enemy steps toward agent. -
status_tick_system
: decrementTimeLimit
. -
Trail is recorded as movers advance (no global trail pass).
-
-
Action, submove 1:
-
push_system
: agent tries to push if needed. -
movement_system
: agent moves right (if not blocked). -
portal_system
: if agent enters the portal tile, teleport. -
damage_system
: apply damage if agent now overlaps spikes or crossed a damager. -
tile_reward_system
: add rewards (e.g., floor bonuses). -
position_system
thenwin_system / lose_system
.
-
-
Action, submove 2 (
Speed
):- Same sequence as above; may exit early if agent died or won.
-
Post-step:
-
status_gc_system
: remove expired effects. -
tile_cost_system
: subtract cost (once). -
win_system / lose_system
: set flags. -
turn++
andrun_garbage_collector
.
-
Design guidelines (idempotence, purity, determinism)¶
-
Purity:
- Systems should be pure:
State
in →State
out (no side effects, no global mutation).
- Systems should be pure:
-
Idempotence:
- Within a single call, avoid making the same change twice if called repeatedly (e.g., resist additive side-effects if called on unchanged input).
-
Determinism:
- Any randomness should be derived from
(state.seed, state.turn)
and not from global RNG, ensuring reproducibility.
- Any randomness should be derived from
-
Minimal scope:
- Iterate only over relevant entities (e.g., keys of a particular component store).
-
Avoid order races:
- If two systems might conflict, verify the intended ordering constraints and document them (e.g., “must run before
tile_reward_system
”).
- If two systems might conflict, verify the intended ordering constraints and document them (e.g., “must run before
Common patterns and pitfalls¶
Patterns:
-
“Conditional single-shot” effects:
- Place in the per-substep suite after movement, check a condition on the agent’s tile, adjust state accordingly.
-
“Global tick” effects:
- Place in pre-action updates, reading/writing only small parts of
State
.
- Place in pre-action updates, reading/writing only small parts of
Pitfalls:
-
Double-counting:
- Do not add costs/rewards both per-substep and per-step unless that is intended.
-
Blocking confusion:
movement_system
ignoresCollidable
but respectsBlocking
;pathfinding_system
uses similar logic. Keep consistency across new systems.
-
Trail confusion:
- Remember that
trail
is Manhattan interpolation between prev and current; don’t assume diagonal adjacency is tracked unless split into axis steps.
- Remember that
-
Portal loops:
- Let
portal_system
useprev_position
and entering detection to avoid instant back-and-forth loops.
- Let
-
GC surprises:
- If you hold references to entities only in local variables, GC will remove them. Keep needed references in
State
stores (Inventory
/Status
/etc.).
- If you hold references to entities only in local variables, GC will remove them. Keep needed references in
Diagnostics and testing¶
-
Instrumentation:
- Log key fields per step:
turn
, agent position,score
,win/lose
, counts of specific components.
- Log key fields per step:
-
Visual debug:
- Save frames from
TextureRenderer
at multiple points (pre-action, after each submove, post-step) if you temporarily expose hooks, or simply render eachState
after step.
- Save frames from
-
Unit tests:
- Write scenario-specific tests for your systems, isolating inputs and asserting outputs (e.g., a poison tile reduces HP by X per submove).
-
Reproducibility:
- Fix
seed
and action sequences to make tests deterministic.
- Fix
-
State.description
:- Use it as a quick health check to assert non-empty stores you expect and zero where you don’t.
Example: inserting a custom per-substep system¶
Suppose you’ve implemented conveyor_belt_system
that nudges the agent along the belt direction if they stand on it after moving.
- Place
conveyor_belt_system
after movement and after portal/damage/reward (so that teleports and damage apply first), or move it just after movement if you want belts to act before portals/damage.
Insertion example:
# step.py (conceptual snippet)
def _after_substep(state: State, action: Action, agent_id: EntityID) -> State:
state = portal_system(state)
state = damage_system(state)
state = tile_reward_system(state, agent_id)
state = conveyor_belt_system(state, agent_id) # custom per-substep
return state
Guideline:
- Always reason about relative ordering to existing effects to avoid surprises (e.g., getting shoved into a portal or damage tile before or after application).