29 KiB
AGENTS.md: agent orientation
Start here. This file maps the kinds of work in this repo so you don't have to reverse-engineer them from the tree:
- Building a game or example (the common ask) → Building a game or example.
- Everyday dev — running tests/examples safely, lint, adding an example → Common dev tasks.
Everything runs from the repo root and must use uv run (bare python/pytest won't resolve
workspace packages).
Building a game or example
The engine is fully featured, but the how lives in runnable examples, not prose. Don't
re-derive patterns from scratch — fork the nearest example and adapt it. This section is the
index into them. Scenes are .py files with a Node subclass (never a .json/.tscn format);
save files are separate (see save/load below).
Minimal skeleton — a scene is a node; App(...).run(root) starts the loop:
from simvx.core import Node2D
from simvx.graphics import App
class Game(Node2D):
dynamic = True # on_draw animates every frame; re-collect it (see pitfalls)
def on_ready(self):
...
def on_update(self, dt): # per-frame gameplay/animation
...
def on_draw(self, renderer): # immediate-mode 2D
renderer.draw_circle((400, 300), 40, colour=(0.4, 0.8, 1.0, 1.0), filled=True)
if __name__ == "__main__":
App(title="Game", width=800, height=600).run(Game())
Lifecycle hooks (on_update, on_input), input actions (InputMap.add_action +
Input.is_action_pressed, or the @on_input(action="jump") decorator that consumes the event on
a truthy return), and Properties are covered under Key patterns; read that once
and don't reinvent it.
Learning ladder (examples/tutorials/, in order) — read these first if unfamiliar with the API:
first_scene (window + on_draw) → nodes_and_signals (tree + signals) →
input_and_movement (input actions) → bouncing_balls (Properties + many children) →
monolith_to_composed (structuring into small nodes) → pong (complete 2D game, ~150 lines) →
gem_collector (first 3D game).
Pick a starting point by genre (fork it). Entries under examples/ports/ ship in the
simvx-examples distribution like every other tier, so a pip install reaches them through the
simvx examples verb like any other tier. Each is a derivative work licensed individually against the game it
re-implements rather than under the Examples Licence, and its ATTRIBUTION.md states its terms;
read that before building on one.
| Building… | Start from |
|---|---|
| Arcade / shooter | examples/demos/asteroids2d.py, spaceinvaders2d.py |
| Precision platformer | examples/features/physics/character_platformer.py |
| Top-down action RPG | examples/ports/heartbeast_rpg/ |
| Grid / falling-block puzzle | examples/ports/raylib_tetris/, raylib_snake/, hextris/ |
| Card game (drag/drop, undo, save) | examples/ports/solitaire/ |
| Endless runner | examples/ports/clumsy_bird/ |
| Tower defence / light RTS | examples/ports/tower_defence_tut/, examples/demos/squad_commander.py |
| Twin-stick / arena (juice) | examples/ports/snkrx/ |
| Isometric / grid strategy | examples/ports/tiny_yurts/ |
| 3D first-person / FPS | examples/ports/q1k3/, examples/features/3d/first_person.py |
| 3D racer | examples/ports/hexgl/ |
| Procedural 3D world | examples/demos/planet_explorer.py, examples/ports/procedural_planets/ |
Feature map — "I need X" → read this (examples/features/, each is self-contained):
| Need | Example(s) |
|---|---|
| Sprites & textures | 2d/sprite.py (Sprite2D(texture=...) takes a file path, PNG bytes, or RGBA ndarray), 2d/animated_sprite.py (spritesheets, add_animation/play), 2d/texture_resource.py (Texture + update() when the image changes at runtime) |
| Camera / scroll / follow | 2d/camera.py; 3d/chase_camera.py, 3d/first_person.py (Camera2D / Camera3D) |
| Audio (sfx / music / spatial) | audio/audio.py, audio/spatial.py (AudioPlayer, AudioPlayer2D, AudioPlayer3D) |
| Physics & collision (bodies) | 2d/collision_shapes.py, 2d/area2d.py, 2d/joints.py, features/physics/character_platformer.py (CharacterBody2D + move_and_slide); 3d/collision_world.py, 3d/raycast.py |
| Manual (non-physics) collision | tutorials/pong/ (AABB by hand — fine for simple arcade games) |
| Tilemaps / levels | 2d/tilemap.py (TileMap / TileMapLayer) |
| Particles & juice | 2d/gpu_particles.py, 2d/fireworks.py, 2d/trail.py, 2d/screen_effects.py |
| Tween / timers | 2d/tween.py, 2d/timer.py |
| Navigation / pathfinding | 2d/navigation.py, 2d/path_follow.py, 3d/navigation.py |
| UI: HUD, menus, dialogs | ui/hud_anchors.py, ui/menus.py, ui/pause_menu.py, ui/dialog.py, ui/settings.py, ui/inventory.py, ui/widget_showcase.py |
| 3D lighting / models / effects | 3d/lighting.py, 3d/animated_model.py, 3d/shadows.py, and the rest of 3d/ |
| In-game debug drawing | debug/draw.py, debug/overlay.py |
| LLM-driven NPCs / behaviour | ai/llm_npc_flavour.py, ai/nodegen.py + packages/ai/README.md (see LLM / AI) |
Core patterns not obvious from a single example:
- Scene transitions (menu → game → game over):
self.tree.change_scene(NextScene()).examples/demos/asteroids2d.pywires all three states; it also usesself.tree.group(name)for broadcast queries.self.treeis valid afteron_enter_tree(). - Assets: pass a file path / bytes / ndarray straight to
Sprite2D(texture=...). Bundle package-shipped assets viaimportlib.resources(pkg://…), never absolute paths — see the resources rule under Key patterns. - An example reads beside
__file__and writes to a user directory. Examples are the deliberate exception to thepkg://rule above: an example is a template for a directory the reader will own, so it resolves what it reads withPath(__file__).parent / "assets", which still works aftercp -r. The other half is not optional: no example writes into its own directory. Saves go under$XDG_DATA_HOME(falling back to~/.local/share), fetched or generated assets under$XDG_CACHE_HOME(falling back to~/.cache), each below asimvx/<example>/path and each overridable by a named environment variable.examples/demos/afterglow/afterglow/progress.pyandexamples/demos/dungeon_explorer/main.pyare the pattern to copy. The reason is that the example library also installs intosite-packages, which is read-only on a distribution install, inside a container and in any venv the reader did not create. - Save / load:
SaveManager(packages/core/src/simvx/core/save_manager.py),save(root, slot)/load(slot)— save files may use any format (only scenes must stay.py). Working example:examples/ports/solitaire/.
Game-dev pitfalls that cost agents time:
- An
on_drawthat animates every frame needsdynamic = True(class attr) so the node is re-collected each frame — a plain node'son_drawreading non-Propertystate renders once. Useself.queue_redraw()only for occasional, event-driven repaints. - Physics belongs in
on_fixed_update(dt)(fixed timestep); gameplay/animation inon_update(dt). - Top-level
Controlsubclasses use anchors + margins (set_anchor_preset()), never absoluteposition. - Register input actions via the root's
input_actions/ready(), notmain()— web export skipsmain(). - Exit via
self.app.quit(), neversys.exit()(leaks the miniaudio thread and hangs). - Wrap any manual
App.run()intimeoutwhen running headless, or it hangs the session.
To verify a finished game, prefer the /playtest skill (drives the real loop) over headless
self-assessment — see Playtest a game.
Architecture
Six namespace packages under packages/:
| Package | Description | Depends on |
|---|---|---|
core |
Backend-agnostic engine. Node hierarchy, signals, properties, UI widgets, audio, animation, collision, scene IO, math, input, tilemap, nav, testing. Ships the simvx CLI. Deps: numpy, freetype-py, miniaudio, parso, cffi. |
— |
graphics |
Vulkan renderer. GPU-driven forward, multi-draw indirect, GLFW/SDL3/PySide6 windowing, GLSL→SPIR-V. | core |
web |
Browser runtime + HTML exporter. WebApp (Pyodide), WebRenderer (WebGPU), JS/WGSL assets, simvx export web. |
core, graphics |
editor |
Visual editor shell on simvx.core.ui. Plugin system via simvx.editor.Plugin. |
core, graphics, web |
ide |
Engine-native Python IDE with LSP, debugging, terminal. | core, graphics |
ai |
LLM layer. Provider-agnostic LLMClient, record/replay cache, LLMBrain. The AI contract (Brain, Blackboard, Sensor) lives in simvx.core.ai. |
core (graphics lazy) |
Lazy core→graphics imports. simvx.core never imports simvx.graphics at module level.
Entry points import lazily inside functions. Module-level core → graphics imports are forbidden.
Editor and IDE are siblings. Neither hard-imports the other; integration is a plugin hook
from the ide side.
Scene format. Scenes are .py files containing a Node subclass, and the editor round-trips
to and from Python source. Never introduce a .json, .scene, .tscn or any other scene format.
Save files (game state, checkpoints) are separate and may use any serialization.
Key patterns
Properties — the Property descriptor carries validation, serialization, and link=True for
parent-child linking. Use on_change="method_name" for change hooks. Hooks fired during
__init__ are deferred until init returns and deduplicated by (property, hook), so they always
see a fully constructed object.
Math — NumPy arrays (Vec2/Vec3 are float32 ndarrays, Quat for rotations). All
internals use radians; UIs convert for display.
Matrices — NumPy arrays in row-major order, transposed to column-major at the GPU
boundary. Camera3D applies the Vulkan Y-flip in projection_matrix().
Input actions — InputMap.add_action("jump", [Key.SPACE]), queried with
Input.is_action_pressed("jump"). Typed enums (Key, MouseButton, JoyButton, JoyAxis).
App access — node.app after on_enter_tree() for window properties; node.tree for the
SceneTree (tree.now, tree.events). Never reach for the renderer directly — use
WorldEnvironment. Never touch self._tree; it is private.
Lifecycle hooks — override on_ready, on_update(dt), on_fixed_update(dt),
on_input(event), on_unhandled_input(event), on_enter_tree(), on_exit_tree(),
on_draw(renderer), or decorate methods with @on_update, @on_input(action="jump"). A truthy
return from @on_input consumes the event.
UI layout — top-level Control subclasses must use anchors and margins
(set_anchor_preset()), never absolute position. Containers legitimately set .position on
their children internally.
Resources — reach package-shipped assets through importlib.resources (pkg://…), never an
absolute path. Examples are the deliberate exception: an example is a template for a directory
the reader will own, so it resolves what it reads with Path(__file__).parent / "assets". No
example writes into its own directory — saves go under $XDG_DATA_HOME, generated assets under
$XDG_CACHE_HOME, because the example library also installs into a read-only site-packages.
Testing modules — simvx.core.testing (SceneRunner, InputSimulator) for scene-level
tests; simvx.core.ui.testing (UITestHarness, DrawLog) for widget tests without a GPU. Tests
exercise the real input pipeline rather than bypassing it: drive simulated mouse and keyboard
input and verify the result programmatically, rather than calling methods directly.
Rendering verification tiers — logic (CPU, no GPU); golden-image regression (GPU,
vulkan-marked, diffed against packages/graphics/tests/baselines/, updated with
--update-baselines --require-gpu); and the smoke gate in tools/build_examples.py, which runs
every example headlessly and hard-fails on a blank frame.
Code style
- Line length 120 (black + ruff). Python 3.14+. Linter rules E, W, F, I, B, C4, UP.
- Spelling: "colour" in our own code; US "color" only in external bindings (Vulkan symbols,
colorsys). - Logging: log errors fully, keep prints minimal during normal operation.
- No process residue. Comments and docstrings describe what the code does for a reader who has
never seen any internal document. No design IDs, phase or sprint numbers, agent notes, or
TODO(agent). Do not write that something is "verified" or "confirmed" — documentation states behaviour and limitations, it does not narrate its own testing history.
ruff check packages/ tools/ examples/ tests/ && black packages/ tools/ examples/ tests/ && mypy
mypy takes its targets from pyproject.toml and needs no path. Lint all four trees or you will
miss what the lint job fails on.
Build artifacts (the tools/build_examples.py subcommands)
| Subcommand | Output |
|---|---|
screenshots |
docs/_static/screenshots/<id>.png (mtime-cached; --force to redo all) |
web |
docs/_demos/<id>.html (picked up via Sphinx html_extra_path) |
editor |
../simvx.com/editor.html (the landing-page "Try in Browser" CTA) |
docs |
docs/examples/<id>.md + gallery index.md |
all |
chains: screenshots → web → editor → docs |
Web export smoketest (when imports changed)
The web exporter bundles sources via a hand-curated allowlist
(packages/web/src/simvx/web/export/sources.py). Unit tests import from the dev environment
where everything resolves, so they cannot catch a module that's imported but not bundled:
it only fails inside Pyodide at boot (ModuleNotFoundError). After any change that adds/changes
a simvx.* import reachable from web.py, run a real export + headless boot before shipping:
uv run simvx export web examples/features/3d/lighting.py -o /tmp/x.html
xvfb-run -a uv run --with playwright python tools/web_smoketest.py /tmp/x.html --frames 60 --out /tmp/x.png
# expect: "PASS ... rendered in-browser"
Use a dependency-light, web-enabled 3D example to isolate the import path from example-specific
deps. (WebGPU canvas on Linux requires a display, hence xvfb-run.)
Common dev tasks
Command recipes for everyday work. These complement Architecture and Key patterns: read it for the why; the exact safe commands are here. Everything runs from the repo root.
Run tests safely
A full monorepo run collects ~13700 tests and will exhaust memory on a modest machine. Always scope the run, and run suites serially.
--package alone does not scope. It selects the environment, not the test set: the rootdir
stays the repository root and the root testpaths lists all six packages plus the repo-root
tests/. Pass a path, or --directory.
# Scope with a path, or with --directory. Both work; `--package` alone does not.
uv run --package simvx-core pytest packages/core/tests
uv run --directory packages/core --package simvx-core pytest
uv run --package simvx-core pytest packages/core/tests/test_x.py::test_y
# The repo-root tests/ tier, which no package-scoped run covers
uv run --package simvx-core pytest tests/test_examples_metadata.py
# Cap memory for a big run
systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 \
uv run --package simvx-core pytest packages/core/tests
# Coverage is OPT-IN (~2.5x memory)
uv run --package simvx-core pytest packages/core/tests --cov=simvx --cov-report=term-missing
- Never run bare
pytestfrom the repository root, and never rely on--packageto scope it. - One package's full suite is fine. The graphics suite is worth running before shipping renderer
changes: ~7 minutes, because that package's
addoptsalready carry--forked. Cap it (MemoryMax=5G). - Run suites serially. A parallel agent runs only its own specific tests, never a full suite.
uv run pytestpitfall (fresh worktrees):uv run --package X pytestcan resolve to the system pytest and run against the wrong tree. Useuv run --with pytest --package X python -m pytestin worktrees.
Run / screenshot an example
# Headless --test modes exit on their own:
uv run python examples/demos/asteroids2d.py --test
# Anything that calls App.run() MUST be wrapped in `timeout` or it hangs the session:
timeout 20 uv run python examples/demos/asteroids2d.py
- Demos must exit via
self.app.quit(), notsys.exit(0)(sys.exit leaks miniaudio threads and hangs the process). - Screenshots for the site are produced by
tools/build_examples.py screenshots(see the export pipeline above), not by running examples by hand.
Lint / format / typecheck (pre-commit gate)
ruff check packages/ tools/ examples/ tests/ && black packages/ tools/ examples/ tests/ && mypy
tools/, examples/ and the repo-root tests/ are in scope: CI checks all four trees, so a
packages/-only run will miss what the lint job fails on. mypy takes its targets from
pyproject.toml and needs no path.
A bare mypy run cannot be green: the engine carries type-check debt in six of the seven
namespace roots. What CI gates is the direction, through tools/mypy_gate.py, which compares
the count per package against tools/mypy_baseline.json and fails on growth, naming the
file and error code of everything that appeared and vanished. A shrink fails too, so the commit
that earns a smaller number records it:
uv sync # first, or the count you measure is not CI's
uv run python tools/mypy_gate.py # the gate, as CI runs it
uv run python tools/mypy_gate.py --update # ratchet after fixing errors
uv sync first, and again before believing a red result. The baseline is recorded from a
clean resolve, and a .venv that predates one reports errors CI never sees. uv.lock is
gitignored, so every environment resolves from pyproject.toml on the day it was built: numpy's
inference changes between releases, and Vec2/Vec3 are numpy arrays, so its version alone
moves the count. A delta confined to files that face a third-party API is an environment
difference until proven otherwise, and the gate's failure text says so.
mypy is pinned in pyproject.toml for this reason: a release that adds one check moves
every number in the baseline, which records the version it was taken under and refuses to
compare across a different one. Upgrading means bumping the pin and re-measuring in the same
commit. Two other settings pin the count the same way: [tool.mypy] declares platform, so a
sys.platform branch resolves identically on every host, and the third-party modules listed in
[[tool.mypy.overrides]] are held at Any by follow_imports, so one of them starting to ship
type information cannot move the count either (an override applies only while a module is
missing or untyped, which is why the flags are needed). A module whose types we do want gets
removed from that list, in the commit that re-records the baseline. A smaller number bought with
# type: ignore looks identical to the gate: the diff review is what tells them apart.
Line length 120; Python 3.14+; ruff rules E, W, F, I, B, C4, UP. Spelling is "colour"
everywhere in our code (US "color" only in extern bindings like Vulkan symbols / colorsys).
Comparing a file's mypy errors against another revision: pointing mypy at a path inside a
git worktree does not typecheck that worktree's copy. mypy_path in the root
pyproject.toml is relative and resolves against the working directory, so simvx.core.*
resolves back to the live checkout and mypy reports the LIVE file — under the live file's own
path. Filtering on the worktree prefix then yields zero lines, and filtering on the bare
relative path yields the working tree's numbers labelled as the other revision's. Both failure
modes read as "no regression".
Override MYPYPATH with absolute paths into the worktree, and check the attribution before
trusting the count:
W=/path/to/worktree
MYPYPATH="$W/packages/core/src:$W/packages/graphics/src:$W/packages/web/src:$W/packages/editor/src:$W/packages/ide/src:$W/packages/ai/src" \
mypy --no-incremental --config-file "$W/pyproject.toml" "$W/packages/core/src/simvx/core/scene_tree.py" \
| grep "^$W/" # must be non-empty, or you measured the live tree
Only the file under test is isolated this way: its imports still resolve live, which is what
makes it a controlled A/B. Compare error messages, not just counts, and normalise type text
before diffing — widening an annotation rewrites "Vec3" to "Vec3 | Vec2" in pre-existing
messages, which a naive set-diff reports as new errors.
What CI runs (and how to run it the same way)
.gitea/workflows/tests.yml runs on every push to dev: one job per package (each an
explicit test path, one at a time, each container memory-capped), then the repo-root tests/
directory, then the nine example test directories (one pytest process each, with the
discovered count asserted, because every example imports a top-level main and nodes by
plain name and two of them cannot share an interpreter), then the packaging tier that builds
the examples wheel and installs it into a venv, then ruff + black over
packages/ tools/ examples/ tests/ and the mypy baseline ratchet above. Every job shares one uv
cache (a host directory bind-mounted at /uv-cache), so a push downloads each third-party wheel
once instead of ten times; uv cache prune
(plain, not --ci, which deletes the downloaded wheels and exists to shrink a cache tarball
before it is uploaded) runs once, at the end of the last job.
.gitea/workflows/native-abi.yml builds both Jolt extensions (default + free-threaded) and
checks each against the cdef; it triggers on packages/physics-jolt/**, weekly, and on demand.
.gitea/workflows/gpu-tests.yml is the GPU lane: manual dispatch only, on a runner with a
device, running the graphics suite with the vulkan tier live and then the three packaging
checks that drive a device (simvx examples run, the fork it produces, and a writing example
under a read-only site-packages), which the push lanes deselect.
tools/run_suite.py is what each job calls, and it works locally too. It finds a memory
cap (a systemd scope here, the container limit there), refuses to run without one, and fails a
suite in which far fewer tests passed than expected -- skips do not count, so neither an
empty collection nor a suite that skipped itself can look green:
uv run python tools/run_suite.py --package simvx-ai --path packages/ai/tests \
--memory 3G --min-passed 80
The push runner has no GPU, so vulkan-marked tests skip there through gpu_gate
(--vulkan-policy skip), and at least one skip per marked test is required: a tier that
started running, or vanished, fails the job rather than passing quietly. A driverless loader
raises VkErrorIncompatibleDriver from vkCreateInstance, which the gate reads as ABSENT —
no driver is an absence, not a present-but-failing device.
Running that tier is gpu-tests.yml's job, and locally it is --vulkan-policy require, which
adds --require-gpu where the suite declares it (an absent device FAILS rather than skipping)
and then looks each marked test up in the report, requiring --min-gpu-passed of them to have
passed. Only packages/graphics/tests/conftest.py declares that option, so over any other path
pytest would refuse it and the runner leaves it off, saying so in its plan line; nothing is lost,
because a suite with no gate has no skip to convert and its device-driving tests fail on their
own. The floor is on the marked tests alone, and it has to be: they are a quarter of that suite,
so a floor on the total is cleared by the very run that skipped every one of them. It is stated
rather than derived because part of the tier is opt-in by flag and skips even on a GPU (the
example smoke gate wants --smoke); measured on a developer GPU, 362 of the 521 marked tests
pass.
uv run python tools/run_suite.py --package simvx-graphics \
--path packages/graphics/tests --memory 6G --min-passed 1200 \
--vulkan-policy require --min-gpu-passed 350
One editor test still has to be excluded by node id rather than gated, and not because a marker
could not reach it: the gate is a pytest_runtest_setup hook in
packages/graphics/tests/conftest.py, so no other suite has anything that reads the marker, and
vulkan is not among the editor's registered markers either. Marking that test would leave it
running; the exclusion is what stops it.
Add a new example
Examples are self-describing: there is no manifest. Each is a .py file whose module
docstring's first line is the title and the rest is the description; an optional
# /// simvx ... # /// TOML block provides overrides.
"""My Example Title.
A sentence or two describing what this example demonstrates.
"""
# /// simvx
# tags = ["3d", "lighting"]
# ///
Place it under the right tree (examples/features/, tutorials/, ports/, demos/).
Validate the metadata gate before committing:
uv run --package simvx-core pytest tests/test_examples_metadata.py
See tools/example_meta.py or any existing example for the exact TOML fields.
GPU golden-image (visual regression) tests
GPU tests are marked @pytest.mark.vulkan and must not self-skip (let gpu_gate decide).
Baselines live in packages/graphics/tests/baselines/; a missing baseline fails under
--require-gpu.
uv run --package simvx-graphics pytest tests/test_visual.py -v
# Regenerate baselines after an intentional visual change:
uv run --package simvx-graphics pytest tests/test_visual.py --update-baselines --require-gpu
Playtest a game
Use the /playtest skill (single agent) or /playtest-team (multi-agent coordinator),
passing the example path as the argument, e.g. /playtest examples/demos/asteroids2d.py.
These drive the real run loop and inspect actual behaviour, preferred over headless
self-assessment for game-feel / live bugs.
LLM / AI testing & interfaces
The LLM/agent layer is the simvx.ai package (packages/ai/); the dependency-free
contract (Brain, Blackboard, Sensor) is in simvx.core.ai. Package overview:
packages/ai/README.md.
Provider / live interface. One provider-agnostic client targeting any OpenAI-compatible
/chat/completions endpoint (self-hosted OpenWebUI / vLLM / llama.cpp, or a hosted API):
from simvx.ai import OpenAICompatibleClient
client = OpenAICompatibleClient.from_env() # SIMVX_LLM_BASE_URL / _API_KEY / _MODEL
resp = await client.complete([{"role": "user", "content": "Hello"}])
base_url includes the path prefix up to (not including) /chat/completions, e.g.
http://localhost:8000/v1.
Record/replay for deterministic tests. CachingClient (packages/ai/src/simvx/ai/cache.py)
wraps any client with an on-disk fixture cache: record once against a real model, replay
forever with no network and no nondeterminism (temperature 0 is not reproducible, so this is
the supported path). Modes: auto (replay if fixture exists else record, default), replay
(must hit a fixture, raises LLMCacheMiss on miss, use in CI), record (always re-record),
off (passthrough).
uv run --package simvx-ai pytest # AI suite uses replayed fixtures, no endpoint needed
Interactive / live-control play. examples/features/ai/agent_playtest.py demonstrates an
agent driving a running game via AgentSession (verbs: observe / send_input / set_state /
step; terminated vs truncated). It runs two ways:
# Offline: scripted walk-through of the session verbs, no endpoint
uv run python examples/features/ai/agent_playtest.py
# Live: against a real endpoint
SIMVX_LLM_BASE_URL=http://host:8000/v1 SIMVX_LLM_MODEL=your-model \
SIMVX_LLM_API_KEY=sk-... uv run python examples/features/ai/agent_playtest.py
Other in-game AI feature examples sit beside it in examples/features/ai/ (LLM NPC flavour,
node generation; see their doc pages under docs/examples/features_ai_*).
Git / branch discipline
- Never commit to
mainin the engine repo. Work ondevor a feature branch offdev; check the branch first and switch if you're onmain. (The site commit in the export pipeline is the separate../simvx.comrepo onmaster, that one is correct.) - The tree may be shared with concurrent agents: stage explicit paths only, never
git add -A; nevergit checkout/reset/revert/cleanfiles you didn't author. - No
Co-Authored-By/ AI-attribution trailers on commits or PRs. - No em-dashes in code, commits, or prose (repo-wide; use a colon/comma/paren/hyphen).
Reminders
- Never commit to
mainin the engine repo. The site commit (step 2) is in the separate../simvx.comrepo onmaster, that one is correct. - Verify external URLs from a machine outside the deployment, not from the host serving them.
- A web export rebuilds all demos + editor + docs. Don't ship a partial refresh.