Wasmcart – Virtual Cartridge Format for Safe, Portable Games
Hacker News•July 24, 2026•10 min read•4 views
A virtual cartridge format for safe, portable games. A wasmcart cart is a standalone WebAssembly module - a self-contained game that owns its own memory and talks to the outside world only through a tiny, well-defined contract: the host writes input + timing, calls wc_render() each frame, and reads back pixels and audio. No filesystem, no syscalls, no ambient authority. Just pixels, sound, input, and opt-in networking.
Because a cart is only WebAssembly + a fixed ABI, the same cart runs anywhere a conforming host exists - Node.js, the browser, a libretro core in RetroArch, a native player, a terminal - on any OS and any hardware with enough power. Write the game once; it runs on all of them, sandboxed.
This repository is the specification and its reference implementations .
Two reference hosts ship in this package - they define, by example, what a conforming host does. Both are pure JavaScript (MIT).
import { CartHost } from 'wasmcart' ; // Node import { CartHostWeb } from 'wasmcart/web' ; // browser Other hosts in the wasmcart org (own repos) run the same carts: a libretro core ( wasmcart-libretro ), native players ( wasmcart-native-host ), and the terminal emulator ( retroemu ). See The wasmcart org below.
npm install wasmcart Play a cart npx wasmcart game.wasc # SDL window + audio + gamepad (the default) npx wasmcart game.wasc --gl # GL cart: OpenGL window via webgl-node npx wasmcart my-cart-dir/ # dev mode: manifest.json + cart.wasm + assets, straight off disk npx wasmcart game.wasc --term # ANSI terminal player (SSH-friendly fallback) npx wasmcart game.wasc --frames 300 --shot out.png --wav out.wav # headless: step, dump, exit npx wasmcart game.wasc --seed 7 --frames 60 --shot a.png # deterministic replay run npx wasmcart pack --wasm cart.wasm -o game.wasc # packing, same front door The windowed player runs on the org's own stack — @kmamal/sdl (window, keyboard, audio queue, game controllers) and webgl-node (WebGL2-over-native GLES for --gl carts) — with audio-paced frame stepping so sound never stutters. Keys: arrows/WASD d-pad, x / z = A/B, Enter = Start, Tab = Select, Esc/ q quits; the first plugged-in controller maps automatically. No display? It falls back to the terminal player, and headless mode is scriptable: same seed → byte-identical PNG, so a shell loop is a regression test. Hosts that embed CartHost (harnesses like romdevtools) keep supplying their OWN backends via load(..., { glBackend }) — these dependencies power the CLI, they are not required by the embedding API.
Format Description .wasm Standalone WASM file, assets embedded as C arrays .wasc ZIP archive: manifest.json + cart.wasm + assets/ (recommended for games with assets) The ABI Every cart exports three functions:
The cart declares all buffers as static globals. The host reads their locations from wc_get_info() , writes input/timing before each frame, and reads pixels/audio after wc_render() returns.
See examples/hello/wasmcart.h for the complete ABI header.
Every cart declares its rendering mode via wc_info_t.gpu_api :
Rendering mode is declared once in wc_get_info() and does not change during the cart's lifetime.
Every wasmcart host has OpenGL. The recommended approach is for all carts to set gpu_api = 1 and render all output through GL - even 2D pixel-buffer carts.
For carts that render pixels to a CPU buffer (software renderers, SDL2 2D games), use the wc_gl_blit() helper to upload the pixel buffer as a GL texture and draw a fullscreen quad:
#define WC_USE_GL #include "wasmcart.h" #include "wc_gl_blit.h" // single-header GL blit library // In wc_get_info(): info . gpu_api = 1 ; // In wc_render(), after drawing to your pixel buffer: wc_gl_blit ( my_pixels , width , height ); // uploads as GL texture + draws quad This eliminates the host-side complexity of detecting 2D vs GL carts and managing two display paths. One rendering path for all carts, all hosts.
Performance: glTexImage2D is a DMA transfer - the GPU pulls pixel data without CPU waiting. At 1080p, this is significantly faster than the old CPU-side pixel copy + format conversion. 2D games that previously ran at 30fps at 1080p now run at 60fps with this approach.
SDL2 carts using the sdl2_wc backend can enable GL blit automatically:
info . gpu_api = 1 ; // in wc_get_info() SDL_WASMCART_SetGLBlit ( 1 ); // in wc_init(), after SDL_Init // Link with: sdl2_wc/sdl2_gl_blit.c SDL's software renderer draws pixels as usual. The sdl2_wc backend uploads them to GL on SDL_RenderPresent . No game code changes needed.
Still supported for simplicity. The cart writes ARGB8888 pixels to a framebuffer, the host reads and displays them. No GL imports needed.
Render via GL function imports ( "gl" WASM module) The host displays GL output directly (swapBuffers) If the host needs pixels (terminal rendering, screenshots), the host performs readback ( glReadPixels ) at whatever frequency it chooses 2D and 3D content can coexist on the same GL context Compositing (e.g., 2D HUD over 3D scene) is the cart's responsibility within its chosen GPU API. There is no hybrid mode - a cart that uses GL for 3D and wants a 2D overlay renders both through GL.
Hosts should reject carts with unsupported gpu_api values gracefully (e.g., "This host does not support WebGPU carts").
The host and cart negotiate resolution through a two-step process:
Host → Cart : Before calling wc_init() , the host writes its preferred resolution to wc_host_info_t.preferred_width and preferred_height . This is a suggestion - the host's display capability, not a requirement. A value of 0 means "no preference."
Cart → Host : During wc_init() , the cart reads the host's preference and decides its actual rendering resolution. It may use the preference directly, scale it, clamp it, or ignore it entirely. The cart writes its chosen resolution to wc_info_t.width and wc_info_t.height .
After wc_init() returns, the host reads the cart's actual width/height. These dimensions define:
Display scaling is the host's responsibility:
If no preferred resolution is specified (both 0), the host should create its window at the cart's returned dimensions - a 1:1 pixel match with no scaling.
The manifest.json inside a .wasc archive describes the cart:
{ "name" : " My Game " , "version" : " 1.0.0 " , "abi" : 3 , "entry" : " cart.wasm " , "players" : 2 , "pointer" : true , "keyboard" : true , "net" : { "websocket" : [ " api.mygame.com " ], "data-channel" : true } } All fields except name , abi , and entry are optional. pointer , keyboard , and net are ABI v3 features - gamepad input is always available regardless.
ABI v3 adds opt-in features beyond the core framebuffer/audio/gamepad loop:
All v3 exports are optional - the host silently skips events if the cart doesn't export the callbacks. Existing v2 carts work unchanged.
There is one GPU ABI: WebGL2 (OpenGL ES 3.0) . All hosts present the same ES 3.0 GL surface. This is the ceiling - no host may expose ES 3.1+ or desktop GL features.
A cart that doesn't use the GPU at all can write pixels directly to a shared-memory framebuffer (ARGB8888). This is not a second GPU ABI - it's just pixels in a buffer, no GL involved.
ES 3.0 core only. Do not use ES 3.1+ features (compute shaders, SSBO, image load/store). The browser host is WebGL2 which is ES 3.0. Native hosts cap GL_VERSION to ES 3.0.
Declare all GL functions as WASM imports at compile time. There is no eglGetProcAddress or runtime function discovery in WASM. If a function isn't in the cart's import table, it cannot be called.
Extensions are informational, not guaranteed. Hosts pass through real driver extensions via GL_EXTENSIONS (some carts like Godot need them for format detection). But extension function pointers are only available if the cart declares them as WASM imports. Calling an undeclared extension function traps.
GPU engines with getProcAddress callbacks (Skia Ganesh, ANGLE, etc.) must override glGetString(GL_EXTENSIONS) in their callback to return empty - preventing the engine from probing for extension function pointers that don't exist as WASM imports. See the porting notes in the wasmcart-sdl2 repo for the full pattern.
Same .wasc runs everywhere. If a cart works in the browser, it must work on Node.js, native, and RetroArch hosts. Staying within ES 3.0 core guarantees this.
2D framebuffer - ARGB8888 pixel buffer for software-rendered carts (no GL) WebGL2 GPU - one GL ABI everywhere. Cart imports WebGL2 functions, host provides them (native GLES3 on Node.js, WebGL2 in browser). Emscripten's GL output works directly. Stereo audio - Float32 or Int16 ring buffer, cart-declared sample rate Gamepad input - 4 pads with buttons, analog sticks, triggers (always available) Pointer input - unified mouse + touch via shared memory state + event callbacks (opt-in) Keyboard input - 256-bit key state bitmask (USB HID scancodes) + event callbacks (opt-in) WebSocket networking - event-driven WebSocket API with domain allowlist (opt-in) Data channels - peer-to-peer communication via host-managed connections (opt-in) Save data - persistent save blob (host manages storage) Asset loading - .wasc carts load files at runtime via wc_asset_size() / wc_load_asset() WASI threads - carts compiled with wasi-sdk -pthread can spawn background threads via pthreads Node.js API import { CartHost } from 'wasmcart' ; const cart = new CartHost ( ) ; await cart . load ( 'game.wasc' ) ; // Main loop const gamepads = [ ] ; // array of { buttons, axes, ... } const frame = cart . runFrame ( gamepads ) ; // frame.framebuffer - Uint8Array of ARGB pixels (for 2D carts) // frame.audio - Int16Array of stereo PCM samples // frame.saveData - Uint8Array (if cart uses save) cart . destroy ( ) ; Options await cart . load ( 'game.wasc' , { glBackend : gl , // required for GL carts (any WebGL2-compatible context) preferredWidth : 800 , // hint for resolution negotiation preferredHeight : 600 , saveData : existingSaveBuffer , // restore previous save } ) ; GL Carts GL carts import functions from the "gl" WASM module. The host must provide a WebGL2-compatible context: