State and Systems¶
This page explains the immutable runtime State
, every system that transforms it, and the precise step()
lifecycle that orchestrates them. It includes data model details, ordering rationales, and practical examples.
Contents
- The State data model
- Immutability and updates
- System-by-system reference
- The step() lifecycle (full order)
- Submoves: speed effects and per-substep systems
- Determinism and randomness
- Practical examples
- Troubleshooting and gotchas
The State data model¶
The runtime State
is an immutable dataclass that holds:
-
Level configuration
width
,height
: Grid dimensions.move_fn
: Movement function (e.g., default, wrap, slippery, windy, gravity).objective_fn
: Win condition function.
-
Entity registry
entity
:PMap[EntityID, Entity]
.EntityID
is an int; every placeable or nested (e.g., inventory/effect) item is an entity.
-
Components (pyrsistent PMaps keyed by
EntityID
)-
Effects
immunity
:PMap[EntityID, Immunity]
phasing
:PMap[EntityID, Phasing]
speed
:PMap[EntityID, Speed]
time_limit
:PMap[EntityID, TimeLimit]
usage_limit
:PMap[EntityID, UsageLimit]
-
Properties
agent
appearance
blocking
collectible
collidable
cost
damage
dead
exit
health
inventory
key
lethal_damage
locked
moving
pathfinding
portal
position
pushable
required
rewardable
status
-
Extra
prev_position
: Snapshot of positions at the start of the step.trail
:Position → set[EntityID]
for entities that traversed a tile this turn.
-
-
Meta and RNG
turn
: int (increments after each step).score
: int.win
,lose
: booleans.message
: optional text.seed
:Optional[int]
used by deterministic systems/rendering.
Tip
- Only non-empty maps are shown in
State.description
, which is a convenient, compact debug view.
Immutability and updates¶
-
State
is frozen (@dataclass(frozen=True)
); systems produce a brand-newState
usingdataclasses.replace
. -
Component maps are pyrsistent PMaps; updates use
.set(key, value)
and.remove(key)
. -
Benefits:
-
Predictable, testable transformations.
-
Easier debugging and time-travel.
-
Avoid accidental in-place mutations across systems.
-
System-by-system reference¶
This section lists each system with its purpose, inputs, outputs, and key details.
-
position_system(state) -> state
-
Purpose: Snapshot current positions into
prev_position
for use by other systems (trail, portals, cross-damage). -
Input:
state.position
. -
Output:
state.prev_position
updated to the positions at the start of the turn. -
Notes: Should run before any movement happens this turn.
-
-
moving_system(state) -> state
-
Purpose: Move autonomous movers according to their
Moving
component (axis, direction, speed, bounce). -
Input:
state.moving
,state.position
. -
Output: Updated position (and possibly
moving.direction
if a bounce happens). -
Blocking rules: Considers in-bounds and
is_blocked_at
; if blocked andbounce=True
, reverse direction once. -
Speed: Applies discrete micro-steps up to
Moving.speed
.
-
-
pathfinding_system(state) -> state
-
Purpose: Move entities that have
Pathfinding
toward their target per turn. -
Modes:
-
STRAIGHT_LINE
: Greedy Manhattan step. -
PATH
: A* pathfinding (uses blocking tiles as obstacles).
-
-
Input:
state.pathfinding
,state.position
,state.blocking
. -
Output: Updated position of pathfinders.
-
Rules:
-
Checks bounds and blocking (ignores
collidable
for movement viability). -
If the pathfinding target has active
Phasing
, a usage tick can be consumed and pursue may be skipped for this step (per code). -
Moves at most one step per turn.
-
-
-
status_tick_system(state) -> state
-
Purpose: Decrement
TimeLimit
for all effects inStatus
sets across all entities. -
Input:
state.status
,state.time_limit
. -
Output: Updated
time_limit
for effect IDs present inStatus.effect_ids
.
-
Trail updates
- Purpose: Record per‑turn traversed tiles for entities as they move.
- Timing: Recorded incrementally during mover/pathfinder updates and after each substep; there is no standalone global trail pass.
- Used by: `portal_system` (to detect entrants), `damage_system` (to detect overlaps and cross paths).
-
push_system(state, eid, next_pos) -> state
-
Purpose: When an agent attempts to step into a cell with a
Pushable
, compute and attempt the push. -
Input: current agent position,
next_pos
. -
Output: If push is valid (destination in-bounds and not blocked), moves both agent and pushable; otherwise no change.
-
Rules:
-
Computes
push_to
viautils.grid.compute_destination(current_pos, next_pos)
, including wrap-around ifmove_fn
iswrap_around_move_fn
. -
Considers blocking/pushable/collidable for the destination.
-
-
-
movement_system(state, eid, next_pos) -> state
-
Purpose: Apply a single-cell move for the agent.
-
Input:
next_pos
frommove_fn
or push flow. -
Output: state with updated agent position if move is valid.
-
Rules:
-
If
Phasing
is active for the agent and valid, consumes usage (if applicable) and ignoresBlocking
. -
Otherwise, requires in-bounds and not
is_blocked_at(..., check_collidable=False)
. -
Collidable
does not block movement here;Blocking
does.
-
-
-
portal_system(state) -> state
-
Purpose: Teleport collidable entities that enter a portal tile to the paired portal’s position.
-
Input:
state.portal
,state.position
,state.prev_position
,state.trail
. -
Output: Updated positions of entities that just entered a portal this turn.
-
Rules:
-
Detects entrants using “augmented trail” and checks
prev_position != position
to avoid re-teleporting. -
Requires both portal ends have valid
Position
.
-
-
-
damage_system(state) -> state
-
Purpose: Apply damage and lethal checks based on co-location or crossing paths with damagers.
-
Input:
state.position
,state.prev_position
,state.trail
,state.damage
,state.lethal_damage
,state.health
. -
Output: Updated
health
/dead
;usage_limit
may be consumed whenImmunity
/Phasing
negate damage. -
Rules:
-
damager_ids
include bothDamage
andLethalDamage
entities in the same tile this turn (trail-aware). -
Cross-damage: If an entity moved out of a tile that a damager moved into from where the entity now is, count it as contact (head-on swap).
-
Immunity
orPhasing
can prevent damage, consuming usage/time.
-
-
-
tile_reward_system(state, agent_id) -> state
-
Purpose: Add score for
Rewardable
entities on the agent’s tile that are not collectible. -
Input:
state.rewardable
,state.collectible
, agent position. -
Output: Updated score.
-
-
tile_cost_system(state, agent_id) -> state
-
Purpose: Subtract score for
Cost
entities on the agent’s tile (post-action, once per action). -
Input:
state.cost
. -
Output: Updated score.
-
Note: Designed to avoid multiplying cost when
Speed
multiplies submoves.
-
-
collectible_system(state, agent_id) -> state
-
Purpose: Pick up items/effects on the agent’s tile.
-
Behavior:
-
If the entity is an effect (
Immunity
/Phasing
/Speed
) and limits allow, add toStatus.effect_ids
. -
Else add to
Inventory.item_ids
(e.g., keys, coins). -
If the entity has
Rewardable
, add to score. -
Remove collected entities from world
position
/collectible
maps.
-
-
Output: Updated status/inventory/score and removal from world.
-
-
unlock_system(state, agent_id) -> state
-
Purpose: Unlock adjacent doors with
Locked(key_id)
if the agent holds a matching key. -
Input: Adjacent tiles;
Inventory
;Key
store. -
Output: Removes
Locked
(andBlocking
) from matching doors and consumes the key. -
Note: Triggered by
Action.USE_KEY
; checks four-neighborhood around agent.
-
-
status_gc_system(state) -> state
-
Purpose: Garbage-collect effect IDs from
Status
sets and remove orphan/expired effect entities. -
Removes:
-
Effect IDs that no longer exist as a component in any effect store (orphaned).
-
Expired effects by time or usage (
<= 0
).
-
-
Output: Updated
status
/entity
/time_limit
/usage_limit
(entity map pruned where needed).
-
-
win_system(state, agent_id) -> state
-
Purpose: Set
win=True
if the configuredobjective_fn
returns true. -
Input:
state.objective_fn
, agent position, stores used by objective. -
Output:
win
flag possibly set.
-
-
lose_system(state, agent_id) -> state
-
Purpose: Set
lose=True
if the agent isDead
. -
Input:
state.dead
. -
Output:
lose
flag possibly set.
-
-
run_garbage_collector(state) -> state
(utils.gc)-
Purpose: Remove entities not reachable from live structures (positions, inventories, status lists, etc.).
-
Output: All component PMaps filtered to only include alive entities.
-
The step() lifecycle (full order)¶
The main reducer is grid_universe.step.step
. Its control flow:
-
Early checks
-
If there is no agent or agent is dead →
lose=True
or no-op. -
If already terminal (
win
orlose
) → no-op.
-
-
Pre-action systems (world updates and bookkeeping)
1)
position_system
2)
moving_system
3)
pathfinding_system
4)
status_tick_system
5) Trail updates
-
Action handling
-
If action is a MOVE (
UP/DOWN/LEFT/RIGHT
):-
Call
_step_move
:-
Determine
move_count
based onSpeed
effect (consumes usage if applicable). -
For each submove (
1..move_count
):-
For each target
Position
frommove_fn(state, agent, action)
:-
Try
push_system
. -
Else try
movement_system
. -
After either, run the per-substep suite:
-
Record trail for the agent’s current tile
-
portal_system
-
damage_system
-
tile_reward_system
-
position_system
thenwin_system
/lose_system
-
-
If
win/lose/dead
or blocked, exit early.
-
-
-
-
-
If action is
USE_KEY
→unlock_system
. -
If action is
PICK_UP
→collectible_system
. -
If action is
WAIT
→ no movement, but still run the per-substep suite once (trail, portal, damage, tile reward, position snapshot, win/lose).
-
-
If action is not a MOVE, immediately run the per-substep suite once
- Record trail for the agent’s current tile →
portal_system
→damage_system
→tile_reward_system
→position_system
→win_system
→lose_system
- Record trail for the agent’s current tile →
-- Post-step systems (always run if not returned earlier)
1) `status_gc_system`
2) `tile_cost_system`
3) `turn += 1`
4) `run_garbage_collector`
Submoves: speed effects and per-substep systems¶
-
Speed(multiplier)
in the agent’sStatus
can increase the number of submoves for one action. -
Usage limits for
Speed
are decremented when it actually increases movement. -
Per-substep suite (trail record, portals, damage, tile rewards, position snapshot, win/lose) runs after each micro-move or push attempt, so immediate effects (like teleport or damage) can end the step early.
-
tile_cost_system
is deliberately post-step (once) to avoid penalizing more for higher speed.
Determinism and randomness¶
-
State.seed
(if provided) is a base for deterministic behavior. -
Examples:
-
windy_move_fn
: secondary step trigger derives fromhash((state.seed or 0, state.turn))
for reproducibility. -
The renderer’s directory-based texture selection uses a deterministic choice derived from
state.seed
.
-
-
Best practice:
-
Always set seeds for reproducible runs and tests.
-
Avoid non-deterministic iteration orders; sort when needed for stable behavior.
-
Practical examples¶
Selecting a move_fn
and objective_fn
on a Level
from grid_universe.levels.grid import Level
from grid_universe.moves import slippery_move_fn
from grid_universe.objectives import exit_objective_fn
level = Level(
width=9, height=9,
move_fn=slippery_move_fn,
objective_fn=exit_objective_fn,
seed=7,
)
Applying a single step with an action
from grid_universe.actions import Action
from grid_universe.step import step
agent_id = next(iter(state.agent.keys()))
state = step(state, Action.RIGHT, agent_id=agent_id)
Manually invoking a system (debugging)
from grid_universe.systems.portal import portal_system
state = portal_system(state) # apply only portal logic to current state
Inspect damage contacts for an entity (pattern)
from grid_universe.systems.damage import get_damager_ids
from grid_universe.utils.trail import get_augmented_trail
from pyrsistent import pset
from grid_universe.components import Position
# Build augmented trail (who traversed each tile) for entities relevant to damage
aug = get_augmented_trail(state, pset(set(state.health) | set(state.damage) | set(state.lethal_damage)))
pos = state.position.get(agent_id)
if pos is not None:
print("Damagers at agent position:", get_damager_ids(state, aug, pos))
Troubleshooting and gotchas¶
-
My agent appears to move “through” enemies but still takes damage.
movement_system
ignoresCollidable
for blocking (it only blocks onBlocking
), butdamage_system
still applies contact-based damage when positions overlap or cross. UseImmunity
to negate, or avoid the overlap.
-
Speed seems to make me pay multiple movement costs.
- By design,
tile_cost_system
is applied once after the action (not per submove). If you observe otherwise, check if you’re adding custom costs elsewhere.
- By design,
-
Portals feel inconsistent.
- Entrants are detected via
trail
andprev_position
; ensureposition_system
ran before moving, and that both portal ends exist withPosition
. Also verify the entity isCollidable
.
- Entrants are detected via
-
Effects don’t expire.
status_tick_system
decrementsTimeLimit
each turn;status_gc_system
removes effects whentime/usage <= 0
. Confirm the effect entity is actually in the holder’sStatus.effect_ids
, and that limits were set on that effect entity (not on the holder).
-
Pathfinding enemy won’t move.
- Confirm blocking rules: if all neighbors are blocked, it won’t step. For
PATH
mode, ensure there is a valid path; forSTRAIGHT_LINE
, it greedily moves closer only if the next tile isn’t blocking.
- Confirm blocking rules: if all neighbors are blocked, it won’t step. For
Tip
- Use
State.description
to quickly see which component stores are non-empty during debugging.