Squad Commander

an LLM officer picks the stance, the squad fights every frame.

📄 Docs only

Tags: 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 Unit reads one word from the battle’s standing plan, attack, hold or retreat, 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 in on_update, with no LLM code and nothing to await.

  • The commander is a Planner from simvx.ai. At most every PLAN_PERIOD seconds it asks a model, on a worker thread, for a plan matching PLAN_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))