LLM NPC Flavour¶
a night guard whose barks come from a low-frequency LLM brain.
▶ Run in browserTags: ai llm
The classical, authoritative game logic runs every frame and is the source of
truth: hp, ammo, and an alert flag drift / toggle in on_update and are written
onto the agent’s blackboard. A child AgentNode runs an LLMBrain that, a few
seconds apart and entirely off the frame thread, turns that authoritative slice
into a single in-character line. A bottom-anchored HUD renders the latest bark
every frame.
The LLM is a stage, not the NPC: while a call is in flight, or if it is late / dropped / fails, the HUD keeps the last good line and the game never stalls.
How to run¶
OFFLINE (default, no LLM, no network, no setup): a scripted fake client picks a canned bark for the situation, after a trivial awaitable that proves the off-thread path. This is what the screenshot walker uses.
uv run python examples/features/ai/llm_npc_flavour.py
LIVE: point it at any OpenAI-compatible chat endpoint (vLLM, llama.cpp, Ollama,
hosted, etc.) by setting these environment variables, then pass –live. The
client is built by OpenAICompatibleClient.from_env():
SIMVX_LLM_BASE_URL required, e.g. http://host:8000/v1
SIMVX_LLM_MODEL required, the model name the endpoint serves
SIMVX_LLM_API_KEY optional, only if your endpoint needs a key
SIMVX_LLM_BASE_URL=http://host:8000/v1 SIMVX_LLM_MODEL=your-model uv run python examples/features/ai/llm_npc_flavour.py --live
Live runs record responses to a local cache (CACHE_DIR) so a re-run is deterministic and free.
Controls: A or click/tap toggles the alert state (watch the next bark change), Esc quits.
Source¶
1"""LLM NPC Flavour: a night guard whose barks come from a low-frequency LLM brain.
2
3The classical, authoritative game logic runs every frame and is the source of
4truth: hp, ammo, and an alert flag drift / toggle in ``on_update`` and are written
5onto the agent's blackboard. A child ``AgentNode`` runs an ``LLMBrain`` that, a few
6seconds apart and entirely off the frame thread, turns that authoritative slice
7into a single in-character line. A bottom-anchored HUD renders the latest bark
8every frame.
9
10The LLM is a stage, not the NPC: while a call is in flight, or if it is late /
11dropped / fails, the HUD keeps the last good line and the game never stalls.
12
13## How to run
14
15OFFLINE (default, no LLM, no network, no setup): a scripted fake client picks a
16canned bark for the situation, after a trivial awaitable that proves the
17off-thread path. This is what the screenshot walker uses.
18
19 uv run python examples/features/ai/llm_npc_flavour.py
20
21LIVE: point it at any OpenAI-compatible chat endpoint (vLLM, llama.cpp, Ollama,
22hosted, etc.) by setting these environment variables, then pass --live. The
23client is built by `OpenAICompatibleClient.from_env()`:
24
25 SIMVX_LLM_BASE_URL required, e.g. http://host:8000/v1
26 SIMVX_LLM_MODEL required, the model name the endpoint serves
27 SIMVX_LLM_API_KEY optional, only if your endpoint needs a key
28
29 SIMVX_LLM_BASE_URL=http://host:8000/v1 SIMVX_LLM_MODEL=your-model \
30 uv run python examples/features/ai/llm_npc_flavour.py --live
31
32Live runs record responses to a local cache (CACHE_DIR) so a re-run is
33deterministic and free.
34
35Controls: A or click/tap toggles the alert state (watch the next bark change),
36Esc quits.
37
38# /// simvx
39# tags = ["ai", "llm"]
40# ///
41"""
42
43from __future__ import annotations
44
45import asyncio
46import math
47import random
48import re
49import sys
50import tempfile
51from pathlib import Path
52
53from simvx.ai import BARK_KEY, CachingClient, LLMBrain, OpenAICompatibleClient
54from simvx.ai.client import LLMClient, LLMResponse
55from simvx.core import AnchorPreset, Input, InputMap, Key, Label, MouseButton, Node2D
56from simvx.core.ai import AgentNode
57from simvx.graphics import App
58
59CACHE_DIR = Path(tempfile.gettempdir()) / "simvx_llm_npc_cache"
60
61
62class ScriptedGuardClient(LLMClient):
63 """An offline fake: a trivial awaitable, then a canned bark for the situation.
64
65 This stands in for a real model so the demo runs with no network. It still
66 exercises the full async path (the ``await`` runs on the AsyncSlot loop, never
67 the frame), so the non-blocking / coalescing / degrade behaviour is identical.
68 """
69
70 CALM = ["All quiet on the wall.", "Another slow shift.", "Nothing moving out there.", "Cold night. Stay sharp."]
71 ALERT = ["Movement, north side!", "I heard something. Eyes up.", "Stay down, company.", "That's not the wind."]
72 LOW_AMMO = ["Running low on rounds.", "Down to my last clip.", "Need a resupply soon."]
73
74 async def complete(self, messages, **kwargs) -> LLMResponse:
75 await asyncio.sleep(0.08) # simulate model latency, off the frame thread
76 user = messages[-1]["content"].lower()
77 # Read the exact ammo value (a substring check would match "ammo": 1 inside 12).
78 ammo_match = re.search(r'"ammo":\s*(\d+)', user)
79 ammo = int(ammo_match.group(1)) if ammo_match else 99
80 if '"alert": true' in user:
81 pool = self.ALERT
82 elif ammo <= 2:
83 pool = self.LOW_AMMO
84 else:
85 pool = self.CALM
86 return LLMResponse(text=random.choice(pool))
87
88
89class NightGuard(Node2D):
90 """Authoritative classical state every frame; an LLMBrain only colours it."""
91
92 def __init__(self, client: LLMClient | None = None, **kwargs) -> None:
93 super().__init__(**kwargs)
94 # Default (no-arg) construction runs offline, so the screenshot walker and
95 # web export (both of which instantiate the root with no args) get the
96 # canned barks; main() passes a real client for --live.
97 self._client = client if client is not None else ScriptedGuardClient()
98 self.hp = 100.0
99 self.ammo = 12
100 self.alert = False
101 self._t = 0.0
102 self.agent: AgentNode | None = None
103 self.hud: Label | None = None
104 self.status: Label | None = None
105
106 def on_ready(self) -> None:
107 InputMap.add_action("toggle_alert", [Key.A, MouseButton.LEFT])
108 InputMap.add_action("quit", [Key.ESCAPE])
109
110 # The child agent runs the LLM brain low-frequency (every 4s).
111 self.agent = AgentNode(
112 brain=LLMBrain(
113 self._client,
114 persona="a terse, tired night-watch guard",
115 facts=["hp", "ammo", "alert"],
116 period=4.0,
117 ),
118 name="GuardBrain",
119 )
120 self.add_child(self.agent)
121
122 # Bottom-anchored HUD (anchors + margins, never absolute position).
123 hud = Label("...", name="Bark")
124 hud.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
125 hud.margin_left = 20
126 hud.margin_right = 20
127 hud.margin_top = -64
128 hud.margin_bottom = -16
129 hud.font_size = 28.0
130 hud.alignment = "center"
131 self.add_child(hud)
132 self.hud = hud
133
134 title = Label("Night Guard - A or click: toggle alert Esc: quit", name="Title")
135 title.set_anchor_preset(AnchorPreset.CENTER_TOP)
136 title.margin_left = -260
137 title.margin_right = 260
138 title.margin_top = 16
139 title.margin_bottom = 40
140 title.font_size = 18.0
141 title.alignment = "center"
142 self.add_child(title)
143
144 # Authoritative classical state, centred, updated every frame (the LLM never
145 # writes this: it only reads it to colour the bark below).
146 status = Label("", name="Status")
147 status.set_anchor_preset(AnchorPreset.CENTER)
148 status.margin_left = -260
149 status.margin_right = 260
150 status.margin_top = -20
151 status.margin_bottom = 20
152 status.font_size = 24.0
153 status.alignment = "center"
154 self.add_child(status)
155 self.status = status
156
157 def on_update(self, dt: float) -> None:
158 # Classical authoritative simulation, every single frame.
159 self._t += dt
160 self.hp = 60.0 + 40.0 * (0.5 + 0.5 * math.sin(self._t * 0.7))
161 if self._t % 2.0 < dt:
162 # Drain a round every couple of seconds, then resupply once empty, so the
163 # demo cycles through calm / low-ammo states (and barks) instead of draining flat.
164 self.ammo = self.ammo - 1 if self.ammo > 0 else 12
165 if Input.is_action_just_pressed("toggle_alert"):
166 self.alert = not self.alert
167 if Input.is_action_just_pressed("quit"):
168 self.app.quit()
169
170 # Publish the authoritative slice onto the agent's blackboard each frame.
171 board = self.agent.blackboard if self.agent else None
172 if board is not None:
173 board.set("hp", round(self.hp))
174 board.set("ammo", self.ammo)
175 board.set("alert", self.alert)
176
177 # Render the authoritative classical state (updates every frame) and the latest
178 # LLM bark (last good line if a call is in flight / failed).
179 if self.status is not None:
180 flag = "ALERT" if self.alert else "calm"
181 self.status.text = f"hp {round(self.hp)} ammo {self.ammo} {flag}"
182 if self.hud is not None and board is not None:
183 self.hud.text = str(board.get(BARK_KEY, "..."))
184
185
186def _build_client(live: bool) -> LLMClient:
187 if not live:
188 return ScriptedGuardClient()
189 # Record/replay so a re-run is deterministic and free.
190 return CachingClient(OpenAICompatibleClient.from_env(), CACHE_DIR, mode="auto")
191
192
193def main() -> None:
194 live = "--live" in sys.argv
195 app = App(title="SimVX LLM NPC Flavour", width=900, height=600)
196 app.run(NightGuard(_build_client(live), name="NightGuard"))
197
198
199if __name__ == "__main__":
200 main()