LLM NPC Flavour¶
an NPC whose barks come from a language model without ever stalling the game.
▶ Run in browserTags: ai llm
A model call takes far longer than a frame, so the model never drives the game. The game is
ordinary Python that runs every frame and owns the truth (here a night guard’s hp, ammo and
alert flag); a Bark from simvx.ai turns a small slice of it into one in-character line:
self.bark = Bark(client, persona="a terse, tired night-watch guard", period=4.0, default="...")
def on_update(self, dt):
self.line.text = self.bark.poll(dt, hp=round(self.hp), ammo=self.ammo, alert=self.alert)
poll never blocks. It returns the last good line at once, and when period seconds have
passed and no call is in flight it starts the next one on a worker thread. The facts the model
sees are exactly the keyword arguments to poll, never the whole game. Until the first answer
lands poll returns default, and a call that is slow or fails leaves the last line in place,
so a model that is down costs the game nothing. Call close() when the NPC leaves the tree.
The client is any LLMClient. By default this example runs offline on ScriptedGuardClient,
a stand-in that waits a moment, as a model would, then answers with a canned line that fits the
situation. 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, such as
http://localhost:8000/v1), SIMVX_LLM_MODEL and, if the endpoint needs one,
SIMVX_LLM_API_KEY. A CachingClient wraps it and records each answer under
$XDG_CACHE_HOME/simvx/llm_npc_flavour/ (LLM_NPC_FLAVOUR_CACHE_DIR overrides it), so a
re-run with the same situations replays them and costs nothing.
Press A, click or tap to toggle the alert: the next bark changes its tune.
Source¶
1# /// simvx
2# tags = ["ai", "llm"]
3# ///
4"""LLM NPC Flavour: an NPC whose barks come from a language model without ever stalling the game.
5
6A model call takes far longer than a frame, so the model never drives the game. The game is
7ordinary Python that runs every frame and owns the truth (here a night guard's hp, ammo and
8alert flag); a `Bark` from `simvx.ai` turns a small slice of it into one in-character line:
9
10 self.bark = Bark(client, persona="a terse, tired night-watch guard", period=4.0, default="...")
11
12 def on_update(self, dt):
13 self.line.text = self.bark.poll(dt, hp=round(self.hp), ammo=self.ammo, alert=self.alert)
14
15`poll` never blocks. It returns the last good line at once, and when `period` seconds have
16passed and no call is in flight it starts the next one on a worker thread. The facts the model
17sees are exactly the keyword arguments to `poll`, never the whole game. Until the first answer
18lands `poll` returns `default`, and a call that is slow or fails leaves the last line in place,
19so a model that is down costs the game nothing. Call `close()` when the NPC leaves the tree.
20
21The client is any `LLMClient`. By default this example runs offline on `ScriptedGuardClient`,
22a stand-in that waits a moment, as a model would, then answers with a canned line that fits the
23situation. Run it with `--live` to use a real model through `OpenAICompatibleClient.from_env()`,
24which reads `SIMVX_LLM_BASE_URL` (the endpoint up to `/chat/completions`, such as
25`http://localhost:8000/v1`), `SIMVX_LLM_MODEL` and, if the endpoint needs one,
26`SIMVX_LLM_API_KEY`. A `CachingClient` wraps it and records each answer under
27`$XDG_CACHE_HOME/simvx/llm_npc_flavour/` (`LLM_NPC_FLAVOUR_CACHE_DIR` overrides it), so a
28re-run with the same situations replays them and costs nothing.
29
30Press A, click or tap to toggle the alert: the next bark changes its tune.
31"""
32
33import asyncio
34import json
35import math
36import os
37import sys
38from pathlib import Path
39
40from simvx.ai import Bark, CachingClient, LLMClient, LLMResponse, Message, OpenAICompatibleClient
41from simvx.core import Anchor, HAlign, Key, Label, Node2D, Pointer, Timer, on_input
42from simvx.graphics import App
43
44
45class ScriptedGuardClient(LLMClient):
46 """An offline stand-in for a model: a short wait, then a canned line for the situation."""
47
48 CALM = ["All quiet on the wall.", "Another slow shift.", "Nothing moving out there.", "Cold night. Stay sharp."]
49 ALERT = ["Movement, north side!", "I heard something. Eyes up.", "Stay down, company.", "That's not the wind."]
50 LOW_AMMO = ["Running low on rounds.", "Down to my last clip.", "Need a resupply soon."]
51
52 def __init__(self):
53 self.turn = 0 # lines cycle rather than being picked at random, so a run is reproducible
54
55 async def complete(self, messages: list[Message], **kwargs) -> LLMResponse:
56 await asyncio.sleep(0.08) # model latency, spent on the Bark's worker thread
57 # Bark hands the client only its prompt, so the facts are read back out of the text.
58 prompt = messages[-1]["content"]
59 facts = json.loads(prompt[prompt.index("{") :])
60 pool = self.ALERT if facts["alert"] else self.LOW_AMMO if facts["ammo"] <= 2 else self.CALM
61 self.turn += 1
62 return LLMResponse(text=pool[self.turn % len(pool)])
63
64
65class NightGuard(Node2D):
66 input_context = {"toggle_alert": [Key.A, Pointer.PRIMARY]}
67
68 def __init__(self, client: LLMClient | None = None, **kwargs):
69 super().__init__(**kwargs)
70 self.bark = Bark(
71 client or ScriptedGuardClient(), persona="a terse, tired night-watch guard", period=4.0, default="..."
72 )
73 self.hp = 100.0
74 self.ammo = 12
75 self.alert = False
76
77 def on_ready(self):
78 line = Label(
79 anchor=Anchor.BOTTOM_WIDE,
80 inset=(20, 16),
81 size=(0, 48),
82 font_size=28,
83 autoshrink=True,
84 alignment=HAlign.CENTRE,
85 )
86 self.line = self.add_child(line)
87 status = Label(anchor=Anchor.CENTER, size=(520, 40), font_size=24, alignment=HAlign.CENTRE)
88 self.status = self.add_child(status)
89 # Spend a round every two seconds and resupply once empty, so the situation keeps changing.
90 self.add_child(Timer(duration=2.0, one_shot=False, autostart=True)).timeout.connect(self.spend_round)
91
92 # Example help overlay: not part of the technique shown.
93 self.add_child(Label("A, click or tap: toggle alert", anchor=Anchor.CENTER_TOP, inset=16, font_size=18))
94
95 def spend_round(self):
96 self.ammo = self.ammo - 1 if self.ammo > 0 else 12
97
98 @on_input("toggle_alert")
99 def toggle_alert(self, _event):
100 self.alert = not self.alert
101
102 def on_update(self, dt):
103 self.hp = 80.0 + 20.0 * math.sin(self.tree.now * 0.7)
104 self.line.text = self.bark.poll(dt, hp=round(self.hp), ammo=self.ammo, alert=self.alert)
105 self.status.text = f"hp {round(self.hp)} ammo {self.ammo} {'ALERT' if self.alert else 'calm'}"
106
107 def on_exit_tree(self):
108 self.bark.close()
109
110
111def live_client() -> LLMClient:
112 """A real model, with its answers recorded to the cache and replayed on a re-run."""
113 cache = os.environ.get("LLM_NPC_FLAVOUR_CACHE_DIR")
114 base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "simvx" / "llm_npc_flavour"
115 return CachingClient(OpenAICompatibleClient.from_env(), Path(cache) if cache else base)
116
117
118if __name__ == "__main__":
119 client = live_client() if "--live" in sys.argv else None
120 App(title="LLM NPC Flavour", width=900, height=600).run(NightGuard(client))