Extending the System¶
This guide explains how to add new components, systems, movement/objective functions, rendering rules, and authoring-time factories to Grid Universe. It walks through the required code changes, ordering in the step()
lifecycle, testing strategies, and common pitfalls.
Contents
- Extension points overview
- Adding a new component
- Mapping components for authoring and runtime
- Creating factories (authoring-time helpers)
- Implementing a new system
- Wiring the system into the
step()
lifecycle - Rendering integration (textures, grouping, recolor)
- Adding a new
MoveFn
- Adding a new
ObjectiveFn
- Serialization and conversion considerations
- Testing and determinism
- Performance and maintenance tips
Extension points overview¶
Grid Universe is built on ECS (Entity–Component–System). You can extend it by:
-
Components
-
New “property” components (e.g.,
BouncePad
,PoisonCloud
). -
New “effect” components (e.g.,
Slow
,DoubleScore
).
-
-
Systems
- Pure functions
State -> State
that read/write relevant component maps.
- Pure functions
-
Movement and objectives
-
MoveFn
: different movement semantics. -
ObjectiveFn
: new win conditions.
-
-
Authoring-time tooling
EntitySpec
fields andLevel
factories for convenient content creation.
-
Rendering
- Texture maps, grouping rules, recoloring, overlays.
-
Gym environment integration
- Use a custom
initial_state_fn
for your levels; no changes to env class needed.
- Use a custom
Adding a new component¶
Suppose we want a “BouncePad” tile that pushes the agent forward an extra step after entering the tile.
1) Create the component dataclass
- Place it under
grid_universe/components/properties
if it is a property, orgrid_universe/components/effects
if it is an effect.
# grid_universe/components/properties/bounce_pad.py
from dataclasses import dataclass
@dataclass(frozen=True)
class BouncePad:
strength: int = 1 # number of extra steps to push in the same direction
2) Export it
- Export from
grid_universe/components/properties/__init__.py
andgrid_universe/components/__init__.py
.
3) Add it to State
- Add a
PMap
field keyed byEntityID
ingrid_universe/state.py
.
# grid_universe/state.py (extract)
from grid_universe.components.properties import BouncePad
from pyrsistent import PMap, pmap
from grid_universe.types import EntityID
@dataclass(frozen=True)
class State:
# ...
bounce_pad: PMap[EntityID, BouncePad] = pmap()
# ...
4) Map it for authoring
- Add an entry to
COMPONENT_TO_FIELD
ingrid_universe/levels/entity_spec.py
.
# grid_universe/levels/entity_spec.py (extract)
from grid_universe.components.properties import BouncePad
COMPONENT_TO_FIELD = {
# ...
BouncePad: "bounce_pad",
}
5) Import/export hygiene
-
Ensure imports do not create circular references.
-
Keep consistent naming and placement alongside existing components.
Mapping components for authoring and runtime¶
-
Authoring-time
EntitySpec
is a bag of optional component fields (None
means absent). -
Conversion to State (
levels.convert.to_state
):-
Entities placed on the Level grid become runtime entities with
Position
. -
Fields present on
EntitySpec
are copied into the corresponding State component stores.
-
-
Conversion from State (
levels.convert.from_state
):-
Positioned entities are reconstructed into
EntitySpec
with components set from State fields. -
Inventory/status lists are reconstructed as authoring-only nested lists.
-
Creating factories (authoring-time helpers)¶
Add a helper in levels/factories.py
to quickly create your object with sensible defaults.
# grid_universe/levels/factories.py (extract)
from grid_universe.components.properties import Appearance, AppearanceName, BouncePad
from .entity_spec import EntitySpec
def create_bounce_pad(strength: int = 1, priority: int = 6) -> EntitySpec:
return EntitySpec(
appearance=Appearance(name=AppearanceName.GEM, priority=priority), # reuse an icon, or add a new appearance
bounce_pad=BouncePad(strength=strength),
# Typically no Blocking/Collidable; acts like a floor behavior overlay
)
Notes:
-
For new tiles with blocking behavior, include
Blocking
. -
For damage-like hazards, add
Damage
and optionallyLethalDamage
. -
For collectables, add
Collectible
and optionallyRewardable
.
Implementing a new system¶
Systems are small, pure functions that transform State. Let’s implement a bounce_pad_system
that, after a successful agent move, pushes the agent forward by BouncePad.strength
steps in the same direction of travel.
Key decision: where to hook
-
Since this depends on the last move direction, we should run after
movement_system
within each submove (i.e., inside the per-substep suite) or derive direction fromprev_position → position
. -
A simple approach: compute direction from
(prev_position[agent] -> position[agent])
for the current step.
# grid_universe/systems/bounce_pad.py
from dataclasses import replace
from grid_universe.state import State
from grid_universe.types import EntityID
from grid_universe.components import Position
from grid_universe.utils.ecs import entities_with_components_at
from grid_universe.utils.grid import is_in_bounds, is_blocked_at
def bounce_pad_system(state: State, agent_id: EntityID) -> State:
# If no movement happened, there's no direction to push
curr = state.position.get(agent_id)
prev = state.prev_position.get(agent_id)
if curr is None or prev is None or (curr.x == prev.x and curr.y == prev.y):
return state
dx = (curr.x > prev.x) - (curr.x < prev.x)
dy = (curr.y > prev.y) - (curr.y < prev.y)
if dx == 0 and dy == 0:
return state
# Is there a BouncePad at the agent's current tile?
pads = entities_with_components_at(state, curr, state.bounce_pad)
if not pads:
return state
pad_id = pads[0]
strength = max(0, state.bounce_pad[pad_id].strength)
if strength == 0:
return state
# Push forward up to strength steps (stop if blocked or OOB)
pos = curr
for _ in range(strength):
next_pos = Position(pos.x + dx, pos.y + dy)
if not is_in_bounds(state, next_pos) or is_blocked_at(state, next_pos, check_collidable=False):
break
# Move the agent by directly updating position (since we're inside a system)
state = replace(state, position=state.position.set(agent_id, next_pos))
pos = next_pos
return state
Notes:
-
We used
prev_position
to derive direction. Ensureposition_system
ran earlier this step. -
We used
is_blocked_at
withcheck_collidable=False
to mirrormovement_system
’s blocking rule. -
For multi-agent or NPC effects, loop over all relevant entities.
Wiring the system into the step()
lifecycle¶
The step()
lifecycle (grid_universe/step.py
) calls:
-
Pre:
position_system → moving_system → pathfinding_system → status_tick_system
-
Per-submove (for MOVE actions):
push_system → movement_system → portal_system → damage_system → tile_reward_system
-
Post:
status_gc_system → tile_cost_system → win_system → lose_system → turn++ → run_garbage_collector
To include bounce_pad_system
after each submove, insert it into _after_substep
:
# grid_universe/step.py (extract)
from grid_universe.systems.bounce_pad import bounce_pad_system
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)
# Add bounce behavior after we've potentially teleported/damaged/scored
state = bounce_pad_system(state, agent_id)
return state
Ordering rationale:
- We want the pad to react to where the agent ended up after any portal teleport. If you want bounce to happen before portals or before damage, adjust ordering accordingly.
Alternative hooks:
-
If your system must happen only once per action, add it to
_after_step
. -
If it changes world tiles before movement, place it before
moving_system
(rare).
Rendering integration (textures, grouping, recolor)¶
-
Add a texture entry for your new appearance or reuse existing ones in
renderer.texture.DEFAULT_TEXTURE_MAP
. -
If you want group-based recoloring (like keys/doors, portals), add a
GroupRule
that assigns a group ID string to your entities and the renderer will recolor viaapply_recolor_if_group
.
Example: color all BouncePads by strength bucket
# custom grouping rule
from typing import Optional
from grid_universe.state import State
from grid_universe.types import EntityID
def bounce_pad_group_rule(state: State, eid: EntityID) -> Optional[str]:
if eid in state.bounce_pad:
s = state.bounce_pad[eid].strength
bucket = "low" if s <= 1 else "mid" if s <= 3 else "high"
return f"bpad:{bucket}"
return None
-
To use this, pass a custom rules list to
derive_groups
inrenderer.texture.render
(copy and adapt the function if needed), appending your rule toDEFAULT_GROUP_RULES
. -
For overlays: you can mimic
draw_direction_triangles_on_image
to add special glyphs on top of textures if the overlay depends on component state.
Adding a new MoveFn
¶
MoveFn
signature: MoveFn(State, EntityID, Action) -> Sequence[Position]
. The function proposes positions for a single high-level action.
Example: “dash then drift” MoveFn
from typing import Sequence
from grid_universe.components import Position
from grid_universe.actions import Action
from grid_universe.types import EntityID
from grid_universe.state import State
def dash_then_drift_move_fn(state: State, eid: EntityID, action: Action) -> Sequence[Position]:
pos = state.position[eid]
dx, dy = {
Action.UP: (0, -1),
Action.DOWN: (0, 1),
Action.LEFT: (-1, 0),
Action.RIGHT: (1, 0),
}[action]
# 2-step dash then one orthogonal drift to the right of the direction
path = [Position(pos.x + dx, pos.y + dy), Position(pos.x + 2*dx, pos.y + 2*dy)]
drift_dx, drift_dy = -dy, dx # rotate 90 degrees
path.append(Position(path[-1].x + drift_dx, path[-1].y + drift_dy))
return path
Register it for convenience:
# grid_universe/moves.py (append)
from grid_universe.types import MoveFn
def dash_then_drift_move_fn(state: State, eid: EntityID, action: Action) -> Sequence[Position]:
# (same as above)
...
MOVE_FN_REGISTRY["dash_drift"] = dash_then_drift_move_fn
Notes:
-
Do not enforce bounds/blocking inside the
MoveFn
; systems will handle that. -
If you need randomness, derive it deterministically from
(state.seed, state.turn)
.
Adding a new ObjectiveFn
¶
ObjectiveFn
signature: ObjectiveFn(State, EntityID) -> bool
. Return True
to set win.
Example: “survive N turns and stand on exit”
from grid_universe.types import ObjectiveFn, EntityID
from grid_universe.state import State
from grid_universe.utils.ecs import entities_with_components_at
def survive_and_exit_objective_fn_factory(turns: int) -> ObjectiveFn:
def _obj(state: State, agent_id: EntityID) -> bool:
if state.turn < turns:
return False
pos = state.position.get(agent_id)
if pos is None:
return False
return len(entities_with_components_at(state, pos, state.exit)) > 0
return _obj
Register it if desired:
# grid_universe/objectives.py (append)
from grid_universe.types import ObjectiveFn
def survive_and_exit_objective_fn_factory(turns: int) -> ObjectiveFn:
# (same as above)
...
OBJECTIVE_FN_REGISTRY["survive_exit_20"] = survive_and_exit_objective_fn_factory(20)
Notes:
-
Keep it fast and pure: do not mutate State.
-
Prefer local checks (e.g., positions, component presence) over scanning all stores.
Serialization and conversion considerations¶
-
to_state(level)
copies present components; ensure your new component is included inCOMPONENT_TO_FIELD
and theState
class. -
from_state(state)
reconstructs authoring-timeEntitySpec
for positioned entities; it will set your component field if present. -
Nested entities (inventory/effects) are handled via
inventory_list
/status_list
. If your new effect is collectible, ensurecollectible_system
logic recognizes it viahas_effect(state, eid)
.
Testing and determinism¶
Unit tests
-
Components: verify round-trip
Level -> State -> Level
preserves your component fields. -
Systems: small, controlled State to test system behavior, including edge cases (OOB, blocked, zero strength).
-
Step lifecycle: integration test to ensure ordering produces expected outcomes.
Determinism
- Any randomness within new systems or
MoveFn
s should be seeded by(state.seed, state.turn)
, e.g.:
import random
def rng_for_turn(state: State) -> random.Random:
base_seed = hash((state.seed if state.seed is not None else 0, state.turn))
return random.Random(base_seed)
Coverage
-
Add tests to ensure expired effects are GC’d, bonuses apply once, etc.
-
Assert that performance-sensitive loops don’t scale badly (e.g., O(N^2) scans).
Performance and maintenance tips¶
-
Keep systems focused and cheap: read only required maps, write only what you change.
-
Reuse helper utilities (
is_in_bounds
,is_blocked_at
,entities_with_components_at
). -
Respect existing blocking rules (movement ignores
Collidable
;Blocking
blocks). -
Think about where your system sits in the lifecycle for best UX and correctness.
-
For rendering:
-
Use cache-friendly parameters (size, group, overlay inputs).
-
Avoid recomputing recolors every frame unless necessary.
-
-
Documentation:
-
Add docstrings following Google style so mkdocstrings renders nice API docs.
-
Update guides referencing your new features.
-
-
Versioning:
- If you change
step()
ordering or semantics, note it in the Changelog and bump a minor/major version as appropriate.
- If you change
Additional example: PoisonCloud
(damage over time)¶
Define the component:
# grid_universe/components/properties/poison_cloud.py
from dataclasses import dataclass
@dataclass(frozen=True)
class PoisonCloud:
amount: int = 1 # damage per substep
Add to State, map in EntitySpec
, export in __init__
. Then implement a system:
# grid_universe/systems/poison.py
from dataclasses import replace
from grid_universe.state import State
from grid_universe.types import EntityID
from grid_universe.components import Health, Dead
from grid_universe.utils.ecs import entities_with_components_at
def poison_system(state: State, agent_id: EntityID) -> State:
pos = state.position.get(agent_id)
if pos is None:
return state
cloud_ids = entities_with_components_at(state, pos, state.poison_cloud) # requires State.poison_cloud
if not cloud_ids or agent_id not in state.health:
return state
hp = state.health[agent_id]
dmg = sum(state.poison_cloud[cid].amount for cid in cloud_ids)
new_hp = max(0, hp.health - dmg)
state = replace(state, health=state.health.set(agent_id, Health(health=new_hp, max_health=hp.max_health)))
if new_hp == 0:
state = replace(state, dead=state.dead.set(agent_id, Dead()))
return state
Hook into _after_substep
so the damage applies right after movement/portal. Adjust order with damage_system
if you want stacking or precedence.
Additional example: Renderer grouping for your component¶
If PoisonCloud
instances should be tinted by intensity:
def poison_group_rule(state: State, eid: EntityID):
if eid in state.poison_cloud:
amt = state.poison_cloud[eid].amount
return f"poison:{amt}"
return None
Append to DEFAULT_GROUP_RULES
(in your local render wrapper) so clouds of different strength get distinct hues while preserving texture tone.