Skip to content

Quality gates ​

npm run validate is the definition of done: it runs everything CI runs, in the same order, and must pass before work is declared finished (see AGENTS.md).

console
npm run format:check && npm run lint && npm run typecheck && npm run knip && npm run spell && npm run loc && npm run e2e-plan && npm run env:check && npm run level:check && npm run models:check && npm test && npm run build && npm run docs:build
StepCommandWhat it checks
Formattingprettier --check .Every file is Prettier-formatted, imports sorted. documents/ is excluded so the design documents keep their own formatting. Fix with npm run format.
Linteslint .Flat config in eslint.config.ts; isomorphic packages may not import Node, DOM, Babylon, Electron or socket modules.
Typestsc --noEmit per workspace + tests/Strict TypeScript 6 with noUncheckedIndexedAccess, exactOptionalPropertyTypes, erasableSyntaxOnly; types is explicit per workspace.
Dead codeknipUnused files, exports and dependencies, per workspace (knip.json).
Spellingcspell --no-progress "**"Unknown words in every non-ignored file, the design documents included. Project words live in cspell.json.
Sizenode build/loc.mjsCounts non-blank tracked source lines per folder, product and tests apart, and rewrites the README badges in badges/. No budget, never fails on size: the count keeps scope visible, and every estimate is stated in it. See Build scripts.
Test plannode build/e2e-plan.mjstests/e2e/TEST-PLAN.md, the tagged Playwright specs and the levels agree: the Spec column names the file, every test-* level has a spec folder, no spec uses a level server's port.
Env filesnode build/env-check.mjsEvery environment variable read is listed in exactly one example file with a comment above it, and every name listed there is read: below.
Levelsnode apps/tools/src/cli.ts check-levelsThe level pipeline round-trips every level file exactly, every scene is named by the convention, every level file is what its committed packed scene exports, nothing saved in the editor is waiting to be exported, every level is playable, nothing is left from a gone scene, the registry is current, the skill file's copies agree, the kit library (kits:export) is what the kits give. No editor or CLI needed. See Levels.
Testsvitest run --coverageUnit + integration projects and the coverage thresholds. See Testing.
Buildsnpm run --workspaces --if-present buildThe Vite build of the client (renderer + Electron main/preload). The server and the packages have no build step.
Docstypedoc then vitepress build .The API reference with treatWarningsAsErrors and notDocumented, then the site with ignoreDeadLinks: false, both on a 4 GB heap: rendering the whole repository's pages outgrew Node's default limit on a 7 GB runner (about 2 GB) in 0.42, the API reference in 0.53.

The environment gate ​

Every environment variable a person can set is in one of three example files, the one for where it is set, with a comment above it saying what it does, what it takes, its default and who reads it, and an # e.g. sample when the value is not obvious:

FileWhat it holds
deploy/.env.exampleEverything the Compose stack reads, interpolated in deploy/compose.yaml. Compose reads every line of deploy/.env, so an entry is NAME=value, an empty value the default.
deploy/fleet/.env.fleet.exampleEverything the Hetzner scripts read (deploy/fleet/*.mjs, deploy/openrouter-key.mjs), and what they pass on to the stack.
.env.exampleEverything else: the apps, the tools and the tests run on their own, grouped by command. Nothing loads it (node --env-file=.env …, or exported in the shell), and every entry is commented out (# NAME=value): many change what an app does by being set at all.

npm run env:check (build/env-check.mjs) scans the repository's files for the names they read (process.env.X and any env record, the quoted names a file hands its helpers when it reads one by a computed key, Python's os.environ, Compose's ${X}, Caddy's {$X}, a Dockerfile's ARG and ENV, the Git hooks' $X) and holds them to the example files. The workflows are not scanned: what a job's env: hands to the repository's scripts is read in those scripts, and its own vars, secrets and step variables are GitHub's. It fails, with the file and line, on:

FailureHow to fix it
(a) a name read that no example file listsAdd it to the file for where it is set, with its comment, in the same change. A name the platform, CI or a test runner provides (CI, PATH, a Dockerfile's build argument) goes into ignore of build/env-check.json with a one-line reason instead; a file whose reads are made up (the gate's own fixtures) into skip.
(b) a name listed that nothing readsRemove the entry (the setting is gone), or, when a tool reads it rather than the code (COMPOSE_PROFILES, Docker Compose's), name it in tools of env-check.json with its reason. A name in ignore is never listed.
(c) an entry with no comment above itWrite the comment. Entries set together may share one header comment: going up from an entry over the entries and # e.g. samples next to it, a comment must come before a blank line does.
(d) a name listed twice, in one file or in twoKeep the one in the file for where it is set, and name it in the others' prose. The settings the fleet passes on to the stack under the same name (STACK_PASSED and STACK_SHARED in deploy/fleet/settings.mjs) may be in both deploy/.env.example and deploy/fleet/.env.fleet.example, once each.

A line of an example file that is neither an entry, a sample nor a comment fails too. A false positive is fixed by a precise rule in the scanner (with its test in build/env-check.test.ts) or an entry of env-check.json with its reason, never by loosening a scanner.

The level audit and the random specs ​

Every bundled level passes the level audit as it is (packages/catalogue/src/levels-audit.test.ts, in npm test), and the generators are held to it on random specs (npm run level:fuzz, apps/worker/src/fuzz.ts): specs drawn from seeds (@foi/catalogue/testing) built through the whole pipeline, held to a bar (the generator never throws; a spec it refuses is refused at paths the spec has; a level has no fault left after its repair and lost at most 0.5 % of its pieces to it; one in ten built twice the same).

WhenWhat
Every pull requestTwelve regions without towns (no Blender in CI), apps/worker/src/fuzz.integration.test.ts, about ten seconds; two hundred cities through the city solver alone, refused at their own paths or solved into a plan and a map (packages/catalogue/src/city/draws.test.ts, about three seconds).
Every night on develop, when it movedThirty grid towns, ten regions with towns and twelve cities the solver accepts in the worker's image, the Blender creators get (.github/workflows/fuzz.yml, on GitHub's standard runner; a new hundred seeds a day).
Every releaseThe specs that ever failed (apps/worker/fuzz-corpus/, --corpus) in the Release workflow's verify.

A failure is one line with its template and seed and a file holding the spec (the nightly run uploads them): npm run level:fuzz -- --replay <file> builds it again; once the generator is fixed, the file joins the corpus.

Git hooks ​

Husky (installed by npm install through the prepare script):

  • pre-commit: npx prettier --check ., npm run typecheck and cspell over the files the commit adds or changes (the config validate uses, so a new word goes into cspell.json before CI finds it).
  • pre-push: npm run validate:push: the fast gates (format, lint, typecheck, knip, spell, the plan, env-file, level and model checks) and the unit and integration tests on two workers without coverage, which fits a laptop short of memory. The full npm run validate (coverage, the builds, the docs) runs on CI for every pull request and on main; run it yourself before declaring a larger change done.

The frame-rate benchmark ​

Not part of validate because it needs a GPU and a few minutes: npm run perf builds the web client for production and measures the frame rate, the frame times and the server's step on main-hillside in three scenarios, the worst a room gets among them (build/perf.mjs). Run it before and after a change that touches what a frame or a tick does. See Performance.

Security scans (SAST and DAST) ​

Not part of validate because they need Semgrep or Docker:

  • npm run sast: npm audit on production dependencies (blocking at high) and Semgrep with the TypeScript/Node/secrets rulesets; ERROR findings fail. Runs in .github/workflows/sast.yml.
  • npm run dast: boots the game server and runs the OWASP ZAP baseline scan against its HTTP surface (/health today). Weekly in .github/workflows/dast.yml.

CI ​

.github/workflows/ci.yml runs validate in jobs side by side: on Ubuntu validate (gates) (formatting, lint, knip, spelling, the line count, the plan, env-file and level gates, the typecheck, the build; about 3½ minutes), validate (tests) (Vitest with coverage) and validate (docs) (the site and the API reference; about 11½); on Windows validate (windows 1/2) and (windows 2/2), the tests in two halves (--shard), the typecheck with the first and the build with the second (the determinism test runs on both platforms on purpose). On GitHub's 2-core runner Vitest has one worker and the tests took 14 minutes with coverage, so validate (tests) runs on the larger runner the variable TESTS_RUNNER names (ubuntu-4-core, three workers, $0.012 a minute; ubuntu-latest without it); the Windows job took 19 minutes in one piece; and beside them the Playwright smoke tier under xvfb and the browser's core tier (e2e.yml, below; not on a push of main, which is a release); on GitHub's runners the e2e jobs cache the Playwright browser per Playwright version. The adapters job runs the API's storage contract against a Postgres service container and a MinIO container (the Postgres store and the S3 artifacts, which validate skips without them). docs.yml builds the public site on every push to develop and publishes it as next on the docs site, and the Release workflow has it publish each release's (Releasing), and release.yml tags and publishes a release on every push to main whose version is new (see Releasing).

The README's three status badges are shields.io endpoint badges, since GitHub shows a private repository's own badges to its members only (anyone else, and an editor's preview, gets a broken image). badges.yml runs when a run of CI on develop, or of Docs or Release on main, finishes (a cancelled or skipped one changes nothing) and writes passing or failing into that badge's file (ci.json, docs.json, release.json) in a secret gist, which shields.io reads. The gist's id is the repository variable BADGES_GIST_ID; the secret BADGES_GIST_TOKEN is a token that may write gists and nothing else (a fine-grained token with the account permission Gists: read and write, or a classic one with the gist scope only). Without either, the job leaves a warning and the badges as they were. shields.io and the gist each keep a badge five minutes, so a badge follows its run within about ten.

The browser's two tiers ​

Every browser test carries one tier, and the plan gate checks it (npm run e2e-plan):

  • @core, the gate: each mechanic's fast tests, run on every pull request and every push to develop (ci.yml calls e2e.yml with run: core). A core test takes 20 seconds at most on the self-hosted runner (CORE_TEST_SECONDS in tests/e2e/support/lanes.ts); a slower one is made faster or moved to @long, and every test-* level keeps at least one core test. Tests of a serial describe that use what an earlier one set up (the platform's players) go to the same tier, since a run of one tier skips the other's tests. The whole tier must pack into 12 minutes in the self-hosted runner's five lanes alone (CORE_RUN_MINUTES), so the gate ends within 15 minutes with the runner's setup even when GitHub's GPU runners are off; on the runners, a job's lanes fail past 15 minutes.
  • @long: the missions played through, the galleries, the tours of generated levels and the boot of every exported level (levels.spec.ts). It runs with the core tier every night on develop when develop moved since the last green night (e2e.yml's schedule), and in the Release workflow, whose publish job waits for both tiers: a release is published only when they pass.
  • @smoke, the Electron app, runs in its own job (e2e-smoke) as before.

e2e.yml also runs by hand (Actions → E2E → Run workflow, core, long or all).

The machines ​

The route job (build/e2e-route.mjs) plans a run's machines from the repository's variables (Settings → Secrets and variables → Actions → Variables), and one lanes job runs on each:

VariableValueWhat it does
E2E_RUNNER(unset)the self-hosted runner's label, rtx-4060 (unset since 2026-10-15, the laptop under maintenance)
E2E_LANES5its lanes side by side (5 without the variable)
E2E_GPU_RUNNERgpu-t4GitHub's GPU runner (none without the variable)
E2E_GPU_JOBS2how many of its machines a run takes (2 without the variable; 0 switches them off)
E2E_GPU_LANES3lanes on each (3 without the variable)
E2E_GPU_SPEED0.74how fast one of its lanes is against one of the self-hosted runner's, for the packing (0.74 without the variable)
E2E_HOSTED_RUNNERubuntu-4-coreGitHub's runner without a GPU, only when neither of the others is there (ubuntu-latest without the variable)
E2E_HOSTED_JOBS(unset)how many of those, one lane each (6 without the variable)

Only the self-hosted runner. The repository variable SELF_HOSTED_ONLY, set to the runner's label (rtx-4060), moves every workflow's jobs onto it so that no minute is billed (set on 2026-10-15 to cut the Actions bill to nothing for a while, and lifted the same day when the laptop's disk filled up and it went offline for maintenance): CI runs npm run validate whole in one job there (validate (self-hosted), instead of the four split jobs) and skips the Windows halves; the adapters, the smoke tier (under the desktop session the runner runs in, which has a display and no xvfb), SAST, the docs, DAST, the nightly fuzz, the badges and the e2e tiers (the route takes that runner alone, no GPU runner, and waits for it when it is offline) all run there; the Release workflow builds the Linux and the Windows installers there (the Windows one in electron-builder's electronuserland/builder:wine image, the runner having no wine) and no macOS installer, which only macOS builds. The runner takes one job at a time, so a pull request's run takes about as long as its jobs one after the other. Jobs that call gh get it from .github/actions/gh-cli (the runner has none); jobs that start containers clean them up after themselves (the stack its containers, volumes and images: the runner's disk is small), and the Release workflow's stack job stops at once when the runner has under 25 GB free (0.61.0's build filled the disk, 14 GB free then, and the runner went offline until it was freed). To go back: gh variable delete SELF_HOSTED_ONLY (or delete it under Settings → Secrets and variables → Actions → Variables); every job is then where it was before. GitHub's runners only (the laptop down): neither SELF_HOSTED_ONLY nor E2E_RUNNER set, so the route plans the e2e tiers on the two GPU runners alone (the state since 2026-10-15); the laptop back, gh variable set E2E_RUNNER --body rtx-4060, and SELF_HOSTED_ONLY too to run everything there.

The self-hosted runner is acer-nitro, a laptop with an NVIDIA RTX 4060 registered on the repository with the labels self-hosted, Linux, X64 and rtx-4060 (Ubuntu 26.04, a Ryzen 7 8845HS: its jobs see 16 logical CPUs and 26 GB). It costs nothing and takes one job at a time; keep one runner instance on the machine, since two jobs there would collide on the lanes' ports. Its jobs never need root: they skip actions/setup-node's npm cache (the runner keeps ~/.npm) and Playwright's install-deps (the system libraries were installed once on the machine), and the runner kills what a job leaves running when it ends. To set the machine up again: the NVIDIA driver with Vulkan, the runner application (Settings → Actions → Runners → New self-hosted runner, Linux, then ./svc.sh install and start to run it as a service), the label rtx-4060, and Playwright's libraries once with root (sudo npx playwright install-deps chromium).

GitHub's GPU runner gpu-t4 is a larger runner of the ggcapo organization (Team plan): the machine size gpu-t4-4-core (4 processors, 28 GB, an NVIDIA Tesla T4) on NVIDIA's image (NVIDIA GPU-Optimized Image for AI and HPC, which carries the driver and Vulkan), at most 2 machines at once, in the Default runner group. It is billed $0.052 a minute a machine, each job rounded up to the minute, and the plan's included minutes do not cover it; it costs nothing while idle. Measured on 2026-10-14 with a throwaway run of 0.57.0's shards: a job's setup takes 78 seconds (the checkout, Node, npm ci 22 s, the browser with its libraries 35 s); with two lanes a lane runs at 0.92 of a self-hosted lane's speed, with three at 0.74, which still gets more done, so a run takes three. To set it up again: in the organization, Settings → Actions → Runners → New runner → New GitHub-hosted runner, Linux x64, the partner image above, the GPU size, the name gpu-t4, at most 2; or through the API:

console
gh api -X POST orgs/ggcapo/actions/hosted-runners -f name=gpu-t4 -f 'image[id]=2348' -f 'image[source]=partner' -f size=gpu-t4-4-core -F runner_group_id=1 -F maximum_runners=2

On both, headless Chromium draws on the GPU through Vulkan: the workflow sets E2E_CHROMIUM_ARGS (--enable-gpu --ignore-gpu-blocklist --use-angle=vulkan --enable-features=Vulkan) and playwright.config.ts hands those flags to every spec's browser (E2E-68 still forces the software rasterizer for itself). The step The machine, and the renderer the browser gets prints the CPUs and the renderer (ANGLE (NVIDIA, Vulkan … RTX 4060 Laptop GPU …) or … Tesla T4 …; SwiftShader or llvmpipe means software).

Offline. GitHub tells whether the laptop is online only to a token that may read the repository's runners: the secret RUNNER_STATUS_TOKEN, a fine-grained personal access token for this repository with the permission Administration: Read-only (gh secret set RUNNER_STATUS_TOKEN). With it, the route leaves the laptop out when it is offline (a warning on the run says so) and the GPU runners carry the run alone; without it, the laptop is taken to be online and its job waits in the queue while it is off.

GitHub's runners without a GPU ​

When neither the laptop nor the GPU runner is there, a run goes to E2E_HOSTED_RUNNER, where headless Chromium draws the game with its software rasterizer (SwiftShader) and the frame rate follows the cores. The game draws cheaper there (no antialiasing, two thirds of the resolution, see apps/client/src/renderer/render-quality.ts), which is still not enough on the standard runner of a private repository, 2 cores and 7 GB: measured with the browser and the servers pinned to the same number of logical processors,

CoresAs drawn on a GPUDrawn cheaper
2~16 fps~21 fps
4~29 fps~45 fps
8~52 fps60 fps

The specs that time frames (smooth drawing, aiming with the mouse, the boost's double tap, a passenger riding along in a second page) need about 30 fps with short stalls, so the fallback uses the larger runner ubuntu-4-core (Ubuntu 24.04, 4 cores, $0.012 a minute, at most 10 machines at once); heavy levels such as test-buildings still draw at about 14 fps there, the small ones at 30–45.

Lanes, packed by measure ​

tests/e2e/timings.json holds every test's seconds on the self-hosted runner, by spec file and plan id. tests/e2e/support/lanes.ts packs a run by them: a level folder's files stay in one lane, one game server to boot, unless the folder is longer than a lane's share of the run; the folders go longest first to the lane that would finish first, a GPU runner's lane counting its seconds at its speed. Every job of a run computes the same plan and runs its own machine's lanes side by side (build/e2e-lanes.mjs), each lane one Playwright process on ports of its own (E2E_PORT_OFFSET, the lane times 1000) with the game servers of its files' levels only and vite preview of the web client, built once in the job (npm run build:web -w apps/client, about 4 seconds; E2E_CLIENT=built). The lanes write blob reports, which the report job merges into the HTML report (the playwright-<run> artifact; tests/e2e/merge.config.ts gives the merge the tests' folder, as the jobs ran at different paths), and JSON reports, kept as the e2e-timings-<machine> artifacts. Nothing is kept by hand: after a run on the laptop, gh run download <run> -p 'e2e-timings-*' -D <dir> and npm run e2e:timings -- <dir>/*/*.json rewrite the timings, and a new test counts 15 seconds until it has some.

Locally, npm run test:e2e:lanes -- --run core --lanes 3 packs a run on this machine's lanes, and E2E_ONLY=levels/test-police npm run test:e2e runs a folder (or a file) with its level's server only. A spec that steps or aims frame by frame behaves differently at 60 fps than in software, so try it where CI runs it, on a GPU: E2E_CHROMIUM_ARGS set as on CI (on Windows --enable-gpu --ignore-gpu-blocklist --use-angle=d3d11), and E2E_CLIENT=built after npm run build:web -w apps/client.

What keeps the lanes steady besides the cores: a game server ticks only its rooms with a player in them (RoomHost.tick), so the level servers a lane starts cost nothing until a spec joins them, and the specs wait for states, never for times (a car's brake is S down to 5 m/s and then the handbrake, which never reverses; presses of W 300 ms apart or closer are the boost's double tap). The retry on CI (retries: 1) stays a safety net: a spec that needs it is a spec to fix, and a trace is recorded only on that retry.