Grid
Nat20 supports both abstract zone topologies and a concrete 2-D grid. Pass a
GridScene to start_combat and combat resolves over a square grid using
Chebyshev (8-direction) distance, where one cell equals cell_size_ft
(default 5).
Positioning
Combatant positions reuse the zone_id string already on
PartyMemberSpec and EncounterMemberSpec. On a grid, that string is a cell
encoded as "col,row". Two helpers handle the encoding:
cell_id(col, row)— build the"col,row"string for a cell.parse_cell(zone_id)— decode it back into coordinates.
A GridScene declares width, height, an optional cell_size_ft, and a
list of blocked_cells — impassable squares movement may not enter. Four
more fields are additive (each defaults empty, preserving prior behavior):
wall_segments— a list ofWallSegment(x1, y1, x2, y2)grid-corner endpoints (mirroring Foundry'sWall.cconvention) that block line of sight between two cells.cover_cells— a{cell_id: "half" | "three_quarters" | "total"}map of obstruction cells granting cover (SRD 5.2 §Cover): half/three-quarters add +2/+5 to a target's AC and Dexterity saves; total makes it untargetable.difficult_terrain_cells— a list of cell ids that cost double to enter (SRD 5.2 §Difficult Terrain).lighting/default_lighting/obscurement_cells— the vision model, see Vision and light below.
Movement
To move, submit a PlayerIntent with intent_type="move" and a
target_zone_id (built with cell_id).
Pathing
The destination may be any cell, not just an adjacent one. The engine routes
with GridTopology.shortest_path and prices the whole route up front — each
leg's edge_distance, with a difficult_terrain_cells cell costing double — so
a route the budget cannot pay for is rejected atomically without moving. A route
it can pay for is walked, decrementing the budget cell by cell, and produces
exactly one ActorMoved carrying the total distance.
A step is legal when it stays on the map, does not enter a blocked_cells
square, does not cross a wall_segments entry, and — for a diagonal — does not
cut an obstruction's corner. Occupancy follows SRD 5.2 §Moving Around Other
Creatures: allies are passable, enemies are not, and a move may not end on
a cell another creature occupies, ally or enemy.
Multi-cell routing and occupancy are both grid-only: on the legacy zone
graph a "move" intent is still a single step to an adjacent zone, and a
non-adjacent destination is still MoveFailed(reason="not_adjacent").
Opportunity attacks fire before the mover leaves each cell's reach; a mover
dropped to 0 HP stops where the drop happened, and the ActorMoved reports the
partial walk.
A rejected move emits MoveFailed with one of:
| Reason | Meaning |
|---|---|
not_adjacent |
no destination given, an untracked position, or the destination is the mover's own cell (the legacy reason name is kept for hosts) |
occupied |
the destination holds another creature — ally or enemy |
blocked_path |
the destination is adjacent, but the single step crosses a wall or cuts a blocked corner |
unreachable |
no legal route at all (enemy-occupied cells are impassable; allies may be passed through) |
insufficient_movement |
a legal route exists but costs more than the remaining budget — atomic, nothing moves |
The route search minimises the number of squares, not their cost, so a mover may be routed through difficult terrain when an equally long detour would be cheaper. Distance for range and reach checks is measured in Chebyshev cells scaled to feet.
Forced movement
Movement a creature does not choose — Thunderwave's push today — goes through
push_combatant(live, target_id, origin_cell, distance_ft) and emits
CombatantMoved(..., forced=True) rather than ActorMoved, so a renderer can
distinguish "is pushed 10 feet" from "moves". Forced movement provokes no
opportunity attack and spends none of the target's budget.
Line of sight, cover, and AoE templates
GridTopology.has_line_of_sight blocks sight when the straight line between two
cells' centers crosses a wall_segments entry or passes through a
blocked_cells square; a blocked ranged attack/cast is rejected the same way
an out-of-range one is.
cover_between folds three obstruction sources into one tier (SRD 5.2 §Cover):
cover_cells, blocked_cells (Total Cover), and any other live creature
standing on the line (Half Cover). Half / three-quarters add +2 / +5 to the
target's AC and Dexterity saves; total makes it untargetable. A save activity
carrying ignore_cover (Sacred Flame) skips the save-side bonus.
GridTopology.cells_in_template(origin, shape, size_ft, *, direction=None)
returns the cell set for a "sphere", "cone", "line", "cube" or
"cylinder" area of effect; "cone", "line" and "cube" require a
direction vector.
AoE spells use this automatically: the spell's typed template is placed at
its SRD point of origin, expanded, and trimmed to the cells with line of effect
from that origin ("To block a line, an obstruction must provide Total Cover").
Every alive creature in a surviving cell is a target — including the caster and
their allies. Aim a directional template with PlayerIntent.direction; omit it
and the engine aims caster → named target. A self-origin cone/line/cube with
neither is rejected before the slot is spent, with
CastFailed(reason="target_invalid").
Vision and light
Three optional GridScene fields model SRD 5.2 §Vision and Light:
lighting—{cell_id: "bright" | "dim" | "dark"}.default_lighting— the level for unlisted cells (default"bright").obscurement_cells—{cell_id: "light" | "heavy"}for fog, foliage and the like.
GridTopology.can_see(a, b, senses) answers whether a viewer at a perceives a
creature at b. It requires line of sight, then checks the target's cell:
Darkness and Heavy Obscurement make it unseen unless the viewer's senses reach —
darkvision covers a dark cell, blindsight and truesight see regardless of light.
Tremorsense is not sight: the SRD defines it as sensing location through
vibration, which does not satisfy "a target you can see".
The result feeds attack rolls both ways (SRD §Unseen Attackers and Targets):
attacking a target you cannot see is Disadvantage, and attacking from a position
the target cannot see is Advantage, tagged with the unseen AdvantageSource.
The model is entirely opt-in — a scene with no lighting data resolves exactly as
a scene did before the fields existed. No light sources, and darkness does not
apply the Blinded condition.
A composite predicate (orchestrator.py::_combatant_can_see) layers Blinded
(viewer) and Invisible (target) on top of can_see, piercing either only when
blindsight/truesight reaches with line of sight — never darkvision. Every SRD
5.2 rule phrased as "if you can see" other than the raw attack-roll unseen
row reads this composite: the Dodge action's attack-disadvantage half, Ranged
Attacks in Close Combat, the Opportunity Attack trigger in both directions,
Hide's "out of any enemy's line of sight" gate, and Frightened's line-of-sight
gate (attack-roll disadvantage and the "can't willingly move closer to the
source of fear" movement rule). See
docs/dev/spatial-geometry.md
for the exact step order.
Zones vs grid
The zone graph is deprecated
start_combat(scene_zones=...) raises a DeprecationWarning as of 0.6.0
and the backend is removed in 0.7.0. Pass a GridScene instead; if you
have no tactical map, a one-row grid preserves the zone semantics:
GridScene(width=len(zones), height=1) with zone_id = cell_id(i, 0).
scene_zones (a SceneTopology of named zones connected by ZoneEdges)
resolves combat over an abstract graph of locations. It and GridScene are
mutually exclusive inputs to the same combat loop. The zone backend has no
positional model at all — sight is always clear, cover is always "none",
can_see is always true between any two known zones, AoE spells fall back to
zone-equality targeting, occupancy is not enforced, and every spatial feature
added in 0.6 is grid-only.