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
self.postMessage() with a transferable buffer. 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 intercepts webR’s internal Web Worker to receive frame data and forward input. Here is the minimal pattern:
<canvas id="canvas" width="600" height="400"></canvas>
<div id="loading">Loading raylibr...</div>
<script type="module">
const canvas = document.getElementById('canvas');
const ctx2d = canvas.getContext('2d');
const loading = document.getElementById('loading');
// Monkey-patch Worker to intercept webR's internal worker
const _OrigWorker = Worker;
let webRWorker = null;
globalThis.Worker = class extends _OrigWorker {
constructor(...args) {
super(...args);
webRWorker = this;
this.addEventListener('message', (e) => {
if (e.data && e.data.type === '__raylibr_cursor') {
canvas.style.cursor = e.data.cursor;
} else if (e.data && e.data.type === '__raylibr_frame') {
// Row-flip: WebGL reads bottom-up, canvas expects top-down
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);
if (loading.style.display !== 'none') loading.style.display = 'none';
}
});
}
};
import { WebR } from 'https://webr.r-wasm.org/v0.6.0/webr.mjs';
// Audio unlock (needed for demos that play sound)
canvas.addEventListener("click", () => {
if (webRWorker) webRWorker.postMessage({type: "__raylibr_audio_unlock"});
});
// Start webR
const webR = new WebR();
await webR.init();
await webR.installPackages(['Rcpp'], { repos: ['https://repo.r-wasm.org'], quiet: true });
const localRepo = new URL('repo', window.location.href).href;
await webR.installPackages(['raylibr'], { repos: [localRepo], quiet: true });
await webR.evalR('library(raylibr)');
await webR.evalR('...'); // Your R code here
</script>For demos that need input, listen for the __raylibr_sab
message to receive the SharedArrayBuffer, then forward mouse/keyboard
events by writing to it with Atomics.store():
// Inside the Worker message listener, add:
if (e.data && e.data.type === '__raylibr_sab') {
inputSAB = new Int32Array(e.data.sab);
}
// Forward mouse position
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 (key pressed)
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();
});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 | Left mouse button (0/1) |
Atomics.store / Atomics.load
|
| 5 | Right mouse button (0/1) |
Atomics.store / Atomics.load
|
| 6 | Key-down bitmask |
Atomics.or / Atomics.and /
Atomics.load
|
| 7 | Char pressed (code point) |
Atomics.store / Atomics.exchange
(consumed) |
The key-down bitmask (index 6) encodes held-key state as bit flags: W=1, A=2, S=4, D=8, Left=16, Right=32, Up=64, Down=128, Space=256, [=512, ]=1024, .=2048, ,=4096, LShift=8192, RShift=16384. Not all demos use all bits.
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 fullscreen or window resizing (OffscreenCanvas has fixed dimensions)
- No touch input forwarding (could be added via SAB)
- One instance per page (the Worker monkey-patch captures a single worker reference)
-
No native file system — use
raylibr_resource()for bundled assets
Audio
Audio works in worker mode via SharedArrayBuffer ring buffers. The
worker generates PCM data each frame (called from
SwapScreenBuffer), and the main thread’s
ScriptProcessorNode reads from the ring buffer for
playback. See raylibr-audio.js for the proxy implementation
and the cubes and beatbox demos for examples.
A user click is required to unlock the AudioContext
(browser autoplay policy).
