LangGraph Gameplay: Auto-Run vs Control Mode + Per-Unit Toggles
Status: ACTIVE (synthesis from MjF + sandshrew conversation) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-11T18:25:00Z Claim: synthesis | 2026-05-11T18:20:00Z Session: MjF gameplay design exploration β LangGraph game surface
Prior Context
- [[langgraph-game-surface]] β LangGraph as turn-based strategy game backend architecture
- This page defines the gameplay loop: dual-mode execution, per-unit/lane toggles, and how LangGraph primitives enforce each
Short Answer
Three execution layers, all natively expressible in LangGraph:
1. Control mode: Human decides every move. interrupt() pauses at each decision point.
2. Auto-run mode: AI decides every move. Graph loops without interrupt().
3. Per-unit toggles: Each unit/lane has an auto_pilot flag. Conditional edges check the flag β true routes to AI decision node, false routes to interrupt() for human input.
The toggle is live: flip a unit's flag mid-game, and the next turn cycle picks it up. No mode switch β it's per-unit granularity by default.
The Turn Cycle
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β TURN CYCLE β
β β
β ββββββββββββ ββββββββββββ ββββββββββββββ β
β β PLANNING βββββΆβEXECUTION βββββΆβ RESOLUTION β β
β β per-unit β β Send β β apply β β
β β decisionsβ β parallel β β effects β β
β ββββββββββββ ββββββββββββ ββββββββββββββ β
β β β β
β βββββ if auto_run ββ loop βββββββ β
β βββββ if control ββ interrupt() ββ pause βββ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Phase 1: Planning (Per-Unit Decisions)
For each unit that needs orders this turn:
def planning_router(state: GameState) -> list[Send]:
"""Fan out: one decision cycle per active unit"""
return [
Send("unit_decision", {"unit": unit})
for unit in state["units"]
if unit_needs_orders(unit)
]
Phase 2: Unit Decision (Human or AI)
def unit_decision(state: UnitDecisionState):
unit = state["unit"]
if unit["auto_pilot"]:
# AI decides β no interrupt, instant
return ai_decide_move(unit)
else:
# Human decides β interrupt, pause, wait for resume
choice = interrupt(f"Order for {unit['name']} at {unit['position']}?")
return apply_human_order(unit, choice)
Phase 3: Execution & Resolution
All moves fire in parallel via Send, results merge via state reducers, turn counter increments.
Phase 4: End-of-Turn Gate
def end_of_turn_router(state: GameState) -> Literal["planning", "end"]:
if state["auto_run_global"]:
return "planning" # loop without pause
else:
interrupt(f"Turn {state['turn']} complete. Continue?")
return "planning" # resume after human acknowledges
State Schema
class UnitState(TypedDict):
unit_id: str
name: str
position: str # node name (hex_17, etc.)
auto_pilot: bool # THE toggle β AI or human?
health: int
inventory: list[str]
role: str # "scout", "builder", "hero", "grunt"
class LaneState(TypedDict):
lane_id: str # "northern_front", "supply_line", etc.
unit_ids: list[str] # units assigned to this lane
auto_pilot: bool # lane-level override β all units in lane
class GameState(TypedDict):
units: list[UnitState]
lanes: list[LaneState]
turn: int
phase: str
auto_run_global: bool # master toggle
history: list[dict] # replay log
Toggle Mechanics
Global Toggle (Master Switch)
auto_run_global = true β no interrupts, AI runs everything, game loops
auto_run_global = false β interrupts fire where auto_pilot=false on units
Toggled via Command(update={"auto_run_global": True/False}) at any interrupt point. Or via a dedicated "command node" that runs before planning.
Per-Unit Toggle
Each unit's auto_pilot flag can be flipped:
- At interrupt points: Human can modify any unit's flag before resuming
- During auto-run: An "overseer" conditional edge can check if a unit should be reclaimed by human control
- Hotkey/command mode: "Take manual control of Rif" β sets auto_pilot: false on that unit
def reclaim_unit(state: GameState):
"""Human interrupts auto-run to seize a unit"""
unit_id = interrupt("Which unit to reclaim?")
# Toggle the flag
for unit in state["units"]:
if unit["unit_id"] == unit_id:
unit["auto_pilot"] = False
return {"units": state["units"]}
Lane Toggle
A lane groups units under one toggle:
def unit_auto_pilot_resolver(state, unit_id: str) -> bool:
"""Unit obeys lane override if set, otherwise its own flag"""
lane = get_lane_for_unit(state, unit_id)
if lane and lane.get("auto_pilot_override") is True:
return True # lane overrides unit flag β AI runs it
return get_unit(state, unit_id)["auto_pilot"]
UX Flow: What the Player Sees
Control Mode (auto_run_global = false)
Turn 3 β your move.
ββββββββββββββββββββββββββββββββββββββββββββββββ
β [AUTO] Scout Alpha patrolling hex_12 β β AI unit, no prompt
β [AUTO] Builder Beta constructing β β AI unit, no prompt
β ββββββββββββββββββββββββββββββββββββββββ β
β β βΆ HERO Rif at hex_17 β β β YOUR unit, interrupt fires
β β [Move] [Attack] [Wait] [Inventory]β β
β β [Auto-pilot this unit] β β β one-click: set auto=true
β ββββββββββββββββββββββββββββββββββββββββ β
β [AUTO] Grunt Gamma idle at hex_09 β β AI unit
ββββββββββββββββββββββββββββββββββββββββββββββββ
Human only sees prompts for auto_pilot=false units. AI units resolve silently.
Auto-Run Mode (auto_run_global = true)
Turn 12 β AI running...
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Scout Alpha hex_12 β hex_14 [patrol] β
β Builder Beta hex_08 constructing 67% β
β Rif hex_17 β hex_20 [advance] β β AI commanding your hero
β Grunt Gamma hex_09 β hex_10 [follow] β
β β
β [STOP] [Reclaim Rif] [Reclaim All Heroes] β β interrupt toggles
ββββββββββββββββββββββββββββββββββββββββββββββββ
Player watches. At any end-of-turn interrupt, they can:
- Hit STOP β sets auto_run_global = false, next turn is full control
- Hit "Reclaim Rif" β sets auto_pilot = false on Rif only, continues auto-run for others
- Let it run β next turn auto-cycles
LangGraph Primitive Mapping
| Gameplay Feature | LangGraph Primitive | How |
|---|---|---|
| Human decision per unit | interrupt() |
Pauses graph, returns human choice via Command(resume=...) |
| AI decision per unit | Node function with LLM call | No interrupt, immediate return |
| Route to human vs AI | Conditional edge on auto_pilot |
graph.add_conditional_edges("planning", route_by_autopilot) |
| Parallel unit execution | Send fan-out |
[Send("unit_decision", {"unit": u}) for u in units] |
| End-of-turn pause | interrupt() gated by auto_run_global |
Only fires when global flag is false |
| Toggle flip mid-game | Command(update=...) or state mutation in node |
Change flag, next cycle picks it up |
| Save/load game | Checkpointer | Persists all state including toggle flags |
| Replay/history | Append-only list in state | history: Annotated[list, operator.add] |
Edge Cases & Failure Modes
- All units auto-pilot, global auto-run = true: Graph runs forever. Needs a max-turn guard (recursion_limit or explicit check).
- Unit dies while human is deciding: Interrupt prompt for a dead unit. Mitigation: validate unit is alive before showing prompt, auto-skip dead units.
- Toggle thrash: Human rapidly toggles auto_pilot on/off. Harmless β state is read fresh each cycle.
- AI makes bad move, human wants to undo: Checkpointer enables time-travel.
graph.get_state(config, checkpoint_id=previous)β rewind. - Concurrent human input: Two devices (RG40XXV + Wii) try to command the same unit. Need a turn-lock: only one client can hold the interrupt at a time.
- Lane override conflicts: Lane says auto=true, unit says auto=false. Resolution order must be explicit. Recommendation: lane override wins unless unit has
lockout: true(hero units override lane).
Decision Needed From Mehdi
- Is the turn cycle structure (Planning β Execution β Resolution β End-of-Turn) the right granularity, or should moves be real-time-with-pause?
- Should per-lane toggles override per-unit toggles, or the reverse?
- Do you want the AI decision node to actually call an LLM (tactical reasoning), or use a simpler heuristic (rule-based AI) for MVP?
- Should the game surface support spectator mode (read-only view of auto-run)?
Next Probe
Implement the dual-mode planning node in a minimal 3-node graph: one hero unit (auto_pilot=false), one scout unit (auto_pilot=true). Prove that hitting invoke() cycles the scout silently and pauses for the hero. Then add the toggle-flip Command to let the human seize the scout mid-auto-run.