stream/ui
Browse the library

Muncher

A maze chase: eat every dot, dodge three bots, and grab a power pellet to turn the tables.

experimental

Try it

Score 0Lv 1 · Best 0

Muncher

Eat every dot. Pink pellets turn the bots.

Press Start, or an arrow key on the board.

How it works

  • Arrows or WASD steer on the focused board (a turn pressed early waits for the opening), swipe on touch screens, or use the pad. Space pauses.
  • Each bot hunts differently: one chases you, one cuts you off ahead, and a shy one backs off up close. Power pellets scare them for a while; eat them for 200, 400, then 800.
  • The rules live in src/lib/games/maze.ts as pure functions, one cell per tick, with a side tunnel that wraps around.
  • Clear the board and the next level starts faster. Three lives; your best score is remembered once you accept the cookie banner.

Source

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

import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Heart, Pause, Play, RotateCcw } from "lucide-react";
import { Button } from "@streamuilib/react";
import { cn } from "@/lib/cn";
import { cryptoInt } from "@/lib/games/memory";
import { useBestScore } from "@/lib/games/best-score";
import { COLS, MAP, ROWS, createGame, isWall, step, tickMs, turn, type Bot, type Dir, type MazeGame } from "@/lib/games/maze";

const KEYS: Record<string, Dir> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
  w: "up",
  s: "down",
  a: "left",
  d: "right",
};

const BOT_TONES = ["bg-pink", "bg-blue", "bg-orange"];
const ROTATION: Record<Dir, number> = { right: 0, down: 90, left: 180, up: 270 };
const LOOK: Record<Dir, string> = { up: "-translate-y-px", down: "translate-y-px", left: "-translate-x-px", right: "translate-x-px" };

/** Where a cell sits on the board, as percentages so the board can scale. */
const place = (x: number, y: number): CSSProperties => ({
  left: `${(x / COLS) * 100}%`,
  top: `${(y / ROWS) * 100}%`,
  width: `${100 / COLS}%`,
  height: `${100 / ROWS}%`,
});

/** Wall cells, each with an ink edge only where it meets a path: the maze reads as outlined shapes. */
const WALLS = MAP.flatMap((row, y) =>
  [...row].flatMap((c, x) =>
    c === "#"
      ? [
          {
            x,
            y,
            edges: cn(
              !isWall(x, y - 1) && y > 0 && "border-t-2",
              !isWall(x, y + 1) && y < ROWS - 1 && "border-b-2",
              !isWall(x - 1, y) && x > 0 && "border-l-2",
              !isWall(x + 1, y) && x < COLS - 1 && "border-r-2",
            ),
          },
        ]
      : [],
  ),
);

/** The chomper: a pie with a mouth that opens and closes each tick, facing its direction. */
function Player({ game }: { game: MazeGame }) {
  const open = game.status !== "playing" || game.tick % 2 === 0;
  const a = open ? 0.75 : 0.15; // half the mouth angle, in radians
  const path = `M5 5 L${5 + 4 * Math.cos(a)} ${5 - 4 * Math.sin(a)} A4 4 0 1 0 ${5 + 4 * Math.cos(a)} ${5 + 4 * Math.sin(a)} Z`;
  return (
    <span className="absolute grid place-items-center" style={place(game.player.x, game.player.y)}>
      <svg viewBox="0 0 10 10" className="size-full scale-110" style={{ transform: `rotate(${ROTATION[game.player.dir]}deg)` }}>
        <path d={path} className="fill-lime stroke-ink" strokeWidth={1} strokeLinejoin="round" />
        <circle cx={5.4} cy={3.1} r={0.6} className="fill-ink" />
      </svg>
    </span>
  );
}

function BotSprite({ bot, index, frightened }: { bot: Bot; index: number; frightened: boolean }) {
  const scared = frightened && bot.mode === "chase";
  return (
    <span className="absolute p-px" style={place(bot.x, bot.y)}>
      {bot.mode === "eyes" ? (
        <span className="flex size-full items-center justify-center gap-px">
          <Eye dir={bot.dir} />
          <Eye dir={bot.dir} />
        </span>
      ) : (
        <span
          className={cn(
            "flex size-full items-start justify-center gap-px rounded-t-full border-2 border-ink pt-1",
            scared ? "border-dashed bg-paper-raised" : BOT_TONES[index % BOT_TONES.length],
          )}
        >
          {scared ? <span className="mt-1 h-0.5 w-2/3 bg-ink" /> : (
            <>
              <Eye dir={bot.dir} />
              <Eye dir={bot.dir} />
            </>
          )}
        </span>
      )}
    </span>
  );
}

function Eye({ dir }: { dir: Dir }) {
  return (
    <span className="grid size-1.5 place-items-center rounded-full bg-paper-raised ring-1 ring-ink">
      <span className={cn("block size-0.5 rounded-full bg-ink", LOOK[dir])} />
    </span>
  );
}

export function MazeLab({ tick }: { tick?: number }) {
  const [game, setGame] = useState<MazeGame>(() => createGame());
  const [best, saveBest] = useBestScore("maze");
  const board = useRef<HTMLDivElement>(null);
  const swipe = useRef<{ x: number; y: number } | null>(null);
  const statusId = useId();
  const playing = game.status === "playing";

  useEffect(() => {
    if (!playing) return;
    const id = setInterval(() => setGame((g) => step(g, cryptoInt)), tick ?? tickMs(game.level));
    return () => clearInterval(id);
  }, [playing, game.level, tick]);

  useEffect(() => {
    if (game.status === "over") saveBest(game.score);
  }, [game.status, game.score, saveBest]);

  function start() {
    setGame((g) => (g.status === "paused" ? { ...g, status: "playing" } : createGame("playing")));
    board.current?.focus();
  }

  const pause = () => setGame((g) => (g.status === "playing" ? { ...g, status: "paused" } : g));
  const steer = (dir: Dir) => setGame((g) => turn(g, dir));

  function onKeyDown(e: KeyboardEvent) {
    const dir = KEYS[e.key] ?? KEYS[e.key.toLowerCase()];
    if (dir) {
      e.preventDefault();
      if (game.status === "ready") start();
      steer(dir);
    } else if (e.key === " " || e.key === "p" || e.key === "P") {
      e.preventDefault();
      if (playing) pause();
      else start();
    } else if (e.key === "Enter" && !playing) {
      e.preventDefault();
      start();
    }
  }

  function onPointerUp(e: PointerEvent) {
    const from = swipe.current;
    swipe.current = null;
    if (!from) return;
    const dx = e.clientX - from.x;
    const dy = e.clientY - from.y;
    if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) return;
    steer(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up");
  }

  const message =
    game.status === "over"
      ? `Game over. Score ${game.score}, level ${game.level}.`
      : game.status === "paused"
        ? "Paused."
        : game.status === "ready"
          ? "Press Start, or an arrow key on the board."
          : `Score ${game.score}. ${game.lives} ${game.lives === 1 ? "life" : "lives"} left. Level ${game.level}.`;

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

      <div
        ref={board}
        tabIndex={0}
        role="group"
        aria-label="Muncher maze. Arrow keys or WASD to steer, Space to pause."
        aria-describedby={statusId}
        onKeyDown={onKeyDown}
        onPointerDown={(e) => (swipe.current = { x: e.clientX, y: e.clientY })}
        onPointerUp={onPointerUp}
        onPointerCancel={() => (swipe.current = null)}
        className="relative aspect-square w-full max-w-80 touch-none overflow-hidden border-2 border-ink bg-paper-sunken shadow-brutal select-none"
      >
        {WALLS.map((w) => (
          <span key={`${w.x}-${w.y}`} className={cn("absolute border-ink bg-violet-soft", w.edges)} style={place(w.x, w.y)} />
        ))}
        {game.pellets.flatMap((row, y) =>
          row.map((v, x) =>
            v === 0 ? null : (
              <span key={`p${x}-${y}`} className="absolute grid place-items-center" style={place(x, y)}>
                {v === 1 ? (
                  <span className="block size-1/5 bg-ink" />
                ) : (
                  <span className="block size-1/2 rounded-full border-2 border-ink bg-pink motion-safe:animate-pulse" />
                )}
              </span>
            ),
          ),
        )}
        {game.bots.map((b, i) => (
          <BotSprite key={i} bot={b} index={i} frightened={game.fright > 0} />
        ))}
        <Player game={game} />

        {!playing && (
          <div className="absolute inset-0 grid place-items-center bg-paper/70">
            <div className="flex flex-col items-center gap-3 border-2 border-ink bg-paper-raised px-5 py-4 text-center shadow-brutal-sm">
              <p className="font-mono text-xs font-bold uppercase">
                {game.status === "over" ? "Game over" : game.status === "paused" ? "Paused" : "Muncher"}
              </p>
              {game.status === "ready" && <p className="max-w-44 text-xs text-ink-muted">Eat every dot. Pink pellets turn the bots.</p>}
              <Button size="sm" onClick={start}>
                {game.status === "over" ? <RotateCcw aria-hidden /> : <Play aria-hidden />}
                {game.status === "paused" ? "Resume" : game.status === "ready" ? "Start" : "Play again"}
              </Button>
            </div>
          </div>
        )}
      </div>

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

      <div className="grid grid-cols-3 gap-1.5" aria-label="Direction pad" role="group">
        <span />
        <Button size="icon" variant="secondary" className="size-10" aria-label="Up" onClick={() => steer("up")}>
          <ChevronUp aria-hidden />
        </Button>
        <span />
        <Button size="icon" variant="secondary" className="size-10" aria-label="Left" onClick={() => steer("left")}>
          <ChevronLeft aria-hidden />
        </Button>
        <Button
          size="icon"
          variant={playing ? "secondary" : "primary"}
          className="size-10"
          aria-label={playing ? "Pause" : "Start"}
          onClick={playing ? pause : start}
        >
          {playing ? <Pause aria-hidden /> : <Play aria-hidden />}
        </Button>
        <Button size="icon" variant="secondary" className="size-10" aria-label="Right" onClick={() => steer("right")}>
          <ChevronRight aria-hidden />
        </Button>
        <span />
        <Button size="icon" variant="secondary" className="size-10" aria-label="Down" onClick={() => steer("down")}>
          <ChevronDown aria-hidden />
        </Button>
        <span />
      </div>
    </div>
  );
}