LLM NPC Flavour

an NPC whose barks come from a language model without ever stalling the game.

▶ Run in browser

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