# Brick Climb

Break bricks, collect coins, and keep ahead of the rising screen. Brick Climb is
a complete game showcase with its own HTML shell and artwork.

## Play the game

Play here in your browser—no download or build needed. Click **LET’S CLIMB**
inside the game, then use your keyboard.

[Play Brick Climb in a full page](/generated/games/brick-climb/) ·
[Inspect the sprite sheets](/generated/games/brick-climb/?preview)

<iframe title="Brick Climb game" src="/generated/games/brick-climb/?embed" loading="lazy" style="display:block;width:100%;height:760px;border:0;border-radius:10px" allow="fullscreen"></iframe>

Use A/D to move, W or Space to jump, S to fall faster, and Escape to pause.
Release jump early for a shorter jump. The pause menu provides Resume and
Restart. Desktop keyboard controls are required.

## What to inspect

- Animated conveyors reverse their middle and endcap clips with their direction.
- Cloud platforms move sideways: jump through from below and ride them from above.
- Trampolines launch the player through bricks; solid gray stone stops the boost.
- Brick fragments fall with physics. Every 500 coins awards an extra life.
- Death and Bifrost respawn animations coordinate with the game's state machine.
- Terrain and its physics resources are removed as the world scrolls past them.

## Start with the small examples

[Sprites and sprite sheets](/playground/#/examples/composition/sprites) isolates
four frames, one frame signal, and pause/resume playback. Read the
[Sprite guide](/guides/sprites/) before adapting the full game.

[State machines](/playground/#/examples/composition/state-machines) demonstrates
general-purpose loading and cancellation without animation. The
[machine animation guide](/guides/machine-animation/) explains state-owned playback.

## Source map

The canonical application is `games/brick-climb/`. `main.tsx` owns the HTML shell
and sheet loading; `scene.tsx` composes sprites and physics; `model.ts` generates
and prunes terrain; `machines.ts` and `player-machine.ts` coordinate game and
character states. The directory also contains the artwork, styles, physics
helpers, and regression tests needed to run the complete game. The main source
files are expanded below for reference.

## Complete source

### main.tsx

```tsx
import { pibbl, signal, drive, repeat, usePlayback, useSignal, Group, Text, Rectangle, type PibblController } from '@pibbl/core';
import { Sprite, loadSpriteSheet, defineSpriteAnimation, type SpriteSheet } from '@pibbl/core/sprites';
import { aseprite } from '@pibbl/core/sprites/formats/aseprite';
import atlasUrl from './assets/sprites.json?url';
import characterUrl from './assets/character-v2.json?url';
import coinUrl from './assets/coins-v3.json?url';
import atlasImage from './assets/sprites.png?url';
import characterImage from './assets/character-v2.png?url';
import coinImage from './assets/coins-v3.png?url';
import sceneryImage from './assets/scenery.svg?url';
import terrainImage from './assets/terrain.svg?url';
import './style.css';
import { createMachine } from '@pibbl/core/machines';
import { sessionMachine } from './machines';
import { createGame, respawnPoint } from './model';
import { setGamePaused } from './pause';
import { GameScene, type GameSession } from './scene';
document.body.classList.toggle('embedded', new URLSearchParams(location.search).has('embed'));
const app = document.getElementById('app')!;
app.innerHTML = `<header><a href="/">Pibbl / PLAY LAB</a><span>01 — SPRITES + PHYSICS</span><a href="?preview">SPRITE INSPECTOR ↗</a></header><main><aside><p class="eyebrow">A LITTLE UPWARD ADVENTURE</p><h1>BRICK<br><em>CLIMB.</em></h1><p>Break a path.<br>Find your footing.<br>Keep going up.</p><div class="controls"><p><kbd>A</kbd> <kbd>D</kbd> Move</p><p><kbd>W</kbd> / <kbd>SPACE</kbd> Hold to jump higher</p><p><kbd>S</kbd> Fast fall</p><p><kbd>ESC</kbd> Pause</p></div><small>500 coins = one extra life.<br>Hit bricks from underneath.<br>Land on gold trampolines to launch.<br>Desktop keyboard · original sprite art</small></aside><section class="cabinet"><div class="cabinet-top"><span>THE ASCENT</span><span id="level">TIER 01</span></div><div class="screen"><canvas width="480" height="640" tabindex="0" aria-label="Brick Climb game. A and D move, W or Space jumps, S falls faster."></canvas><div id="splash"><p class="eyebrow">READY TO MAKE A BREAK?</p><h2>Up is the<br>only way.</h2><p>Jump beneath bricks to break them.<br>Keep ahead of the rising screen.</p><button id="start">LET’S CLIMB →</button><small>Click to begin, then use your keyboard.</small></div></div><div class="cabinet-bottom"><span id="status">PRESS START</span><button id="restart">RESTART ↻</button></div></section><aside class="notes"><p class="eyebrow">HOW IT WORKS</p><h3>Small sprites.<br>Real physics.</h3><p>Belts carry you with their tread. Cloud platforms drift. Gray stone stays still and stops a boost. Every fragment is a physical body. Every animated frame runs on Pibbl’s shared clock.</p><div class="rule"></div><p class="eyebrow">THE GOAL</p><p>Climb as high as you can. The screen keeps rising. Falling below it costs a life.</p><a href="?preview">Inspect the sprite frames →</a></aside></main><footer>BUILT WITH Pibbl <span>Canvas 2D · Signals · Animation · Physics</span></footer>`;
const canvas = document.querySelector('canvas')!;
const splash = document.getElementById('splash')!;
const screen = document.querySelector('.screen')!;
let pauseOverlay: HTMLDivElement | undefined;

function removePauseOverlay() {
  pauseOverlay?.remove();
  pauseOverlay = undefined;
}

function resume() {
  if (!session) return;
  setGamePaused(session, false);
  removePauseOverlay();
  canvas.focus();
}

function pause() {
  if (!session || session.game.phase !== 'playing' || session.paused) return;
  session.keys.clear();
  session.jump = false;
  setGamePaused(session, true);
  pauseOverlay = document.createElement('div');
  pauseOverlay.className = 'pause-overlay';
  pauseOverlay.style.cssText = 'position:absolute;inset:0;display:grid;place-items:center;overflow:hidden;background:#102832;color:#fff2cf;text-align:center';
  pauseOverlay.innerHTML = `<img alt="Frozen game screen"><div><p class="eyebrow">PAUSED</p><h2>Take a breath.</h2><button type="button" data-resume>RESUME →</button><button type="button" data-restart>RESTART ↻</button></div>`;
  const frozen = pauseOverlay.querySelector('img')!;
  frozen.src = canvas.toDataURL();
  frozen.setAttribute('style', 'position:absolute;inset:0;width:100%;height:100%;opacity:.28;image-rendering:pixelated');
  const controls = pauseOverlay.querySelector('div')!;
  controls.setAttribute('style', 'position:relative;display:grid;gap:14px');
  pauseOverlay.querySelector('[data-resume]')!.addEventListener('click', resume);
  pauseOverlay.querySelector('[data-restart]')!.addEventListener('click', start);
  screen.appendChild(pauseOverlay);
  pauseOverlay.querySelector<HTMLButtonElement>('[data-resume]')!.focus();
}
// Import images explicitly: small metadata files may become data URLs in a build,
// so relative image paths inside their JSON cannot be used as a URL base.
const assets = [[atlasUrl, atlasImage], [characterUrl, characterImage], [coinUrl, coinImage]];
const loaded = await Promise.allSettled([...assets.map(([url, image]) => {
  const parser = aseprite();
  return loadSpriteSheet(url, { format: { parse(data) {
    return { ...parser.parse(data), images: [new URL(image, document.baseURI).href] };
  } } });
}), loadSpriteSheet(sceneryImage, { grid: { frameWidth: 64, frameHeight: 32 } }), loadSpriteSheet(terrainImage, { grid: { frameWidth: 40, frameHeight: 40 } })]);
const sheets = loaded.flatMap(result => result.status === 'fulfilled' ? [result.value] : []);
const failure = loaded.find(result => result.status === 'rejected');
if (failure?.status === 'rejected') {
  sheets.forEach(resource => resource.dispose());
  throw failure.reason;
}
const sheet: SpriteSheet = { frames: Object.freeze(Object.assign({}, ...sheets.map(s => s.frames), { 'cloud-0': sheets[3].frames[0], 'cloud-1': sheets[3].frames[4], trampoline: sheets[3].frames[8], conveyor: sheets[4].frames[0], 'conveyor-left': sheets[4].frames[4], 'conveyor-right': sheets[4].frames[8], 'cloud-block': sheets[4].frames[12] })), animations: Object.freeze(Object.assign({}, ...sheets.map(s => s.animations), Object.fromEntries(['cloud-0', 'cloud-1', 'spring'].map((name, row) => [name, defineSpriteAnimation({ frames: Array.from({ length: 4 }, (_, column) => sheets[3].frames[row * 4 + column]), fps: row < 2 ? 3 : 6 })])), Object.fromEntries(['conveyor', 'conveyor-left', 'conveyor-right', 'cloud-block'].map((name, row) => [name, defineSpriteAnimation({ frames: Array.from({ length: 4 }, (_, column) => sheets[4].frames[row * 4 + column]), fps: row === 3 ? 3 : 8 })])))), dispose: () => sheets.forEach(s => s.dispose()) };
let controller: PibblController | undefined;
let session: GameSession;
const preview = new URLSearchParams(location.search).has('preview');
function update() {
  const g = session.game;
  const snapshot = session.flow.snapshot.get();
  Object.assign(g, snapshot.context, { phase: snapshot.value });
  document.getElementById('level')!.textContent = `TIER ${String(g.tier + 1).padStart(2, '0')}`;
  document.getElementById('status')!.textContent = g.phase === 'playing' ? 'BREAK · JUMP · COLLECT' : g.phase.toUpperCase();
  if (g.phase === 'over') {
    splash.hidden = false;
    splash.querySelector('h2')!.innerHTML = 'One more<br>climb?';
    splash.querySelector('p:not(.eyebrow)')!.textContent = `Score ${g.score} · ${g.coins} coins collected`;
    document.getElementById('start')!.textContent = 'CLIMB AGAIN →';
    session.keys.clear();
  }
}
function start() {
  removePauseOverlay();
  controller?.dispose();
  session?.flow.stop();
  const flow = createMachine(sessionMachine, { input: { respawn: () => {
    respawnPoint(session.game);
    session.presentation = 'spawning';
    session.boostUntil = 0;
    session.previousPosition = undefined;
    session.grounded = false;
    session.jump = false;
    session.jumpVelocity = undefined;
  } } });
  session = { game: createGame(), revision: signal(0), keys: new Set(), jump: false, grounded: false, boostUntil: 0, presentation: 'spawning', sheet, flow, onUpdate: update };
  flow.start(); flow.send({ type: 'START' }); update(); splash.hidden = true;
  controller = pibbl(canvas, <GameScene session={session} />); canvas.focus(); update();
}
document.getElementById('start')!.addEventListener('click', start);
document.getElementById('restart')!.addEventListener('click', start);
window.addEventListener('keydown', event => {
  if (!session || session.game.phase !== 'playing') return;
  if (event.code === 'Escape') {
    event.preventDefault();
    if (event.repeat) return;
    session.paused ? resume() : pause();
    return;
  }
  if (document.activeElement !== canvas || session.presentation || session.paused || !['KeyA', 'KeyD', 'KeyW', 'KeyS', 'Space'].includes(event.code)) return;
  event.preventDefault(); session.keys.add(event.code);
  if (!event.repeat && (event.code === 'KeyW' || event.code === 'Space')) session.jump = true;
});
canvas.addEventListener('keyup', event => { session?.keys.delete(event.code); });
canvas.addEventListener('blur', () => { session?.keys.clear(); if (session) session.jump = false; });
function Preview() {
  const frame = useSignal(sheet.frames['run-0']);
  const coin = useSignal(sheet.frames['coin-0']);
  usePlayback(repeat(drive(frame, sheet.animations.walk), { iterations: Infinity }), { autoplay: true });
  usePlayback(repeat(drive(coin, sheet.animations.spin), { iterations: Infinity }), { autoplay: true });
  const names = ['standing', 'idle-1', ...Array.from({ length: 6 }, (_, i) => `run-${i}`), 'jump', 'fall', 'skid-0', 'skid-1', ...Array.from({ length: 8 }, (_, i) => `coin-${i}`), 'brick', 'stone', 'shard-0', 'shard-1'].filter(name => sheet.frames[name]);
  return <>
    <Rectangle style={{ width: 480, height: 640, fill: '#142f3c' }} />
    <Group style={{ translateX: 110, translateY: 20 }}><Sprite frame={frame} sampling="nearest" style={{ width: 96, height: 120 }} /></Group>
    <Group style={{ translateX: 280, translateY: 50 }}><Sprite frame={coin} sampling="nearest" style={{ width: 72, height: 72 }} /></Group>
    <Text style={{ left: 25, top: 156, fill: '#fff2cf', font: '12px monospace' }}>6 run poses · 2 airborne poses · 8 coin frames</Text>
    {names.map((name, index) => <Group key={name} style={{ translateX: 16 + index % 6 * 77, translateY: 190 + Math.floor(index / 6) * 96 }}>
      <Rectangle style={{ width: 65, height: 69, fill: '#23434b' }} />
      <Sprite frame={sheet.frames[name]} sampling="nearest" style={{ width: 56, height: 60 }} />
      <Text style={{ left: 2, top: 75, fill: '#fff2cf', font: '10px monospace' }}>{name}</Text>
    </Group>)}
  </>;
}
if (preview) { splash.hidden = true; document.getElementById('level')!.textContent = 'FRAME INSPECTOR'; controller = pibbl(canvas, <Preview />); }
window.addEventListener('pagehide', () => { controller?.dispose(); session?.flow.stop(); sheet.dispose(); });
// Read-only diagnostics for the same manually playable scene.
Object.defineProperty(window, 'brickClimb', { value: { observe: () => session ? { phase: session.game.phase, coins: session.game.coins, lives: session.game.lives, score: session.game.score, bricks: session.game.blocks.filter(b => b.brick).length, debris: session.game.debris.length, camera: session.game.camera, position: session.body?.position.get(), velocity: session.body?.linearVelocity.get(), grounded: session.grounded, playerState: session.playerState, machineStatus: session.flow.snapshot.get().status, presentation: session.presentation, paused: !!session.paused, boosting: session.boostUntil > session.game.time, trampolines: session.game.trampolines.map(item => ({ ...item })) } : { phase: 'start' } } });

```

### scene.tsx

```tsx
import { Group, Rectangle, Text, drive, repeat, sequence, wait, marker, tween, parallel, useSignal, useReaction, computed, useVisibilityTransition, bifrost, type WritableSignal } from '@pibbl/core';
import { Sprite, defineSpriteAnimation, type SpriteFrame, type SpriteSheet } from '@pibbl/core/sprites';
import { PhysicsWorld2D, pibblPhysicsBody2D, dynamicBody2D, staticBody2D, collider2D, setLinearVelocity2D, teleportBody2D, type PibblPhysicsBodyHandle2D } from '@pibbl/core/physics/2d';
import { blockX, advanceEndlessWorld, cutJumpVelocity, breakBrick, type GameState, type Block, type Debris, type Flight, type Trampoline } from './model';

import { useMachine, type MachineActor } from '@pibbl/core/machines';
import { launchBoost, stopBoost, boostVelocity, landedTrampoline } from './boost';
import { useGamePlayback, useGameBody, whenGameResumed, type GamePauseResources } from './pause';
import { playerMachine } from './player-machine';
import { playerCollider2D } from './player-physics';
import { type SessionContext, type SessionEvent, type SessionInput } from './machines';

export interface GameSession {
  game: GameState; revision: WritableSignal<number>; keys: Set<string>; jump: boolean;
  paused?: boolean; pauseResources?: GamePauseResources;
  presentation?: 'spawning' | 'dying'; deathPosition?: { x: number; y: number };
  body?: PibblPhysicsBodyHandle2D; grounded: boolean; sheet: SpriteSheet;
  onUpdate: () => void;
  flow: MachineActor<SessionContext, SessionEvent, SessionInput>;
  jumpVelocity?: number; playerState?: string; boostUntil: number; previousPosition?: readonly [number, number];
}
type TerrainFrames = {
  left: WritableSignal<SpriteFrame>; right: WritableSignal<SpriteFrame>; cloud: WritableSignal<SpriteFrame>;
  leftCapLeft: WritableSignal<SpriteFrame>; leftCapRight: WritableSignal<SpriteFrame>;
  rightCapLeft: WritableSignal<SpriteFrame>; rightCapRight: WritableSignal<SpriteFrame>;
};
function Brick({ block, session, frames }: { block: Block; session: GameSession; frames: TerrainFrames }) {
  return <Group style={{ translateX: block.x, translateY: block.y }} physics={staticBody2D({ collider: false })}>
    <BlockPaint block={block} session={session} frames={frames} />
  </Group>;
}
function BlockPaint({ block, session, frames }: { block: Block; session: GameSession; frames: TerrainFrames }) {
  const neighbor = (offset: number) => session.game.blocks.some(other => other.y === block.y && other.x === block.x + offset && other.conveyor === block.conveyor);
  const frame = block.motion ? frames.cloud : block.conveyor === undefined ? session.sheet.frames[block.brick ? 'brick' : 'stone'] :
    !neighbor(-40) ? block.conveyor < 0 ? frames.leftCapLeft : frames.rightCapLeft :
      !neighbor(40) ? block.conveyor < 0 ? frames.leftCapRight : frames.rightCapRight :
        block.conveyor < 0 ? frames.left : frames.right;
  return <>
    <Rectangle style={{ width: block.width, height: block.height }} physics={collider2D({ tag: `block:${block.id}`, friction: 0, onCollisionStart: event => {
      if (event.otherTag !== 'player') return;
      if (event.normal[1] > 0.6) session.grounded = true;
      if (!block.brick && session.boostUntil > session.game.time && event.normal[1] < 0.6) stopBoost(session);
      if (block.brick && event.normal[1] < -0.6) {
        const brick = breakBrick(session.game, block.id);
        if (brick) {
          session.flow.send({ type: 'BREAK_BRICK' });
          session.flow.send({ type: 'COLLECT', amount: 5 });
          session.game.flights.push({ id: session.game.nextId++, x: brick.x + 30, y: brick.y - session.game.camera });
          session.revision.update(value => value + 1); session.onUpdate();
        }
      }
    }, onCollisionEnd: event => { if (event.otherTag === 'player' && event.normal[1] > 0.6) session.grounded = false; } })} />
    <Sprite frame={frame} sampling="nearest" style={{ width: block.width, height: block.height }} pointerEvents="none" />
  </>;
}
function CloudPlatform({ blocks, session, frames }: { blocks: Block[]; session: GameSession; frames: TerrainFrames }) {
  const first = blocks[0];
  const x = useSignal(computed(() => {
    session.revision.get();
    return Math.round(blockX(first, session.game.time));
  })).get();
  // Clouds are one-way terrain, so they draw directly and never create side or ceiling colliders.
  return <Group style={{ translateX: x, translateY: Math.round(first.y - session.game.camera) + session.game.camera }}>
    {blocks.map(block => <Group key={`tile:${block.id}`} style={{ translateX: block.x - first.x }}>
      <Sprite frame={frames.cloud} sampling="nearest" style={{ width: block.width, height: block.height }} pointerEvents="none" />
    </Group>)}
  </Group>;
}
function Spring({ spring, frame }: { spring: Trampoline; frame: WritableSignal<SpriteFrame> }) {
  return <Group style={{ translateX: spring.x, translateY: spring.y }}>
    <Sprite frame={frame} sampling="nearest" style={{ width: 40, height: 20 }} pointerEvents="none" />
  </Group>;
}
function Clouds({ session }: { session: GameSession }) {
  const far = useSignal(session.sheet.frames['cloud-0']), nearFrame = useSignal(session.sheet.frames['cloud-1']);
  useGamePlayback(session, parallel(...[far, nearFrame].map((frame, index) => repeat(drive(frame,
    session.sheet.animations[`cloud-${index}`] ?? defineSpriteAnimation({ frames: [frame.get()], fps: 1 }),
  ), { iterations: Infinity }))), { autoplay: true });
  // A fixed set wraps in screen space: climbing never allocates more cloud objects.
  return <>{Array.from({ length: 8 }, (_, index) => {
    const near = index >= 4;
    const span = 900;
    const y = ((index * 193 - session.game.camera * (near ? 0.32 : 0.14)) % span + span) % span - 130;
    return <Group key={index} style={{ translateX: (index * 137) % 540 - 80, translateY: y }}>
      <Sprite frame={index % 2 ? nearFrame : far} sampling="nearest" style={{ width: near ? 192 : 128, height: near ? 96 : 64, opacity: near ? 0.32 : 0.16 }} pointerEvents="none" />
    </Group>;
  })}</>;
}
function Fragment({ fragment, sheet, session }: { fragment: Debris; sheet: SpriteSheet; session: GameSession }) {
  const body = pibblPhysicsBody2D();
  useGameBody(session, body);
  return <Group style={{ translateX: fragment.x, translateY: fragment.y }} physics={dynamicBody2D({ handle: body, mass: 0.1, collider: false, linearVelocity: [fragment.vx, fragment.vy], angularVelocityDegreesPerSecond: fragment.vx * 3 })}>
    <Rectangle style={{ width: 12, height: 10 }} physics={collider2D({ sensor: true })} />
    <Sprite frame={sheet.frames[`shard-${fragment.id % 2}`]} sampling="nearest" style={{ width: 14, height: 12 }} />
  </Group>;
}
function FlyingCoin({ flight, session }: { flight: Flight; session: GameSession }) {
  const x = useSignal(flight.x), y = useSignal(flight.y);
  const frame = useSignal(session.sheet.frames['coin-0']);
  useGamePlayback(session, parallel(
    drive(x, tween({ from: flight.x, to: 355, duration: 550 })),
    drive(y, tween({ from: flight.y, to: 26, duration: 550 })),
    repeat(drive(frame, session.sheet.animations.spin), { iterations: 2 }),
  ), { autoplay: true, onEvent: event => {
    if (event.type === 'finish') { session.game.flights = session.game.flights.filter(item => item.id !== flight.id); session.revision.update(v => v + 1); }
  } });
  return <Group style={{ translateX: x, translateY: y }}><Sprite frame={frame} style={{ width: 24, height: 24 }} sampling="nearest" /></Group>;
}
function Player({ session }: { session: GameSession }) {
  const body = pibblPhysicsBody2D();
  useGameBody(session, body);
  // Authored pose is the mount-time spawn; later respawns use teleportBody2D.
  const spawn = useSignal(session.game.checkpoint).get();
  const frame = useSignal(session.sheet.frames.standing);
  const facing = useSignal(false);
  const player = useMachine(playerMachine, { input: { frame, sheet: session.sheet, jump: () => { session.jumpVelocity = -560; session.grounded = false; } } });
  useReaction(() => { session.body = body; return () => { session.body = undefined; }; }, []);
  useGamePlayback(session, repeat(sequence(wait(1000 / 60), marker('tick')), { iterations: Infinity }), { autoplay: true, onEvent: event => {
    if (event.type !== 'marker' || session.game.phase !== 'playing' || session.paused || session.presentation || !body.enabled.get()) return;
    const g = session.game;
    g.time += 1000 / 60;
    let [px, py] = body.position.get();
    let [vx, vy] = body.linearVelocity.get();
    const previous = session.previousPosition;
    const landed = previous && landedTrampoline(g.trampolines, previous, [px, py]);
    if (landed) {
      g.trampolines = g.trampolines.filter(spring => spring.id !== landed.id);
      launchBoost(session, g.time);
    }
    // Gray and conveyor terrain use ordinary physical contact. Clouds are swept
    // one-way ledges: only descending feet may land, never side or ceiling hits.
    let cloudSupport = previous && vy >= 0 ? g.blocks.find(b => b.motion && px + 24 > blockX(b, g.time) && px < blockX(b, g.time) + b.width && previous[1] + 40 <= b.y && py + 40 >= b.y) : undefined;
    if (cloudSupport) {
      py = cloudSupport.y - 40;
      vy = 0;
      teleportBody2D(body, { position: [px, py], rotationDegrees: 0 });
      setLinearVelocity2D(body, [vx, 0]);
    }
    session.previousPosition = [px, py];
    const support = Math.abs(vy) < 20 ? g.blocks.find(b => !b.motion && px + 24 > blockX(b, g.time) && px < blockX(b, g.time) + b.width && Math.abs(py + 40 - b.y) < 4) : undefined;
    session.grounded = !!support || !!cloudSupport;
    const direction = Number(session.keys.has('KeyD')) - Number(session.keys.has('KeyA'));
    player.send({ type: 'MOVE', direction });
    if (session.boostUntil > g.time) player.send({ type: 'BOOST' });
    else if (session.grounded) player.send({ type: 'LAND' });
    else if (vy >= -30) player.send({ type: 'FALL' });
    if (session.jump && session.grounded) player.send({ type: 'JUMP' });
    let nextY = session.jumpVelocity ?? vy;
    nextY = boostVelocity(session, g.time, nextY);
    if (!session.boostUntil) nextY = cutJumpVelocity(nextY, session.keys.has('Space') || session.keys.has('KeyW'));
    session.jumpVelocity = undefined;
    session.jump = false;
    if (session.keys.has('KeyS') && !session.grounded) nextY = Math.max(nextY, 300);
    const carried = support?.conveyor ?? (cloudSupport ? (blockX(cloudSupport, g.time) - blockX(cloudSupport, g.time - 1000 / 60)) * 60 : 0);
    const nextX = direction * 190 + carried;
    if (nextX !== vx || nextY !== vy) setLinearVelocity2D(body, [nextX, nextY]);
    if (direction) facing.set(direction < 0);
    session.playerState = player.snapshot.get().value;
    if (px < 0 || px > 456) teleportBody2D(body, { position: [Math.max(0, Math.min(456, px)), py], rotationDegrees: 0 });
    advanceEndlessWorld(g, py, 1000 / 60);
    const tier = Math.max(0, Math.floor((520 - py) / 240));
    if (session.grounded && tier > g.tier) { g.tier = tier; g.checkpoint = { x: px, y: py - 2 }; }
    for (const coin of [...g.coinsLeft]) if (Math.abs(px + 12 - coin.x) < 27 && Math.abs(py + 20 - coin.y) < 36) {
      g.coinsLeft = g.coinsLeft.filter(c => c.id !== coin.id); session.flow.send({ type: 'COLLECT', amount: 1 });
      g.flights.push({ id: g.nextId++, x: coin.x, y: coin.y - g.camera });
    }
    g.debris = g.debris.filter(d => g.time - d.born < 1600);
    if (py - g.camera >= 640) {
      session.deathPosition = { x: px + 12, y: Math.min(610, py - g.camera) };
      session.presentation = 'dying';
      session.keys.clear();
      session.jump = false;
      session.boostUntil = 0;
    }
    session.revision.update(v => v + 1); session.onUpdate();
  } });
  return <Group physics={dynamicBody2D({ handle: body, mass: 1, collider: false, fixedRotation: true, ccd: true })} style={{ translateX: spawn.x, translateY: spawn.y }}>
    <Rectangle style={{ width: 24, height: 40 }} physics={playerCollider2D()} />
    <Group style={{ translateX: -9, translateY: -12 }}>
      <Sprite frame={frame} flipX={facing} sampling="nearest" style={{ width: 42, height: 52 }} />
    </Group>
  </Group>;
}
function SpawnArrival({ session }: { session: GameSession }) {
  const transition = useVisibilityTransition({ initial: false, enter: bifrost({ region: { x: -21, y: -52, width: 42, height: 52 }, entry: { x: 0, y: -700 } }), duration: 850 });
  useReaction(() => {
    let active = true;
    let cancelResume = () => {};
    queueMicrotask(() => {
      if (!active) return;
      void transition.show().then(result => {
      if (active && result.status === 'finished') {
        cancelResume = whenGameResumed(session, () => {
          if (!active) return;
          session.presentation = undefined;
          session.revision.update(value => value + 1);
        });
      }
      });
    });
    return () => { active = false; cancelResume(); };
  }, []);
  return <Group style={{ translateX: session.game.checkpoint.x + 12, translateY: session.game.checkpoint.y + 40 - session.game.camera }}>
    <Group style={{ transition: transition.binding }}>
      <Group style={{ translateX: -21, translateY: -52 }}><Sprite frame={session.sheet.frames.standing} sampling="nearest" style={{ width: 42, height: 52 }} /></Group>
    </Group>
  </Group>;
}
function DeathFlight({ session }: { session: GameSession }) {
  const origin = session.deathPosition ?? { x: 240, y: 610 };
  const x = useSignal(origin.x), y = useSignal(origin.y), scale = useSignal(1), rotation = useSignal(-12);
  useGamePlayback(session, parallel(
    drive(x, tween({ from: origin.x, to: 240, duration: 450 })),
    sequence(drive(y, tween({ from: origin.y, to: 240, duration: 450 })), drive(y, tween({ from: 240, to: 960, duration: 750 }))),
    drive(scale, tween({ from: 1, to: 5.5, duration: 1200 })),
    drive(rotation, tween({ from: -12, to: 20, duration: 1200 })),
  ), { autoplay: true, onEvent: event => {
    if (event.type !== 'finish') return;
    session.flow.send({ type: 'LIFE_LOST' });
    session.onUpdate();
    session.revision.update(value => value + 1);
  } });
  // Translate the camera-facing sprite separately: Group scales/rotates its own translation.
  return <Group style={{ translateX: x, translateY: y }}>
    <Group style={{ scaleX: scale, scaleY: scale, rotationDegrees: rotation }}>
      <Group style={{ translateX: -21, translateY: -26 }}>
        <Sprite frame={session.sheet.frames['skid-1'] ?? session.sheet.frames.standing} sampling="nearest" style={{ width: 42, height: 52 }} />
      </Group>
    </Group>
  </Group>;
}
export function GameScene({ session }: { session: GameSession }) {
  return session.flow.snapshot.get().matches('playing') ? <PlayingScene session={session} /> : <Rectangle style={{ width: 480, height: 640, fill: '#142f3c' }} />;
}
function PlayingScene({ session }: { session: GameSession }) {
  session.revision.get();
  const g = session.game;
  const moving = new Map<number, Block[]>();
  for (const block of g.blocks) if (block.platformId !== undefined) {
    const group = moving.get(block.platformId) ?? []; group.push(block); moving.set(block.platformId, group);
  }
  const coin = useSignal(session.sheet.frames['coin-0']);
  const springFrame = useSignal(session.sheet.frames.trampoline);
  const leftBelt = useSignal(session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const rightBelt = useSignal(session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const leftCapLeft = useSignal(session.sheet.frames['conveyor-left'] ?? session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const leftCapRight = useSignal(session.sheet.frames['conveyor-right'] ?? session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const rightCapLeft = useSignal(session.sheet.frames['conveyor-left'] ?? session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const rightCapRight = useSignal(session.sheet.frames['conveyor-right'] ?? session.sheet.frames.conveyor ?? session.sheet.frames.stone);
  const cloudBlock = useSignal(session.sheet.frames['cloud-block'] ?? session.sheet.frames.stone);
  const terrainFrames = { left: leftBelt, right: rightBelt, leftCapLeft, leftCapRight, rightCapLeft, rightCapRight, cloud: cloudBlock };
  const belt = session.sheet.animations.conveyor ?? defineSpriteAnimation({ frames: [session.sheet.frames.stone], fps: 1 });
  const capLeft = session.sheet.animations['conveyor-left'] ?? belt;
  const capRight = session.sheet.animations['conveyor-right'] ?? belt;
  useGamePlayback(session, parallel(
    repeat(drive(leftBelt, belt), { iterations: Infinity, direction: 'reverse' }),
    repeat(drive(rightBelt, belt), { iterations: Infinity }),
    repeat(drive(leftCapLeft, capLeft), { iterations: Infinity, direction: 'reverse' }),
    repeat(drive(leftCapRight, capRight), { iterations: Infinity, direction: 'reverse' }),
    repeat(drive(rightCapLeft, capLeft), { iterations: Infinity }),
    repeat(drive(rightCapRight, capRight), { iterations: Infinity }),
    repeat(drive(cloudBlock, session.sheet.animations['cloud-block'] ?? defineSpriteAnimation({ frames: [session.sheet.frames.stone], fps: 1 })), { iterations: Infinity }),
    repeat(drive(coin, session.sheet.animations.spin), { iterations: Infinity }),
    repeat(drive(springFrame, session.sheet.animations.spring ?? defineSpriteAnimation({ frames: [session.sheet.frames.trampoline], fps: 1 })), { iterations: Infinity }),
  ), { autoplay: true });
  return <>
    <Rectangle style={{ width: 480, height: 640, fill: '#142f3c' }} />
    <Clouds session={session} />
    {Array.from({ length: 12 }, (_, i) => <Rectangle key={`star:${i}`} style={{ left: (i * 97) % 480, top: ((i * 73 - g.camera * 0.15) % 640 + 640) % 640, width: 2, height: 2, fill: '#719798' }} />)}
    <Group style={{ translateY: -g.camera }}>
      <PhysicsWorld2D space="local" gravity={[0, 1050]}>
        {g.blocks.filter(block => block.platformId === undefined).map(block => <Brick key={`block:${block.id}`} block={block} session={session} frames={terrainFrames} />)}
        {[...moving].map(([id, blocks]) => <CloudPlatform key={`platform:${id}`} blocks={blocks} session={session} frames={terrainFrames} />)}
        {g.trampolines.map(spring => <Spring key={`spring:${spring.id}`} spring={spring} frame={springFrame} />)}
        {g.coinsLeft.map(item => <Group key={`coin:${item.id}`} style={{ translateX: item.x - 12, translateY: item.y - 12 }}><Sprite frame={coin} style={{ width: 24, height: 24 }} sampling="nearest" /></Group>)}
        {g.debris.map(fragment => <Fragment key={`fragment:${fragment.id}`} fragment={fragment} sheet={session.sheet} session={session} />)}
        {!session.presentation && <Player session={session} />}
      </PhysicsWorld2D>
    </Group>
    {session.presentation === 'spawning' && <SpawnArrival session={session} />}
    {session.presentation === 'dying' && <DeathFlight session={session} />}
    {g.flights.map(flight => <FlyingCoin key={`flight:${flight.id}`} flight={flight} session={session} />)}
    <Rectangle style={{ width: 480, height: 58, fill: '#0c2029', opacity: 0.96 }} />
    <Text style={{ left: 20, top: 20, fill: '#fff2cf', font: '17px monospace' }}>SCORE {String(g.score).padStart(6, '0')}</Text>
    <Text style={{ left: 270, top: 20, fill: '#ffd777', font: '17px monospace' }}>{g.coins} COINS</Text>
    <Text style={{ left: 395, top: 20, fill: '#ff987b', font: '17px monospace' }}>♥ {g.lives}</Text>
  </>;
}

```

### model.ts

```ts
export const BLOCK_SIZE = 40;
/** A shared horizontal path for every tile in one moving stone ledge. */
export interface PlatformMotion { amplitude: number; period: number; phase: number }
/** One square terrain tile. Moving ledges share both platformId and motion. */
export interface Block { id: number; x: number; y: number; width: number; height: number; brick: boolean; platformId?: number; motion?: PlatformMotion; /** Signed pixels per second for a static conveyor belt. */ conveyor?: number }
export interface Coin { id: number; x: number; y: number }
export interface Trampoline { id: number; x: number; y: number }
export interface Debris { id: number; x: number; y: number; vx: number; vy: number; born: number }
export interface Flight { id: number; x: number; y: number }
export interface GameState {
  phase: 'start' | 'playing' | 'over'; score: number; coins: number; lives: number;
  camera: number; checkpoint: { x: number; y: number }; tier: number; time: number;
  blocks: Block[]; coinsLeft: Coin[]; trampolines: Trampoline[]; debris: Debris[]; flights: Flight[]; nextId: number;
  nextTier: number;
}

const WORLD_WIDTH = 480;
const PLAYER_WIDTH = 24;
const PLAYER_HEIGHT = 40;
const TIER_SPACING = 240;
const GENERATE_AHEAD = 1_280;
const PRUNE_BELOW = 900;
const CAMERA_SPEED = 12;
const CAMERA_SPEED_CAP = 70;
const CAMERA_HEADROOM = 270;

function tierY(tier: number): number { return 400 - tier * TIER_SPACING; }

/** Returns a tile's world x position at a logical game time. */
export function blockX(block: Block, time: number): number {
  if (!block.motion) return block.x;
  const { amplitude, period, phase } = block.motion;
  return block.x + amplitude * Math.sin(time / period * Math.PI * 2 + phase);
}

function cameraSpeed(camera: number): number {
  return Math.min(CAMERA_SPEED_CAP, CAMERA_SPEED + Math.max(0, -camera) / 80);
}

/** A stable tiny integer hash for generation; it is independent of gameplay IDs. */
function randomAt(tier: number, salt: number): number {
  let value = (tier + 1) * 0x9e3779b1 + salt * 0x85ebca6b;
  value = Math.imul(value ^ value >>> 16, 0x7feb352d);
  value = Math.imul(value ^ value >>> 15, 0x846ca6b);
  return ((value ^ value >>> 16) >>> 0) / 0x1_0000_0000;
}

function generateTier(game: GameState, tier: number): void {
  const y = tierY(tier);
  let ledgeWidth = 2 + Math.floor(randomAt(tier, 1) * 3);
  let ledgeX = randomAt(tier, 2) < 0.5 ? 40 : 480 - 40 - ledgeWidth * BLOCK_SIZE;
  const moving = tier > 2 && tier % 3 === 0;
  const conveyor = tier > 2 && tier % 3 === 1 ? (tier % 2 ? 65 : -65) : undefined;
  if (conveyor !== undefined) ledgeWidth = Math.max(3, ledgeWidth);
  const platformId = moving ? game.nextId : undefined;
  const amplitude = 60 + Math.floor(randomAt(tier, 3) * 41);
  if (moving) ledgeX = Math.max(amplitude + 8, Math.min(480 - ledgeWidth * BLOCK_SIZE - amplitude - 8, ledgeX));
  const motion = moving ? Object.freeze({ amplitude, period: 4_000 + Math.floor(randomAt(tier, 4) * 2_001), phase: randomAt(tier, 5) * Math.PI * 2 }) : undefined;
  for (let column = 0; column < ledgeWidth; column++) game.blocks.push({ id: game.nextId++, x: ledgeX + column * BLOCK_SIZE, y: y + 120, width: BLOCK_SIZE, height: BLOCK_SIZE, brick: false, platformId, motion, conveyor });
  const gap = tier > 2 ? 2 + Math.floor(randomAt(tier, 6) * 8) : -1;
  const gapWidth = tier > 2 && randomAt(tier, 7) > 0.45 ? 2 : 1;
  for (let column = 0; column < 12; column++) {
    if (column >= gap && column < gap + gapWidth) continue;
    game.blocks.push({ id: game.nextId++, x: column * BLOCK_SIZE, y, width: BLOCK_SIZE, height: BLOCK_SIZE, brick: true });
  }
  const coinCount = 4 + Math.floor(randomAt(tier, 8) * 3);
  for (let column = 0; column < coinCount; column++) game.coinsLeft.push({ id: game.nextId++, x: 55 + column * (370 / Math.max(1, coinCount - 1)) + Math.round((randomAt(tier, column + 9) - 0.5) * 20), y: y - 34 });
  if (!moving && randomAt(tier, 16) < 1 / 3) game.trampolines.push({ id: game.nextId++, x: ledgeX + Math.floor(ledgeWidth / 2) * BLOCK_SIZE, y: y + 100 });
}

/** Generates deterministic tiers through the upward buffer above the current camera. */
export function ensureWorldAhead(game: GameState): void {
  const target = game.camera - GENERATE_AHEAD;
  while (tierY(game.nextTier) >= target) { generateTier(game, game.nextTier); game.nextTier++; }
}

/** Releases platforms, pickups, and coins far below the screen. */
export function pruneWorld(game: GameState): void {
  const cutoff = game.camera + PRUNE_BELOW;
  game.blocks = game.blocks.filter(block => block.y <= cutoff);
  game.coinsLeft = game.coinsLeft.filter(coin => coin.y <= cutoff);
  game.trampolines = game.trampolines.filter(trampoline => trampoline.y <= cutoff);
}

/** Advances the upward-only camera, fills the generation buffer, and prunes old world data. */
export function advanceEndlessWorld(game: GameState, playerY: number, deltaMilliseconds: number): void {
  const elapsed = Math.max(0, Number.isFinite(deltaMilliseconds) ? deltaMilliseconds : 0);
  const slowCamera = game.camera - cameraSpeed(game.camera) * elapsed / 1000;
  const followCamera = Number.isFinite(playerY) ? playerY - CAMERA_HEADROOM : game.camera;
  game.camera = Math.min(game.camera, slowCamera, followCamera);
  ensureWorldAhead(game);
  pruneWorld(game);
}

/** Chooses the lowest visible solid ledge or creates a centered recovery ledge without moving camera. */
export function respawnPoint(game: GameState): { x: number; y: number } {
  const visible = game.blocks.filter(block => !block.brick && !block.motion && block.conveyor === undefined && block.y - game.camera >= 250 && block.y - game.camera <= 560).sort((first, second) => second.y - first.y);
  let ledge = visible[0];
  if (!ledge) {
    const x = (WORLD_WIDTH - BLOCK_SIZE * 3) / 2;
    const y = game.camera + 480;
    const recovery = Array.from({ length: 3 }, (_, column) => ({ id: game.nextId++, x: x + column * BLOCK_SIZE, y, width: BLOCK_SIZE, height: BLOCK_SIZE, brick: false }));
    game.blocks.push(...recovery);
    ledge = recovery[1];
  }
  const point = { x: Math.max(0, Math.min(WORLD_WIDTH - PLAYER_WIDTH, blockX(ledge, game.time) + ledge.width / 2 - PLAYER_WIDTH / 2)), y: ledge.y - PLAYER_HEIGHT };
  game.checkpoint = point;
  return point;
}

/** Cuts a released rising jump to a short hop while preserving held and falling velocity. */
export function cutJumpVelocity(verticalVelocity: number, held: boolean): number {
  return held || verticalVelocity >= 0 ? verticalVelocity : Math.max(verticalVelocity, -220);
}

export function createGame(): GameState {
  const game: GameState = {
    phase: 'start', score: 0, coins: 0, lives: 3, camera: 0, checkpoint: { x: 230, y: 540 }, tier: 0, time: 0,
    blocks: [], coinsLeft: [], trampolines: [], debris: [], flights: [], nextId: 1, nextTier: 0,
  };
  for (let column = 0; column < 12; column++) game.blocks.push({ id: game.nextId++, x: column * BLOCK_SIZE, y: 580, width: BLOCK_SIZE, height: BLOCK_SIZE, brick: false });
  ensureWorldAhead(game);
  return game;
}
export function awardCoins(game: GameState, count: number): void {
  game.lives += Math.floor((game.coins + count) / 500) - Math.floor(game.coins / 500);
  game.coins += count; game.score += count * 100;
}
export function breakBrick(game: GameState, id: number): Block | undefined {
  const brick = game.blocks.find(block => block.id === id && block.brick);
  if (!brick) return;
  game.blocks = game.blocks.filter(block => block.id !== id); game.score += 25;
  for (let i = 0; i < 4; i++) game.debris.push({ id: game.nextId++, x: brick.x + 5 + i * 9, y: brick.y, vx: (i - 1.5) * 75, vy: -180 - (i % 2) * 90, born: game.time });
  return brick;
}

```

### machines.ts

```ts
import { assign, defineMachine } from '@pibbl/core/machines';

/** Session accounting, independent of character animation and physical poses. */
export interface SessionContext {
  /** Total coins collected in this run. */
  coins: number;
  /** Remaining attempts, including the currently active player. */
  lives: number;
  /** Points earned through coins and broken bricks. */
  score: number;
}
/** Events emitted by the start controls and the physical world. */
export type SessionEvent =
  | { type: 'START' }
  | { type: 'RESTART' }
  | { type: 'COLLECT'; amount: number }
  | { type: 'BREAK_BRICK' }
  | { type: 'LIFE_LOST' };
/** Application-owned world operations; the machine owns their admission. */
export interface SessionInput {
  /** Restore the character on a safe visible platform. */
  respawn: () => void;
}

/** Game lifecycle and scoring. Events outside playing cannot award points. */
export const sessionMachine = defineMachine<SessionContext, SessionEvent, SessionInput>({
  initial: 'start',
  context: () => ({ coins: 0, lives: 3, score: 0 }),
  states: {
    start: { on: { START: 'playing' } },
    playing: {
      on: {
        COLLECT: {
          guard: ({ event }) => Number.isSafeInteger(event.amount) && event.amount > 0,
          actions: assign(({ context, event }) => {
            const coins = context.coins + event.amount;
            return {
              coins,
              lives: context.lives + Math.floor(coins / 500) - Math.floor(context.coins / 500),
              score: context.score + event.amount * 100,
            };
          }),
        },
        BREAK_BRICK: {
          actions: assign(({ context }) => ({ score: context.score + 25 })),
        },
        LIFE_LOST: [
          { guard: ({ context }) => context.lives === 1, target: 'over', actions: assign(() => ({ lives: 0 })) },
          { actions: [assign(({ context }) => ({ lives: context.lives - 1 })), ({ input }) => input.respawn()] },
        ],
      },
    },
    over: { on: { RESTART: { target: 'playing', actions: assign(() => ({ coins: 0, lives: 3, score: 0 })) } } },
  },
});

```

### player-machine.ts

```ts
import { drive, repeat, type WritableSignal } from '@pibbl/core';
import { assign, defineMachine } from '@pibbl/core/machines';
import { playAnimation } from '@pibbl/core/machines/animation';
import { defineSpriteAnimation, type SpriteFrame, type SpriteSheet } from '@pibbl/core/sprites';

/** Horizontal intent retained while airborne. */
export interface PlayerContext { /** Left, stationary, or right. */ direction: number }
/** Physical contacts and keyboard intent, independent of animation frame ticks. */
export type PlayerEvent =
  | { type: 'MOVE'; direction: number }
  | { type: 'JUMP' }
  | { type: 'FALL' }
  | { type: 'LAND' }
  | { type: 'DIE' }
  | { type: 'BOOST' };
/** Resources owned by the mounted character. */
export interface PlayerInput {
  /** Sprite output sampled by the current state's playback. */ frame: WritableSignal<SpriteFrame>;
  /** Shared atlas resources owned by the game. */ sheet: SpriteSheet;
  /** Apply a grounded jump impulse to the physics body. */ jump: () => void;
}

const clip = (name: 'idle' | 'walk' | 'jump' | 'fall') => playAnimation<PlayerContext, PlayerEvent, PlayerInput>(({ input }) => {
  const animation = input.sheet.animations[name] ?? defineSpriteAnimation({ frames: [input.sheet.frames[name]], fps: 1 });
  return repeat(drive(input.frame, animation), { iterations: Infinity });
});

/** Locomotion owns exactly one sprite playback; physical motion remains in the world. */
export const playerMachine = defineMachine<PlayerContext, PlayerEvent, PlayerInput>({
  initial: 'idle',
  context: () => ({ direction: 0 }),
  on: { DIE: 'dead', BOOST: 'rising' },
  states: {
    idle: {
      invoke: { task: clip('idle') },
      on: {
        MOVE: { guard: ({ event }) => event.direction !== 0, target: 'running', actions: assign(({ event }) => ({ direction: event.direction })) },
        JUMP: { target: 'rising', actions: ({ input }) => input.jump() },
        FALL: 'falling',
      },
    },
    running: {
      invoke: { task: clip('walk') },
      on: {
        MOVE: [
          { guard: ({ event }) => event.direction === 0, target: 'idle', actions: assign(() => ({ direction: 0 })) },
          { guard: ({ context, event }) => context.direction !== event.direction, actions: assign(({ event }) => ({ direction: event.direction })) },
        ],
        JUMP: { target: 'rising', actions: ({ input }) => input.jump() },
        FALL: 'falling',
      },
    },
    rising: {
      invoke: { task: clip('jump') },
      on: {
        MOVE: { guard: ({ context, event }) => context.direction !== event.direction, actions: assign(({ event }) => ({ direction: event.direction })) },
        FALL: 'falling',
      },
    },
    falling: {
      invoke: { task: clip('fall') },
      on: {
        MOVE: { guard: ({ context, event }) => context.direction !== event.direction, actions: assign(({ event }) => ({ direction: event.direction })) },
        LAND: [
          { guard: ({ context }) => context.direction !== 0, target: 'running' },
          { target: 'idle' },
        ],
      },
    },
    dead: { on: { DIE: {} } },
  },
});

```

## Documentation version

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.
