Files

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:

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.py wires all three states; it also uses self.tree.group(name) for broadcast queries. self.tree is valid after on_enter_tree().
  • Assets: pass a file path / bytes / ndarray straight to Sprite2D(texture=...). Bundle package-shipped assets via importlib.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 the pkg:// rule above: an example is a template for a directory the reader will own, so it resolves what it reads with Path(__file__).parent / "assets", which still works after cp -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 a simvx/<example>/ path and each overridable by a named environment variable. examples/demos/afterglow/afterglow/progress.py and examples/demos/dungeon_explorer/main.py are the pattern to copy. The reason is that the example library also installs into site-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_draw that animates every frame needs dynamic = True (class attr) so the node is re-collected each frame — a plain node's on_draw reading non-Property state renders once. Use self.queue_redraw() only for occasional, event-driven repaints.
  • Physics belongs in on_fixed_update(dt) (fixed timestep); gameplay/animation in on_update(dt).
  • Top-level Control subclasses use anchors + margins (set_anchor_preset()), never absolute position.
  • Register input actions via the root's input_actions/ready(), not main() — web export skips main().
  • Exit via self.app.quit(), never sys.exit() (leaks the miniaudio thread and hangs).
  • Wrap any manual App.run() in timeout when 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 actionsInputMap.add_action("jump", [Key.SPACE]), queried with Input.is_action_pressed("jump"). Typed enums (Key, MouseButton, JoyButton, JoyAxis).

App accessnode.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 modulessimvx.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 pytest from the repository root, and never rely on --package to 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 addopts already carry --forked. Cap it (MemoryMax=5G).
  • Run suites serially. A parallel agent runs only its own specific tests, never a full suite.
  • uv run pytest pitfall (fresh worktrees): uv run --package X pytest can resolve to the system pytest and run against the wrong tree. Use uv run --with pytest --package X python -m pytest in 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(), not sys.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 main in the engine repo. Work on dev or a feature branch off dev; check the branch first and switch if you're on main. (The site commit in the export pipeline is the separate ../simvx.com repo on master, that one is correct.)
  • The tree may be shared with concurrent agents: stage explicit paths only, never git add -A; never git checkout/reset/revert/clean files 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 main in the engine repo. The site commit (step 2) is in the separate ../simvx.com repo on master, 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.