stream/ui
Browse the library

Jumper

A side-scrolling platformer: run, jump, bump blocks for coins, stomp walkers, and reach the flag.

experimental

Try it

Score 00000Coins ×00Best 0

Jumper

Run to the flag. Bump blocks, grab coins, stomp the walkers.

Press Space, or the jump button, to start.

How it works

  • Arrows or A/D run, Space, Up, or W jumps (let go early for a short hop), and P pauses. On touch, hold the arrows and tap jump.
  • Coyote time and a jump buffer make jumps forgiving: you can still jump a moment after leaving a ledge, or a moment before landing.
  • Physics live in src/lib/games/jumper.ts in pixels and seconds. The level is built in code, and tests check every pit can be cleared.
  • Halfway is a checkpoint, and a fresh life is briefly safe so you can't respawn into a walker.

Source

src/components/demos/JumperLab.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { ArrowBigUp, ChevronLeft, ChevronRight, Heart, Pause, Play, RotateCcw } from "lucide-react";
import { Button, type ButtonProps } from "@streamuilib/react";
import { brand } from "@/lib/brand";
import { cn } from "@/lib/cn";
import { useBestScore } from "@/lib/games/best-score";
import {
  FLAG_X,
  LEVEL_H,
  PLAYER,
  TILE,
  VIEW,
  WALKER,
  cameraX,
  createGame,
  step,
  type Input,
  type JumperGame,
  type Status,
  type Tile,
} from "@/lib/games/jumper";

const LINE = 2.5;

/* ------------------------------------------------------------------ */
/* Drawing: neo-brutalist shapes on a 2D canvas                        */
/* ------------------------------------------------------------------ */

function box(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, fill: string, shadow = 0, radius = 0) {
  if (shadow) {
    ctx.fillStyle = brand.ink;
    ctx.beginPath();
    ctx.roundRect(x + shadow, y + shadow, w, h, radius);
    ctx.fill();
  }
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, radius);
  ctx.fillStyle = fill;
  ctx.fill();
  ctx.lineWidth = LINE;
  ctx.strokeStyle = brand.ink;
  ctx.stroke();
}

function drawBackdrop(ctx: CanvasRenderingContext2D, cam: number) {
  ctx.fillStyle = brand.blueSoft;
  ctx.fillRect(0, 0, VIEW.width, VIEW.height);
  // Hills and clouds scroll slower than the level, for depth.
  for (let i = 0; i < 6; i++) {
    const x = i * 260 - ((cam * 0.3) % 260) - 40;
    ctx.beginPath();
    ctx.ellipse(x + 80, 10 * TILE, 90, 54, 0, Math.PI, 0);
    ctx.fillStyle = brand.limeSoft;
    ctx.fill();
    ctx.lineWidth = LINE;
    ctx.strokeStyle = brand.ink;
    ctx.stroke();
  }
  for (let i = 0; i < 5; i++) {
    const x = i * 190 - ((cam * 0.15) % 190) - 30;
    box(ctx, x, 30 + (i % 2) * 34, 64, 20, brand.raised, 3, 10);
  }
}

function drawTile(ctx: CanvasRenderingContext2D, t: Tile, x: number, y: number, above: Tile, time: number) {
  const s = TILE;
  switch (t) {
    case "#": {
      ctx.fillStyle = brand.orange;
      ctx.fillRect(x, y, s, s);
      ctx.fillStyle = brand.ink;
      ctx.globalAlpha = 0.25;
      ctx.fillRect(x, y + s / 2, s, 2);
      ctx.fillRect(x + (Math.floor(y / s) % 2 ? s / 2 : 0), y, 2, s / 2);
      ctx.globalAlpha = 1;
      if (above !== "#") {
        ctx.fillStyle = brand.lime;
        ctx.fillRect(x, y, s, 7);
        ctx.fillStyle = brand.ink;
        ctx.fillRect(x, y, s, LINE);
        ctx.fillRect(x, y + 7, s, 2);
      }
      return;
    }
    case "=":
      box(ctx, x, y, s, s, brand.orange);
      ctx.fillStyle = brand.ink;
      ctx.fillRect(x, y + s / 2 - 1, s, 2);
      ctx.fillRect(x + s / 2 - 1, y, 2, s / 2);
      ctx.fillRect(x + s / 4, y + s / 2, 2, s / 2);
      return;
    case "!":
    case "u":
      box(ctx, x + 1, y + 1, s - 2, s - 2, t === "!" ? brand.lime : brand.sunken, 0, 3);
      if (t === "!") {
        ctx.fillStyle = brand.ink;
        ctx.font = "bold 16px 'Space Mono', monospace";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText("!", x + s / 2, y + s / 2 + 1);
      }
      return;
    case "P": {
      const cap = above !== "P";
      if (cap) box(ctx, x - 2, y, s + 4, 10, brand.lime);
      box(ctx, x + 2, y + (cap ? 10 : 0), s - 4, s - (cap ? 10 : 0), brand.lime);
      return;
    }
    case "S":
      box(ctx, x + 1, y + 1, s - 2, s - 2, brand.raised, 0, 2);
      ctx.fillStyle = brand.faint;
      ctx.fillRect(x + 5, y + 5, s - 10, s - 10);
      return;
    case "c": {
      const w = 5 + Math.abs(Math.sin(time * 4 + x * 0.05)) * 6;
      ctx.beginPath();
      ctx.ellipse(x + s / 2, y + s / 2, w, 9, 0, 0, Math.PI * 2);
      ctx.fillStyle = brand.orange;
      ctx.fill();
      ctx.lineWidth = LINE;
      ctx.strokeStyle = brand.ink;
      ctx.stroke();
      return;
    }
  }
}

function drawFlag(ctx: CanvasRenderingContext2D, cam: number) {
  const x = FLAG_X * TILE + TILE / 2 - cam;
  if (x < -40 || x > VIEW.width + 40) return;
  ctx.fillStyle = brand.ink;
  ctx.fillRect(x - 2, 2 * TILE, 4, 8 * TILE);
  ctx.beginPath();
  ctx.moveTo(x + 2, 2 * TILE + 6);
  ctx.lineTo(x + 34, 2 * TILE + 18);
  ctx.lineTo(x + 2, 2 * TILE + 30);
  ctx.closePath();
  ctx.fillStyle = brand.pink;
  ctx.fill();
  ctx.lineWidth = LINE;
  ctx.strokeStyle = brand.ink;
  ctx.stroke();
  box(ctx, x - 6, 2 * TILE - 10, 12, 12, brand.lime, 0, 6);
}

function drawWalker(ctx: CanvasRenderingContext2D, x: number, y: number, time: number) {
  const stepUp = Math.sin(time * 10 + x) > 0 ? 2 : 0;
  box(ctx, x + 2, y + WALKER.h - 5 - stepUp, 6, 5, brand.ink);
  box(ctx, x + WALKER.w - 8, y + WALKER.h - 5 - (2 - stepUp), 6, 5, brand.ink);
  box(ctx, x, y, WALKER.w, WALKER.h - 4, brand.pink, 0, 8);
  ctx.fillStyle = brand.raised;
  ctx.fillRect(x + 4, y + 5, 5, 5);
  ctx.fillRect(x + 11, y + 5, 5, 5);
  ctx.fillStyle = brand.ink;
  ctx.fillRect(x + 5, y + 7, 2, 3);
  ctx.fillRect(x + 12, y + 7, 2, 3);
}

function drawPlayer(ctx: CanvasRenderingContext2D, game: JumperGame, cam: number, time: number) {
  const p = game.player;
  // Blink while respawn-safe.
  if (game.safeFor > 0 && Math.floor(time * 12) % 2 === 0) return;
  const x = p.x - cam;
  const y = p.y;
  const running = p.onGround && Math.abs(p.vx) > 10;
  const kick = running && Math.sin(time * 18) > 0 ? 2 : 0;
  box(ctx, x + 1, y + PLAYER.h - 5, 7, 5, brand.pink, 0, 2);
  box(ctx, x + PLAYER.w - 8, y + PLAYER.h - 5 - kick, 7, 5, brand.pink, 0, 2);
  box(ctx, x, y, PLAYER.w, PLAYER.h - 4, brand.lime, 2, 3);
  const eye = p.facing > 0 ? x + 10 : x + 3;
  ctx.fillStyle = brand.ink;
  ctx.fillRect(eye, y + 5, 3, 5);
  ctx.fillRect(eye + 4, y + 5, 3, 5);
}

function draw(ctx: CanvasRenderingContext2D, game: JumperGame, time: number) {
  const cam = cameraX(game);
  drawBackdrop(ctx, cam);
  const from = Math.floor(cam / TILE);
  for (let ty = 0; ty < LEVEL_H; ty++) {
    for (let tx = from; tx <= from + Math.ceil(VIEW.width / TILE); tx++) {
      const t = game.grid[ty]?.[tx];
      if (!t || t === " ") continue;
      drawTile(ctx, t, tx * TILE - cam, ty * TILE, game.grid[ty - 1]?.[tx] ?? " ", time);
    }
  }
  drawFlag(ctx, cam);
  for (const w of game.walkers) if (w.alive) drawWalker(ctx, w.x - cam, w.y, time);
  if (game.status !== "dead" || Math.floor(time * 8) % 2 === 0) drawPlayer(ctx, game, cam, time);
}

/* ------------------------------------------------------------------ */
/* Game                                                                */
/* ------------------------------------------------------------------ */

const LEFT = new Set(["ArrowLeft", "a", "A"]);
const RIGHT = new Set(["ArrowRight", "d", "D"]);
const JUMP = new Set([" ", "ArrowUp", "w", "W"]);

type Hud = { score: number; coins: number; lives: number; status: Status };
type HoldKey = "left" | "right" | "jump";

/** An on-screen button held while pressed: down on press, up on lift, leave, or cancel. */
function HoldButton({ onHold, ...props }: { onHold: (down: boolean) => void } & Omit<ButtonProps, "onPointerDown">) {
  return (
    <Button
      {...props}
      onPointerDown={(e) => {
        e.preventDefault();
        e.currentTarget.setPointerCapture(e.pointerId);
        onHold(true);
      }}
      onPointerUp={() => onHold(false)}
      onPointerCancel={() => onHold(false)}
      onLostPointerCapture={() => onHold(false)}
    />
  );
}

export function JumperLab() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const boardRef = useRef<HTMLDivElement>(null);
  const gameRef = useRef<JumperGame>(createGame());
  const input = useRef<Input>({ left: false, right: false, jump: false, jumpPressed: false });
  const [hud, setHud] = useState<Hud>({ score: 0, coins: 0, lives: 3, status: "ready" });
  const [best, saveBest] = useBestScore("jumper");
  const statusId = useId();

  // The loop runs outside React; React state changes only when the HUD does.
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (canvas && ctx) {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = VIEW.width * dpr;
      canvas.height = VIEW.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }
    let raf = 0;
    let last = performance.now();
    const frame = (now: number) => {
      const seconds = (now - last) / 1000;
      last = now;
      const prev = gameRef.current;
      const next = step(prev, input.current, seconds);
      input.current.jumpPressed = false;
      gameRef.current = next;
      if (next.score !== prev.score || next.coins !== prev.coins || next.lives !== prev.lives || next.status !== prev.status) {
        setHud({ score: next.score, coins: next.coins, lives: next.lives, status: next.status });
        if ((next.status === "over" || next.status === "won") && next.status !== prev.status) saveBest(next.score);
      }
      if (ctx) draw(ctx, next, now / 1000);
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [saveBest]);

  /** Show a change made outside the loop right away, without waiting for the next frame. */
  const sync = useCallback(() => {
    const g = gameRef.current;
    setHud({ score: g.score, coins: g.coins, lives: g.lives, status: g.status });
  }, []);

  const start = useCallback(() => {
    const g = gameRef.current;
    gameRef.current = g.status === "paused" ? { ...g, status: "playing" } : createGame("playing");
    input.current = { left: false, right: false, jump: false, jumpPressed: false };
    sync();
    boardRef.current?.focus();
  }, [sync]);

  const togglePause = useCallback(() => {
    const g = gameRef.current;
    if (g.status === "playing") gameRef.current = { ...g, status: "paused" };
    else if (g.status === "paused") gameRef.current = { ...g, status: "playing" };
    sync();
  }, [sync]);

  function onKey(e: KeyboardEvent, down: boolean) {
    const status = gameRef.current.status;
    if (LEFT.has(e.key)) input.current.left = down;
    else if (RIGHT.has(e.key)) input.current.right = down;
    else if (JUMP.has(e.key)) {
      if (down && !e.repeat) {
        if (status === "ready" || status === "over" || status === "won") start();
        input.current.jumpPressed = true;
      }
      input.current.jump = down;
    } else if (down && (e.key === "p" || e.key === "P" || e.key === "Escape")) togglePause();
    else if (down && e.key === "Enter" && status !== "playing") start();
    else return;
    e.preventDefault();
  }

  const hold = useCallback(
    (key: HoldKey, down: boolean) => {
      if (down && key === "jump") {
        const status = gameRef.current.status;
        if (status === "ready" || status === "over" || status === "won") start();
        input.current.jumpPressed = true;
      }
      input.current[key] = down;
    },
    [start],
  );

  const message =
    hud.status === "over"
      ? `Game over. Score ${hud.score}, ${hud.coins} coins.`
      : hud.status === "won"
        ? `You reached the flag! Score ${hud.score}.`
        : hud.status === "paused"
          ? "Paused."
          : hud.status === "dead"
            ? `Ouch. ${hud.lives} ${hud.lives === 1 ? "life" : "lives"} left.`
            : hud.status === "ready"
              ? "Press Space, or the jump button, to start."
              : `Score ${hud.score}. Coins ${hud.coins}.`;

  const showOverlay = hud.status === "ready" || hud.status === "over" || hud.status === "won" || hud.status === "paused";

  return (
    <div className="flex w-full flex-col items-center gap-4">
      <div className="flex w-full max-w-96 items-center justify-between font-mono text-xs uppercase">
        <span>
          Score <span className="font-bold tabular-nums">{String(hud.score).padStart(5, "0")}</span>
        </span>
        <span>
          Coins ×<span className="font-bold tabular-nums">{String(hud.coins).padStart(2, "0")}</span>
        </span>
        <span className="flex items-center gap-1" aria-label={`${hud.lives} lives`}>
          {Array.from({ length: 3 }, (_, i) => (
            <Heart key={i} aria-hidden className={cn("size-3.5", i < hud.lives ? "fill-pink text-ink" : "text-ink-faint")} />
          ))}
        </span>
        <span className="text-ink-muted">
          Best <span className="font-bold text-ink tabular-nums">{best}</span>
        </span>
      </div>

      <div
        ref={boardRef}
        tabIndex={0}
        role="group"
        aria-label="Jumper. Left and right arrows or A and D to run, Space or Up to jump, P to pause."
        aria-describedby={statusId}
        onKeyDown={(e) => onKey(e, true)}
        onKeyUp={(e) => onKey(e, false)}
        onBlur={() => (input.current = { left: false, right: false, jump: false, jumpPressed: false })}
        className="relative aspect-10/9 w-full max-w-96 touch-none overflow-hidden border-2 border-ink bg-blue-soft shadow-brutal-lg select-none"
      >
        <canvas ref={canvasRef} aria-hidden className="block size-full" />

        {showOverlay && (
          <div className="absolute inset-0 grid place-items-center bg-paper/50 p-6">
            <div className="flex w-full max-w-60 flex-col items-center gap-3 border-2 border-ink bg-paper-raised p-5 text-center shadow-brutal">
              <p className="font-display text-2xl leading-none tracking-tight">
                {hud.status === "over" ? "Game over" : hud.status === "won" ? "You made it!" : hud.status === "paused" ? "Paused" : "Jumper"}
              </p>
              {hud.status === "ready" && (
                <p className="text-sm text-ink-muted">Run to the flag. Bump blocks, grab coins, stomp the walkers.</p>
              )}
              {(hud.status === "over" || hud.status === "won") && (
                <p className="font-mono text-xs uppercase">
                  Score <span className="font-bold">{hud.score}</span> · Coins <span className="font-bold">{hud.coins}</span>
                </p>
              )}
              <Button size="sm" onClick={hud.status === "paused" ? togglePause : start}>
                {hud.status === "over" || hud.status === "won" ? <RotateCcw aria-hidden /> : <Play aria-hidden />}
                {hud.status === "paused" ? "Resume" : hud.status === "ready" ? "Start" : "Play again"}
              </Button>
            </div>
          </div>
        )}
      </div>

      <p id={statusId} aria-live="polite" className="sr-only">
        {message}
      </p>

      {/* Touch controls: hold to run, tap to jump. The keyboard works on the focused game. */}
      <div role="group" aria-label="Controls" className="flex w-full max-w-96 items-center justify-between gap-2">
        <div className="flex gap-2">
          <HoldButton size="icon" variant="secondary" className="size-12 touch-none" aria-label="Run left" onHold={(d) => hold("left", d)}>
            <ChevronLeft aria-hidden />
          </HoldButton>
          <HoldButton size="icon" variant="secondary" className="size-12 touch-none" aria-label="Run right" onHold={(d) => hold("right", d)}>
            <ChevronRight aria-hidden />
          </HoldButton>
        </div>
        <Button size="icon" variant="secondary" className="size-10" aria-label={hud.status === "paused" ? "Resume" : "Pause"} onClick={togglePause}>
          {hud.status === "paused" ? <Play aria-hidden /> : <Pause aria-hidden />}
        </Button>
        <HoldButton size="icon" variant="pink" className="size-12 touch-none" aria-label="Jump" onHold={(d) => hold("jump", d)}>
          <ArrowBigUp aria-hidden />
        </HoldButton>
      </div>
    </div>
  );
}