Godot Web Client WebTransport: Implementation Record
Note (active focus): the browser/web client direction is currently deferred while we ship VR (20260425-godot-player.md) first. Browser observation is SOMEDAY (per 20260425-godot-observer.md), and no new Playwright work is being added — the spec referenced below is a one-time historical PoC that proved the bug fixes recorded here. Future test orchestration is shell-based (see 20260425-headless-test-matrix.md).
The Context
The Infinite Aquarium / Virtual Creator Market targets web browsers as the primary client delivery mechanism — zero install, URL-based discovery. The feat/module-http3 module provides WebTransportPeer for both native (picoquic) and web export (browser’s native WebTransport API via quic_web_glue.js). This ADR records the bugs found and the proof-of-concept that the web client works end-to-end.
The Problem Statement
Four independent bugs prevented the Godot wasm32 web export from using WebTransportPeer from GDScript. Without all four fixed, datagrams could not flow between a browser-loaded Godot game and a Godot zone server.
The Bugs and Fixes
Bug 1: JS library not reaching Emscripten linker
modules/http3/SCsub called env_quic.AddJSLibraries(...) on a cloned environment. platform/web/SCsub reads sys_env["JS_LIBS"] (the root env). Cloned env changes do not propagate upward — quic_web_glue.js was never passed as --js-library to em++.
Fix: env.Append(JS_LIBS=[File("#modules/http3/quic_web_glue.js")]) directly on the root env.
Bug 2: Duplicate symbol silences web backend
quic_picoquic_backend.cpp’s #else !GODOT_QUIC_NATIVE_BACKEND branch provides empty stubs for register_quic_picoquic_backend(). Since both .cpp files compile with the same env_quic (which has GODOT_QUIC_WEB_BACKEND defined), the linker sees two definitions and silently picks the empty stub. The web backend was never registered.
Fix: Guard the empty stubs with #elif !defined(GODOT_QUIC_WEB_BACKEND) so they are only compiled when neither backend is active.
Bug 3: Worker thread cannot call browser WebTransport API
Godot’s game loop runs in a pthread worker. quic_web_glue.js functions like godot_wt_connect call new WebTransport(url) — a browser API unavailable in workers. The original file had a misnamed proxy declaration (godot_WTServerSession__proxy) that did nothing. Emscripten 5.x rejects orphaned proxy declarations.
Fix: Add __proxy: 'sync' to every exported function in quic_web_glue.js. Each call from the worker is proxied to the main browser thread synchronously.
Bug 4: poll() never ingests incoming datagrams
web_transport_peer.cpp::poll() was extended to call session_state_func for the web backend, but returned immediately afterward without pulling received datagrams from sess.datagrams_in (the JS queue) into the C++ incoming queue. get_available_packet_count() always returned 0.
Fix: Add poll_incoming_func static pointer. quic_web_backend.cpp implements _web_poll_incoming which calls godot_wt_recv_datagram in a loop, forwarding each datagram to _push_wt_incoming_datagram. poll() calls it after session_state_func.
Proof of Concept
modules/http3/demo/ contains wt_client_web.gd (extends Node), a scene, and a Godot project. Exported as PCK with --export-pack "Web" and loaded via bin/.web_zip/godot.js (wasm32 threaded) plus a Node.js COOP/COEP HTTP server in the Playwright test.
e2e/godot_wt_e2e.spec.ts (in multiplayer-fabric-zone-backend/frontend/) starts wt_server_demo.gd as echo server, loads the web export in Chromium, injects window.WT_CERT_HASH and monkey-patches window.WebTransport to add serverCertificateHashes, waits for window.__wt_beacons to contain a {"event":"pass"} beacon.
Result: PASS in 3.3 s.
browser: {"event":"init","host":"127.0.0.1","port":54370}
browser: [WT patch] connecting to: https://127.0.0.1:54370/wt
browser: [WT patch] ready: https://127.0.0.1:54370/wt
browser: {"event":"sent","msg":"Hello Godot WebTransport"}
browser: {"event":"pass","echo":"Hello Godot WebTransport"}
server: {"count":1,"event":"recv"}
server: {"event":"echo","len":24,"mode":0}
What Is Not Decided Here
- Production cert pinning: the monkey-patch approach is test-only. Production connections use normal TLS (no
window.WT_CERT_HASH). - Loading a full game project in the browser (requires export templates). The PoC uses
--export-packwithbin/.web_zipengine files. - Replacing the gescons32 manual web_zip copy step with a proper SCons install target.
Status
Status: Accepted
Decision Makers
- iFire