The 2D layer
The 2D layer (src/engine/2d/, exported from @/engine) is a full-screen Skia canvas for flat games: puzzles, one-tap arcade, cards, word games, idle screens, platformers and top-down games. Use it instead of the 3D EngineCanvas (Canvas and rendering) whenever the game has no 3D scene: it loads no three.js and no WebGPU scene, so a 2D game never pays for one.
A 2D game is plain game state plus two functions: onStep(dt) moves things at a fixed 60 Hz, and onRender(draw) draws the frame. The canvas maps every finger into stage units, runs the loop on requestAnimationFrame, and replays your draw calls into one Skia picture on the UI thread. React never re-renders per frame. The puzzle module is built on this layer.
Smallest working example
Put this in src/game/index.tsx. Tap anywhere: the ball jumps to your finger, pops with a tween, throws sparks and ticks the haptics.
import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import {
GameCanvas2D,
TopBar,
createParticles2D,
createTweens,
haptics,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
import { nunitoBlack } from '@/engine/ui/fonts/nunitoBlack';
import { nunitoBold } from '@/engine/ui/fonts/nunitoBold';
const FONTS = { display: nunitoBlack, ui: nunitoBold };
const Game = ({ onExit }: GameScreenProps) => {
const tweens = useMemo(() => createTweens(), []);
const sparks = useMemo(() => createParticles2D(300), []);
const ball = useRef({ x: 180, y: 300, scale: 1 }).current;
return (
<View style={styles.root}>
<GameCanvas2D
background="#14102A"
fonts={FONTS}
onStep={(dt) => {
tweens.step(dt);
sparks.step(dt);
}}
onPointer={(e) => {
if (e.type !== 'tap') return;
ball.x = e.x;
ball.y = e.y;
tweens.add({ from: 1.4, to: 1, duration: 0.25, easing: 'backOut', target: ball, key: 'scale' });
sparks.burst({ x: e.x, y: e.y, count: 20, color: ['#FFD166', '#FFFFFF'] });
haptics.tap();
}}
onRender={(draw, ctx) => {
draw.label('Tap anywhere', ctx.viewport.width / 2, ctx.viewport.safe.y + 120, { size: 28, weight: 'display' });
draw.disc(ball.x, ball.y, 30 * ball.scale, { color: '#FF7AB6' });
draw.particles(sparks);
}}
/>
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<TopBar title="Tap Demo" status="Tap anywhere" onExit={onExit} />
</View>
</View>
);
};
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#14102A' } });
const game: GameDefinition = { id: 'tap-demo', title: 'Tap Demo', description: 'Tap to move the ball.', Component: Game };
export default game;
To run it, follow the quickstart. For a complete game on the layer (a 30 second round, score, best score on a leaderboard, an autopilot, a perf scene), read examples/tap-pop/index.tsx; see Examples.
The pattern to keep:
- Game state lives in refs or plain objects, never in React state that changes every frame.
- All movement happens in
onStep.onRenderonly draws. - The HUD (top bar, buttons, result cards) is React views over the canvas, updated only when a value changes. See HUD and UI.
GameCanvas2D props
| Prop | Type | Default | What it does |
|---|---|---|---|
onRender | (draw: Draw2D, ctx: Canvas2DContext) => void | required | Called every displayed frame, after the steps. Draw the game here. |
onStep | (dt: number, ctx: Canvas2DContext) => void | none | Fixed-step simulation. dt is the step length (1/60 s by default). Step tweens, particles, cameras and bodies here. |
onPointer | (event: PointerEvent, ctx: Canvas2DContext) => void | none | Taps, drags, swipes and long-presses, in stage units. |
stage | StageOptions | { width: 360, fit: 'width' } | The logical stage. See Stage units. |
background | ColorLike | none | Fills the whole canvas, letterbox bars included, before each frame. |
fonts | { display?, ui? } | system faces | The game's font pair from @/engine/ui/fonts (anything with a source). Text drawn with weight: 'display' or 'ui' uses them. Until they load, the system heavy and bold weights stand in. |
sheets | Sheet[] | none | Sprite sheets the game draws. Each loads in the background. |
pixelArt | boolean | false | Pixel-art mode every frame: nearest-neighbour sprites and tiles, no soft edges on shapes, the camera and sprites snapped to whole device pixels. |
pointer | Partial<PointerThresholds> | DEFAULT_POINTER | Tap, long-press and swipe thresholds. |
paused | boolean | false | Stops the simulation. Rendering continues. |
step | number | 1/60 | Seconds per simulation step. A change applies from the next frame without restarting the loop. |
onReady | (ctx: Canvas2DContext) => void | none | Called once, when the canvas knows its size. |
onStats | (stats: Canvas2DStats, ctx: Canvas2DContext) => void | none | About four times a second, for dev telemetry. |
testID | string | 'game-canvas-2d' | Test id of the canvas view. |
style | StyleProp<ViewStyle> | { flex: 1 } | Extra style for the canvas view. |
The loop stops when the canvas unmounts, so closing the game stops it. Inside the Moonspell app, a game kept off screen in the feed stops stepping and redraws twice a second; outside the app the canvas always runs.
Canvas2DContext
Every callback gets the same ctx object.
| Field | What |
|---|---|
viewport | The current Viewport (below). |
time | Simulation time in seconds (the sum of steps). |
frame | Frames drawn so far. |
measure(text, size, weight) | Text width in stage units with the canvas's real fonts. |
stats | The live Canvas2DStats. |
pixelRatio | Device pixels per point, for pixelZoom. |
Canvas2DStats
| Field | What |
|---|---|
fps | Displayed frames per second, smoothed. |
steps | Simulation steps since start. |
frameMs | JS time per frame spent in steps and render, in ms, smoothed. |
ops | Draw ops in the last frame. |
touches | Fingers down. |
Stage units
Lay everything out in stage units, never pixels. The viewport maps the stage onto the screen and maps touches back.
fit: 'width'(the default): the stage iswidthunits wide (360) and as tall as the screen's aspect makes it. Nothing is letterboxed. Readctx.viewport.heightto lay things out vertically.fit: 'contain': a fixedwidthbyheightstage (default 360 by 640), scaled to fit and centred, with bars on the sides that do not match the aspect.
import { GameCanvas2D } from '@/engine';
<GameCanvas2D stage={{ width: 360, height: 640, fit: 'contain' }} onRender={render} />
StageOptions:
| Field | Default | What |
|---|---|---|
width | 360 (DEFAULT_STAGE_WIDTH) | Logical width in stage units. |
height | 640 (DEFAULT_STAGE_HEIGHT) | Logical height. Used only by contain. |
fit | 'width' | 'width' or 'contain'. |
Viewport fields:
| Field | What |
|---|---|
width, height | Stage size in stage units. |
scale | Points per stage unit. |
offsetX, offsetY | Where stage (0, 0) sits on the canvas, in points. |
pixelWidth, pixelHeight | Canvas size in points. |
fit | The fit in use. |
safe | The part of the stage clear of the notch and the home indicator, as a Rect in stage units. Keep controls inside it. The insets come from useSafeInsets(): the device's on phones (a notched iPhone's, top 47 and bottom 34, when none were measured), env(safe-area-inset-*) or 0 in a browser. |
visible | All of the canvas in stage units (wider than the stage when letterboxed). |
toStage(px, py) | Canvas point to stage point. |
toPixels(sx, sy) | Stage point to canvas point. |
Helpers: createViewport({ stage, pixels, insets }) builds one by hand (tests, custom hosts), pointInRect(x, y, rect) tests a point, insetRect(rect, d) shrinks a rect by d on every side (grows it when negative). A Rect is { x, y, w, h } with y down.
Pointer events
One gesture tracks every finger. Every event has id (the finger), x, y, startX, startY (where the finger went down) and t (ms), all in stage units.
type | Extra fields | When |
|---|---|---|
down | A finger touches. | |
move | dx, dy | A finger moves. |
up | A finger lifts. | |
cancel | The system took the touches away (an incoming call). | |
tap | Lifted without leaving slop, before longPressMs. | |
longPress | Held still for longPressMs. Fires while the finger is still down. | |
dragStart | Moved past slop. | |
drag | dx, dy, vx, vy | Every move while dragging. Velocity is in units per second. |
dragEnd | vx, vy | The dragging finger lifts. Velocity is 0 if the finger stopped before lifting. |
swipe | direction, distance, speed | A drag of at least swipeMinDistance at swipeMinVelocity. On release, or mid-gesture with swipeEarly. direction is 'left', 'right', 'up' or 'down'. |
React on down for instant feel (pick up a piece, pop a dot), on tap for buttons and board taps, on swipe for four-way games, and on the drag events for aiming and dragging.
PointerThresholds (pass any of them in the pointer prop):
| Field | Default | What |
|---|---|---|
slop | 8 | Movement in stage units below which a finger is still a tap. |
longPressMs | 400 | Held this long without moving is a long-press. |
tapMaxMs | 400 | Taps longer than this are dropped. |
swipeMinDistance | 24 | Minimum travel for a swipe, stage units. |
swipeMinVelocity | 300 | Minimum speed for a swipe, stage units per second. |
swipeEarly | false | Fire the swipe as soon as it qualifies, before the finger lifts (snappier merge games). |
createPointerClassifier(thresholds) is the pure classifier the canvas uses (down, move, up, cancel, tick, active). Use it in tests.
For a stick and buttons over a 2D canvas, use CharacterControls from Input; the platformer and top-down controllers below read its input.
The loop
The canvas runs createLoop for you: whole steps of step seconds from an accumulator, so the game behaves the same at 60 Hz and 120 Hz. After a stall (the app was in the background) at most 4 steps run and the rest of the time is dropped, so nothing jumps through a wall. Pausing does not replay the paused time.
createLoop(options) is exported for tests and custom hosts:
| Option | Default | What |
|---|---|---|
step | 1/60 | Seconds per step. |
maxCatchUp | 4 | Most steps per tick before time is dropped. |
onStep(dt, time) | Each step. | |
onRender(alpha, time) | After the steps of a tick. alpha is the leftover fraction of a step. |
The returned Loop has tick(nowMs) (returns the steps run), start(), stop(), pause(), resume(), setStep(seconds) (a new step size from the next tick, keeping time and steps; 0 or less means 1/60), and read-only running, paused, time, steps, dropped and step.
Drawing
onRender gets a Draw2D. Every call takes stage units and appends numbers to a buffer; nothing touches Skia or React until the frame is handed over. Colours are ColorLike: a CSS string (#rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), rgba(), or transparent, black, white, red, green, blue, yellow, gray) or an ARGB number (0xAARRGGBB). An unknown string draws magenta so you notice it.
Shapes and text
| Call | Draws |
|---|---|
clear(color) | The whole canvas, letterbox bars included. |
rect(x, y, w, h, style) | A rect, rounded with radius. RectStyle: color, stroke, strokeWidth (default 2 when stroke is set), alpha, radius. Omit color for an outline only. |
tile(x, y, w, h, style) | A game tile: a rounded rect with an optional drop shadow, gloss band and centred glyph, scaled around its centre. See TileStyle below. |
disc(cx, cy, r, style) | A filled circle. color defaults to white; stroke, strokeWidth, alpha. |
ring(cx, cy, r, width, style) | A circle outline width units thick. color (default white), alpha. |
line(x1, y1, x2, y2, style) | A line. width (default 2), color (default white), alpha, round (round caps, default true). |
poly(points, style) | A polygon from a flat [x0, y0, x1, y1, ...] array. closed: false draws an open polyline. Same fill and stroke fields as rect. |
label(text, x, y, style) | Text, centred and middle-aligned by default. See LabelStyle below. |
textWidth(text, size?, weight?) | Text width in stage units (size default 18, weight default 'bold'). |
TileStyle (extends RectStyle):
| Field | Default | What |
|---|---|---|
color | white | Tile colour. |
radius | 18% of the shorter side | Corner radius. |
shadow | none | true for a flat shadow in a darker colour under the tile, or { dy, color }. |
highlight | false | A lighter band on the top part, for a glossy look. |
scale | 1 | Scale around the centre (pop and press effects). |
glyph | none | A short label in the middle (a number, a letter). |
glyphColor | '#FFFFFF' | Glyph colour. |
glyphSize | 0.5 | Glyph size as a fraction of the tile's height. |
glyphWeight | 'display' | Glyph font weight. |
LabelStyle:
| Field | Default | What |
|---|---|---|
size | 18 | Font size, stage units. |
weight | 'bold' | 'regular', 'bold', 'heavy' (system faces) or 'display', 'ui' (the game's fonts). |
color | '#FFFFFF' | Text colour. |
align | 'center' | 'left', 'center' or 'right'. |
baseline | 'middle' | 'alphabetic', 'middle', 'top' or 'bottom'. |
alpha | 1 | Opacity. |
Composition
| Call | What |
|---|---|
alpha(k, fn) | Multiplies every colour drawn inside fn by k (0 to 1). Nests. |
group({ x, y, scale, rotate, alpha }, fn) | Draws fn moved, scaled and rotated (degrees) around (x, y). |
camera(cam, fn) | Draws fn through a Camera2D (world units, shake included). |
clip(rect) | Clips drawing to a rect until the matching restore(). |
save(), restore(), translate(x, y), scale(sx, sy?), rotate(degrees) | Raw transform stack. |
particles(pool) | Every live particle of a Particles2D as a disc. |
sprite(...), sprites(...) | Sprite sheets, below. |
setPixelArt(on) | Pixel-art mode for the rest of the frame. The pixelArt prop calls it every frame. |
Draw2D also has count (ops this frame), viewport (the frame's viewport) and, for tests, ops() and snapshot(). approxMeasure is the rough text measure used before the canvas mounts.
Tweens
A tween moves a number from from to to over duration seconds with an easing. Create one set per game and step it in onStep.
import { createTweens } from '@/engine';
const tweens = createTweens();
const dot = { scale: 0, alpha: 1 };
// Grow in, then fade out and run a callback.
tweens.add({ from: 0, to: 1, duration: 0.3, easing: 'cubicOut', target: dot, key: 'scale' }).then({
from: 1,
to: 0,
duration: 0.12,
target: dot,
key: 'alpha',
onDone: () => console.log('gone'),
});
// In onStep:
tweens.step(dt);
TweenSpec:
| Field | Default | What |
|---|---|---|
from, to | required | Start and end values. |
duration | required | Seconds. 0 jumps to to on the next step. |
delay | 0 | Seconds before it starts. |
easing | 'quadOut' | An easing name or your own (t) => number. |
target, key | none | Writes the value into target[key] every step. |
onUpdate(value) | none | Called with the value every step. |
onDone() | none | Called once at the end. |
Tweens methods: add(spec) returns a TweenHandle; to(target, key, to, duration, options?) tweens from the current value; step(dt); cancel(id); cancelTarget(target) cancels every tween writing into that object; clear(); count (running or waiting). A TweenHandle has id, then(spec) (a chain that starts the same step this one ends, with the leftover time), cancel() (cancels the chain) and done.
Easings (EASINGS): linear, quadIn, quadOut, quadInOut, cubicIn, cubicOut, cubicInOut, sineInOut, backIn, backOut, elasticOut, bounceOut.
Particles
A fixed pool in typed arrays: no allocation per particle. createParticles2D(max = 400, seed = 1) returns a pool with burst(options), step(dt), clear(), forEach(fn), max and live. Draw it with draw.particles(pool). When the pool is full, new particles overwrite old ones.
import { createParticles2D } from '@/engine';
const particles = createParticles2D(600);
particles.burst({ x: dot.x, y: dot.y, count: 22, color: [dot.color, '#FFFFFF'], size: [dot.r * 0.22, 0], speed: [120, 320], gravity: 500 });
BurstOptions:
| Field | Default | What |
|---|---|---|
x, y | required | Where the burst starts. |
count | 16 | Particles to emit. |
angle | -90 | Direction in degrees (0 right, 90 down, -90 up). |
spread | 360 | Spread around angle in degrees. 360 is every way. |
speed | [80, 220] | Speed range, units per second. |
gravity | 420 | Downward acceleration, units per second squared. |
drag | 1.2 | Velocity lost per second, as a fraction. |
life | [0.35, 0.7] | Life range, seconds. |
size | [5, 0] | Radius at birth and at death. |
color | white | Birth colour, or a list to pick one per particle. |
colorEnd | the birth colour, transparent | Colour each particle fades towards. |
For GPU particles in a 3D scene, see GPU effects.
Sprite sheets
A sheet is one PNG holding many frames, as a grid (read left to right, top to bottom) or as named rects. Create it once at module level, pass it to the canvas, and draw frames by index or name.
import { createSheet, GameCanvas2D, type Draw2D } from '@/engine';
// Your PNG under assets/: a 4 by 2 grid of frames.
const TILES = createSheet(require('../../assets/sprites/tiles.png'), { cols: 4, rows: 2 });
// Named frames: createSheet(require(...), { frames: { coin: { x: 0, y: 0, w: 64, h: 64 } } })
const render = (draw: Draw2D) => {
if (TILES.ready) draw.sprite(TILES, 3, 40, 40, 48, 48);
else draw.tile(40, 40, 48, 48, { color: '#FFB454' }); // the shape until the art loads
};
// <GameCanvas2D sheets={[TILES]} onRender={render} />
A sheet that has not loaded draws nothing, so keep a vector fallback. That way art can arrive later without code changes. A 1 by 1 image counts as a placeholder: it loads, but sheet.ready stays false and sheet.placeholder is true, so the fallback keeps drawing.
Sheet fields: id, source, layout, width, height (pixels, once loaded), ready, placeholder, count (frames) and frame(f) (the source rect, or null when unknown or not loaded). SheetLayout is { cols, rows } or { frames }. gridFrameRect(cols, rows, width, height, index) and markSheetLoaded(sheet, width, height) are the pure helpers behind it (use markSheetLoaded in tests).
draw.sprite and draw.sprites
draw.sprite(sheet, frame, x, y, w, h, style) draws one frame into a rect. SpriteStyle:
| Field | Default | What |
|---|---|---|
alpha | 1 | Opacity. |
rotate | 0 | Degrees, around the rect's centre. |
flipX, flipY | false | Mirror left-right (a character facing left) or upside down. |
tint | none | Multiplies the sprite's colours. White leaves it as drawn; a blue gives a frozen version. |
flash | none | Paints this colour over the sprite's shape (a white hit flash). |
flashAmount | 1 | 0 to 1 of the flash colour. |
draw.sprites(sheet, list, style) draws many frames of one sheet in a single draw call (tiles, coins, particles with art). Each AtlasSprite is { frame, x, y, size, rotate?, tint? }, where (x, y) is the centre and size is the drawn width (height follows the frame's aspect). The batch style (BatchStyle) takes alpha, tint, flash and flashAmount.
A hit flash from Sky Runner: set w.flash = 1 on a hit, fade it in onStep, and draw:
// RUNNER is a createSheet(...) of the runner's poses; anim is a sprite animator (below).
draw.sprite(RUNNER, anim.frame, x, y, 35, 35, {
flipX: p.facing < 0,
flash: w.flash > 0 ? '#FFFFFF' : undefined,
flashAmount: w.flash,
});
Animated sprites
createSpriteAnimator(clips, initial?) plays named clips of frames. Each SpriteClip is { frames, fps, loop? } (loop defaults to true; false holds the last frame). The animator has frame (to draw now), clip, done (a non-looping clip finished), play(name, restart?) (no-op if already playing, unless restart) and update(dt).
import { createSpriteAnimator } from '@/engine';
const anim = createSpriteAnimator({ idle: { frames: [0, 1], fps: 3 }, run: { frames: [2, 3, 4, 5], fps: 12 } }, 'idle');
// In onStep:
anim.play(Math.abs(p.vx) > 20 ? 'run' : 'idle');
anim.update(dt);
// In onRender:
draw.sprite(RUNNER, anim.frame, p.x, p.y, 32, 32);
Pixel art
<GameCanvas2D pixelArt> keeps pixel art crisp: nearest-neighbour sampling, no antialiasing on shapes (no seams between tiles), and the camera offset and sprite rects snapped to whole device pixels so nothing shimmers while scrolling. For every art pixel to be the same size on screen, also give the camera a whole-pixel zoom with pixelZoom(viewport, zoom, artPixel = 1, pixelRatio = 1). artPixel is world units per art pixel: 1 for 32-pixel tiles drawn 32 units wide, 2 for 16-pixel tiles drawn 32 units wide.
import { GameCanvas2D, pixelZoom } from '@/engine';
<GameCanvas2D pixelArt sheets={SHEETS} onReady={(ctx) => (camera.zoom = pixelZoom(ctx.viewport, 1, 1, ctx.pixelRatio))} onStep={onStep} onRender={onRender} />
Camera
createCamera2D(options) gives a camera in stage units: the world point at the centre of the view, a zoom, smooth follow, screen shake and optional bounds the view never leaves. Step it in onStep and draw world content inside draw.camera(cam, fn). Draw the HUD-like parts outside it.
import { cameraView, createCamera2D, drawTileMap } from '@/engine';
const camera = createCamera2D({ viewWidth: 360, viewHeight: 640, bounds: { x: 0, y: 0, w: MAP.width, h: MAP.origin.y + MAP.height } });
// In onStep:
camera.follow(p.x + p.w / 2 + 40, p.y + p.h / 2 - 60, 0.12);
camera.step(dt);
// In onRender:
draw.camera(camera, () => drawTileMap(draw, MAP, { view: cameraView(camera) }));
Camera2DOptions:
| Field | Default | What |
|---|---|---|
viewWidth, viewHeight | required | View size in stage units (the stage's width and height). |
x, y | the view's centre | Starting world point at the centre. |
zoom | 1 | Zoom (at least 0.05). |
bounds | null | A world Rect the view stays inside. |
seed | 7 | Seed for the shake. |
Camera2D methods: follow(tx, ty, lerp = 0.12) (eases towards the point; lerp is the fraction of the gap closed per 1/60 s, so it is frame-rate independent), lookAt(tx, ty) (jumps there and stops following), shake(amplitude, duration), step(dt), transform() and toWorld(sx, sy) (a stage point, such as a touch, to a world point). Fields x, y, zoom, viewWidth, viewHeight and bounds are writable; shakeX and shakeY are read-only.
cameraView(cam) returns the part of the world the camera sees, for culling. drawParallax(draw, cam, factor, fn, { repeatWidth }) draws a background layer that scrolls at factor of the camera (0 fixed to the screen, 0.3 far hills, 1 the world). With repeatWidth, fn(offsetX) is called at every offset needed to cover the view, for a strip that repeats without gaps. Call drawParallax outside draw.camera.
import { drawParallax } from '@/engine';
drawParallax(draw, camera, 0.2, (ox) => {
for (let i = 0; i < 4; i++) draw.disc(ox + 60 + i * 110, 360, 90 + (i % 2) * 30, { color: '#2A2160' });
}, { repeatWidth: 440 });
Tile maps
A level is rows of characters, one tile per character, in stage units with y down.
import { createTileMap } from '@/engine';
const MAP = createTileMap(
[
'..........o.o........',
'.........=====.....G.',
'..S.................#',
'#######...####^^#####',
],
{ tileSize: 32, legend: { '#': { kind: 'solid', color: '#3B3F6B' }, o: { kind: 'empty' }, S: { kind: 'empty' }, G: { kind: 'empty' } } },
);
const WORLD = { tiles: MAP };
const start = MAP.find('S')[0]; // { col, row }
The default legend (DEFAULT_TILES): . and space are empty, # is solid, = is oneway (you jump up through it and land on it), ^ is hazard (spikes, lava). Your legend is merged over it. A TileDef is { kind, frame?, color? }: frame is the cell in a tileset sheet, color a flat colour drawn when there is no sheet. Characters not in the legend are empty, which makes them good markers (start, goal, coins) that you read with find.
TileMapOptions: tileSize (default 32), origin (top-left on the stage, default { x: 0, y: 0 }), legend.
TileMap has cols, rows, tileSize, origin, width, height, and the methods tile(col, row), kindAt(col, row), cellAt(x, y), rectOf(col, row), overlapping(rect) (every non-empty cell in a rect), set(col, row, ch) (a block breaks, a door opens) and find(ch). Cells outside the map are empty.
drawTileMap(draw, map, { sheet, view, alpha, tint, flash, flashAmount }) draws the tiles inside view (default the whole map). With a loaded sheet, tiles with a frame go out in one atlas draw; otherwise flat colours are merged into runs along each row. Call it inside draw.camera.
Bodies, platformer and top-down
These are boxes that collide with a tile map and extra rects. They are not a physics engine; for rigid bodies in 3D see Physics.
Bodies
createBody2D(x, y, w, h) returns a Body2D: top-left x, y, size w, h, velocity vx, vy, and the contact flags onGround, onWall (-1 left, 1 right, 0 none), onCeiling and hurt (touched a hazard tile this step).
moveBody(body, dx, dy, world) moves a body x first, then y, so it slides along walls and lands on floors. Big moves are split so a fast body cannot pass through a thin wall. One-way tiles and rects only stop a body falling onto them from above. It sets the contact flags and zeroes the velocity into what it hit.
A World2D is { tiles?, solids?, oneWays? }: a tile map, extra solid rects (crates, moving platforms) and extra one-way rects.
raycast2D(world, x1, y1, x2, y2, oneWays = false) returns the first solid a segment hits as { x, y, t, nx, ny } (point, fraction along the segment, face normal), or null. Use it for line of sight, lasers, and enemies that turn at walls and ledges.
Triggers2D handles coins, keys, exits and checkpoints: add(rect, { data, once, onEnter, onExit }), then update([{ id, body }]) each step. onEnter(id, data) fires once per body entering; a once trigger is removed after its first enter. size is the number of triggers left.
import { Triggers2D } from '@/engine';
const triggers = new Triggers2D<number>();
coins.forEach((c, i) => triggers.add({ x: c.x - 10, y: c.y - 10, w: 20, h: 20 }, { data: i, once: true, onEnter: () => (coins[i].taken = true) }));
// In onStep:
triggers.update([{ id: 'player', body: player }]);
Platformer controller
createPlatformer(x, y, w = 24, h = 32) returns a PlatformerState (a Body2D plus facing, and jumped and landed, true on the step they happen). stepPlatformer(state, input, dt, world, params = DEFAULT_PLATFORMER) runs it: acceleration and braking, air control, coyote time, a jump buffer, short hops when jump is released early, wall slide and wall jump.
The input is { x, jump, jumpPressed } (PlatformerInput). platformerInput(input, jumpButton = 'jump') builds it from the CharacterInput that CharacterControls writes, and clears the jump press it read. This is the core of examples/sky-runner/index.tsx:
import React, { useMemo } from 'react';
import { View } from 'react-native';
import {
CharacterControls,
GameCanvas2D,
createCharacterInput,
createPlatformer,
createTileMap,
drawTileMap,
platformerInput,
stepPlatformer,
type Draw2D,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
const MAP = createTileMap(['..........', '..........', '####..####'], { tileSize: 32, origin: { x: 0, y: 400 } });
const WORLD = { tiles: MAP };
const Runner = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const player = useMemo(() => createPlatformer(40, 300, 22, 30), []);
const onStep = (dt: number) => {
const cmd = platformerInput(input);
for (const k of Object.keys(input.pressed)) delete input.pressed[k]; // rising edges are read once
stepPlatformer(player, cmd, dt, WORLD);
if (player.hurt || player.y > 800) Object.assign(player, createPlatformer(40, 300, 22, 30));
};
const onRender = (draw: Draw2D) => {
drawTileMap(draw, MAP);
draw.rect(player.x, player.y, player.w, player.h, { color: '#7C5CFF', radius: 6 });
};
return (
<View style={{ flex: 1 }}>
<GameCanvas2D background="#1B1440" onStep={onStep} onRender={onRender} />
<CharacterControls input={input} look={false} />
</View>
);
};
const game: GameDefinition = { id: 'runner', title: 'Runner', description: 'Run and jump.', Component: Runner };
export default game;
DEFAULT_PLATFORMER (PlatformerParams; pass your own for floatier or snappier games):
| Field | Default | What |
|---|---|---|
runSpeed | 220 | Top run speed, units per second. |
accel | 1800 | Ground acceleration. |
decel | 2400 | Ground braking. |
airAccel | 1300 | Steering in the air. |
gravity | 1900 | Units per second squared. |
maxFall | 900 | Top fall speed. |
jumpHeight | 110 | Jump apex height in units. Take-off speed follows from gravity. |
jumpCut | 0.45 | Share of upward speed kept when jump is released while rising (short hops). |
coyoteTime | 0.1 | Seconds after leaving a ledge that a jump still works. |
jumpBuffer | 0.12 | Seconds a jump press is remembered before landing. |
wallSlideSpeed | 110 | Slide speed down a wall you push into. 0 turns it off. |
wallJumpPush | 260 | Sideways push of a wall jump. 0 turns wall jumps off. |
With the defaults, keep gaps a player must jump at most 4 tiles wide.
Top-down controller
createTopDown(x, y, w = 24, h = 24) returns a TopDownState (a Body2D plus facing, an angle in radians with 0 right and y down). stepTopDown(state, stick, dt, world, params = DEFAULT_TOP_DOWN) moves it 8 ways with acceleration and friction; stick is { x, y } with y down, clamped to the unit disc. topDownInput(input) builds the stick from CharacterControls input (it flips the stick's up to the stage's down).
DEFAULT_TOP_DOWN: speed 160, accel 1400, friction 1800.
Grid
createGrid({ rows, cols, rect, gap = 0, cellSize? }) lays square cells inside a rect, centred, with gap between them: the math every board game shares.
import { createGrid, insetRect } from '@/engine';
const grid = createGrid({ rows: 8, cols: 8, rect: insetRect(ctx.viewport.safe, 16), gap: 4 });
const cell = grid.cellAt(e.x, e.y); // null outside the grid or in a gap
A Grid has rows, cols, gap, cell (side of one cell), bounds, and cellAt(x, y), nearestCell(x, y) (clamped, for snapping drags), rectOf(cell), centerOf(cell), inBounds(cell), neighbors(cell, { diagonal }), cells() (row by row), index(cell) and cellOf(index). A Cell is { row, col }.
Shape tests
Pure functions for arcade games, on Rect ({ x, y, w, h }) and Circle ({ x, y, r }):
| Function | Returns |
|---|---|
aabbOverlaps(a, b) | Whether two rects overlap. |
aabbContains(outer, inner) | Whether outer fully contains a rect or a point. |
circleOverlaps(a, b) | Whether two circles overlap. |
circleRectOverlaps(c, r) | Whether a circle and a rect overlap. |
resolveAabb(moving, fixed) | { dx, dy } to push moving out along the axis of least penetration; zeros when apart. |
segmentHitsRect(x1, y1, x2, y2, r) | { t, x, y } where the segment enters the rect, or null. A start inside hits at t = 0. |
Colours
parseColor(string) returns ARGB (cached). toArgb(colorLike) accepts a string or a number. argb(a, r, g, b) builds one. withAlpha(argb, k), mixColor(a, b, t) and shade(argb, k) (lighter for k above 0, darker below) work on ARGB numbers, so convert strings with toArgb first.
Haptics and sounds
haptics has tap() (a piece snaps in), select() (a selection change), success() (a line cleared), warning() (an illegal move), error() (game over) and heavy() (a big hit). Calls are rate-limited (35 ms apart), do nothing where haptics do not exist, and follow the player's haptics setting (setSettings({ haptics: false }), see Saves). haptics.enabled = false mutes them in code.
useOneShots(sources, options) loads short sound effects by name and releases them on unmount, so nothing keeps sounding after the game exits:
import { useOneShots } from '@/engine';
const SFX = { pop: require('../../assets/sfx/pop.wav') };
// Inside the component:
const sfx = useOneShots(SFX);
sfx.play('pop', { volume: 0.8, rate: 1.1 });
| Option | Default | What |
|---|---|---|
voices | 3 (1 to 6) | Players per sound, so overlapping plays do not cut each other off. |
volume | 1 | Master volume 0 to 1. |
play(name, { volume, rate }) (rate clamped to 0.5 to 2). muted, counts (plays per sound, for telemetry) and dispose() are on the returned object. Sounds mix with other audio and follow the sound and volume settings. createOneShots is the same thing outside a component. For music, buses and positional sound, see Audio.
Gotchas
- Never allocate per frame in
onRender. Keep state in refs or plain objects and update React state only on change (Tap Pop emits a snapshot every sixth frame and skips equal ones). - Move things only in
onStep.onRenderruns at the display rate,onStepat a fixed rate; movement inonRenderruns faster on 120 Hz phones. - Props are read through refs. Changing
onStep,onRender,pausedorsteptakes effect next frame; the loop keeps running (simulation time and step count carry on). - Sheets draw nothing until loaded. Always check
sheet.readyand draw a shape otherwise. createSheetbelongs at module level, not inside a render, so the sheet keeps its id and its loaded image.- Text uses the system faces until
fontsload. Measure withctx.measureordraw.textWidtheach frame instead of caching widths from the first frame. - No emoji in canvas text. Draw icons with
polyanddisc, use sprites, or put anIconin the React HUD (HUD and UI). - The canvas has no accessibility elements. Automated tests cannot find what you draw; give React controls a
testIDand, in dev builds, print the pixel position of things a test must tap withviewport.toPixels. - Rising edges from
CharacterControls.platformerInputreads the jump press once and clearsinput.pressed[jumpButton], likeconsumeInputfrom Input, so call it once per step. It leaves other buttons' presses alone: read and clear those yourself (as above). - Performance budget. The layer is measured at 64 tiles plus a 200-particle burst well under 8 ms of JS per frame (Tap Pop's dev perf button shows it).
See also
- Examples:
tap-pop(canvas, taps, loop, tweens, particles, haptics, a one-shot sound, a best score) andsky-runner(tile map, platformer controller, one-way ledges, spikes, coins as triggers, camera follow, parallax, pixel-art sheets, an autopilot built onraycast2D). - Puzzle module: block, merge, match and sort games on this layer.
- Input:
CharacterControls,Joystick, keyboard on web. - Game feel, Audio, HUD and UI.
- Net for the leaderboard Tap Pop uses, and Saves for coins, unlocks and settings.