raylibr can run in the browser via webR, with R and Raylib both compiled to WebAssembly. Every demo on this site runs entirely client-side — no server required.
Detecting webR
Use is_web() to check whether your code is running under
Emscripten/webR:
if (is_web()) {
# Browser-specific setup
} else {
# Desktop-specific setup
}The game loop
On desktop, R can use a blocking while loop. In the
browser, blocking the thread would freeze the page.
run_game_loop() handles both cases:
run_game_loop(
init_fn = function() {
init_window(600L, 400L, "My Game")
set_target_fps(60L)
},
update_fn = function() {
begin_drawing()
clear_background("black")
draw_text("Hello from webR!", 100L, 180L, 30L, "white")
end_drawing()
},
cleanup_fn = close_window
)On desktop this runs a while (!window_should_close())
loop. On webR it registers update_fn as an Emscripten
main-loop callback, letting the browser’s event loop stay
responsive.
JavaScript interop
eval_js() executes JavaScript from R, useful for
browser-specific features:
Architecture
Raylib runs in a webR web worker, not on the main thread. Rendering and input are bridged across threads:
Browser main thread webR Web Worker
┌──────────────────────┐ ┌──────────────────────┐
│ <canvas> │ │ R interpreter │
│ 2D rendering ctx │◄──frames──│ raylibr (WASM) │
│ │ │ Raylib (WASM) │
│ Mouse/keyboard │ │ OffscreenCanvas │
│ event listeners │──input──►│ WebGL context │
│ │ (SAB) │ │
└──────────────────────┘ └──────────────────────┘
Frame transfer: Raylib’s
SwapScreenBuffer() calls gl.readPixels() on
the OffscreenCanvas, then sends pixel data to the main thread via
BroadcastChannel.postMessage(). The main thread flips the
rows (WebGL is bottom-up) and draws to the visible
<canvas> with putImageData().
Input forwarding: The main thread writes mouse
coordinates and key codes to a SharedArrayBuffer. Raylib’s
PollInputEvents() reads from this buffer each frame using
Atomics.load() and Atomics.exchange().
Sleeping: WindowShouldClose() and
WaitTime() use Atomics.wait() instead of
emscripten_sleep(), avoiding ASYNCIFY overhead. This works
in web workers where Atomics.wait is permitted.
Building raylibr for webR
Prerequisites:
- Emscripten SDK (tested with 4.0+)
- R with
Rcppinstalled natively (for headers) - The
raylibrpackage installed natively (R CMD INSTALL .)
Run the build script from the repository root:
This produces a webR-compatible package tarball. The script:
- Extracts Raylib 6.0 from the bundled tarball
- Patches Raylib’s platform code for worker mode
(
inst/wasm/patch_raylib.py) - Compiles Raylib with
emcc(-DPLATFORM_WEB_EMSCRIPTEN -DGRAPHICS_API_OPENGL_ES2) - Compiles raylibr’s C++ sources with
em++as a side module - Links everything into
raylibr.so(a WASM side module) - Packages as
.tgzwith R’s lazy-load database from the native install
HTML host page
Each demo page follows this pattern:
<canvas id="canvas" width="600" height="400"></canvas>
<script type="module">
import { WebR } from 'https://webr.r-wasm.org/v0.6.0/webr.mjs';
const canvas = document.getElementById('canvas');
const ctx2d = canvas.getContext('2d');
let inputSAB = null;
// Receive frames and SAB from worker
const bc = new BroadcastChannel('raylibr-frames');
bc.onmessage = (e) => {
if (e.data.t === 'f') {
const src = new Uint8ClampedArray(e.data.px);
const w = e.data.w, h = e.data.h, stride = w * 4;
const dst = new Uint8ClampedArray(src.length);
for (let y = 0; y < h; y++) {
dst.set(src.subarray((h - 1 - y) * stride, (h - y) * stride), y * stride);
}
ctx2d.putImageData(new ImageData(dst, w, h), 0, 0);
} else if (e.data.t === 'sab') {
inputSAB = new Int32Array(e.data.sab);
}
};
// Forward mouse input via SAB
canvas.addEventListener('mousemove', (e) => {
if (!inputSAB) return;
const rect = canvas.getBoundingClientRect();
Atomics.store(inputSAB, 0, Math.round(e.clientX - rect.left));
Atomics.store(inputSAB, 1, Math.round(e.clientY - rect.top));
Atomics.store(inputSAB, 2, 1);
});
// Forward keyboard input
canvas.addEventListener('keydown', (e) => {
if (!inputSAB) return;
const KEY_MAP = { ArrowUp: 265, ArrowDown: 264, ArrowLeft: 263, ArrowRight: 262 };
const raylibKey = KEY_MAP[e.key] || e.key.toUpperCase().charCodeAt(0);
Atomics.store(inputSAB, 3, raylibKey);
e.preventDefault();
});
// Start webR
const webR = new WebR();
await webR.init();
await webR.installPackages(['Rcpp'], { repos: ['https://repo.r-wasm.org'], quiet: true });
await webR.installPackages(['raylibr'], { repos: ['./repo'], quiet: true });
await webR.evalR('library(raylibr)');
await webR.evalR('demo("helloworld", package = "raylibr")');
</script>SharedArrayBuffer layout
The input SAB is an Int32Array with 8 elements:
| Index | Contents | Access |
|---|---|---|
| 0 | Mouse X |
Atomics.store / Atomics.load
|
| 1 | Mouse Y |
Atomics.store / Atomics.load
|
| 2 | Cursor on screen (0/1) |
Atomics.store / Atomics.load
|
| 3 | Last key pressed (Raylib key code) |
Atomics.store / Atomics.exchange
(consumed) |
| 4-7 | Reserved |
Cross-origin isolation
SharedArrayBuffer requires cross-origin isolation.
Your server must send these headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
For GitHub Pages (which doesn’t support custom headers), this site uses coi-serviceworker to set them via a service worker.
For local development, webR’s PostMessage channel works
without these headers but is slower.
Limitations
- No audio in worker mode (Web Audio API requires the main thread)
- No fullscreen or window resizing (OffscreenCanvas has fixed dimensions)
- No touch input forwarding (could be added via SAB)
- One instance per page (BroadcastChannel uses a fixed name)
-
No native file system — use
raylibr_resource()for bundled assets
