Skip to content

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, an EXPORTED_FUNCTIONS list, a custom index.html). That workflow is obsolete, replaced by the wasmsdl bundler 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:

  1. sdl.wasm — SDL3 + SDL3_ttf + SDL3_image + SDL3_mixer (with a Web Audio backend) compiled by Emscripten into a single module, loaded by sdl.js.
  2. main.wasm — the Go experiment binary compiled with GOOS=js GOARCH=wasm, loaded by Go's wasm_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

replace github.com/Zyko0/go-sdl3 => github.com/chrplr/go-sdl3-wasm v0.1.2

(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. GetParticipantInfo cannot 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> with history.replaceState before starting Go, which is where platformPrepareFlags reads 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 a requestAnimationFrame callback. 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 uniform transform: scale() is safe (and is how Memory_span's page fits the canvas into a short viewport), but object-fit: contain is 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:

http://localhost:8080/?s=3&w        equivalent to:  -s 3 -w

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=3&level=2&fullscreen=false

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. wasmsdl writes 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's Module.locateFile — the glue fetches sdl.wasm by 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.html exists it is used as the base and only the runtime paths are rewritten, so Memory_span and Reading-1 keep their bespoke instructions. Everything else gets a page generated from the example's meta.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:embed and 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 build for the library (all packages except triggers/, which needs a serial port and stays desktop-only) and examples.
  • wasmsdl build bundles a goxpyriment example; the page boots both WASM modules.
  • Full control.Experiment initialization: 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/TextLine with wrap alignment and string metrics), fixation cross, SetLogicalSize letterboxing.
  • The full trial machinery inside exp.Run: ShowInstructions, Blank/ShowTimed/Wait, ShowAndGetRT with hardware-timestamp RTs, exp.Data.Add, ESC/quit handling via pumpFrame (HasEvent/GetKeyboardState).
  • Keyboard events reach SDL from the canvas; RTs come back as plausible millisecond values (timestamp granularity not yet measured — see below).
  • results output: at experiment end the browser downloads one archive holding the .csv and 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, and Tone all work (see "Audio in the browser" below); confirmed by ear in an interactive session, not just headlessly.
  • Mouse-driven responses: examples/Memory_span runs in the browser with its on-screen button grid, including Renderer.RenderCoordinatesFromWindow mapping window pixels into a SetLogicalSize letterboxed 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 for parity_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 from main. 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.FuncOf callback instead wedges the tab (the scheduler spins in findRunnable). Consequently sdl.RunLoop on js paces frames with requestAnimationFrame but runs the experiment logic on the goroutine that called Run; the RAF callback only signals a channel. (SDL_Delay is a no-op on js — it would busy-wait; use time.Sleep.)
  • Assets must be embedded. There is no filesystem; //go:embed + FontFromMemory / sdl.IOFromBytes is 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 NewScreen always 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. (SetRenderLogicalPresentation used to be on this list; it is implemented in the vendored fork and present in wasm/exported_functions.json.)
  • GetParticipantInfo never opens its dialog on js — it cannot, needing its own SDL window and an sdl.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 at Renderer.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 AudioManager instead 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's CLAUDE.md documents the rules (opaque handles vs input structs vs out-params, float32 passed directly, size_t as i32 not BigInt, u64 returns via internal.GetInt64, struct returns via internal.NewObject, HEAPU8 fetched fresh).
  • Module._SDL_Foo is not a function at runtime means the C symbol is missing from the sdl.wasm export list — regenerate the list with go run ./cmd/gen-wasm-exports/ and relink the blob (.docker/emscripten-build/Dockerfile in the fork).
  • After changing the fork, re-sync goxpyriment's vendor tree: GOWORK=off go mod vendor at 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.