LLM NPC Flavour

a night guard whose barks come from a low-frequency LLM brain.

▶ Run in browser

Tags: 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()