Portable, sandboxed game cartridges powered by WebAssembly
One .wasc file. Runs on your desktop, browser, terminal, retro handheld device, or phone. No install, no dependencies.
For players
A cart is one file. There is no launcher, no store, no account, and nothing to update. Put it on any machine you own and it runs the same.
A .wasc file is a single self-contained artifact — game, assets and all. No dependencies, no drivers, no runtime to install first.
Carts are sandboxed by WebAssembly: no filesystem access, and no network at all unless the cart was packaged with an explicit list of hosts it may reach. Running a stranger's cart is closer to running a ROM than an .exe.
Gamepad support is always on, up to four players. Keyboard maps onto a pad automatically, and touch devices get a virtual d-pad, so a cart written for a controller still plays on a phone.
Each language runtime ships prebuilt example carts in its latest release — download a .wasc and you have something to play.
Three ways to run one, depending on what you already have.
A single native binary for Linux, macOS and Windows. Real window, audio, gamepads.
Download wasmcart-nativeA libretro core, so carts sit in your existing frontend next to your ROMs — including on handhelds and Android.
Get the libretro coreWith Node 22+ installed, play any cart straight from a terminal — including in the terminal.
npx wasmcart game.wasc
npx wasmcart game.wasc --term
For developers
wasmcart sits between fantasy consoles (too constrained) and full game engines (too heavy, not sandboxed). It is a portable cartridge format with no artificial limits.
Full OpenGL ES 3.0, float audio, up to 4GB of assets. Port a 3D shooter or write a 2KB snake game — same ABI.
The ABI is three exported functions and a struct — anything that compiles to WASM can meet it. Or skip the compiler entirely and ship Lua, Python, Ruby, GDScript or JavaScript on a prebuilt engine.
No cross-compilation. A single .wasc file runs on desktop, the web, retro handhelds, phones, RetroArch, and terminal renderers.
GLES3/WebGL2 draw calls go straight to the hardware GPU. No emulation, no translation layer. WASM overhead only applies to game logic.
Deterministic replay is built in: same seed, byte-identical output. An opt-in debug ABI exposes named game state to a harness — and is structurally absent when you don't use it.
A minimal cart is ~40 lines of C. Need networking? Declare the hosts you may reach. Need pointer or keyboard input? Set a flag. The ABI grows with the game's needs.
A cart is just WebAssembly plus a fixed ABI, so the language question is really two questions: does your toolchain emit standalone WASM, and are you willing to compile at all?
These work differently from everything below. The engine is already compiled and ships as WASM — a full language runtime that happens to satisfy the ABI. Your game is Lua, Python, Ruby, GDScript or JavaScript source, packed into the cart as an asset. There is no compiler in your loop, no WASM target to install, and nothing to cross-compile: edit a script, run it. The tradeoff is a larger cart, since it carries an interpreter, and interpreter speed rather than native.
Real Lua 5.4 — coroutines, metatables, the GC — behind a LÖVE-style API: love.load, love.update(dt), love.draw. Drawing batches through a WebGL2 renderer, with a software rasterizer in the same binary for hosts without GL. Pure-Lua libraries just work, and anything that transpiles to Lua (Teal, Fennel, YueScript) rides along.
CPython 3.13 and pygame-ce compiled to WASM, so existing pygame games port largely as-is. moderngl, numpy and glm shims are included, which is enough for 3D: the examples include a raycaster and a shadow-mapped GL scene.
An mruby runtime with DragonRuby-style APIs — def tick args, args.outputs, args.inputs, args.state. Copy the template, edit app/main.rb, rerun. Experimental, MIT, every layer open.
The Godot 4 engine compiled to standalone WebAssembly. Export a .pck from the editor and it becomes a cart, so an existing Godot game ships without a code change. Rendering goes through Godot's GLES3 Compatibility renderer, and 3D works: the examples include a third-person action game at 1080p.
Standard browser JS in a sandboxed QuickJS-in-WASM runtime: Canvas 2D (GPU-accelerated via Skia Ganesh), WebGL2 passthrough, Web Audio, ES modules, gamepad. No DOM, no XSS, no filesystem — run untrusted JS games the way you'd run a ROM.
Get started with JavaScriptEach of these ships prebuilt carts you can play without cloning anything — grab a .wasc from the runtime's latest release and run npx wasmcart <file>.
Here your code becomes the cart: you compile to standalone WASM and the result satisfies the ABI directly. No interpreter in the middle, so carts are small and run at native WASM speed, and the cost is a toolchain in your loop. These are the languages carts are actually written in today, each with bindings in the org.
The primary path and the language of the ABI header itself. Build with Emscripten (-sSTANDALONE_WASM=1) or wasi-sdk. The include/ directory ships framebuffer, GL, math, matrix and PCM-mixer headers.
Same toolchain as C, and the language most existing game ports arrive in. Engine-scale C++ works: the constraint is the GL surface, not the language.
Get started with C++Freestanding wasm32-unknown-unknown: no runtime to embed, no JS glue, no wasm-bindgen. The bindings are #[repr(C)] structs, some constants and one macro, which makes Rust carts the smallest of any language here. The GL example is a 1.9 KB wasm.
Bindings, not a runtime: no interpreter, no GC, no engine. A cart compiles to a freestanding wasm32 module a host runs directly, and a complete one is a few hundred bytes with zero imports. Pinned to Zig 0.16, since the language is pre-1.0 and build.zig moves between releases.
Porting an existing C or SDL2 game is its own path: wasmcart-sdl2 provides an SDL2 backend whose video and audio drivers target the cart's framebuffer and audio ring, so a game links against it instead of a windowing system.
Two requirements do the real filtering, and neither is about syntax. First, the toolchain must emit standalone WASM that exports wc_get_info, wc_init and wc_render — not a JS-glue bundle that expects a browser to boot it. Second, for GPU carts, every GL function must be declared as a WASM import at compile time: there is no eglGetProcAddress in WebAssembly, so a runtime that discovers GL entry points dynamically cannot draw. Languages with a heavy managed runtime work, but pay for it in cart size and startup.
npm install wasmcart
Requires Node.js 22 or newer.
The minimal ABI is three exported functions. Set gpu_api = 1 so the cart renders through GL on every host:
#include "wasmcart.h"
static uint32_t framebuffer[320 * 240];
static wc_info_t info;
__attribute__((export_name("wc_get_info")))
wc_info_t* wc_get_info(void) {
info.version = 3;
info.width = 320;
info.height = 240;
info.gpu_api = 1;
info.fb_ptr = (uint32_t)(uintptr_t)framebuffer;
return &info;
}
__attribute__((export_name("wc_init")))
void wc_init(void) {
// called once at startup
}
__attribute__((export_name("wc_render")))
void wc_render(void) {
// called every frame — draw here
}
Emscripten is the reference producer — its GL output already matches the ABI's WebGL2 surface, so a conforming cart falls out with no glue:
emcc -sSTANDALONE_WASM=1 -sALLOW_MEMORY_GROWTH=1 \
--no-entry -O2 -o cart.wasm cart.c
npx wasmcart pack --wasm cart.wasm --assets ./assets/ \
--name "My Game" -o game.wasc
npx wasmcart opens a real SDL window with audio and gamepad support. GL carts are auto-detected from the wasm import section — there is no flag to pass:
npx wasmcart game.wasc # SDL window, audio, gamepad
npx wasmcart my-cart-dir/ # dev mode: straight off disk
npx wasmcart game.wasc --term # ANSI terminal player
# headless: step N frames, dump a PNG + WAV, exit
npx wasmcart game.wasc --frames 300 --shot out.png --wav out.wav
# deterministic replay — same seed, byte-identical PNG
npx wasmcart game.wasc --seed 7 --frames 60 --shot a.png
Embedding the host in your own app instead:
import { CartHost } from 'wasmcart'; // Node
import { CartHostWeb } from 'wasmcart/web'; // browser
const cart = new CartHost();
await cart.load('game.wasc', {
// a factory runs only if the cart actually imports GL
glBackend: () => canvas.getContext('webgl2'),
});
const frame = cart.runFrame(gamepads);
// frame.framebuffer, frame.audio, frame.saveData
Everything below is part of the cartridge contract. Most of it is opt-in: a cart that never asks for a feature carries none of it.
Up to 4 players. Always available — keyboard maps to gamepad buttons, touch shows virtual d-pad.
Opt-in unified mouse + touch. 10 pointer slots for multitouch. Shared memory state + event callbacks.
Opt-in raw key state via 256-bit bitmask (USB HID scancodes). Event callbacks for key up/down.
One networking primitive: connect to a peer, send bytes, receive bytes. The transport — a server socket, a relay, a direct link — is the host's business, not the cart's, so the same game works over any of them.
A cart reaches nothing until its package grants it. Dialling out needs both the cart's own flag and an explicit host list from whoever packaged it; without that it fails closed.
Around 190 GL function imports. Hardware GPU rendering — no emulation layer, no ES 3.1+ features.
Stereo ring buffer, F32 or I16, configurable sample rate. Cart writes native format, host adapts.
Synchronous asset access from the .wasc archive. Efficient fd-based random access in Node.js.
Carts built with wasi-sdk -pthread spawn real background threads for CPU-bound work.
Persistent save blob managed by the host. The CLI player writes a .sav next to the cart.
Opt-in. A cart names values a harness can read by name; pull-only, never per frame. Absent by default, at zero cost.
wc_frame_yield inverts the loop for engines that insist on owning main(), so they port unmodified.