# The 2D layer

> GameCanvas2D and the pieces a flat game uses, from stage units, pointer events and the fixed-step loop to drawing, tweens, particles, sprite sheets, tile maps, platformer and top-down bodies, and a 2D camera.

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](https://docs.moonspell.app/engine/canvas-and-rendering.md)) 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](https://docs.moonspell.app/modules/puzzle.md) 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.

```tsx
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](https://docs.moonspell.app/start/quickstart.md). 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](https://docs.moonspell.app/examples.md).

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](https://docs.moonspell.app/engine/hud-and-ui.md).

## `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](https://docs.moonspell.app/engine/2d.md#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 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.

```tsx
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](https://docs.moonspell.app/engine/input.md); 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`.

```tsx
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.

```tsx
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](https://docs.moonspell.app/engine/gpu-effects.md).

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

```tsx
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:

```tsx
// 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)`.

```tsx
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.

```tsx
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.

```tsx
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`.

```tsx
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.

```tsx
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](https://docs.moonspell.app/engine/physics.md).

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

```tsx
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`:

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

```tsx
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](https://docs.moonspell.app/engine/saves.md)). `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:

```tsx
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](https://docs.moonspell.app/engine/audio.md).

## 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](https://docs.moonspell.app/engine/hud-and-ui.md)).
- **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](https://docs.moonspell.app/engine/input.md), 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](https://docs.moonspell.app/examples.md): `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](https://docs.moonspell.app/modules/puzzle.md): block, merge, match and sort games on this layer.
- [Input](https://docs.moonspell.app/engine/input.md): `CharacterControls`, `Joystick`, keyboard on web.
- [Game feel](https://docs.moonspell.app/engine/game-feel.md), [Audio](https://docs.moonspell.app/engine/audio.md), [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).
- [Net](https://docs.moonspell.app/engine/net.md) for the leaderboard Tap Pop uses, and [Saves](https://docs.moonspell.app/engine/saves.md) for coins, unlocks and settings.
