DOCUMENTATION / VERSION 0.2.0
Build the interface.
Keep your engine.
A practical guide from the first component to a game-driven rendering loop.
Getting started
Ludic Canvas is an ESM TypeScript library for browser game interfaces. It uses React 19.3.1, a custom reconciler, Yoga 3.2.1 compiled with raw Clang/LLVM, and WebGL 1. Consumers use the included compiled assets; CMake and LLVM are only needed to rebuild the native module.
Install the release package
npm install ludic-canvas@^0.2.0 react@19.3.1Releases include JavaScript, TypeScript declarations, the layout module and a default font atlas. Starting with 0.2.0, the package also includes the portable font CLI. The 0.1.0 GitHub tarball predates npm publication and the CLI. Use a bundler such as Vite with its automatic React JSX transform.
Give the canvas a size
<div style="position:relative;width:100%;height:480px">
<canvas id="ui" style="display:block;width:100%;height:100%"></canvas>
</div>The root measures CSS dimensions. The canvas parent should contain only the game view and its UI overlay; an accessibility mirror is positioned relative to it. Avoid CSS transforms that scale or rotate the canvas. If you manage a different layout, supply your own aligned accessibilityContainer.
const canvas = document.querySelector<HTMLCanvasElement>('#ui')!;
import { createUI, View, Text } from 'ludic-canvas';
const ui = await createUI({ canvas });
ui.render(
<View style={{ padding: 24, gap: 12 }}>
<Text style={{ fontSize: 28 }}>Ready to play</Text>
<View onPress={() => startGame()} label="Start game"
style={{ padding: 16, background: '#526dff', borderRadius: 8 }}>
<Text>Start game</Text>
</View>
</View>,
);
// When leaving the scene:
ui.destroy();Architecture
The React reconciler owns a retained host tree. Each host node owns a Yoga layout node. After layout, boxes are read in one batch from WebAssembly memory. The painter traverses the tree, animates visual values and sends primitive quads to a WebGL batch. Pointer input hit-tests the most recently painted frame. A separate DOM mirror provides keyboard controls.
The package owns UI infrastructure. Applications own domain models, state, persistence, audio, scene effects and asset catalogs. Feed immutable view data into components and pass actions back to the simulation. The library has no game-state schema or ECS dependency.
The native layout ABI is versioned and checked at load. The custom React host config is kept internal and paired with an exact supported React version because react-reconciler has an unstable API.
Compose primitives
View lays out and paints panels, gradients, borders, glows and fill meters. Text measures and draws MSDF glyphs. Image loads raster images or rasterizes SVGs with cover/contain/fill fitting. Line draws solid or marching dashed connections.
<View style={{ flexDirection: 'row', gap: 16, padding: 20 }}>
<Image src="portrait" style={{ width: 72, height: 72, borderRadius: 12 }} />
<View style={{ flex: 1, gap: 8 }}>
<Text style={{ fontSize: 24 }}>Pilot</Text>
<View fill={0.7} fillColor="#72dfbb" transition={200}
style={{ height: 8, background: '#243247', borderRadius: 4 }} />
</View>
</View>Use ordinary React components and hooks to compose buttons, panels and screens. Host refs expose internal nodes and are not part of the supported public API. Plain strings belong inside Text. Images are referenced through their src property.
Layout & styling
Layout follows Yoga flexbox, rather than browser CSS. Dimensions accept numbers in CSS pixels, percentage strings and 'auto'. Supported properties include flex direction, alignment, wrapping, grow/shrink, absolute positioning, min/max size, margin, padding and gap. The default direction is a column. See LayoutStyle for the complete supported surface.
Colors currently accept hexadecimal strings (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba(). Named colors and CSS variables are not supported. Text style is set on each Text node and does not inherit from View. There is no browser cascade.
Use overflow: 'hidden' for rectangular scissor clipping and overflow: 'scroll' for vertical wheel/touch scrolling. Border radius rounds a node's own background and images; child clipping remains rectangular. Visual transforms and scissor clips have limited nesting support, so use layout for interactive positioning. zIndex controls sibling paint order.
onLayout is queued after layout and reports the local box in CSS pixels. Avoid feedback loops that continuously resize the same node. transition and enter durations are milliseconds; line animation speed uses pixels per second.
Images & fonts
const ui = await createUI({
canvas,
assets: {
portrait: { url: '/images/pilot.webp' },
emblem: { url: '/images/emblem.svg', kind: 'vector', width: 128, height: 128 },
},
fonts: [
{ name: 'interface', atlas: '/fonts/interface.png', metrics: '/fonts/interface.json' },
],
});
await ui.preloadImages(['portrait', 'emblem']);Omit assets to use image URLs directly. Alternatively pass a resolver function that maps an identifier to image metadata. Unknown catalog identifiers render no image; explicit preloading rejects unknown identifiers. Failed requests call onError and cause preloads to reject. Failed cache entries stay failed until you recreate the root. Assets must be immutable for a root's lifetime. Fetches and decoded uploads are root-owned and disposed when it is destroyed.
Register one to four MSDF/MTSDF font faces; the first face is the default. Choose a face with fontFamily. Each atlas needs a fallback question-mark glyph. Atlas JSON must use top-origin coordinates and the msdf-atlas-gen metrics schema. Glyphs absent from the atlas fall back to ?. The included face covers Latin-1 and selected punctuation. There is no kerning, bidi, ligature shaping or emoji fallback in this release.
Generate custom atlases with the packaged font CLI. It uses the bundled WASI compiler and works without CMake, FreeType installation, native executables or network access. Font licenses remain with their fonts. Native tools are needed only to rebuild the compiler itself.
The texture budget is a soft LRU budget. Recently visible textures remain resident even if they exceed it. SVGs are quantized and rasterized at the requested pixel size. Cross-origin image and font URLs require CORS headers.
Font atlas CLI
The installed package exposes ludic-canvas fonts. Node 24+ runs a bundled, offline WebAssembly compiler containing FreeType, Brotli and msdf-atlas-gen. TTF, OpenType CFF/OTF, WOFF and WOFF2 outline fonts are supported; collections and bitmap-only fonts are outside this release.
1. Create a configuration
npx ludic-canvas fonts --init
Edit ludic-canvas.fonts.json and set your font paths. Every path is relative to this configuration file.
{
"$schema": "https://libraries.ludic-canvas.victorqueiroz.dev/font-atlas.schema.json",
"output": "public/fonts",
"charset": "[0x20, 0x7E]",
"fonts": [
{
"name": "interface",
"source": "fonts/Interface.woff2",
"size": 48,
"distanceRange": 6,
"characters": "áéíóúçãõ…"
}
]
}
2. Generate and register
npx ludic-canvas fonts --config ludic-canvas.fonts.json
This creates interface.png, interface.json and font-atlas-manifest.json. The manifest stores content hashes for caching and uses relative output names. Register the generated files through the existing fonts option:
const ui = await createUI({
canvas,
fonts: [{ name: 'interface', atlas: '/fonts/interface.png', metrics: '/fonts/interface.json' }],
});
For deployments under a URL prefix, include your application's base path in those URLs. The source TTF/OTF/WOFF files are not needed by the browser.
3. Integrate with development and builds
{
"scripts": {
"fonts:build": "ludic-canvas fonts --config ludic-canvas.fonts.json",
"predev": "npm run fonts:build",
"dev": "vite",
"prebuild": "npm run fonts:build",
"build": "vite build"
}
}
Unchanged sources, configuration and compiler bytes reuse cached output. Modified or damaged PNG/JSON files are regenerated. --force always regenerates. A failure in any face leaves existing output files unchanged; successful generations replace the configured files but do not delete old fonts removed from configuration.
Configuration reference
size is atlas pixels per em (8–128, default 48), independent of runtime fontSize. distanceRange is the distance range in atlas pixels (1–32, default 6). charset uses msdf-atlas-gen expressions, such as [0x20, 0x7E], [0xA0, 0xFF]. A font-level charset overrides the configuration default. characters adds literal Unicode characters. Space and ? are always included. Missing requested characters fail with their Unicode codepoints; runtime text outside the generated set uses the fallback.
Variable fonts accept four-character axis tags: "variations": { "wght": 600 }. Unknown axes and values outside the font's axis range fail. Each face name must contain 1–64 ASCII letters, digits, hyphens or underscores and be unique ignoring case. The compiler supports at most 16,384 requested codepoints and an 8192×8192 atlas; choose the character coverage your interface needs.
The JSON schema is also exported as ludic-canvas/font-atlas.schema.json for editors. --help and --version need no compilation. Node currently emits its standard WASI experimental-feature warning during generation; a successful command still exits with status 0.
Game-loop integration
const ui = await createUI({ canvas: overlayCanvas, autoStart: false });
ui.render(<Hud snapshot={game.snapshot()} onPause={() => game.pause()} />);
function tick(now: number) {
game.update(now);
game.draw();
ui.frame(now);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);A transparent overlay canvas is the simplest integration with any browser rendering engine: give the world and UI separate canvases in the same positioned container. For shared rendering, pass a WebGL 1 context and call frame() after the game pass. Canvas dimensions remain engine-owned by default when a context is supplied.
The UI binds the default framebuffer and changes viewport, buffers, shaders, texture bindings, vertex attributes, blending and depth/cull/scissor state. It does not snapshot or restore the engine's GL state. Reset or invalidate your engine's state cache before its next draw. Shared WebGL 2 contexts and rendering to an engine framebuffer are not supported. An overlay canvas is the supported route for engines using WebGL 2, WebGPU or other renderers.
New React renders commit asynchronously. Re-render after game state changes; do not re-render solely to advance animations. Call stop() to pause automatic frames and destroy() when leaving a scene. Destroying is idempotent and allows the same canvas to be mounted again.
Input & accessibility
Use onPress, onHover, hoverStyle, pressStyle, disabled and hotkey on primitives. Give every pressable a meaningful label; descendant text supplies a fallback. Pointer input, vertical wheel scrolling and touch drags are supported. Canvas hotkeys activate only while that canvas or one of its mirrored controls has focus, so roots do not steal each other's keys.
Each painted pressable is mirrored by an invisible DOM button in paint order. Tab moves between buttons and Enter/Space activates them; focus is drawn in the canvas. Canvas focus itself enables scoped hotkeys. Set announce on Text to publish its changed content to a polite live region.
This is a limited control mirror, not complete accessibility semantics. It does not expose all static canvas text as a document, trap focus in modals, support editable text or provide gamepad navigation. Validate the complete interface with keyboard users and assistive technology. Provide a separate DOM interface where richer semantics are needed.
Reduced motion initially follows the browser preference; setReducedMotion(true) disables enter transitions, tweens, hover interpolation, glow pulses, breathing and marching-line motion. setTextScale() changes both text measurement and drawing. Dynamic system preference changes require the application to call the setter.
Build, verify & contribute
git clone https://github.com/VictorQueiroz/ludic-canvas.git
cd ludic-canvas
npm ci
npm run dev
npm run check
# Optional: rebuild Yoga with raw Clang/LLVM (no Emscripten)
npm run toolchain -- --llvm /path/to/llvm
npm run wasm
npm run fontgen:wasm
# Build the distributable package
npm packNode 24 or newer is required for repository tooling. The native build needs CMake 3.28+, LLVM with wasm32 and wasm-ld. The bootstrap downloads SHA-256-pinned WASI sysroot and compiler-rt inputs. Yoga sources are pinned by version and hash. The committed layout module lets consumers and Vercel build without the native toolchain.
The public repository uses a fresh history and contains the library, generic examples, font notices and reproducible build sources. The API reference is generated from emitted TypeScript declarations during every documentation build. Run npm run check before a release. The Vercel project builds npm run docs:build and serves site/ from the repository root.
Compatibility: React 19.3.1 is the tested peer version; react-reconciler 0.34.0 is pinned. Browser requirements include WebAssembly SIMD/bulk-memory, WebGL 1, createImageBitmap and Pointer Events. Browser integration tests cover the development browser; native engine bindings and wider device certification are outside this release.