wasmcart

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.

One .wasc file running on desktop, browser, handheld, phone, terminal and RetroArch

For players

Load a cart. Play.

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.

Zero install

A .wasc file is a single self-contained artifact — game, assets and all. No dependencies, no drivers, no runtime to install first.

Safe to run

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.

Plays like a console

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.

  • gamepad
  • keyboard
  • touch

Get some carts

Each language runtime ships prebuilt example carts in its latest release — download a .wasc and you have something to play.

  • Lua breakout, cavern, shmup, platformer, ping
  • Python spaceshooter, a raycaster, a GL 3D scene
  • JavaScript three.js, space3d, adventure-ai
  • Ruby flappy-wyvern, blocks, showcase

Pick a player

Three ways to run one, depending on what you already have.

Standalone player

A single native binary for Linux, macOS and Windows. Real window, audio, gamepads.

Download wasmcart-native

RetroArch / RetroDECK

A libretro core, so carts sit in your existing frontend next to your ROMs — including on handhelds and Android.

Get the libretro core

One command, no download

With 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

Write it once. It runs everywhere.

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.

No artificial constraints

Full OpenGL ES 3.0, float audio, up to 4GB of assets. Port a 3D shooter or write a 2KB snake game — same ABI.

Bring your own language

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.

One build, every platform

No cross-compilation. A single .wasc file runs on desktop, the web, retro handhelds, phones, RetroArch, and terminal renderers.

Real GPU access

GLES3/WebGL2 draw calls go straight to the hardware GPU. No emulation, no translation layer. WASM overhead only applies to game logic.

Ship the artifact you debugged

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.

Opt-in complexity

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.

Language support

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?

No toolchain required

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.

Each 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>.

Compile it yourself

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.

C

reference

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.

Get started with C

C++

reference

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++

Rust

wasmcart-rust

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.

Get started with Rust

Zig

wasmcart-zig

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.

Get started with Zig

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.

What actually decides it

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.

Getting Started

1. Install

npm install wasmcart

Requires Node.js 22 or newer.

2. Write a cart

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
}

3. Compile to WASM

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

4. Pack into a cartridge

npx wasmcart pack --wasm cart.wasm --assets ./assets/ \
  --name "My Game" -o game.wasc

5. Run it

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

wasmcart features

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.

Gamepad Input

Up to 4 players. Always available — keyboard maps to gamepad buttons, touch shows virtual d-pad.

Pointer Input

Opt-in unified mouse + touch. 10 pointer slots for multitouch. Shared memory state + event callbacks.

Keyboard Input

Opt-in raw key state via 256-bit bitmask (USB HID scancodes). Event callbacks for key up/down.

Peer connections

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.

Sandboxed by default

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.

OpenGL ES 3.0

Around 190 GL function imports. Hardware GPU rendering — no emulation layer, no ES 3.1+ features.

Float Audio

Stereo ring buffer, F32 or I16, configurable sample rate. Cart writes native format, host adapts.

Asset Loading

Synchronous asset access from the .wasc archive. Efficient fd-based random access in Node.js.

WASI Threads

Carts built with wasi-sdk -pthread spawn real background threads for CPU-bound work.

Save Data

Persistent save blob managed by the host. The CLI player writes a .sav next to the cart.

Debug State

Opt-in. A cart names values a harness can read by name; pull-only, never per frame. Absent by default, at zero cost.

Frame Yield

wc_frame_yield inverts the loop for engines that insist on owning main(), so they port unmodified.