Web¶
Current Status¶
The Web backend is Experimental (Kotlin/Wasm preview) on the Godot 4.7 stable baseline. It compiles Kanama project scripts to Kotlin/Wasm and runs them against a Godot 4.7 Web export through a generated per-call proxy and a versioned JavaScript bridge (protocol 21). It is not a Supported target: the renderer is single-thread Compatibility only, the browser matrix and performance budgets are still being hardened, and there is no packaged install path yet.
Evidence. The full twelve-demo corpus — Bunnymark, Starter-Kit-Match3, dodge, web3d, 3D-Platformer, squash, FPS, character-controller, third-person, Racing, City-Builder and tps-demo — passes the automated production export smoke in Chrome and Firefox (both CI cells) and Safari (a local gate — it has no headless mode), each with a play-and-teardown driver run, zero console errors, and live handles draining to zero. Every corpus export is also proven to embed no build-machine paths in any served file, and to be reproducible from a clean clone (see Fresh-Checkout Gate).
Browser version floors. The declared floors live in one machine-readable
file, scripts/web/browser_floors.json, and every smoke run is checked against
them — a run on an older browser fails the gate instead of printing the same
PASS line as a declared one.
| Browser | Floor | Basis | Corpus validated at |
|---|---|---|---|
| Chrome | 130 | tested | 150 (headless) |
| Firefox | 141 | tested | 152–153 (headless) |
| Safari | 26.5 | validated-at | 26.5 / WebKit 605.1.15, macOS 26.5.1 |
"Tested" means the gate was run on that version and on the one below it (2026-07-28, macOS arm64, protocol-15 export). Chrome 129 never boots the Kotlin/Wasm module; 130 runs it — that is where WebAssembly JS String Builtins shipped. The Firefox number is a harness bound, not an engine verdict: 141, 143 and 145 all pass, while 140 ESR and older never expose a reachable WebDriver BiDi endpoint to the driver's launch recipe, so they cannot be judged either way. Safari is "validated-at" only: it ships with the OS and cannot be installed side by side, so no lower bound is testable at all.
iOS and iPadOS are hand-checked only, not gated — no mobile-WebKit claim is made here (see Known Limitations and Testing On A Phone Or Tablet).
This page is the reproducible export workflow. For the architecture — batching, snapshots, handle generations, the bridge protocol — see Web Internals.
How Web Differs¶
Unlike desktop, Android, and iOS, the Web backend uses no JVM and no FFM/PanamaPort path. Project gameplay is ahead-of-time compiled to WebAssembly (Kotlin/Wasm, which depends on the WasmGC and exception-handling proposals, so it targets modern browsers). The Kanama Wasm module and Godot's own Emscripten/Wasm runtime are separate modules that cannot share a heap, so calls cross as typed commands over a JavaScript bridge rather than as direct FFI.
Requirements¶
- Godot 4.7 stable editor binary (matching the pinned baseline).
- The
web_nothreads_releaseexport template for that exact Godot version. The single-thread template is required: the preview backend does not use threads or cross-origin isolation. - A modern browser with WasmGC + exception handling (recent Chrome/Firefox/Safari).
- Node.js (only for the export-smoke harness, not for the export itself).
- Reproducible builds currently require
--no-daemon -Pkotlin.compiler.execution.strategy=in-process; the Kotlin daemon can exhaust memory on these builds while the in-process path is stable.
Web-Compatible Project Scripts¶
The Web export compiles gameplay from a merged view of two directories in the project checkout:
<project>/kotlin-src/— the shared script root, the same files the desktop build compiles. A script is directly Wasm-compatible when written portably: the constructor takesGodotHandle(a typealias forMemorySegmenton the JVM, so desktop semantics are unchanged); numeric reads of vector fields go through.toDouble()where the value is passed as a parameter (desktop fields are single-precisionreal_t, Web's areDouble— mixed arithmetic widens automatically, bare parameter passes do not); raw pointer identity (handle.address()) is replaced byisSameInstance(); and no JVM-only APIs are used.<project>/web/kotlin-src/— per-file overrides. A file here replaces the same-named shared file in the Web build. Use it for scripts that are genuinely platform-different (a smoke hook, a capability stub), not for copies.
A desktop-only script that must keep its res://kotlin-src/ path (for example
one selected by path at runtime, like Bunnymark's benchmark variants) is listed
in <project>/web/kotlin-src-excludes.txt, one kotlin-src-relative path per
line; the merge skips it and fails loudly on a stale or contradictory entry.
Anything a shared script calls that the Web API surface does not model yet
fails the Web compile — that is the fail-loud coverage gate working as
intended. Files under the merged root resolve to res://kotlin-src/*.kt and
match the scene script attachments on both platforms.
@ScriptProperty/@Export declarations are portable including NodePath
properties and hint metadata (a PropertyHint.RANGE hint reaches the generated
proxy as @export_range(...)). Two Web-specific rules fail the build loudly
instead of silently mis-hydrating: a property default must be spelled as a
plain literal (1.0471975511965976, not Mathf.PI / 3.0 — the proxy re-emits
the default into GDScript and pushes it back into Kotlin at hydration), and a
property type or hint outside the supported Web set is rejected with an error
naming the property.
Physics loops should derive movement from velocity, not from re-reading a
spatial value they just wrote. On Web, spatial reads (self.position,
self.rotation, …) come from a mirrored snapshot rather than a live engine
call. The snapshot refreshes at the start of every _process and (since
task 87) every _physics_process dispatch, so a per-tick read is coherent with
the previous tick — but it is still a start-of-dispatch mirror: a value the
engine changes inside the current tick (after moveAndSlide(), for example)
is not visible until the next dispatch. Desktop reads the engine live, so a
spelling that re-reads a just-written spatial value can be subtly
platform-different even when it works. Preferred spelling: accumulate movement
in self.velocity + moveAndSlide(), and derive orientation from the intended
direction (as squash's Player does with lookAtFromPosition(self.position,
self.position + direction, UP) — the direction is the input, not a read-back
of engine physics).
Build The Web Scripts¶
buildWebScripts generates the GDScript proxy bundle (proxies + manifest +
protocol descriptor) and collects the Kotlin/Wasm runtime a project attaches:
./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process \
:web-runtime:buildWebScripts
The bundle lands in web-runtime/build/web-scripts/ with a
build-web-scripts.report.json recording the protocol version, renderer, and
files. No source maps are published.
Export A Demo¶
exportWeb stages a disposable copy of the project, runs the Godot Web export,
installs the Kotlin/Wasm runtime and bridge, cache-busts the entry scripts, and
writes a self-contained served directory plus a release payload report. Point it
at a clean checkout of the demo (never a shared working tree); the Web script
root is auto-derived from <project>/web.
Each demo is selected with -PkanamaWebDemo=<key> and given its checkout with
the matching -PkanamaWeb<Key>ProjectDir:
| Key | Demo project | Key | Demo project |
|---|---|---|---|
match3 |
Starter-Kit-Match3 | thirdperson |
godot-4-3d-third-person-controller |
bunnymark |
Bunnymark | charactercontroller |
godot-4-3d-character-controller-tutorial |
dodge |
godot-demo-2d-dodge-the-creeps | racing |
Starter-Kit-Racing |
platformer |
Starter-Kit-3D-Platformer | citybuilder |
Starter-Kit-City-Builder |
squash |
godot-demo-3d-squash-the-creeps | tpsdemo |
tps-demo-kanama |
fps |
Starter-Kit-FPS | web3d |
in-repo fixture (no checkout needed) |
Match3:
./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process \
:web-runtime:exportWeb \
-PkanamaWebDemo=match3 \
-PkanamaGodotExecutable=/absolute/path/to/godot \
-PkanamaWebTemplateRelease=/absolute/path/to/web_nothreads_release.zip \
-PkanamaWebMatch3ProjectDir=/absolute/path/to/checkout/Starter-Kit-Match3
Bunnymark (the validated 256-sprite V1Sprites variant):
./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process \
:web-runtime:exportWeb \
-PkanamaWebDemo=bunnymark \
-PkanamaWebBunnymarkVariant=BunnymarkV1Sprites \
-PkanamaGodotExecutable=/absolute/path/to/godot \
-PkanamaWebTemplateRelease=/absolute/path/to/web_nothreads_release.zip \
-PkanamaWebBunnymarkProjectDir=/absolute/path/to/checkout/Bunnymark
The export lands in web-runtime/build/web-export/<demo>/. It is self-contained
— no workstation-absolute paths leak into the served HTML — and includes
kanama-web/export-report.json listing every served file, its size, the total
payload, the renderer, the thread setting, and the source-map policy.
Serve The Export¶
The export must be served over HTTP (opening index.html from disk will not
load the Wasm modules). Any static server works. The repository ships a minimal
one that binds an ephemeral localhost port, sends the correct
application/wasm MIME type, and sets Cache-Control: no-store:
python3 scripts/web/serve_export.py web-runtime/build/web-export/match3
# prints PORT=<n>; open http://127.0.0.1:<n>/
Testing On A Phone Or Tablet¶
Loopback is the default so an export server is never reachable from the network
unless asked for. --lan binds every interface — and must be combined with
--https, because Godot's Web export requires a secure context: 127.0.0.1
is one, but a LAN address over plain HTTP is not, and the engine never starts
on the device (measured: a fatal error in audio-worklet init right after
"starting Godot…"; the page now refuses up front instead). --https serves
with a cached self-signed certificate — accept the device's one-time warning
(iOS Safari: Show Details → visit this website):
python3 scripts/web/serve_export.py --lan --https web-runtime/build/web-export/match3
# prints PORT=<n> and LAN=https://<this-machine>:<n>/
Hand-checking on a device on the same network is the standard recipe. Safari on
a USB-connected iPhone or iPad can additionally be driven over WebDriver:
enable Settings → Safari → Advanced → Remote Automation on the device, keep it
unlocked, and create a safaridriver session with platformName: iOS, the
device UDID (xcrun xctrace list devices), and acceptInsecureCerts: true —
one session per device at a time. No automated iOS gate exists yet, and mobile
WebKit remains hand-validated, not gated — see Known Limitations.
Run The Export Smoke¶
web_export_smoke.sh serves an already-built export, drives the demo in a real
browser through a full play + teardown sequence, validates the machine-readable
result against a versioned schema, and proves the served tree was not mutated. A
run is never green from page load alone.
scripts/web_export_smoke.sh \
--engine chrome \
--export-dir web-runtime/build/web-export/match3 \
--demo match3 \
--result /tmp/match3-chrome.json
Each gate asserts gameplay deltas, crossing budgets, handle/callback/scheduler
teardown to baseline, stale-handle rejection, console-error checks, a
protocol-version match, and — per demo — that the registered script members on
its required list in scripts/web/required_members.json actually dispatched
during the run (the exercised-member census, task 81).
Read the harness's own web_export_smoke: PASS line, not a driver's check count.
The driver's checks and the envelope schema are two different gates: a demo can
pass 13/13 of its own checks and still be rejected by the schema (which is what
enforces liveAfterTeardown === 0 and the required-member census), and that is
exactly how one demo stayed un-gated on every engine for weeks.
Publishing A Web Export¶
packageWebExport turns an already-built, smoke-validated export into a
publishable zip. It never builds or refreshes an export — a missing or stale
one (wrong demo, wrong protocol version, a report that no longer matches the
served bytes) fails loudly, so the artifact is always the export you
validated:
./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process \
:web-runtime:packageWebExport -PkanamaWebDemo=match3
The zip lands in
web-runtime/build/distributions/kanama-web-<demo>-v<version>.zip with
index.html at the zip root. Before zipping, the gate:
- quotes the export's
buildIdand protocol version fromkanama-web/export-report.jsonand rejects a stale export; - checks the payload against itch.io's HTML5 defaults — at most 500 MB and 1000 files — printing the measured size and file count either way; and
- scans every served byte for workstation-absolute paths with
scripts/web/check_no_local_paths.py, the fresh-checkout gate's scanner.
Validate the artifact itself — not the export directory it came from — before
uploading. web_package_smoke.sh unzips the artifact to a scratch directory,
serves that copy, and drives it through the full export smoke:
scripts/web_package_smoke.sh \
--zip web-runtime/build/distributions/kanama-web-match3-v<version>.zip \
--demo match3 --engine chrome --result /tmp/match3-package.json
Read the inner web_export_smoke: PASS line, as always.
itch.io¶
Upload the zip with butler (it unpacks zips server-side; the channel name marks the upload as HTML5):
butler push web-runtime/build/distributions/kanama-web-<demo>-v<version>.zip \
<user>/<game>:html5
Then on the project's edit page, confirm the upload is set to "This file
will be played in the browser". Leave the SharedArrayBuffer support toggle
off: exports use the web_nothreads template, so they need no
cross-origin isolation and no COOP/COEP headers (see
Renderer And Thread Constraints). That is
also why itch.io-style hosting works at all — it serves games over HTTPS from
a subpath inside an iframe, and the nothreads export is compatible with
exactly that shape.
Any Static HTTPS Host¶
Any static file host works if it serves two things:
- HTTPS. Godot's Web export requires a secure context —
127.0.0.1is one, a plain-HTTP LAN or internet address is not, and the engine never starts without one (the same constraint as Testing On A Phone Or Tablet). - A correct
application/wasmMIME type for.wasmfiles.
No COOP/COEP headers, no special subpath handling, and no server-side code are needed: unzip the artifact into any directory the host serves.
The tps-demo Exception¶
tps-demo serves ~638 MB (570 MB of upstream demo assets in index.pck) and
exceeds the itch.io defaults; packageWebExport fails it by name rather than
producing an artifact nobody could upload. This is the same known exception
recorded in scripts/web/budgets.json — shrinking it is asset work in the
demo, not a packaging concern.
Publishing changes nothing about the backend's status: Web remains Experimental, and exporting a game still requires a Kanama source checkout.
Browser Matrix¶
web_ci_matrix.sh is the corpus-wide gate: it exports each demo and drives it in
each requested browser through web_export_smoke.sh, then aggregates every cell
into one evidence JSON plus a Markdown summary.
scripts/web_ci_matrix.sh \
--godot /absolute/path/to/godot \
--template "$HOME/Library/Application Support/Godot/export_templates/4.7.stable/web_nothreads_release.zip" \
--demos-dir /absolute/path/to/kanama-demos \
--demo-set full \
--engine chrome --engine firefox \
--evidence /tmp/web-matrix.json
Every cell runs even after an earlier one fails, so a red run reports the whole
picture. --demo-set pr is the per-PR subset (match3, web3d, dodge — a
pointer-drag demo, a 3D demo needing no demos checkout, and a full-lifecycle
demo); --demo-set full is the 12-demo corpus plus the gated spike benchmark
cell (as is ci, minus what a hosted runner cannot build — see the Spike
Benchmark Cell below). Per-demo budgets live in scripts/web/demos.sh; scale
them for a slower host with --timeout-scale rather than editing them, so local
and CI numbers stay comparable.
Chrome and Firefox are the CI cells (.github/workflows/web.yml): the PR
subset on every Web-relevant pull request, the full corpus on push to main and
nightly. Safari is a local pre-promotion gate, not a CI cell — see Known
Limitations. Run it with the same script, --engine safari, one run at a time.
Regression Cadence¶
Which gate runs when, and where. A gate that is not on this list runs nowhere.
| Gate | Cadence | Where |
|---|---|---|
| PR subset × Chrome + Firefox | every Web-relevant pull request | CI (web workflow) |
ci corpus × Chrome + Firefox (everything a runner can build) |
push to main, and nightly |
CI |
| tps-demo | before a release tag | local (OOM-killed on a hosted runner) |
Soak (10 min, --demo soak) |
nightly | CI |
Spike transport benchmark (--demo spike, in the ci/full sets) |
push to main, and nightly |
CI |
| Full corpus on Safari | before a release tag, and before any promotion decision | local (no headless mode) |
| Fresh-checkout gate | before a release tag | local |
| Browser floor re-bisect | when a floor is claimed to move, or a browser major ships that breaks a cell | local |
| Everything above | on a Godot baseline bump — the export template, generated proxy and bridge protocol all move together | local + CI |
The nightly run matters because two of the inputs change without anyone touching the repository: the browsers on the runner image, and the runner itself. A red nightly on an unchanged tree is a browser-side regression, which is exactly the class of failure a per-PR gate can never see.
Soak Gate¶
KANAMA_WEB_SOAK_SECONDS=600 scripts/web_ci_matrix.sh \
--godot /absolute/path/to/godot --template <web_nothreads_release.zip> \
--demos-dir /absolute/path/to/kanama-demos --demo soak --engine chrome
The soak driver runs against the dodge export for ten minutes, restarting the round every sixty seconds. Dodge is the choice on purpose: leak detection wants churn, not polygons — a mob is instantiated every half second and frees itself on leaving the screen, so a ten-minute run is hundreds of full node create/free cycles through the handle registry, the signal-connection table and the deferred-free path.
It splits its samples in half and compares high-water marks, so it fails on a trend rather than on a threshold: live handles, pending signal callbacks and registered coroutine jobs must not be higher in the second half than the first (plus a few handles of sampling slack), gameplay must still be running at the end, and teardown must still drain to zero after a long run rather than only after a short one.
Spike Benchmark Cell¶
scripts/web_ci_matrix.sh --godot /absolute/path/to/godot \
--template <web_nothreads_release.zip> --demo spike --engine chrome --engine firefox
The spike is the synthetic transport benchmark (the staged in-repo
webSpikeGodot project — it needs no demos checkout and exports through its own
:web-runtime:exportWebSpike task). It is opt-in: the bridge's frame
fallthrough is the real _process, and only a page that stamps
globalThis.KanamaWebMode = "spike" reaches the benchmark transport. This cell
is what keeps the opt-in path from rotting. Its driver asserts the staging still
names the mode (an export that loses the mode line falls back to gameplay and
fails here, loudly), that the benchmark branch's own counters advanced while the
real _process path stayed at exactly zero — the inverse of the demo drivers'
realProcessPathDispatched — folds the page's structural verdict (the
10000-mutation batch must cross the boundary exactly ONCE) into the envelope,
and quits the SceneTree to drain live handles to zero. Transport cost is gated
structurally rather than by a ratio: the spike never takes the
_process/_physics_process tick paths, so crossings-per-tick is exempt in
scripts/web/budgets.json with the reason recorded.
Performance Budgets¶
Every smoke run is checked against a per-demo budget declared in
scripts/web/budgets.json, so a demo cannot quietly grow a payload, stop
starting, or start doing per-frame work at the module boundary.
| Budget | Unit | Why |
|---|---|---|
| Payload | bytes | Host-independent by construction |
| Startup | ms | Wall-clock, so deliberately loose — it catches a demo that stopped booting, not a slow runner |
| Crossings per engine tick | ratio | The real invariant: what batching and snapshots exist to bound |
Why per engine. The ratio is engine-stable across most of the corpus — Chrome
vs Firefox: bunnymark 2.22/1.97, dodge 0.19/0.20, fps 1.10/1.02 — but not for
the input-heavy demos, where Firefox reports several times the boundary work
(charactercontroller 4.50/19.58, thirdperson 1.15/6.01). Budgets are
therefore measured and declared per engine. The asymmetry itself is settled
(task 74): nothing paces requestAnimationFrame in headless Firefox, so
render-frame-driven (_process) command emission multiplies freely while
physics-driven work tracks the wall-capped physics step — a per-opcode
histogram shows the multiplier concentrated in per-render-frame emissions,
with physics-driven calls at exactly the tick ratio on both engines. The
number is a property of the headless environment, not of Firefox as users run
it (display-paced, it sits near Chrome); per-engine budgets bound regressions
within each environment's own baseline.
Why the headline budget is per tick rather than per second. Godot's Web main
loop is paced by requestAnimationFrame and advances a fixed step per iteration,
so the same build runs at very different rates depending on the host — measured
between roughly 2× and 8.7× real time across four hosts on one day. A budget
denominated in wall-clock seconds would grade the machine rather than the
backend. Crossings per tick means the same thing everywhere.
A tick is one engine dispatch into the script layer, counting both
_process and _physics_process. Counting render frames alone reported zero
ticks for a third of the corpus, because the character-controller, racing and
third-person demos do all their work in the physics tick.
Every number in budgets.json was measured, and each demo records the run it
came from in its measured block. Re-baseline by running the corpus and
regenerating, never by nudging a number until a run passes:
scripts/web_ci_matrix.sh --godot <godot> --skip-export --demo-set full --engine chrome \
--result-dir /tmp/budgets
python3 scripts/web/check_budgets.py /tmp/budgets/<demo>-chrome.json --report
One demo is exempt from the ratio, with its reason recorded. match3 is
input-driven — the driver spends its run on pointer gestures and settle waits, so
the script layer is dispatched only ~20–50 times per run (53 locally, 23 on a CI
Chrome). A ratio whose denominator swings 2× with host speed is not a
measurement, and failing on it would grade the runner. Its payload and startup
budgets still apply, and an exemption without a stated reason is a hard error in
the checker rather than a quiet pass.
One demo is over any sane budget and says so: tps-demo serves 638 MB, of
which 570 MB is upstream demo assets in index.pck. It runs, but nobody would
download it. The budget file records that as a known exception rather than
blessing it — fixing it is asset work in the demo, which 60f puts out of scope,
because budgets are not met by editing gameplay or scene content.
What CI Runs, And What Stays Local¶
Not everything can or should run on a hosted runner, and the two reasons are different:
- Local-only — CI structurally cannot do it, and no fix changes that. Safari (no headless mode; it needs a logged-in GUI session) and tps-demo (its Kotlin/Wasm compile is OOM-killed on a 16 GB GitHub runner) are both in this tier. They are release gates a maintainer runs by hand, and they pass there.
- Quarantined — a real defect, temporary, tied to a task. See below.
--demo-set ci is the corpus minus the local-only demos and is what the workflow
runs; --demo-set full always means the full corpus, so a local run is never
quietly narrowed. Skipped demos are announced and written into the evidence
JSON with their reason, because a corpus that silently shrinks is how "the
corpus is green" stops meaning anything.
Quarantined Cells¶
A known-failing demo:engine pair can be quarantined in
scripts/web/demos.sh with a reason that names a task. A quarantined cell still
exports, still runs and still reports — it just does not fail the build. Deleting
the demo from the matrix instead would be the trap this gate exists to close: the
corpus would look green because nobody was looking.
A quarantined cell that passes is reported just as loudly, with an explicit "lift the quarantine" line, because a stale quarantine is worse than none. Lifting one is a one-line deletion.
Currently quarantined: dodge:firefox — task 71, spawned mobs never free on a
Linux host (dodge passes on macOS, and dodge:chrome passes on Linux, so it is
neither a browser nor a demo property). squash:firefox was lifted on
2026-08-11 (the task-71 signature stopped reproducing there and the cell passes
outright under the task-81 gameplay checks, death path included).
squash:chrome was lifted on 2026-08-12: its quarantine covered the
death-phase choreography only (the parked player waited for a randomly-heading
mob, 1-for-2 on the slow runner), and the driver now steers the player into a
mob instead of parking — deterministic by construction, 3/3 Chrome + 3/3
Firefox local repeats green; the runner-scale proof is the lifting PR's own CI
plus the next main full-corpus run.
Bumping The Demos Pin¶
The workflow checks out kanama-demos at the DEMOS_REF commit pinned in
.github/workflows/web.yml. This is deliberate: an unpinned checkout lets an
unrelated demo-repo commit redden every Kanama pull request. When a demo port
lands in kanama-demos, bump DEMOS_REF in the same pass — a Kanama change that
needs new demo code is not green until both sides are pinned together.
Fresh-Checkout Gate¶
web_fresh_checkout_smoke.sh answers a different question from the export smoke:
not "does this export run?" but "can anyone reproduce it?". It clones Kanama (and
kanama-demos, when the selected demo lives there) into a throwaway workspace with
its own HOME, Gradle home and Maven-local, exports from that clone, and then
asserts what a promotion review needs to see:
- no build-machine path in any served file — the whole export tree is
scanned byte-for-byte, not just
index.html; - the demo source tree is untouched — checksummed before and after, plus a
git statuscheck on the demo checkout; and - the artifact really runs — the export smoke drives it in a real browser using the harness from the fresh clone, so the tooling is proven to ship.
scripts/web_fresh_checkout_smoke.sh \
--template "$HOME/Library/Application Support/Godot/export_templates/4.7.stable/web_nothreads_release.zip" \
--demo web3d --demo match3 \
--evidence /tmp/web-fresh-checkout.json \
/absolute/path/to/godot
The default demo set is web3d (an in-repo fixture, so the Kanama clone alone is
enough) plus match3 (an external demo, exercising the demos checkout);
--demo all runs the whole corpus. --kanama-source / --demos-source accept a
local path for validating an unmerged branch, and --skip-browser reduces the
run to the export and artifact checks. The --evidence JSON records the clone
commits, per-demo checksums, payload sizes, protocol version and driver results.
Browser Debugging¶
- Chrome — the driver self-launches headless Chrome and drives it over the
DevTools Protocol. Godot's Compatibility renderer needs a WebGL context, which
in headless Chrome comes from ANGLE's SwiftShader software path
(
--enable-unsafe-swiftshader --use-angle=swiftshader); do not pass--disable-gpu, which disables it. It collects console/exception events. - Firefox — driven over WebDriver BiDi; console errors via
log.entryAdded. - Safari — driven over classic W3C WebDriver; needs a one-time
safaridriver --enableand "Allow Remote Automation" in the Develop menu. SafariDriver exposes no browser-log endpoint, so the Safari gate asserts bridge callback/telemetry plus every gameplay and teardown invariant. Three traps, all of which have cost real debugging time:- Retina coordinates. Pointer coordinates are CSS client pixels (W3C),
and Godot's own coordinate space is CSS pixels too. A driver that derives
screen geometry from
canvas.width— thedevicePixelRatio-scaled backing store — is correct only at DPR 1, so it passes headless Chrome/Firefox and silently misses on a Retina Safari. UsegetBoundingClientRect(). - One
POST /actionsper gesture. SafariDriver dispatches a pointer sequence on the trailingDELETE /actions, and does not carry pointer position across requests — a press sent in its own request lands at0,0. Put the whole press/move/release in a single request. - The browser outlives its driver. The automation Safari is not a child
of
safaridriver, so killing the driver leaks it; the driver reaps it by PID instead.
- Retina coordinates. Pointer coordinates are CSS client pixels (W3C),
and Godot's own coordinate space is CSS pixels too. A driver that derives
screen geometry from
Renderer And Thread Constraints¶
- Compatibility renderer only (
rendering_method = gl_compatibility). The Forward+/Mobile renderers are not used for Web. - Single thread. The export uses the
web_nothreadstemplate and does not require COOP/COEP cross-origin isolation, so it can be served from a plain static host. Threaded Godot Web is out of scope for the preview.
Payload And Source Maps¶
- The Kotlin gameplay Wasm is content-hashed by the build (its filename is its hash), so it is cache-busted automatically when gameplay changes.
- The two fixed-name entry scripts (
kanama-web-spike.js,kanama-web-bridge.js) are cache-busted with a content-derived?v=<hash>query string stamped intoindex.html. - No source maps ship (webpack
sourceMaps = false); bothbuildWebScriptsandexportWebfail loudly if a.mapfile appears. kanama-web/export-report.jsonreports the full payload for budget tracking.
Known Limitations¶
- Safari cannot run headless. Chrome and Firefox gate the corpus headless, so
they run unattended;
safaridriverdrives a real Safari window on a logged-in GUI session. The Safari gate is therefore a local gate, not a CI cell, and two Safari runs must not be started concurrently on one machine. - Safari on iOS/iPadOS is hand-checked only, and no mobile-WebKit version
floor is claimed. Every iOS browser is WebKit.
safaridrivercan drive Safari on a USB-connected device (see Testing On A Phone Or Tablet), but no automated iOS gate exists and mobile WebKit is not part of the validated claim. - Lifecycle virtuals are limited to what the proxy dispatches:
_enter_tree,_ready,_process,_physics_process,_draw,_exit_tree,_input, and_unhandled_input. Anything else is rejected at build time with a KSP error naming the script and the function, rather than compiling and then never running. Move the work into one of the dispatched virtuals. - Single-thread only — exports use Godot's
nothreadsWeb template: no threads, no SharedArrayBuffer, no cross-origin-isolation requirement. This is deliberate: any static HTTPS server can host the export (itch.io-style hosts included, whose COOP/COEP support is experimental). It is also a double gate: even with Godot's threads template, gameplay runs on Kotlin/Wasm, whose own threading support is immature — threads are not one fix away. - Compatibility renderer only — Forward+/Mobile are not used for Web, so renderer-dependent features are absent: no volumetric fog, no global illumination, and first-use shader compiles can hitch once.
- No multiplayer — ENet is unavailable in browsers, and Godot's WebSocket/WebRTC multiplayer peers are not wired into the Kanama Web backend. Web builds are single-player (tps-demo's online lobby is deliberately inert for this reason).
- No Web editor, no compiler, no hot reload, no Web GDExtension, and no TeaVM or Kotlin/JS production path. Gameplay is AOT-compiled into the Wasm payload, so a script edit means rebuild + re-export.
- A packaged/addon install path (exporting without the Kanama checkout) is not yet available; the current workflow is a source-checkout export.
- One defect is unexplained, not solved (task 71). On one Linux CI host,
spawned mobs in two demos travel far off screen yet
VisibleOnScreenNotifier2D.screen_exitednever fires, so they are never freed and live handles plateau. Five hypotheses have been eliminated by measurement across three hosts, and a plain-GDScript control shows the notifier itself firing on that host under Chrome — nothing measured implicates the Kanama backend, but "not our bug" has not been earned either. The affected cells are quarantined, not hidden (see Quarantined Cells above). If enemies accumulate without despawning in a Web export, check that task before assuming a project bug. - Not a Supported target: no support claim, and the corpus/browser matrix and budgets are still being hardened.