Squad Commander¶
an LLM officer picks the stance, the squad fights every frame.
📄 Docs onlyTags: ai llm rts agent
A model call takes far longer than a frame, so the model never drives the game. This demo splits the work into two layers, and only the fast one is authoritative:
The squad runs every frame. Each blue
Unitreads one word from the battle’s standing plan,attack,holdorretreat, and acts on it: close on the nearest red enemy and shoot it, settle on its home slot, or fall back to the rally point. That is ordinary Python inon_update, with no LLM code and nothing to await.The commander is a
Plannerfromsimvx.ai. At most everyPLAN_PERIODseconds it asks a model, on a worker thread, for a plan matchingPLAN_SCHEMA: a stance and one line of reasoning.self.planner = Planner(client, schema=PLAN_SCHEMA, validate=validate_plan, period=2.5, default=DEFAULT_PLAN) def on_update(self, dt): self.plan = self.planner.poll(dt, enemy_count=len(self.enemies), forced_retreat=...)
poll never blocks. The keyword arguments are the whole of what the commander sees, a small slice
of the battle chosen by the game, never the scene tree. Between answers, and while a call is in
flight, poll hands back the last good plan, so a call that is slow, fails or answers nonsense
costs the squad nothing. A schema is a request, not a guarantee: validate_plan is the second
gate, refusing a stance the units cannot carry out so the last good plan survives. Until the first
answer lands the squad fights on DEFAULT_PLAN, and it would keep doing so with the planner
deleted. Call close() when the planner’s owner leaves the tree.
The client is any LLMClient. By default the demo runs offline on ScriptedCommanderClient, a
stand-in that waits a moment, as a model would, then answers in the same schema from the same
facts. Run it with --live to use a real model through OpenAICompatibleClient.from_env(), which
reads SIMVX_LLM_BASE_URL (the endpoint up to /chat/completions), SIMVX_LLM_MODEL and, if the
endpoint needs one, SIMVX_LLM_API_KEY. A CachingClient records each answer under
$XDG_CACHE_HOME/simvx/squad_commander/ (SQUAD_COMMANDER_CACHE_DIR overrides it), so a re-run
replays them for free.
Press Space or the Spawn wave button to send in three enemies, and R or the Fall back button to order the squad to break contact for a few seconds; Esc quits. A first wave arrives on its own two seconds in, so a viewer who touches nothing still sees the commander switch from HOLD to ATTACK. With four or more enemies on the field the scripted commander orders a retreat instead.
Source¶
1# /// simvx
2# tags = ["ai", "llm", "rts", "agent"]
3# web = { disabled = true, reason = "requires a local/remote LLM server; not available in the browser runtime" }
4# ///
5"""Squad Commander: an LLM officer picks the stance, the squad fights every frame.
6
7A model call takes far longer than a frame, so the model never drives the game. This demo splits
8the work into two layers, and only the fast one is authoritative:
9
10- **The squad runs every frame.** Each blue `Unit` reads one word from the battle's standing
11 plan, `attack`, `hold` or `retreat`, and acts on it: close on the nearest red enemy and shoot
12 it, settle on its home slot, or fall back to the rally point. That is ordinary Python in
13 `on_update`, with no LLM code and nothing to await.
14- **The commander is a `Planner`** from `simvx.ai`. At most every `PLAN_PERIOD` seconds it asks a
15 model, on a worker thread, for a plan matching `PLAN_SCHEMA`: a stance and one line of
16 reasoning.
17
18 self.planner = Planner(client, schema=PLAN_SCHEMA, validate=validate_plan, period=2.5, default=DEFAULT_PLAN)
19
20 def on_update(self, dt):
21 self.plan = self.planner.poll(dt, enemy_count=len(self.enemies), forced_retreat=...)
22
23`poll` never blocks. The keyword arguments are the whole of what the commander sees, a small slice
24of the battle chosen by the game, never the scene tree. Between answers, and while a call is in
25flight, `poll` hands back the last good plan, so a call that is slow, fails or answers nonsense
26costs the squad nothing. A schema is a request, not a guarantee: `validate_plan` is the second
27gate, refusing a stance the units cannot carry out so the last good plan survives. Until the first
28answer lands the squad fights on `DEFAULT_PLAN`, and it would keep doing so with the planner
29deleted. Call `close()` when the planner's owner leaves the tree.
30
31The client is any `LLMClient`. By default the demo runs offline on `ScriptedCommanderClient`, a
32stand-in that waits a moment, as a model would, then answers in the same schema from the same
33facts. Run it with `--live` to use a real model through `OpenAICompatibleClient.from_env()`, which
34reads `SIMVX_LLM_BASE_URL` (the endpoint up to `/chat/completions`), `SIMVX_LLM_MODEL` and, if the
35endpoint needs one, `SIMVX_LLM_API_KEY`. A `CachingClient` records each answer under
36`$XDG_CACHE_HOME/simvx/squad_commander/` (`SQUAD_COMMANDER_CACHE_DIR` overrides it), so a re-run
37replays them for free.
38
39Press Space or the Spawn wave button to send in three enemies, and R or the Fall back button to
40order the squad to break contact for a few seconds; Esc quits. A first wave arrives on its own two
41seconds in, so a viewer who touches nothing still sees the commander switch from HOLD to ATTACK.
42With four or more enemies on the field the scripted commander orders a retreat instead.
43"""
44
45import asyncio
46import json
47import os
48import sys
49from dataclasses import dataclass
50from pathlib import Path
51from typing import Any
52
53from simvx.ai import CachingClient, LLMClient, LLMResponse, Message, OpenAICompatibleClient, Planner
54from simvx.core import (
55 Anchor,
56 Button,
57 CanvasLayer,
58 HAlign,
59 HBoxContainer,
60 Key,
61 Label,
62 Node2D,
63 Property,
64 Timer,
65 Vec2,
66 on_input,
67)
68from simvx.graphics import App
69
70SQUAD_SIZE = 5
71UNIT_SPEED = 130.0
72UNIT_RADIUS = 12.0
73ATTACK_RANGE = 60.0
74DAMAGE_PER_SECOND = 90.0
75
76WAVE_SIZE = 3
77ENEMY_RADIUS = 12.0
78ENEMY_SPEED = 25.0
79ENEMY_HP = 100.0
80
81FIRST_WAVE_DELAY = 2.0 # seconds before the opening wave arrives unprompted
82RETREAT_SECONDS = 4.0 # how long a fallback order stays in the commander's facts
83PLAN_PERIOD = 2.5 # seconds between commander decisions
84
85# The squad knows exactly three stances, so the schema names them and a model cannot invent a fourth.
86STANCES = ("attack", "hold", "retreat")
87PLAN_SCHEMA = {
88 "type": "object",
89 "properties": {
90 "stance": {"type": "string", "enum": list(STANCES)},
91 "rationale": {"type": "string"},
92 },
93 "required": ["stance", "rationale"],
94 "additionalProperties": False,
95}
96DEFAULT_PLAN = {"stance": "hold", "rationale": "no orders yet, holding the line"}
97
98
99def validate_plan(plan: dict[str, Any]) -> dict[str, Any] | None:
100 """The plan the squad should adopt, or None to keep the last good one."""
101 if plan.get("stance") not in STANCES:
102 return None
103 return {"stance": plan["stance"], "rationale": str(plan.get("rationale", ""))[:60]}
104
105
106class ScriptedCommanderClient(LLMClient):
107 """An offline stand-in for a model: a short wait, then a plan for the situation."""
108
109 async def complete(self, messages: list[Message], **kwargs) -> LLMResponse:
110 await asyncio.sleep(0.06) # model latency, spent on the planner's worker thread
111 # Planner hands the client only its prompt, so the facts are read back out of the text.
112 prompt = messages[-1]["content"]
113 facts = json.loads(prompt[prompt.index("{") :])
114 if facts["forced_retreat"]:
115 plan = {"stance": "retreat", "rationale": "fallback ordered, break contact"}
116 elif facts["enemy_count"] == 0:
117 plan = {"stance": "hold", "rationale": "no contacts, hold the rally"}
118 elif facts["enemy_count"] >= 4:
119 plan = {"stance": "retreat", "rationale": "outnumbered, fall back"}
120 else:
121 plan = {"stance": "attack", "rationale": "engage, we have the numbers"}
122 return LLMResponse(text=json.dumps(plan))
123
124
125@dataclass
126class Enemy:
127 """A red target. A plain object, not a node: the battle moves, draws and culls them."""
128
129 position: Vec2
130 hp: float = ENEMY_HP
131
132
133class Unit(Node2D):
134 """A blue squad unit. It reads the battle's plan every frame and acts on it."""
135
136 # The world point this unit is shooting at, or None. A Property, so each write redraws the unit;
137 # the point stays right in local space because a firing unit stands still.
138 firing_at = Property(None)
139
140 def __init__(self, **kwargs):
141 super().__init__(**kwargs)
142 self.home = Vec2() # the slot this unit holds when there is nothing to do
143
144 def on_enter_tree(self):
145 self.battle = self.ancestor(Battle) # the plan, the enemies and the rally point live there
146
147 def on_update(self, dt):
148 match self.battle.plan["stance"]:
149 case "attack":
150 self.attack(dt)
151 case "retreat":
152 self.firing_at = None
153 self.move_toward(self.battle.rally, dt)
154 case _: # hold
155 self.firing_at = None
156 self.move_toward(self.home, dt)
157
158 def attack(self, dt):
159 """Shoot the nearest enemy in range, else close on it, else regroup at the rally point."""
160 enemy = self.battle.nearest_enemy(self.position)
161 if enemy is None:
162 self.firing_at = None
163 self.move_toward(self.battle.rally, dt)
164 elif (enemy.position - self.position).length() <= ATTACK_RANGE:
165 enemy.hp -= DAMAGE_PER_SECOND * dt
166 self.firing_at = enemy.position.copy()
167 else:
168 self.firing_at = None
169 self.move_toward(enemy.position, dt)
170
171 def move_toward(self, target, dt):
172 delta = target - self.position
173 distance = float(delta.length())
174 if distance > 1.0:
175 self.position = self.position + delta * (min(UNIT_SPEED * dt, distance) / distance)
176
177 def on_draw(self, renderer):
178 renderer.circle((0, 0), UNIT_RADIUS, fill=(0.3, 0.6, 1.0, 1.0))
179 if self.firing_at is not None:
180 renderer.line((0, 0), self.to_local(self.firing_at), stroke=(1.0, 0.9, 0.3, 0.8), width=2.0)
181
182
183class Battle(Node2D):
184 """The battlefield: the squad, the enemies, and the commander's standing plan."""
185
186 dynamic = True # the enemies are plain objects that move every frame
187 input_context = {"spawn_wave": [Key.SPACE], "fall_back": [Key.R], "quit": [Key.ESCAPE]}
188
189 def __init__(self, client: LLMClient | None = None, **kwargs):
190 super().__init__(**kwargs)
191 self.planner = Planner(
192 client or ScriptedCommanderClient(),
193 schema=PLAN_SCHEMA,
194 validate=validate_plan,
195 period=PLAN_PERIOD,
196 default=DEFAULT_PLAN,
197 )
198 self.plan = dict(DEFAULT_PLAN) # the standing orders: the planner writes them, the units read them
199 self.enemies: list[Enemy] = []
200 self.rally = Vec2()
201
202 def on_ready(self):
203 self.units = [self.add_child(Unit(name=f"Unit{i}")) for i in range(SQUAD_SIZE)]
204 self.layout()
205 for unit in self.units:
206 unit.position = unit.home
207 self.add_child(Timer(duration=FIRST_WAVE_DELAY, autostart=True)).timeout.connect(self.spawn_wave)
208 self.fallback = self.add_child(Timer(duration=RETREAT_SECONDS))
209
210 hud = self.add_child(CanvasLayer())
211 self.plan_label = hud.add_child(
212 Label(anchor=Anchor.BOTTOM_WIDE, inset=(20, 16), size=(0, 48), font_size=22, alignment=HAlign.CENTRE)
213 )
214
215 # Example help overlay: not part of the technique shown.
216 bar = hud.add_child(HBoxContainer(anchor=Anchor.TOP_LEFT, inset=12))
217 bar.add_child(Button("Spawn wave", action="spawn_wave"))
218 bar.add_child(Button("Fall back", action="fall_back"))
219
220 def layout(self):
221 """Rally point and home slots follow the window, so a resize just moves them."""
222 width, height = self.viewport_size
223 self.rally = Vec2(width * 0.14, height * 0.5)
224 for i, unit in enumerate(self.units):
225 unit.home = Vec2(width * 0.22, height * (0.22 + 0.14 * i))
226
227 @on_input("spawn_wave")
228 def spawn_wave(self, _event=None):
229 """Send a wave of enemies in from the right edge."""
230 width, height = self.viewport_size
231 self.enemies += [Enemy(Vec2(width - 120, height * (0.26 + 0.19 * i))) for i in range(WAVE_SIZE)]
232
233 @on_input("fall_back")
234 def order_fall_back(self, _event):
235 self.fallback.start()
236
237 @on_input("quit")
238 def quit(self, _event):
239 self.app.quit()
240
241 def nearest_enemy(self, point):
242 living = [enemy for enemy in self.enemies if enemy.hp > 0]
243 return min(living, key=lambda enemy: (enemy.position - point).length(), default=None)
244
245 def on_update(self, dt):
246 self.layout()
247 # Enemies advance on the rally point and are cleared once dead or past it.
248 for enemy in self.enemies:
249 enemy.position.x -= ENEMY_SPEED * dt
250 self.enemies = [e for e in self.enemies if e.hp > 0 and e.position.x > self.rally.x]
251
252 self.plan = self.planner.poll(dt, enemy_count=len(self.enemies), forced_retreat=not self.fallback.stopped)
253 self.plan_label.text = f'COMMANDER {self.plan["stance"].upper()} "{self.plan["rationale"]}"'
254
255 def on_exit_tree(self):
256 self.planner.close()
257
258 def on_draw(self, renderer):
259 renderer.circle(self.rally, 18, fill=(0.3, 0.6, 1.0, 0.25))
260 for enemy in self.enemies:
261 renderer.circle(enemy.position, ENEMY_RADIUS, fill=(1.0, 0.35, 0.35, 1.0))
262 bar = (28 * max(0.0, enemy.hp) / ENEMY_HP, 4)
263 renderer.rect((enemy.position.x - 14, enemy.position.y - 22), bar, fill=(0.4, 1.0, 0.4, 1.0))
264
265
266def live_client() -> LLMClient:
267 """A real model, with its answers recorded to the cache and replayed on a re-run."""
268 cache = os.environ.get("SQUAD_COMMANDER_CACHE_DIR")
269 base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "simvx" / "squad_commander"
270 return CachingClient(OpenAICompatibleClient.from_env(), Path(cache) if cache else base)
271
272
273if __name__ == "__main__":
274 client = live_client() if "--live" in sys.argv else None
275 App(title="Squad Commander", width=1000, height=700).run(Battle(client))