Skip to main content

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. onRender only 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​

PropTypeDefaultWhat it does
onRender(draw: Draw2D, ctx: Canvas2DContext) => voidrequiredCalled every displayed frame, after the steps. Draw the game here.
onStep(dt: number, ctx: Canvas2DContext) => voidnoneFixed-step simulation. dt is the step length (1/60 s by default). Step tweens, particles, cameras and bodies here.
onPointer(event: PointerEvent, ctx: Canvas2DContext) => voidnoneTaps, drags, swipes and long-presses, in stage units.
stageStageOptions{ width: 360, fit: 'width' }The logical stage. See Stage units.
backgroundColorLikenoneFills the whole canvas, letterbox bars included, before each frame.
fonts{ display?, ui? }system facesThe 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.
sheetsSheet[]noneSprite sheets the game draws. Each loads in the background.
pixelArtbooleanfalsePixel-art mode every frame: nearest-neighbour sprites and tiles, no soft edges on shapes, the camera and sprites snapped to whole device pixels.
pointerPartial<PointerThresholds>DEFAULT_POINTERTap, long-press and swipe thresholds.
pausedbooleanfalseStops the simulation. Rendering continues.
stepnumber1/60Seconds per simulation step. A change applies from the next frame without restarting the loop.
onReady(ctx: Canvas2DContext) => voidnoneCalled once, when the canvas knows its size.
onStats(stats: Canvas2DStats, ctx: Canvas2DContext) => voidnoneAbout four times a second, for dev telemetry.
testIDstring'game-canvas-2d'Test id of the canvas view.
styleStyleProp<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.

FieldWhat
viewportThe current Viewport (below).
timeSimulation time in seconds (the sum of steps).
frameFrames drawn so far.
measure(text, size, weight)Text width in stage units with the canvas's real fonts.
statsThe live Canvas2DStats.
pixelRatioDevice pixels per point, for pixelZoom.

Canvas2DStats​

FieldWhat
fpsDisplayed frames per second, smoothed.
stepsSimulation steps since start.
frameMsJS time per frame spent in steps and render, in ms, smoothed.
opsDraw ops in the last frame.
touchesFingers 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 is width units wide (360) and as tall as the screen's aspect makes it. Nothing is letterboxed. Read ctx.viewport.height to lay things out vertically.
  • fit: 'contain': a fixed width by height stage (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:

FieldDefaultWhat
width360 (DEFAULT_STAGE_WIDTH)Logical width in stage units.
height640 (DEFAULT_STAGE_HEIGHT)Logical height. Used only by contain.
fit'width''width' or 'contain'.

Viewport fields:

FieldWhat
width, heightStage size in stage units.
scalePoints per stage unit.
offsetX, offsetYWhere stage (0, 0) sits on the canvas, in points.
pixelWidth, pixelHeightCanvas size in points.
fitThe fit in use.
safeThe 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.
visibleAll 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.

typeExtra fieldsWhen
downA finger touches.
movedx, dyA finger moves.
upA finger lifts.
cancelThe system took the touches away (an incoming call).
tapLifted without leaving slop, before longPressMs.
longPressHeld still for longPressMs. Fires while the finger is still down.
dragStartMoved past slop.
dragdx, dy, vx, vyEvery move while dragging. Velocity is in units per second.
dragEndvx, vyThe dragging finger lifts. Velocity is 0 if the finger stopped before lifting.
swipedirection, distance, speedA 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):

FieldDefaultWhat
slop8Movement in stage units below which a finger is still a tap.
longPressMs400Held this long without moving is a long-press.
tapMaxMs400Taps longer than this are dropped.
swipeMinDistance24Minimum travel for a swipe, stage units.
swipeMinVelocity300Minimum speed for a swipe, stage units per second.
swipeEarlyfalseFire 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:

OptionDefaultWhat
step1/60Seconds per step.
maxCatchUp4Most 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​

CallDraws
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):

FieldDefaultWhat
colorwhiteTile colour.
radius18% of the shorter sideCorner radius.
shadownonetrue for a flat shadow in a darker colour under the tile, or { dy, color }.
highlightfalseA lighter band on the top part, for a glossy look.
scale1Scale around the centre (pop and press effects).
glyphnoneA short label in the middle (a number, a letter).
glyphColor'#FFFFFF'Glyph colour.
glyphSize0.5Glyph size as a fraction of the tile's height.
glyphWeight'display'Glyph font weight.

LabelStyle:

FieldDefaultWhat
size18Font 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'.
alpha1Opacity.

Composition​

CallWhat
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:

FieldDefaultWhat
from, torequiredStart and end values.
durationrequiredSeconds. 0 jumps to to on the next step.
delay0Seconds before it starts.
easing'quadOut'An easing name or your own (t) => number.
target, keynoneWrites the value into target[key] every step.
onUpdate(value)noneCalled with the value every step.
onDone()noneCalled 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:

FieldDefaultWhat
x, yrequiredWhere the burst starts.
count16Particles to emit.
angle-90Direction in degrees (0 right, 90 down, -90 up).
spread360Spread around angle in degrees. 360 is every way.
speed[80, 220]Speed range, units per second.
gravity420Downward acceleration, units per second squared.
drag1.2Velocity lost per second, as a fraction.
life[0.35, 0.7]Life range, seconds.
size[5, 0]Radius at birth and at death.
colorwhiteBirth colour, or a list to pick one per particle.
colorEndthe birth colour, transparentColour 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:

FieldDefaultWhat
alpha1Opacity.
rotate0Degrees, around the rect's centre.
flipX, flipYfalseMirror left-right (a character facing left) or upside down.
tintnoneMultiplies the sprite's colours. White leaves it as drawn; a blue gives a frozen version.
flashnonePaints this colour over the sprite's shape (a white hit flash).
flashAmount10 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:

FieldDefaultWhat
viewWidth, viewHeightrequiredView size in stage units (the stage's width and height).
x, ythe view's centreStarting world point at the centre.
zoom1Zoom (at least 0.05).
boundsnullA world Rect the view stays inside.
seed7Seed 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):

FieldDefaultWhat
runSpeed220Top run speed, units per second.
accel1800Ground acceleration.
decel2400Ground braking.
airAccel1300Steering in the air.
gravity1900Units per second squared.
maxFall900Top fall speed.
jumpHeight110Jump apex height in units. Take-off speed follows from gravity.
jumpCut0.45Share of upward speed kept when jump is released while rising (short hops).
coyoteTime0.1Seconds after leaving a ledge that a jump still works.
jumpBuffer0.12Seconds a jump press is remembered before landing.
wallSlideSpeed110Slide speed down a wall you push into. 0 turns it off.
wallJumpPush260Sideways 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 }):

FunctionReturns
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 });
OptionDefaultWhat
voices3 (1 to 6)Players per sound, so overlapping plays do not cut each other off.
volume1Master 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. onRender runs at the display rate, onStep at a fixed rate; movement in onRender runs faster on 120 Hz phones.
  • Props are read through refs. Changing onStep, onRender, paused or step takes effect next frame; the loop keeps running (simulation time and step count carry on).
  • Sheets draw nothing until loaded. Always check sheet.ready and draw a shape otherwise.
  • createSheet belongs at module level, not inside a render, so the sheet keeps its id and its loaded image.
  • Text uses the system faces until fonts load. Measure with ctx.measure or draw.textWidth each frame instead of caching widths from the first frame.
  • No emoji in canvas text. Draw icons with poly and disc, use sprites, or put an Icon in the React HUD (HUD and UI).
  • The canvas has no accessibility elements. Automated tests cannot find what you draw; give React controls a testID and, in dev builds, print the pixel position of things a test must tap with viewport.toPixels.
  • Rising edges from CharacterControls. platformerInput reads the jump press once and clears input.pressed[jumpButton], like consumeInput from 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) and sky-runner (tile map, platformer controller, one-way ledges, spikes, coins as triggers, camera follow, parallax, pixel-art sheets, an autopilot built on raycast2D).
  • 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.