Skip to content

Concept: GridWorld

What It Is

The GridWorld is a bounded 2D discrete grid — the physical substrate of the simulation. It owns the canonical state of the world: agent positions, obstacle positions, episode progress, and capture events.

It is implemented as GridWorldEnv in src/ppage/core/gridworld.py and inherits from gymnasium.Env.

A snapshot of the gridworld: predators, prey, and obstacles on a bounded grid


State Model

At any point in an episode, the world state consists of:

State Variable Type Description
_obstacle_location List[np.ndarray] Fixed obstacle positions for this episode
Each agent's _agent_location np.ndarray [x,y] Current position per agent
_captured_agents List[str] Names of all prey captured so far (persistent)
_captured_this_step List[str] Prey names captured in the current step only (reset each step)
_capturing_predators set Predator names that made a capture this step (reset each step)
_captures_total int Total capture events this episode
_episode_steps int Timesteps elapsed
capture_threshold int Captures needed to terminate (from config, default 1)
max_steps int \| None Step limit before truncation (from config)

The grid itself has no explicit array representation — positions are stored on agents and in the obstacle list, not in a 2D array.


Coordinate System

(0,0) ──────────────► x
  │  [ ][ ][ ][ ][ ]
  │  [ ][ ][ ][ ][ ]
  │  [ ][ ][ ][ ][ ]
  ▼  [ ][ ][ ][ ][ ]
  y
  • Origin [0,0] is top-left
  • X increases right, Y increases down
  • Positions are np.ndarray([x, y], dtype=int32)
  • Grid is size × size cells (square, configurable)

Invariants

These properties hold throughout any well-formed episode:

  1. No agent at an obstacle cell — obstacle placement skips agent start positions; moves into obstacle cells are rejected — but only while block_agents_by_obstacles is true (the default); set it false in env.yaml and agents may enter obstacle cells
  2. Captured agents do not move — once in _captured_agents, an agent's position is frozen
  3. All positions are in-bounds — the step loop np.clips each axis independently, so an out-of-bounds component is clamped to the edge while the in-bounds component still applies (a diagonal move at a wall slides along it rather than being fully rejected)
  4. Randomness flows through self.rng only — no global random state

allow_cell_sharing controls same-role stacking. With the default true, any agents may share a cell. Set it false (in env.yaml) to forbid two same-role agents (two predators, or two prey) from occupying the same cell — a move onto a cell already held by a same-role agent is rejected. Predator–prey overlap is always allowed, so the capture mechanic (which is exactly such an overlap) keeps working either way.


Episode Lifecycle

reset()
  │  seed RNG
  │  place obstacles (random, avoiding agent positions)
  │  place agents (random, avoiding obstacles)
  │  zero episode counters
  └► return (initial_obs, info)

step() × N
  │  move all agents simultaneously
  │  detect captures
  │  compute rewards
  │  check termination
  └► return dict {"obs", "reward", "terminated", "truncated", "info"}

close()
  └► release Pygame resources

Physics Rules

Movement

  • Each agent picks one action integer; the active ActionSpace plugin maps it to a [dx, dy] vector (default discrete_5: Right, Up, Left, Down, Noop — see concepts/actions.md for the other two shipped action spaces)
  • All moves are processed simultaneously (no ordering)
  • Out-of-bounds → each axis is clipped independently (np.clip), so only the offending component is cancelled; a diagonal move at an edge partially applies, sliding the agent along the wall
  • Obstacle cell → agent stays in place
  • Captured agent → action ignored, position frozen

Capture

  • After all moves are applied, the grid is scanned for cells containing both a predator and a prey
  • If any such cell exists: prey is added to _captured_agents
  • Multiple captures in one step are all processed

Termination

  • Episode ends when _captures_total >= capture_threshold (read from env.yaml → termination.capture_threshold)
  • Episode truncates when max_steps is not None and _episode_steps >= max_steps

Extension Contract

The GridWorld is immutable. It exposes four injection points for plugins:

env.reward_fn             # callable(env) → Dict[str, float]
env.observation_builder    # callable(env) → Dict[str, dict]
env.observation_encoder    # callable(obs, env) → array-like — required by the neural baselines
env.action_space_plugin    # ActionSpace instance — .to_direction(int) → np.ndarray

All four are None at construction and must be set before calling step() (run_from_config.build_environment() wires them). They are read-only with respect to the environment — plugins may not modify env state through these calls.

run_from_config.build_environment() additionally wraps the fully-wired env in SpeedWrapper before returning it, so in practice most code interacts with a SpeedWrapper-wrapped GridWorldEnv, not the raw class. See concepts/wrappers.md, specs/observation-builder-spec.md, specs/reward-function-spec.md, and specs/action-space-spec.md for the per-plugin behavioral contracts.