shadimage
gamekitui
componentdino-runnersource: ok · refreshed

Dino Runner

The offline-dino endless runner. Jump the cacti. Perfect for 404 pages.

npx shadcn add "https://gamekitui.com/r/dino-runner.json"
free

Where it goes

error-state

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/7/2026, 7:17:31 PM

registry/games/dino-runner.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/*                              Shared primitives                              */
/* -------------------------------------------------------------------------- */

const THEME_TOKENS = [
  "background",
  "foreground",
  "primary",
  "primary-foreground",
  "secondary",
  "accent",
  "muted",
  "muted-foreground",
  "border",
  "ring",
  "destructive",
] as const;
type ThemeTokens = Record<(typeof THEME_TOKENS)[number], string>;

function readTheme(el: HTMLElement | null): ThemeTokens {
  const target = el ?? (typeof document !== "undefined" ? document.documentElement : null);
  const cs = target ? getComputedStyle(target) : null;
  const out = {} as ThemeTokens;
  for (const t of THEME_TOKENS) out[t] = cs ? cs.getPropertyValue(`--${t}`).trim() : "";
  return out;
}

function useGameTheme(ref: React.RefObject<HTMLElement | null>): ThemeTokens {
  const [theme, setTheme] = React.useState<ThemeTokens>(() => readTheme(null));
  React.useEffect(() => {
    const read = () => setTheme(readTheme(ref.current));
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => mo.disconnect();
  }, [ref]);
  return theme;
}

function usePrefersReducedMotion(): boolean {
  const [reduce, setReduce] = React.useState(false);
  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduce(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduce;
}

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

// Logical (design) coordinate space. All draw + physics happen in this space;
// the canvas is scaled to fit the responsive playfield.
const LOGW = 480;
const LOGH = 180;

const GROUND_Y_RATIO = 0.72; // ground line as fraction of logical height
const GROUND_Y = Math.floor(LOGH * GROUND_Y_RATIO);

const DINO_W = 28;
const DINO_H = 32;
const DINO_X = 60; // fixed horizontal position

const GRAVITY = 1800; // px/s²
const JUMP_VY = -560; // px/s (negative = upward) — clears tallest cactus at apex
const DUCK_HEIGHT = 18; // reduced height when ducking
const INITIAL_SPEED = 220; // px/s
const SPEED_INCREMENT = 8; // px/s per 100 score points
const MAX_SPEED = 600;

const SCORE_RATE = 10; // score points per second

// Collision forgiveness: inset both boxes so a near-miss isn't a hit.
const DINO_INSET_X = 5;
const DINO_INSET_Y = 5;
const CACTUS_INSET = 2;

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

interface Obstacle {
  id: number;
  x: number;
  w: number;
  h: number;
}

interface DinoState {
  y: number; // height of dino bottom above ground (0 = on ground)
  vy: number; // vertical velocity px/s (positive = downward)
  ducking: boolean;
  jumping: boolean;
  step: number; // accumulated phase for running leg animation
}

/* -------------------------------------------------------------------------- */
/*                                  Props                                       */
/* -------------------------------------------------------------------------- */

export interface DinoRunnerProps {
  className?: string;
  /** Optional max width (px) of the responsive game; defaults to fluid full-width. */
  width?: number;
  paused?: boolean;
  autoFocus?: boolean;
  /**
   * When true (the default), the game listens for keys on `window` so it works
   * without being focused first — ideal for single-game pages (404s, loading /
   * empty states). Set false when multiple games share a page, or the game sits
   * in scrollable content, so it only responds while focused. Ignores keys aimed
   * at form fields.
   */
  captureGlobalKeys?: boolean;
  persistHighScore?: boolean | string;
  onScoreChange?: (score: number) => void;
  onGameOver?: (r: { score: number; won: boolean }) => void;
  onStart?: () => void;
}

/* -------------------------------------------------------------------------- */
/*                               Component                                      */
/* -------------------------------------------------------------------------- */

export function DinoRunner({
  className,
  width,
  paused = false,
  autoFocus = true,
  captureGlobalKeys = true,
  persistHighScore = true,
  onScoreChange,
  onGameOver,
  onStart,
}: DinoRunnerProps) {
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
  const playfieldRef = React.useRef<HTMLDivElement | null>(null);
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
  const theme = useGameTheme(wrapperRef);
  const reduce = usePrefersReducedMotion();

  const [status, setStatus] = React.useState<"idle" | "playing" | "gameover">("idle");
  const [score, setScore] = React.useState(0);
  const [high, setHigh] = React.useState(0);
  const [offscreen, setOffscreen] = React.useState(false);

  const storageKey =
    typeof persistHighScore === "string"
      ? persistHighScore
      : persistHighScore === false
        ? null
        : "gamekitui:dino-runner:hi";

  // ---- mutable live refs (read by rAF loop without re-subscribing) ----
  const statusRef = React.useRef(status);
  const pausedRef = React.useRef(paused);
  const offscreenRef = React.useRef(offscreen);
  const themeRef = React.useRef(theme);
  const reduceRef = React.useRef(reduce);
  statusRef.current = status;
  pausedRef.current = paused;
  offscreenRef.current = offscreen;
  themeRef.current = theme;
  reduceRef.current = reduce;

  // ---- game state refs ----
  const dinoRef = React.useRef<DinoState>({
    y: 0,
    vy: 0,
    ducking: false,
    jumping: false,
    step: 0,
  });
  const obstaclesRef = React.useRef<Obstacle[]>([]);
  const speedRef = React.useRef(INITIAL_SPEED);
  const scoreRef = React.useRef(0);
  const nextObstacleIdRef = React.useRef(0);
  const nextObstacleXRef = React.useRef(LOGW + 80);
  // accumulated score fraction (for smooth scoring)
  const scoreAccRef = React.useRef(0);
  // ground scroll for decorative ticks
  const groundOffsetRef = React.useRef(0);

  // ---- load high score ----
  React.useEffect(() => {
    if (!storageKey) return;
    try {
      const raw = window.localStorage.getItem(storageKey);
      if (raw) setHigh(Number(raw) || 0);
    } catch {
      /* ignore */
    }
  }, [storageKey]);

  const submitHigh = React.useCallback(
    (s: number) => {
      setHigh((prev) => {
        if (s <= prev) return prev;
        if (storageKey) {
          try {
            window.localStorage.setItem(storageKey, String(s));
          } catch {
            /* ignore */
          }
        }
        return s;
      });
    },
    [storageKey],
  );

  // ---- offscreen / visibility pause ----
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => {
      if (e) setOffscreen(!e.isIntersecting);
    });
    io.observe(el);
    const onVis = () => setOffscreen(document.hidden);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  // ---- start / reset ----
  const start = React.useCallback(() => {
    dinoRef.current = { y: 0, vy: 0, ducking: false, jumping: false, step: 0 };
    obstaclesRef.current = [];
    speedRef.current = INITIAL_SPEED;
    scoreRef.current = 0;
    scoreAccRef.current = 0;
    groundOffsetRef.current = 0;
    nextObstacleIdRef.current = 0;
    nextObstacleXRef.current = LOGW + 80 + Math.random() * 200;
    setScore(0);
    setStatus("playing");
    onStart?.();
    onScoreChange?.(0);
    // Focus the wrapper so keyboard controls work after the Start button unmounts.
    wrapperRef.current?.focus();
  }, [onStart, onScoreChange]);

  // ---- responsive canvas sizing: match playfield CSS size × DPR, scale to logical space ----
  React.useEffect(() => {
    const canvas = canvasRef.current;
    const field = playfieldRef.current;
    if (!canvas || !field) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const resize = () => {
      const rect = field.getBoundingClientRect();
      const w = Math.max(1, Math.round(rect.width));
      const h = Math.max(1, Math.round(rect.height));
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      const scale = w / LOGW;
      // Draw in logical space (LOGW × LOGH); transform maps it onto the device pixels.
      ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(field);
    return () => ro.disconnect();
  }, []);

  // ---- main rAF loop + draw ----
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const groundY = GROUND_Y;

    // ---- obstacle spawning helpers ----
    const spawnGap = () => 280 + Math.random() * 220;

    const spawnObstacle = (): Obstacle => {
      const h = 22 + Math.random() * 22; // 22–44 px tall
      const w = 12 + Math.random() * 10; // 12–22 px wide
      return {
        id: nextObstacleIdRef.current++,
        x: nextObstacleXRef.current,
        w,
        h,
      };
    };

    // ---- draw helpers ----
    const roundRect = (
      x: number,
      y: number,
      w: number,
      h: number,
      r: number,
      fill: string,
    ) => {
      ctx.fillStyle = fill;
      ctx.beginPath();
      ctx.roundRect(x, y, w, h, r);
      ctx.fill();
    };

    const drawDino = (t: ThemeTokens, dino: DinoState) => {
      const ducking = dino.ducking;
      const dh = ducking ? DUCK_HEIGHT : DINO_H;
      // Bottom of the body sits `dino.y` above the ground.
      const dy = groundY - dh - dino.y; // top pixel of dino body

      const bodyColor = t.primary || "#000";
      const legColor = t.primary || "#000";

      // body
      roundRect(DINO_X, dy, DINO_W, dh, 5, bodyColor);

      // eye (white)
      const eyeR = 3;
      const eyeX = DINO_X + DINO_W - eyeR - 4;
      const eyeY = dy + eyeR + 4;
      ctx.fillStyle = t["primary-foreground"] || "#fff";
      ctx.beginPath();
      ctx.arc(eyeX, eyeY, eyeR, 0, Math.PI * 2);
      ctx.fill();

      // pupil
      ctx.fillStyle = bodyColor;
      ctx.beginPath();
      ctx.arc(eyeX + 1, eyeY + 1, 1.5, 0, Math.PI * 2);
      ctx.fill();

      if (!ducking) {
        const legW = 7;
        const bodyBottom = dy + dh;
        const airborne = dino.jumping || dino.y > 0.5;

        if (airborne) {
          // Tucked jump pose: both legs short and pulled up.
          const legH = 6;
          roundRect(DINO_X + 4, bodyBottom - 2, legW, legH, 2, legColor);
          roundRect(DINO_X + 14, bodyBottom - 2, legW, legH, 2, legColor);
        } else {
          // Running: alternate two leg poses based on the step phase.
          const phase = Math.floor(dino.step) % 2 === 0;
          const frontH = phase ? 10 : 5;
          const backH = phase ? 5 : 10;
          roundRect(DINO_X + 4, bodyBottom, legW, backH, 2, legColor);
          roundRect(DINO_X + 14, bodyBottom, legW, frontH, 2, legColor);
        }
      }
    };

    const drawObstacle = (t: ThemeTokens, obs: Obstacle) => {
      const x = obs.x;
      const y = groundY - obs.h;
      const cactusColor = t["muted-foreground"] || "#555";

      // main stem
      roundRect(x, y, obs.w, obs.h, 3, cactusColor);

      // arm stubs for taller cacti
      if (obs.h > 30) {
        const armW = Math.floor(obs.w * 0.7);
        const armH = 5;
        const midY = y + obs.h * 0.35;
        roundRect(x - armW + 2, midY, armW, armH, 2, cactusColor);
        roundRect(x + obs.w - 2, midY + 4, armW, armH, 2, cactusColor);
      }
    };

    const drawGround = (t: ThemeTokens) => {
      ctx.strokeStyle = t["muted-foreground"] || "#888";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(0, groundY);
      ctx.lineTo(LOGW, groundY);
      ctx.stroke();

      // scrolling tick marks
      ctx.strokeStyle = t["muted-foreground"] || "#888";
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.4;
      const spacing = 40;
      const offset = groundOffsetRef.current % spacing;
      for (let x = -offset; x < LOGW; x += spacing) {
        ctx.beginPath();
        ctx.moveTo(x, groundY + 2);
        ctx.lineTo(x + 8, groundY + 2);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    };

    const draw = () => {
      const t = themeRef.current;
      ctx.clearRect(0, 0, LOGW, LOGH);
      ctx.fillStyle = t.background || "#fff";
      ctx.fillRect(0, 0, LOGW, LOGH);

      drawGround(t);

      for (const obs of obstaclesRef.current) {
        drawObstacle(t, obs);
      }

      drawDino(t, dinoRef.current);
    };

    // ---- collision detection (tight AABB on both axes, with forgiveness inset) ----
    const checkCollision = (dino: DinoState): boolean => {
      const dh = dino.ducking ? DUCK_HEIGHT : DINO_H;
      // Dino body box in logical space.
      const dinoLeft = DINO_X + DINO_INSET_X;
      const dinoRight = DINO_X + DINO_W - DINO_INSET_X;
      const dinoBottom = groundY - dino.y; // rises as the dino jumps
      const dinoTop = dinoBottom - dh;
      // Apply vertical forgiveness.
      const dTop = dinoTop + DINO_INSET_Y;
      const dBottom = dinoBottom - DINO_INSET_Y;

      for (const obs of obstaclesRef.current) {
        const cLeft = obs.x + CACTUS_INSET;
        const cRight = obs.x + obs.w - CACTUS_INSET;
        const cTop = groundY - obs.h + CACTUS_INSET;
        const cBottom = groundY - CACTUS_INSET;

        const overlapX = dinoLeft < cRight && dinoRight > cLeft;
        const overlapY = dTop < cBottom && dBottom > cTop;
        if (overlapX && overlapY) return true;
      }
      return false;
    };

    // ---- game over callback holder ----
    const handleGameOver = (finalScore: number) => {
      setStatus("gameover");
      submitHigh(finalScore);
      onGameOver?.({ score: finalScore, won: false });
    };

    let raf = 0;
    let last = 0;

    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      if (last === 0) last = now;
      const dtMs = now - last;
      last = now;

      const active =
        statusRef.current === "playing" &&
        !pausedRef.current &&
        !offscreenRef.current;

      if (active) {
        // cap dt to avoid tunneling on tab refocus; freeze under reduced motion
        const dt = reduceRef.current ? 0 : Math.min(dtMs / 1000, 0.05);

        const dino = dinoRef.current;
        const speed = speedRef.current;

        // ---- physics ----
        if (dino.jumping || dino.y > 0) {
          dino.vy += GRAVITY * dt;
          dino.y -= dino.vy * dt;
          if (dino.y <= 0) {
            dino.y = 0;
            dino.vy = 0;
            dino.jumping = false;
          }
        } else {
          // ---- running leg animation: advance step phase proportional to speed ----
          dino.step += dt * (speed / 30);
        }

        // ---- scroll obstacles ----
        groundOffsetRef.current += speed * dt;
        const surviving: Obstacle[] = [];
        for (const obs of obstaclesRef.current) {
          obs.x -= speed * dt;
          if (obs.x + obs.w > -10) surviving.push(obs);
        }
        obstaclesRef.current = surviving;

        // ---- spawn new obstacle ----
        nextObstacleXRef.current -= speed * dt;
        if (nextObstacleXRef.current <= LOGW) {
          obstaclesRef.current.push(spawnObstacle());
          nextObstacleXRef.current = LOGW + spawnGap();
        }

        // ---- score ----
        scoreAccRef.current += SCORE_RATE * dt;
        const newScore = Math.floor(scoreAccRef.current);
        if (newScore !== scoreRef.current) {
          scoreRef.current = newScore;
          setScore(newScore);
          onScoreChange?.(newScore);
          // ramp speed
          const targetSpeed = Math.min(
            INITIAL_SPEED + Math.floor(newScore / 100) * SPEED_INCREMENT,
            MAX_SPEED,
          );
          speedRef.current = targetSpeed;
        }

        // ---- collision ----
        if (checkCollision(dino)) {
          handleGameOver(scoreRef.current);
        }
      }

      draw();
    };

    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [onGameOver, onScoreChange, submitHigh]);

  // ---- keyboard input ----
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      // In global mode, ignore keys aimed at form fields / editable content.
      const t = e.target as HTMLElement | null;
      if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;

      const key = e.key;
      const isJump = key === " " || key === "ArrowUp" || key === "w" || key === "W";
      const isDuck = key === "ArrowDown" || key === "s" || key === "S";

      if (!isJump && !isDuck) return;
      e.preventDefault();

      if (statusRef.current === "idle") {
        start();
        return;
      }
      if (statusRef.current === "gameover") {
        if (isJump) start();
        return;
      }

      const dino = dinoRef.current;
      if (isJump && !dino.jumping && dino.y === 0) {
        dino.vy = JUMP_VY;
        dino.jumping = true;
        dino.ducking = false;
      }
      if (isDuck) {
        dino.ducking = true;
      }
    };

    const onKeyUp = (e: KeyboardEvent) => {
      const isDuck = e.key === "ArrowDown" || e.key === "s" || e.key === "S";
      if (isDuck) dinoRef.current.ducking = false;
    };

    // Single-game pages can opt into window-level capture so keys work without
    // focusing the game; otherwise listen on the wrapper (focus-scoped).
    const target: Window | HTMLElement | null = captureGlobalKeys
      ? window
      : wrapperRef.current;
    target?.addEventListener("keydown", onKey as EventListener);
    target?.addEventListener("keyup", onKeyUp as EventListener);
    return () => {
      target?.removeEventListener("keydown", onKey as EventListener);
      target?.removeEventListener("keyup", onKeyUp as EventListener);
    };
  }, [start, captureGlobalKeys]);

  // ---- pointer input (mouse + touch + pen): tap/click to start & jump ----
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    const onPointerDown = (e: PointerEvent) => {
      // Let overlay buttons (Start / Play again) handle their own clicks.
      if ((e.target as HTMLElement | null)?.closest("button")) return;
      e.preventDefault();
      if (statusRef.current !== "playing") {
        start();
        return;
      }
      const dino = dinoRef.current;
      if (!dino.jumping && dino.y === 0) {
        dino.vy = JUMP_VY;
        dino.jumping = true;
        dino.ducking = false;
      }
    };
    el.addEventListener("pointerdown", onPointerDown);
    return () => el.removeEventListener("pointerdown", onPointerDown);
  }, [start]);

  // ---- autofocus ----
  React.useEffect(() => {
    if (autoFocus) wrapperRef.current?.focus();
  }, [autoFocus]);

  return (
    <div
      ref={wrapperRef}
      tabIndex={0}
      role="application"
      aria-label="Dino Runner game"
      style={width ? { maxWidth: width } : undefined}
      className={cn(
        "relative flex w-full select-none flex-col gap-2 outline-none",
        // Focus ring only matters when keys are focus-scoped; global capture hides it.
        !captureGlobalKeys &&
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        className,
      )}
    >
      {/* Score bar */}
      <div className="flex items-center justify-between gap-3 text-sm">
        <span className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums">
          Score {score}
        </span>
        <span className="text-muted-foreground tabular-nums">Best {high}</span>
      </div>

      {/* Responsive playfield */}
      <div
        ref={playfieldRef}
        className="relative aspect-[8/3] w-full overflow-hidden rounded-lg border bg-background"
      >
        <canvas ref={canvasRef} className="absolute inset-0 block h-full w-full touch-none" />

        {/* Overlay */}
        {status !== "playing" && (
          <div className="absolute inset-0 grid place-items-center bg-background/80 backdrop-blur-[1px]">
            <div className="flex flex-col items-center gap-3 px-6 text-center">
              {status === "gameover" ? (
                <>
                  <p className="font-semibold text-lg">Game over</p>
                  <p className="text-muted-foreground text-sm">
                    You scored {score}
                    {score >= high && score > 0 ? " — new best!" : ""}
                  </p>
                </>
              ) : (
                <>
                  <p className="font-semibold text-lg">Dino Runner</p>
                  <p className="text-muted-foreground text-sm">
                    Space / Up / W to jump. Down / S to duck.
                  </p>
                </>
              )}
              <button
                type="button"
                onClick={start}
                className={cn(
                  "inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm",
                  "transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                )}
              >
                {status === "gameover" ? "Play again" : "Start"}
              </button>
            </div>
          </div>
        )}
      </div>

      {/* Accessible live region */}
      <div aria-live="polite" className="sr-only">
        {status === "playing" ? `Score: ${score}` : null}
        {status === "gameover" ? `Game over. Final score: ${score}. Best: ${high}.` : null}
      </div>
    </div>
  );
}

export default DinoRunner;

More from gamekitui

Browse all components in this library →