Running goxpyriment experiments in a web browser (WASM)¶
Status (2026-07-13): a real experiment runs end-to-end in the browser.
examples/parity_decision — instructions screen, fixation cross, 10 RT trials
with keyboard responses, feedback logic, and CSV + info-file download — was
run to completion in headless Chrome, driven by DevTools-protocol key events.
The data files that Chrome downloaded are well-formed goxpyriment output with
correct browser metadata (video_driver: emscripten). See
What remains for the open items.
An earlier version of this document described a manual Emscripten workflow (hand-built
SDL3.js, anEXPORTED_FUNCTIONSlist, a customindex.html). That workflow is obsolete, replaced by thewasmsdlbundler described below. A condensed record of the old recipe is kept in the appendix as a reference for rebuilding the SDL wasm blob.
Architecture¶
Two WASM runtimes cooperate in the same page:
sdl.wasm— SDL3 + SDL3_ttf + SDL3_image + SDL3_mixer (with a Web Audio backend) compiled by Emscripten into a single module, loaded bysdl.js.main.wasm— the Go experiment binary compiled withGOOS=js GOARCH=wasm, loaded by Go'swasm_exec.js.
The Go side talks to the Emscripten side through go-sdl3's js bindings
(syscall/js calls into Module._SDL_*), sharing one Emscripten heap.
Where the pieces live¶
| Piece | Location |
|---|---|
| go-sdl3 fork with the js/wasm target | github.com/chrplr/go-sdl3-wasm, branch wasm-render-fixes (local clone: ~/00_git/go-sdl3-wasm). The module path is unchanged (github.com/Zyko0/go-sdl3), and goxpyriment's go.mod has a replace pointing at a pinned pseudo-version of the fork; vendor/ is kept in sync with GOWORK=off go mod vendor |
Prebuilt sdl.js / sdl.wasm + bundler |
cmd/wasmsdl in the fork — embeds the blobs and an index.html, and builds/serves a complete browser bundle. Rebuild recipe: .docker/emscripten-build/Dockerfile in the fork |
| goxpyriment js platform code | control/platform_js.go (URL-parameter flags, no dialog, audio device open), apparatus/screen_newscreen_js.go (canvas window), apparatus/screen_present_js.go (RAF-synced flips), results/output_file_wasm.go + results/data_wasm.go (session → one .zip download) — all build tag js |
| Export-list generator | cmd/gen-wasm-exports — scans go-sdl3's js bindings + goxpyriment's own calls as compiled for GOOS=js (files excluded by build constraints, and triggers/, are not counted), emits wasm/exported_functions.json for emcc -sEXPORTED_FUNCTIONS=@…, and lists the go-sdl3 calls whose js bindings are still panic-stubs (the remaining-work list) |
The go-sdl3 replace — what dependents need to know¶
goxpyriment's go.mod pins the fork with a line of the form
(the authoritative version is whatever go.mod at the repo root says — it is
bumped whenever the fork changes. It is a released tag today; while the fork is
being developed between tags it is a pseudo-version of the form
v0.1.2-0.<timestamp>-<hash> instead.)
Go applies replace directives only in the main module, so a project that
imports goxpyriment as a dependency does not inherit this line. Such a
project resolves upstream Zyko0/go-sdl3 — fine on desktop (behaviour is
unchanged there), but the browser build will hit panic("not implemented on
js") stubs. To build your own experiment for the browser, copy the current
replace line from goxpyriment's go.mod into your project's go.mod.
(It works because the fork keeps the upstream module path.)
When developing the fork itself, point the replace at the local clone instead
(go mod edit -replace github.com/Zyko0/go-sdl3=/home/cp983411/00_git/go-sdl3-wasm,
then GOWORK=off go mod vendor), and restore the pinned GitHub pseudo-version
before committing (git log -1 --format=%cd-%h --date=format:%Y%m%d%H%M%S in
the fork gives the timestamp/hash for a fresh pseudo-version; the base tag is
v0.1.1, so the form is v0.1.2-0.<timestamp>-<12-char-hash>).
Building and serving an example¶
From the repo root:
# Build a self-contained bundle (index.html + sdl.js + sdl.wasm + main.wasm
# + wasm_exec.js) into _build/wasm/parity_decision/
make wasm-parity_decision
# Or build + serve on http://localhost:8080 in one step, then open
# http://localhost:8080/?s=1
make wasm-parity_decision-serve
The targets run the wasmsdl bundler out of the pinned go-sdl3 fork
(go run github.com/Zyko0/go-sdl3/cmd/wasmsdl …), so they need no local
fork clone and no Emscripten install. wasmsdl serve also sends the
COOP/COEP headers that unlock the high-resolution browser clock (see
"Timing in the browser").
WASM cannot be loaded from file:// URLs; for the build output any static
server works (python3 -m http.server), but plain servers don't send the
COOP/COEP headers — timestamps then tick at ~100 µs instead of ~5 µs.
Per-example launcher pages¶
By default the bundle gets wasmsdl's page: a bare canvas that starts the
experiment the moment the wasm loads. An example can ship its own landing page
instead by adding examples/NAME/web/index.html; the wasm-% targets detect
it and pass it to the bundler with -html. The bundler writes sdl.js,
sdl.wasm, wasm_exec.js and main.wasm alongside it, which is all the page
has to load.
examples/Memory_span/web/ is the worked example (see its README). The same
web/index.html path is what docs/StandaloneExampleRepo.md expects when an
example is split out into its own repo with a GitHub Pages build, so a launcher
written here travels with the example. A launcher page is worth writing when
the experiment needs any of:
- A participant-ID field.
GetParticipantInfocannot run on js, and typing a URL parameter is not something to ask a participant to do. The page writes the field back as?s=<id>withhistory.replaceStatebefore starting Go, which is whereplatformPrepareFlagsreads it from. - A user gesture before the first sound. Browsers create the AudioContext suspended. A Start button doubles as the gesture that resumes it.
- Instructions in HTML, so the participant reads them before the canvas takes over the window.
Two things such a page must get right:
- Start Go from a fresh task (
setTimeout(…, 0)), not from inside the click handler or arequestAnimationFramecallback. The experiment blocks its main goroutine for the whole session, and the wasm scheduler can only park it — returning to the JS event loop so DOM events keep reaching SDL — when it is not nested inside another callback's stack. - Do not resize the canvas with CSS after SDL has sized its window, unless
the aspect ratio is preserved. Emscripten maps pointer events through
getBoundingClientRect, so a uniformtransform: scale()is safe (and is how Memory_span's page fits the canvas into a short viewport), butobject-fit: containis not: it letterboxes the bitmap inside an element box that Emscripten still assumes the bitmap fills, and every click lands off target.
Session settings via URL parameters¶
There is no command line in a browser. On GOOS=js,
control.NewExperimentFromFlags synthesizes os.Args from the page URL's
query string, so the standard flags — and any experiment-specific flags the
program registered — work unchanged:
Keys that don't match a registered flag are ignored with a console note. The
participant-info dialog never opens in the browser (it needs its own SDL
window); when s is absent the subject ID defaults to 0, exactly like
-headless.
Programs that call control.GetParticipantInfo directly are answered from the
same query string, by platformParticipantInfo in control/platform_js.go.
Each field is looked up by its own Name, falling back to the cached value and
then to its Default:
subject_id also accepts the short s that works everywhere else. A
FieldSelect takes either the exact option text or a 1-based index into its
options — the escape hatch for options like "Level 2 — Name", which are
miserable to type into a URL. A FieldCheckbox takes true/false/1/0/
yes/no, and a bare key means true. Every resolved value is logged to the
console, since there is no dialog to read the settings back off afterwards.
Headless verification (no display needed)¶
cd /tmp/hello_bundle && python3 -m http.server 8123 &
google-chrome --headless=new --use-gl=swiftshader --no-sandbox \
--window-size=1100,850 --virtual-time-budget=6000 \
--screenshot=/tmp/hello.png --enable-logging=stderr \
"http://localhost:8123/?s=1" 2>&1 | grep "INFO:CONSOLE"
Go panics (with full stack traces) appear as INFO:CONSOLE lines; the
screenshot shows what rendered. Each remaining stub panics with its binding's
file:line, which tells you exactly what to un-stub next.
Publishing to downloads.pallier.org¶
Every tagged release publishes 79 of the 91 examples as browser builds, served from the Cloudflare R2 bucket. A visitor follows a Run link on https://downloads.pallier.org/builds/latest/ and the experiment starts — no download, no install, no Gatekeeper.
builds/{sha}/wasm/_runtime/{sdl.js,sdl.wasm,wasm_exec.js} shared, 5.3 MB
builds/{sha}/wasm/{app}/index.html launcher page
builds/{sha}/wasm/{app}/main.wasm the experiment
examples/installers/build-wasm-apps.sh drives it, and cmd/gen-wasm-launcher
writes the pages. Three things about that pipeline are worth knowing:
- The runtime is shared.
wasmsdlwrites five files per bundle, but three of them are byte-identical everywhere. They are published once and every page loads them from../_runtime/, which requires setting Emscripten'sModule.locateFile— the glue fetchessdl.wasmby name, not through a tag, so without that line a page loads and then cannot find its runtime. Sharing saves ~415 MB per build. - An example may keep its own launcher. When
examples/NAME/web/index.htmlexists it is used as the base and only the runtime paths are rewritten, soMemory_spanandReading-1keep their bespoke instructions. Everything else gets a page generated from the example'smeta.yaml. - 12 examples are excluded, listed with reasons in
examples/installers/wasm-skip.txt. They read stimuli from disk, which compiles for js and fails at run time — so the list is maintained by hand rather than inferred from a build. Converting one to//go:embedand deleting its line publishes it.
The bucket sends the COOP/COEP headers through a Cloudflare response-header
rule, so published experiments get the ~5 µs clock; see
docs/copy_apps_to_cloudflare_R2.md for the rule and the storage budget.
What works today (verified 2026-07-13)¶
GOOS=js GOARCH=wasm go buildfor the library (all packages excepttriggers/, which needs a serial port and stays desktop-only) and examples.wasmsdl buildbundles a goxpyriment example; the page boots both WASM modules.- Full
control.Experimentinitialization: SDL + TTF init, canvas window + renderer, embedded font via the in-memory IOStream path (IOFromDynamicMem/Write/Seek), keyboard/mouse wiring, system- and display-info gathering, data-file creation. - Text rendering (
stimuli.TextBox/TextLinewith wrap alignment and string metrics), fixation cross,SetLogicalSizeletterboxing. - The full trial machinery inside
exp.Run:ShowInstructions,Blank/ShowTimed/Wait,ShowAndGetRTwith hardware-timestamp RTs,exp.Data.Add, ESC/quit handling viapumpFrame(HasEvent/GetKeyboardState). - Keyboard events reach SDL from the canvas; RTs come back as plausible millisecond values (timestamp granularity not yet measured — see below).
resultsoutput: at experiment end the browser downloads one archive holding the.csvand the-info.txt(verified: the files land intact and unzip to what a native run writes). It must stay one download — see "Why the results arrive as a .zip" below.- Audio playback: the buzzer/feedback sounds,
PlaySync/PlayAsync, andToneall work (see "Audio in the browser" below); confirmed by ear in an interactive session, not just headlessly. - Mouse-driven responses:
examples/Memory_spanruns in the browser with its on-screen button grid, includingRenderer.RenderCoordinatesFromWindowmapping window pixels into aSetLogicalSizeletterboxed space (verified 2026-08-27 by driving headless Chrome over the DevTools protocol: sequence presentation, click responses, correct/incorrect feedback, and the staircase all behave). The end-of-session download was verified forparity_decision, not re-verified for a full 30-trial Memory_span run.
Why the results arrive as a .zip¶
results.DataFile.Finalize writes one file in the browser: a .zip
containing the .csv and the -info.txt under the names the desktop build
gives them. Unzipping a browser session yields byte-for-byte what a native run
writes, so nothing downstream has to know where the data came from.
This is not packaging convenience — it is a correctness fix. Until 2026-08-29 the browser build fired the two downloads back to back, and the second one was routinely lost: browser sessions arrived with their metadata and no results.
The mechanism, measured against Chrome's own downloads table
(~/.config/google-chrome/Default/History): when the browser is set to ask
where to save each file, two downloads requested milliseconds apart are
serialized into two modal Save-As dialogs, and only the first is shown. The
second stays queued, and is cancelled the moment the page goes away — which is
precisely what a participant does when the session ends. Chrome records the
casualty as state=CANCELLED, interrupt_reason=USER_CANCELED, 0 bytes
received and no chosen filename. Because Finalize wrote the info file first,
the metadata always survived and the results never did.
Two things that look like the cause are not, and were each disproven by experiment before this fix landed:
- The wasm instance exiting. A probe that kept the module alive for two
minutes after
Experiment.End()behaved no differently from one that exited immediately: in the immediate case the Save-As dialog still appeared and the file still completed 34 s after the Go program had returned frommain. What cancels a pending download is the document being destroyed (navigating away, closing the tab), not the Go program finishing. - Revoking the object URL right after
click(). It looks like the classic antipattern. A controlled test (one download pair per page load, no user gesture) delivered 5/5 with the immediate revoke and 3/5 with a deferred one. The revoke stays where it is.
So: never add a second download to the browser path. If a future experiment needs to hand back another artefact, add it to the archive.
The key design points¶
- Blocking experiment code works in the browser — but only on the main
goroutine. Go's wasm scheduler parks a blocked main goroutine and returns
to the JS event loop, so DOM events fire and SDL sees them. Code that blocks
inside a
js.FuncOfcallback instead wedges the tab (the scheduler spins infindRunnable). Consequentlysdl.RunLoopon js paces frames withrequestAnimationFramebut runs the experiment logic on the goroutine that calledRun; the RAF callback only signals a channel. (SDL_Delayis a no-op on js — it would busy-wait; usetime.Sleep.) - Assets must be embedded. There is no filesystem;
//go:embed+FontFromMemory/sdl.IOFromBytesis the portable path (and was already the recommended pattern on desktop). Path-based loaders fail on js.
What remains for a complete port¶
All five phases of the roadmap are done: branch consolidation and
vendor/fork unification; IOStream + info bindings, URL-parameter setup,
demo_hello_world rendering; parity_decision running end-to-end (keyboard trials,
RTs, CSV download) under the redesigned main-goroutine RunLoop; timestamp
granularity measured and fixed; flips aligned to requestAnimationFrame with
measured 60.00 Hz pacing and zero dropped frames; audio playback; and
packaging (make wasm-NAME / wasm-NAME-serve targets, obsolete artifacts
removed, GOOS=js builds in CI via .github/workflows/go-build.yml).
Open decision: PR the fork's js/wasm work upstream to Zyko0/go-sdl3, or
keep maintaining the fork.
Fixed since: Experiment.Initialize recorded the audio device name via
AudioDeviceID.Name(), whose js binding is still a panic-stub — so every
browser experiment aborted during initialization, before its first frame, with
panic: not implemented on js. The call now goes through a build-tagged
platformAudioDeviceName (control/platform_js.go) that reports
"Web Audio (browser)" instead; there is no physical device to name when SDL
routes everything through one Web Audio context. (Fixed 2026-08-27. The stub
itself is still worth un-stubbing in the fork, but nothing depends on it now.)
Known gaps:
- Fullscreen and multi-monitor are meaningless in a canvas; the js
NewScreenalways opens 1024×768 (or the requested size). A "resize canvas to viewport" option would be nicer for participants. - Less-common APIs are still stubbed (gamepad/joystick enumeration, audio
recording,
WaitEvent/WaitEventTimeout, video playback). They panic with a clear console message when hit. (SetRenderLogicalPresentationused to be on this list; it is implemented in the vendored fork and present inwasm/exported_functions.json.) GetParticipantInfonever opens its dialog on js — it cannot, needing its own SDL window and ansdl.Quit()afterwards — and is answered from URL parameters instead (see "Session settings via URL parameters"). Until 2026-08-31 there was no such check, and all eleven examples that call it directly died in the browser inside the dialog atRenderer.CurrentOutputSize, a panic-stub, showing the participant a black page after they pressed Start.
Audio in the browser¶
Audio playback works (verified 2026-07-13): platformInitAudio on js opens
the default playback device exactly like on desktop, and the whole
goxpyriment audio API — exp.Audio.PlayBuzzer/PlayCorrect/PlaySync/PlayAsync,
Sound.PreloadDevice/Play/Wait, Tone — runs on SDL's Emscripten Web Audio
backend. Verified in parity_decision: the device opens as 48 kHz stereo
F32LE with a 2048-frame buffer, the AudioContext reaches running, and the
buzzer feedback plays on incorrect trials.
Browser specifics:
- First sound needs a user gesture. Browsers create the AudioContext suspended; SDL's Emscripten backend resumes it automatically on the first click or keypress. Any experiment that starts with a "press SPACE" screen satisfies this without extra code. Sounds triggered before any interaction stay silent until the first one.
- Latency is higher than desktop. The Emscripten device reports a
2048-frame buffer at 48 kHz ≈ 43 ms;
SetAudioSampleFrames(an SDL hint) is not honoured the same way by the Web Audio backend. Treat audio onset timing as approximate in the browser;PlaySyncedWithFlip's desktop guarantees do not transfer. - If the device cannot be opened, the experiment continues with a silent
no-op
AudioManagerinstead of crashing (browser audio support varies).
Timing in the browser¶
Clock / timestamp resolution (measured 2026-07-13, headless Chrome)¶
SDL timestamps in the browser (sdl.TicksNS and input-event timestamps, i.e.
what ShowAndGetRT / GetKeyEventTS use) tick at:
| Configuration | Resolution |
|---|---|
| plain page | ~100 µs |
| cross-origin isolated page (COOP/COEP headers) | ~5 µs |
wasmsdl serve sends the COOP/COEP headers, so served experiments get the
~5 µs clock; any other hosting needs Cross-Origin-Opener-Policy: same-origin
and Cross-Origin-Embedder-Policy: require-corp to match.
These numbers required a fix in the fork (commit 5eb7455): out of the box,
every SDL timestamp was quantized to 1 ms, because SDL3's
SDL_GetPerformanceCounter probes CLOCK_MONOTONIC_RAW, Emscripten's WASI
shim rejects that clock id, and SDL silently falls back to gettimeofday =
Date.now(). The fork's sdl.js asset patches emscripten_date_now to
performance.timeOrigin + performance.now(), and the Dockerfile re-applies
the patch on rebuild.
Note: Go's time.Now() on js still has 1 ms resolution (the wall clock is
Date.now()). This is one more reason for the existing rule: RTs come from
the SDL event clock, never wall-clock deltas.
Frame pacing (measured 2026-07-13, headless Chrome)¶
On js, Screen.Update() (and therefore Flip/FlipTS/Show)
presents to the canvas and then parks until the browser's next
requestAnimationFrame tick — the browser's VSYNC equivalent (implemented in
apparatus/screen_present_js.go on top of sdl.WaitAnimationFrame in the
fork). This is required for correctness, not just pacing: canvas updates are
only composited when the page yields, and the desktop busy-wait
would freeze the tab. A 250 ms fallback tick prevents deadlock in background
tabs (where browsers throttle RAF) — but run experiments in a focused,
foreground tab.
Measured flip-loop intervals (299 frames, alternating full-screen colors):
| Loop | mean | SD | min–max | dropped frames |
|---|---|---|---|---|
Update loop |
16.666 ms (60.00 Hz) | 0.12 ms | 16.1–17.3 ms | 0 |
Flip loop |
16.666 ms (60.00 Hz) | 0.11 ms | 16.3–17.1 ms | 0 |
Caveat: headless Chrome ticks a virtual 60 Hz compositor; on real hardware the rate follows the display (e.g. 120 Hz laptop panels) and jitter depends on system load. The hardware-locked VSYNC timing available on desktop (DRM ioctl, CoreVideo) is not available in a browser, and there is no photodiode validation of actual pixel onset yet. Experiments that require sub-millisecond stimulus onset precision (rapid RSVP, subliminal priming) should be run natively. Cognitive tasks (choice RT, memory, attention) work fine.
Notes for developers¶
- Un-stubbing a go-sdl3 js binding is usually just deleting the
panic("not implemented on js")first line, but audit the marshalling: the fork'sCLAUDE.mddocuments the rules (opaque handles vs input structs vs out-params,float32passed directly,size_tas i32 not BigInt, u64 returns viainternal.GetInt64, struct returns viainternal.NewObject,HEAPU8fetched fresh). Module._SDL_Foo is not a functionat runtime means the C symbol is missing from thesdl.wasmexport list — regenerate the list withgo run ./cmd/gen-wasm-exports/and relink the blob (.docker/emscripten-build/Dockerfilein the fork).- After changing the fork, re-sync goxpyriment's vendor tree:
GOWORK=off go mod vendorat the repo root.
Appendix: historical manual Emscripten recipe¶
The early-2026 attempt built the SDL blob by hand. Kept here (condensed) as a
reference; the current blob is built by .docker/emscripten-build/Dockerfile
in the fork and additionally bundles SDL3_image and SDL3_mixer.
# Emscripten SDK
git clone https://github.com/emscripten-core/emsdk.git && cd emsdk
./emsdk install latest && ./emsdk activate latest && source ./emsdk_env.sh
# SDL3 (static)
git clone https://github.com/libsdl-org/SDL.git -b release-3.2.x SDL3-src
emcmake cmake -S SDL3-src -B SDL3-wasm -DCMAKE_BUILD_TYPE=Release \
-DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF
emmake make -C SDL3-wasm -j$(nproc)
cmake --install SDL3-wasm --prefix "$PREFIX"
# FreeType, then SDL3_ttf against the same prefix
emcmake cmake -S freetype-src -B freetype-wasm -DCMAKE_BUILD_TYPE=Release
emmake make -C freetype-wasm -j$(nproc) && cmake --install freetype-wasm --prefix "$PREFIX"
emcmake cmake -S SDL3_ttf-src -B SDL3_ttf-wasm -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$PREFIX" -DSDL3TTF_HARFBUZZ=OFF -DSDL3TTF_SAMPLES=OFF
emmake make -C SDL3_ttf-wasm -j$(nproc) && cmake --install SDL3_ttf-wasm --prefix "$PREFIX"
# Link one module; export list generated by cmd/gen-wasm-exports
emcc -o sdl.js "$PREFIX"/lib/libSDL3.a "$PREFIX"/lib/libSDL3_ttf.a "$PREFIX"/lib/libfreetype.a \
-s MODULARIZE=0 -s ENVIRONMENT=web -s ALLOW_TABLE_GROWTH=1 \
-s EXPORTED_RUNTIME_METHODS='["HEAPU8","stackAlloc","stackSave","stackRestore","getValue","setValue","stringToUTF8OnStack","UTF8ToString","addFunction"]' \
-s EXPORTED_FUNCTIONS=@wasm/exported_functions.json
Pitfalls recorded at the time: use -DCMAKE_PREFIX_PATH (where
SDL3Config.cmake was installed), not -DSDL3_DIR at the raw build dir;
SDL3_ttf pins a minimum SDL3 version — if they mismatch, rebuild SDL3 from the
tag SDL3_ttf requires; all libraries must be linked into one module
because the Go bindings share a single Emscripten heap; force-link static
archives (-Wl,--whole-archive) or EXPORT_ALL drops unreferenced symbols.