Functional API¶
This page presents practical, task-oriented entry points for building, running, rendering, and inspecting Grid Universe. It complements the module reference with end-to-end flows and focused recipes.
Contents
- Quick start: build, step, render
- Core workflow in code
- State inspection and queries
- Movement, pushing, portals, damage
- Collecting items and unlocking doors
- Rendering programmatically
- Gym environment usage
- Level conversion and serialization
- Procedural generation
- Controlling randomness (seeding)
- Registries and plugin-style selection
- Error handling patterns
- Performance tips
Quick start: build, step, render¶
Create a tiny level, step a few actions, and render the result.
from grid_universe.levels.grid import Level
from grid_universe.levels.factories import create_floor, create_agent, create_coin, create_exit
from grid_universe.levels.convert import to_state
from grid_universe.moves import default_move_fn
from grid_universe.objectives import default_objective_fn
from grid_universe.actions import Action
from grid_universe.step import step
from grid_universe.renderer.texture import TextureRenderer
# Authoring-time level
level = Level(5, 5, move_fn=default_move_fn, objective_fn=default_objective_fn, seed=123)
for y in range(level.height):
for x in range(level.width):
level.add((x, y), create_floor())
level.add((1, 1), create_agent(health=5))
level.add((2, 1), create_coin(reward=10))
level.add((3, 3), create_exit())
# Runtime state
state = to_state(level)
agent_id = next(iter(state.agent.keys()))
# Step a sequence
for a in [Action.RIGHT, Action.PICK_UP, Action.DOWN, Action.DOWN]:
state = step(state, a, agent_id)
if state.win or state.lose:
break
# Render
TextureRenderer(resolution=480).render(state).save("quickstart.png")
Core workflow in code¶
Most programs follow this loop:
-
Build a Level (or generate a State).
-
Convert Level → State if needed.
-
For each user or agent action:
- Call step() with the chosen Action and agent_id to produce a new State.
-
Render or extract observations.
-
Stop when state.win or state.lose.
from grid_universe.actions import Action
from grid_universe.step import step
running = True
while running:
# get_action() is your controller/agent logic
action = Action.RIGHT # example
state = step(state, action, agent_id=agent_id)
if state.win or state.lose:
running = False
State inspection and queries¶
Get a compact summary and query entities at positions.
# Summary of non-empty stores
desc = state.description
for k, v in desc.items():
print(k, type(v), len(v) if hasattr(v, "__len__") else "")
# First agent
agent_id = next(iter(state.agent.keys()))
# Agent position and health
pos = state.position.get(agent_id)
hp = state.health.get(agent_id)
print("Agent at:", (pos.x, pos.y), "HP:", (hp and hp.health))
Query entities at a tile:
from grid_universe.utils.ecs import entities_at, entities_with_components_at
ids_here = entities_at(state, pos)
blocking_here = entities_with_components_at(state, pos, state.blocking)
print("At agent tile:", ids_here, "blocking:", blocking_here)
Grid helpers and bounds/block checks:
from grid_universe.utils.grid import is_in_bounds, is_blocked_at, compute_destination
from grid_universe.components import Position
p = Position(2, 2)
print("In bounds:", is_in_bounds(state, p))
print("Blocked (strict):", is_blocked_at(state, p, check_collidable=True))
# Compute push destination given current -> next (wrap-aware if move_fn is wrap)
current = state.position[agent_id]
next_pos = Position(current.x + 1, current.y)
dest = compute_destination(state, current, next_pos)
print("Push destination:", dest)
Movement, pushing, portals, damage¶
Apply a single action:
from grid_universe.actions import Action
from grid_universe.step import step
state = step(state, Action.UP, agent_id)
Movement/push happens before portal/damage processing in each submove. You can invoke low-level systems for targeted checks or debugging.
-
Push logic (pattern):
from grid_universe.utils.ecs import entities_with_components_at from grid_universe.components import Position from grid_universe.systems.push import push_system # Try to push anything pushable at (current + dx,dy) pos = state.position[agent_id] target = Position(pos.x + 1, pos.y) if entities_with_components_at(state, target, state.pushable): state_after_push = push_system(state, agent_id, target) # state_after_push may or may not differ based on blocking/destination
-
Portals (system-only debug):
from grid_universe.systems.portal import portal_system state = portal_system(state)
-
Damage (simple local check):
The engine doesn’t expose a public “who will damage me” helper. For a quick local snapshot of potential threats on your current tile (overlap only), you can intersect entities-at-position with known damagers:
from grid_universe.utils.ecs import entities_at pos = state.position[agent_id] ids_here = entities_at(state, pos) damagers = set(state.damage) | set(state.lethal_damage) print("Damagers overlapping agent:", list(ids_here & damagers))
Note: true damage resolution also considers cross‑paths, swaps, and trails; use the main reducer (step()) to advance the state and rely on
damage_system
invoked by the step pipeline for authoritative results.
Collecting items and unlocking doors¶
Collect items/effects on the current tile:
from grid_universe.systems.collectible import collectible_system
state = collectible_system(state, agent_id)
inv = state.inventory.get(agent_id)
print("Inventory count:", (inv and len(inv.item_ids)) or 0)
Use key on adjacent doors:
from grid_universe.actions import Action
from grid_universe.step import step
state = step(state, Action.USE_KEY, agent_id=agent_id)
print("Remaining locked:", len(state.locked))
Rendering programmatically¶
Use TextureRenderer or plug a custom lookup function.
from grid_universe.renderer.texture import TextureRenderer, DEFAULT_TEXTURE_MAP
renderer = TextureRenderer(resolution=640, texture_map=DEFAULT_TEXTURE_MAP, asset_root="assets")
img = renderer.render(state) # PIL.Image.RGBA
img.save("frame.png")
Customize textures by overriding entries or using directories for variants:
from copy import deepcopy
from grid_universe.renderer.texture import TextureRenderer, DEFAULT_TEXTURE_MAP
from grid_universe.components.properties import AppearanceName
custom_map = deepcopy(DEFAULT_TEXTURE_MAP)
custom_map[(AppearanceName.HUMAN, tuple([]))] = "my_assets/hero_idle.png"
custom_map[(AppearanceName.WALL, tuple([]))] = "skins/walls" # directory -> deterministic pick
TextureRenderer(texture_map=custom_map, asset_root="assets").render(state).save("custom.png")
Gym environment usage¶
The Gymnasium wrapper provides obs dicts with an image and structured info.
import numpy as np
from grid_universe.gym_env import GridUniverseEnv
from grid_universe.examples.maze import generate as maze_generate
env = GridUniverseEnv(initial_state_fn=maze_generate, render_mode="texture", width=9, height=9, seed=7)
obs, info = env.reset()
print(obs["image"].shape, obs["info"]["status"])
# Apply an action (0 == Action.UP)
obs, reward, terminated, truncated, info = env.step(np.int64(0))
if terminated or truncated:
obs, info = env.reset()
# Render (PIL image) if render_mode="texture"
img = env.render()
img.save("gym_frame.png")
Level conversion and serialization¶
Convert back and forth between authoring Level and runtime State.
from grid_universe.levels.convert import to_state, from_state
state = to_state(level)
level2 = from_state(state)
# level2 is a mutable authoring representation reconstructed from positioned entities
Serialization notes:
-
The library does not impose a file format for Level/State.
-
For reproducibility, prefer regenerating levels from seeds.
-
If you need persistence, you can:
-
Serialize Level: your own JSON/YAML schema capturing grid and EntitySpec fields.
-
Serialize State: custom encoder for PMaps and dataclasses; or pickle for internal tooling (not recommended for long-term storage).
-
Procedural generation¶
Use the example generator for a rich layout with floors/walls, items, doors/keys, portals, hazards, enemies, and powerups.
from grid_universe.examples.maze import generate
state = generate(
width=13, height=11,
num_required_items=2,
num_rewardable_items=3,
num_portals=1,
num_doors=1,
wall_percentage=0.8,
seed=42,
)
agent_id = next(iter(state.agent.keys()))
Controlling randomness (seeding)¶
Set seeds to make runs reproducible.
-
Level(seed=...): stored in State.seed.
-
Some features derive per-turn randomness from (state.seed, state.turn), such as windy_move_fn and renderer directory choices.
Pattern for per-turn RNG:
import random
from grid_universe.state import State
def rng_for_turn(state: State) -> random.Random:
base = hash(((state.seed or 0), state.turn))
return random.Random(base)
Registries and plugin-style selection¶
Select movement/objective functions by name.
from grid_universe.levels.grid import Level
from grid_universe.moves import MOVE_FN_REGISTRY
from grid_universe.objectives import OBJECTIVE_FN_REGISTRY
move_fn = MOVE_FN_REGISTRY["slippery"]
objective_fn = OBJECTIVE_FN_REGISTRY["unlock"]
level = Level(9, 9, move_fn=move_fn, objective_fn=objective_fn, seed=1)
Error handling patterns¶
Common runtime checks:
-
Missing agent or terminal state:
- step() returns the same state or sets lose=True if the agent is dead.
-
Invalid action:
- step() raises ValueError if action not in Action enum.
-
wrap_around_move_fn without width/height:
- Raises ValueError; ensure State has proper dimensions.
Defensive usage example:
from grid_universe.utils.terminal import is_terminal_state, is_valid_state
if not is_valid_state(state, agent_id) or is_terminal_state(state, agent_id):
# Skip stepping or handle end-of-episode
pass
Performance tips¶
-
Reuse a single TextureRenderer across frames to benefit from texture cache.
-
Keep render resolution constant to maximize cache hits.
-
Prefer smaller grids or fewer simultaneous moving overlays for real-time loops.
-
Avoid heavy postprocessing per frame; batch-render only when needed.
-
For large automation runs, skip rendering and log State.description or extract concise signals (score, win/lose, positions).