Turn Manager
The Turn Manager is McRogueFace’s engine-native turn system. Instead of writing a Python scheduler that walks your actor list every turn, you attach a behavior to each entity, and a single call — grid.step() — advances the whole world by one round: enemies patrol, wander, sleep, chase, and report back to your code through trigger callbacks.
This page explains how a round actually unfolds and why the pieces (turn order, behaviors, labels, triggers) fit together the way they do. For a hand-rolled initiative system built in Python, see the Turn-Based Game Loop recipe — the native manager replaces it for the common case.
Overview
Every call to grid.step() runs one round. A round is a fixed pipeline:
grid.step()
├── collect entities where turn_order != 0
├── sort by turn_order (ascending)
└── for each entity:
├── target_label set and a labeled entity visible?
│ └── fire TARGET trigger, skip behavior this round
└── otherwise execute the behavior:
├── MOVED → update cell + draw position
├── DONE → fire DONE, revert to default_behavior
├── BLOCKED → fire BLOCKED
└── no action (IDLE, CUSTOM, SLEEP mid-count)
The turn manager is deliberately synchronous and deterministic in structure: nothing happens between rounds unless you call step() again. Your game decides when a round happens (typically after the player acts); the engine decides what each entity does within it.
Objects
| Object | Purpose |
|---|---|
| Grid | Hosts entities; step() drives rounds |
| Entity | Carries behavior, turn order, labels, callbacks |
| Behavior | Enum of built-in behavior types |
| Trigger | Enum passed to step callbacks |
| DijkstraMap | Pathfinder for SEEK/FLEE behaviors |
Stepping the World
grid.step(n=1, turn_order=None)
n: how many full rounds to execute (default 1).turn_order: if given, only entities with exactly thisturn_ordervalue are processed — useful for running one phase of a multi-phase turn (e.g. “fast monsters” before “slow monsters”).
grid.step() is a plain method call, so it works identically in a windowed game (call it from your on_key handler after the player moves) and in headless scripts. Entities whose behavior is IDLE are skipped entirely — they neither move nor fire triggers.
def on_key(key, state):
if state != mcrfpy.InputState.PRESSED:
return
if try_player_move(key): # your movement logic
grid.step() # ...then the world takes its turn
scene.on_key = on_key
Turn Order
Each entity has an integer turn_order (default 1):
0means skip. The turn manager never processes the entity. This is the standard setting for the player, who is moved by your input code rather than by a behavior.- Higher values act later. Within a round, entities are sorted ascending, so
turn_order=1entities all act beforeturn_order=2entities. Entities sharing a value run in no guaranteed relative order.
player.turn_order = 0 # manual control, never stepped
guard.turn_order = 1 # acts first
slime.turn_order = 2 # acts after all guards
Because grid.step(turn_order=N) filters to a single value, you can also drive phases manually — grid.step(turn_order=1) then, after some animation or interrupt check, grid.step(turn_order=2).
Behaviors
A behavior is the entity’s standing orders. Set one with set_behavior():
entity.set_behavior(type, waypoints=None, turns=0, path=None, pathfinder=None)
| Behavior | Arguments | What it does |
|---|---|---|
Behavior.IDLE |
— | Nothing (entity is skipped) |
Behavior.CUSTOM |
— | No built-in action; your code drives the entity |
Behavior.NOISE4 |
— | Random step in 4 cardinal directions |
Behavior.NOISE8 |
— | Random step in 8 directions |
Behavior.PATH |
path=[(x, y), ...] |
Follow a precomputed path, then DONE |
Behavior.WAYPOINT |
waypoints=[(x, y), ...] |
A* through waypoints in order, then DONE |
Behavior.PATROL |
waypoints=[...] |
Walk waypoints back and forth (never DONE) |
Behavior.LOOP |
waypoints=[...] |
Cycle waypoints forever (never DONE) |
Behavior.SLEEP |
turns=N |
Wait N rounds, then DONE |
Behavior.SEEK |
pathfinder=... |
Move toward a target each round |
Behavior.FLEE |
pathfinder=... |
Move away from a target each round |
behavior_type is read-only — set_behavior() is the only way to change it, and each call resets the behavior’s internal state (waypoint progress, path index, sleep counter).
SEEK and FLEE pathfinders
pathfinder accepts a DijkstraMap, an AStarPath, or a plain (x, y) target tuple. The Dijkstra form is the roguelike workhorse: root the map at the target, and any number of entities can descend it simultaneously.
dm = grid.get_dijkstra_map(player.cell_pos)
for enemy in pack:
enemy.set_behavior(mcrfpy.Behavior.SEEK, pathfinder=dm)
get_dijkstra_map() caches maps per root position; call grid.clear_dijkstra_maps() after changing walkability. When the target moves, hand the entity a freshly rooted map (see the chase example below).
Trigger Callbacks
Behaviors report noteworthy moments through the entity’s step callback:
def on_trigger(trigger, data):
...
entity.step = on_trigger
Subclasses of Entity can define an on_step(self, trigger, data) method instead of assigning entity.step — the turn manager falls back to it when no step callback is set.
| Trigger | When it fires | data |
|---|---|---|
Trigger.DONE |
Behavior completed (path exhausted, sleep finished) | None |
Trigger.BLOCKED |
Movement blocked, no path found, or SEEK/FLEE cannot advance | Entity occupying the intended cell, or None |
Trigger.TARGET |
An entity matching target_label is visible (see Labels) |
The spotted Entity |
Exceptions raised inside a callback are printed to stderr and swallowed; they do not abort the round.
Three ordering rules matter in practice:
- DONE reverts the behavior after your callback returns. The engine sets
behavior_typeback todefault_behavior(defaultIDLE) once the DONE callback finishes — so aset_behavior()call made inside the callback is clobbered unlessdefault_behaviormatches the type you set. The patrol example below shows the idiom. - TARGET replaces the behavior for that round. While
target_labelis set and a matching entity is in view, the TARGET trigger fires instead of running the behavior — every round. React once (e.g. switch to SEEK) and cleartarget_label, or your entity will stand still admiring its target. - SEEK has no arrival trigger. Reaching the Dijkstra root — or having nowhere better to step — reports BLOCKED. Treat “BLOCKED while seeking” as “arrived or stuck” and check positions yourself.
Labels: Collision and Targeting
entity.labels is a frozenset of strings. Assign any iterable to replace the whole set, or use add_label() / remove_label() / has_label():
player = mcrfpy.Entity((5, 5), grid=grid, labels=["player"])
orc.labels = {"hostile", "orc"}
orc.add_label("elite")
Labels feed two mechanisms:
- Targeting. Set
entity.target_label = "player"and the turn manager scans withinentity.sight_radius(default 10) each round. If a labeled entity is there and line-of-sight holds (a real FOV check — walls hide targets), the TARGET trigger fires with the spotted entity asdata. - Pathfinding collision.
grid.find_path(..., collide="hostile")andgrid.get_dijkstra_map(..., collide="hostile")treat cells occupied by matching entities as blocked. Behavior movement itself only respects tile walkability — entities do not physically block each other, so usecollide=(or checkcell_posyourself) where it matters.
Logical Position vs Draw Position
An entity has two positions, and the turn system is built on the distinction:
grid_pos(aliascell_pos, withgrid_x/grid_y/cell_x/cell_ycomponents) — the integer cell the entity logically occupies. Collision, FOV, targeting, and the turn manager all use this.draw_pos— the fractional tile position used for rendering, so a sprite can glide between cells while the logic already considers the move complete.posis the same thing in pixels (draw_pos * tile_size).
When a behavior moves an entity, grid.step() updates both. When you move an entity by assigning grid_pos, the draw position does not follow — set draw_pos too (or animate it) if the entity should visibly move. move_speed (default 0.15) is the animation duration in seconds for behavior movement; 0 means instant.
One consequence worth knowing: pathfinding methods that accept an Entity argument read its draw position. If you teleport the player with grid_pos alone, root your Dijkstra maps at player.cell_pos rather than passing player itself.
Rendering Interaction
The renderer caches grid composition aggressively and skips redrawing an unchanged grid. grid.step() participates in this correctly: if any entity moved during the call, the grid’s content generation is bumped and every attached view invalidates its render cache automatically. You never need to poke the grid after stepping — moves made by the turn manager appear on the next frame, exactly like moves made through the Python position setters.
Example: Waypoint Patrol
A guard walks a three-point route; the DONE trigger reverses the route so it marches back. Note the default_behavior idiom from rule 1. Run it with ./mcrogueface --headless --exec patrol.py.
"""Waypoint patrol: a guard walks a route, and the DONE trigger sends it back."""
import mcrfpy
import sys
# --- Build a small walled room -------------------------------------------
scene = mcrfpy.Scene("patrol")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(12, 8), texture=tex, pos=(0, 0), size=(384, 256))
scene.children.append(grid)
for y in range(8):
for x in range(12):
cell = grid.at(x, y)
wall = x == 0 or y == 0 or x == 11 or y == 7
cell.walkable = not wall
cell.transparent = not wall
# --- Guard: WAYPOINT route, reversed on completion ------------------------
route = [(2, 2), (9, 2), (9, 5)]
guard = mcrfpy.Entity((2, 2), grid=grid, sprite_index=84)
guard.set_behavior(mcrfpy.Behavior.WAYPOINT, waypoints=route)
# After a DONE trigger the engine reverts behavior_type to default_behavior.
# That revert happens AFTER the step callback returns, so it would clobber a
# set_behavior() call made inside the callback unless the default matches.
guard.default_behavior = mcrfpy.Behavior.WAYPOINT
def on_trigger(trigger, data):
if trigger == mcrfpy.Trigger.DONE:
# Route finished: walk it back the other way.
route.reverse()
guard.set_behavior(mcrfpy.Behavior.WAYPOINT, waypoints=route)
print(" route complete, reversing")
elif trigger == mcrfpy.Trigger.BLOCKED:
print(" path blocked at", guard.cell_pos)
guard.step = on_trigger
# --- Run 24 turns ---------------------------------------------------------
for turn in range(1, 25):
grid.step()
print(f"turn {turn:2d}: guard at ({guard.cell_x}, {guard.cell_y})")
sys.exit(0)
Output (abridged):
turn 7: guard at (9, 2)
turn 8: guard at (9, 3)
turn 9: guard at (9, 4)
turn 10: guard at (9, 5)
route complete, reversing
turn 11: guard at (9, 5)
turn 12: guard at (9, 4)
(If you only want the ping-pong, Behavior.PATROL does it in one line with no callback — this example uses WAYPOINT to show the DONE machinery.)
Example: Detect and Chase
A hound wanders randomly with target_label armed. When the player crosses into its FOV, the TARGET trigger fires and the hound switches to SEEK on a Dijkstra map re-rooted at the player every round. Note rule 2: the handler clears target_label so SEEK actually runs.
"""Label-based detection + Dijkstra chase: a hound wanders until it spots
the player, then seeks along a refreshed DijkstraMap every turn."""
import mcrfpy
import sys
# --- Build a walled room with one obstacle wall ---------------------------
scene = mcrfpy.Scene("chase")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(16, 10), texture=tex, pos=(0, 0), size=(512, 320))
scene.children.append(grid)
for y in range(10):
for x in range(16):
cell = grid.at(x, y)
wall = x == 0 or y == 0 or x == 15 or y == 9
cell.walkable = not wall
cell.transparent = not wall
for y in range(1, 7): # vertical wall with a gap at the bottom
grid.at(8, y).walkable = False
grid.at(8, y).transparent = False
# --- Player: manually controlled, skipped by step() -----------------------
player = mcrfpy.Entity((13, 5), grid=grid, sprite_index=84, labels=["player"])
player.turn_order = 0 # 0 = the turn manager never processes this entity
# --- Hound: wanders until the player enters its FOV ------------------------
hound = mcrfpy.Entity((2, 2), grid=grid, sprite_index=105)
hound.target_label = "player" # scan for entities labeled "player"
hound.sight_radius = 6
hound.set_behavior(mcrfpy.Behavior.NOISE4)
state = {"seeking": False}
def on_trigger(trigger, data):
if trigger == mcrfpy.Trigger.TARGET:
# data is the spotted Entity. While target_label is set and the
# target stays visible, TARGET fires INSTEAD of running the
# behavior -- clear it so SEEK can actually move next round.
hound.target_label = None
dm = grid.get_dijkstra_map(data.cell_pos)
hound.set_behavior(mcrfpy.Behavior.SEEK, pathfinder=dm)
state["seeking"] = True
print(f" spotted {sorted(data.labels)[0]} at {data.cell_pos}")
elif trigger == mcrfpy.Trigger.BLOCKED:
# SEEK has no arrival trigger: reaching the Dijkstra root (or
# having no downhill step) reports BLOCKED.
print(" hound cannot advance from", hound.cell_pos)
hound.step = on_trigger
# --- Run: player flees left along row 8, hound pursues --------------------
for turn in range(1, 31):
if player.cell_x > 2: # scripted player move (a real game reads input)
target = (player.cell_x - 1, player.cell_y if player.cell_y == 8
else player.cell_y + 1)
if grid.at(target).walkable:
player.grid_pos = target # logical cell (collision, FOV)
player.draw_pos = target # render position (decoupled!)
if state["seeking"]:
# The player moved, so re-root the map and hand it to the hound.
hound.set_behavior(mcrfpy.Behavior.SEEK,
pathfinder=grid.get_dijkstra_map(player.cell_pos))
grid.step()
print(f"turn {turn:2d}: hound {hound.cell_pos} player {player.cell_pos}")
if hound.cell_pos == player.cell_pos:
print("caught!")
break
sys.exit(0)
Output (abridged):
turn 5: hound <Vector (2, 5)> player <Vector (8, 8)>
spotted player at <Vector (7, 8)>
turn 6: hound <Vector (2, 5)> player <Vector (7, 8)>
turn 7: hound <Vector (3, 6)> player <Vector (6, 8)>
turn 8: hound <Vector (4, 7)> player <Vector (5, 8)>
turn 9: hound <Vector (4, 8)> player <Vector (4, 8)>
caught!
The wander phase is random, so the spotting turn varies between runs.