shadimage
gamekitui
componentflappysource: ok · hit

Flappy

Tap to flap through the pipes. A tiny infinite runner for loading screens.

npx shadcn add "https://gamekitui.com/r/flappy.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 2:55:53 PM

registry/games/flappy.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 playfield dimensions. All physics/drawing happen in this fixed
// coordinate space; the canvas is scaled to its rendered size at draw time.
const LOGW = 300;
const LOGH = 400;

const BIRD_X_FRAC = 0.25; // bird fixed x as fraction of width
const BIRD_RADIUS = 14; // bird half-size (drawn as rounded square)
const GRAVITY = 1800; // px/s² (normal)
const GRAVITY_REDUCED = 600; // px/s² (reduced motion)
const FLAP_VY = -480; // px/s (normal)
const FLAP_VY_REDUCED = -280; // px/s (reduced motion)
const PIPE_WIDTH = 52;
const PIPE_GAP = 130; // vertical gap between top/bottom pipes
const PIPE_SPEED = 180; // px/s (normal)
const PIPE_SPEED_REDUCED = 90; // px/s (reduced motion)
const PIPE_SPACING = 230; // horizontal distance between pipes
const GROUND_H = 24; // height of ground strip

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

interface Pipe {
  x: number;
  gapY: number; // center y of gap
  scored: boolean;
}

interface FlappyLiveState {
  birdY: number;
  birdVY: number;
  pipes: Pipe[];
  score: number;
  blipAlpha: number; // accent flash when scoring
}

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

export interface FlappyProps {
  className?: string;
  width?: number;
  height?: 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;
}

export function Flappy({
  className,
  width,
  paused = false,
  autoFocus = true,
  captureGlobalKeys = true,
  persistHighScore = true,
  onScoreChange,
  onGameOver,
  onStart,
}: FlappyProps) {
  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:flappy:hi";

  // Mutable refs the rAF loop reads without re-subscribing.
  const liveRef = React.useRef<FlappyLiveState>({
    birdY: LOGH / 2,
    birdVY: 0,
    pipes: [],
    score: 0,
    blipAlpha: 0,
  });
  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);
  // Callbacks stored as refs to avoid rAF loop re-creation
  const onScoreChangeRef = React.useRef(onScoreChange);
  const onGameOverRef = React.useRef(onGameOver);
  const onStartRef = React.useRef(onStart);
  const submitHighRef = React.useRef<(s: number) => void>(() => undefined);

  statusRef.current = status;
  pausedRef.current = paused;
  offscreenRef.current = offscreen;
  themeRef.current = theme;
  reduceRef.current = reduce;
  onScoreChangeRef.current = onScoreChange;
  onGameOverRef.current = onGameOver;
  onStartRef.current = onStart;

  // Load persisted 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],
  );

  // Keep submitHigh in a ref so the rAF closure can call it
  submitHighRef.current = submitHigh;

  // Pause when scrolled offscreen or tab hidden
  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);
    };
  }, []);

  // Helper: initialise live state for a fresh game
  const resetLive = React.useCallback(() => {
    // Spawn first pipe just off right edge
    const firstPipeX = LOGW + PIPE_SPACING * 0.5;
    const gapY = LOGH * 0.4 + Math.random() * (LOGH * 0.25);
    // Begin mid-flap: give the bird a gentle upward velocity (0.8× a full flap)
    // so it rises briefly instead of dropping under gravity immediately.
    const startVY = (reduceRef.current ? FLAP_VY_REDUCED : FLAP_VY) * 0.8;
    liveRef.current = {
      birdY: LOGH / 2,
      birdVY: startVY,
      pipes: [{ x: firstPipeX, gapY, scored: false }],
      score: 0,
      blipAlpha: 0,
    };
  }, []);

  const start = React.useCallback(() => {
    resetLive();
    setScore(0);
    setStatus("playing");
    onStartRef.current?.();
    onScoreChangeRef.current?.(0);
    // Focus the wrapper so keyboard controls work after the Start button unmounts.
    wrapperRef.current?.focus();
  }, [resetLive]);

  // Flap action — callable from keyboard/touch/click
  const flap = React.useCallback(() => {
    if (statusRef.current === "gameover") {
      start();
      return;
    }
    if (statusRef.current === "idle") {
      start();
      // velocity will be set after start; we set it directly on live state
    }
    const vy = reduceRef.current ? FLAP_VY_REDUCED : FLAP_VY;
    liveRef.current.birdVY = vy;
  }, [start]);

  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,
  // then scale the context so all drawing/physics can use the 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, rect.width);
      const h = Math.max(1, 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;
      ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(field);
    return () => ro.disconnect();
  }, []);

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

    const birdX = LOGW * BIRD_X_FRAC;
    const playH = LOGH - GROUND_H; // playable height above ground

    const drawRoundRect = (
      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 draw = () => {
      const t = themeRef.current;
      const live = liveRef.current;

      // Clear + background (logical space)
      ctx.clearRect(0, 0, LOGW, LOGH);
      ctx.fillStyle = t.background || "#fff";
      ctx.fillRect(0, 0, LOGW, LOGH);

      // Draw pipes (secondary color)
      for (const pipe of live.pipes) {
        const topH = pipe.gapY - PIPE_GAP / 2;
        const botY = pipe.gapY + PIPE_GAP / 2;
        const botH = playH - botY;
        const pipeColor = t["muted-foreground"] || "#888";
        const r = 4;
        // Top pipe
        if (topH > 0) drawRoundRect(pipe.x, 0, PIPE_WIDTH, topH, r, pipeColor);
        // Bottom pipe
        if (botH > 0) drawRoundRect(pipe.x, botY, PIPE_WIDTH, botH, r, pipeColor);
      }

      // Ground strip (border/muted)
      ctx.fillStyle = t.border || t.muted || "#ccc";
      ctx.fillRect(0, playH, LOGW, GROUND_H);
      // Ground line
      ctx.fillStyle = t["muted-foreground"] || "#aaa";
      ctx.fillRect(0, playH, LOGW, 2);

      // Score blip overlay (themed flash)
      if (live.blipAlpha > 0) {
        ctx.globalAlpha = live.blipAlpha * 0.18;
        ctx.fillStyle = t.primary || "#ff0";
        ctx.fillRect(0, 0, LOGW, LOGH);
        ctx.globalAlpha = 1;
      }

      // Bird (primary, rounded square)
      const birdColor = t.primary || "#000";
      drawRoundRect(
        birdX - BIRD_RADIUS,
        live.birdY - BIRD_RADIUS,
        BIRD_RADIUS * 2,
        BIRD_RADIUS * 2,
        BIRD_RADIUS * 0.45,
        birdColor,
      );

      // Eye detail (primary-foreground)
      const eyeColor = t["primary-foreground"] || "#fff";
      ctx.fillStyle = eyeColor;
      ctx.beginPath();
      ctx.arc(birdX + BIRD_RADIUS * 0.35, live.birdY - BIRD_RADIUS * 0.25, 3.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = "#333";
      ctx.beginPath();
      ctx.arc(birdX + BIRD_RADIUS * 0.35, live.birdY - BIRD_RADIUS * 0.25, 1.5, 0, Math.PI * 2);
      ctx.fill();
    };

    let raf = 0;
    let last = 0;

    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      if (last === 0) last = now;
      const dt = Math.min((now - last) / 1000, 0.05); // seconds, capped at 50ms
      last = now;

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

      if (active) {
        const live = liveRef.current;
        const isReduce = reduceRef.current;
        const gravity = isReduce ? GRAVITY_REDUCED : GRAVITY;
        const pipeSpeed = isReduce ? PIPE_SPEED_REDUCED : PIPE_SPEED;
        const playH = LOGH - GROUND_H;

        // Physics
        live.birdVY += gravity * dt;
        live.birdY += live.birdVY * dt;

        // Move pipes & spawn new ones
        for (const pipe of live.pipes) {
          pipe.x -= pipeSpeed * dt;
        }

        // Remove pipes that scrolled off-screen
        while (live.pipes.length > 0 && (live.pipes[0]?.x ?? 0) < -PIPE_WIDTH - 10) {
          live.pipes.shift();
        }

        // Spawn new pipe when last pipe is far enough left
        const lastPipe = live.pipes[live.pipes.length - 1];
        if (!lastPipe || lastPipe.x < LOGW - PIPE_SPACING) {
          const minGapY = PIPE_GAP / 2 + 30;
          const maxGapY = playH - PIPE_GAP / 2 - 30;
          const gapY = minGapY + Math.random() * Math.max(0, maxGapY - minGapY);
          live.pipes.push({ x: LOGW + PIPE_WIDTH, gapY, scored: false });
        }

        // Score: bird passed pipe center
        for (const pipe of live.pipes) {
          if (!pipe.scored && pipe.x + PIPE_WIDTH < birdX) {
            pipe.scored = true;
            live.score += 1;
            live.blipAlpha = 1;
            const sc = live.score;
            setScore(sc);
            onScoreChangeRef.current?.(sc);
          }
        }

        // Decay blip
        if (live.blipAlpha > 0) {
          live.blipAlpha = Math.max(0, live.blipAlpha - dt * 4);
        }

        // Collision: ceiling
        const hitCeiling = live.birdY - BIRD_RADIUS < 0;
        // Collision: ground
        const hitGround = live.birdY + BIRD_RADIUS > playH;
        // Collision: pipes
        let hitPipe = false;
        for (const pipe of live.pipes) {
          const birdL = birdX - BIRD_RADIUS + 3; // slight shrink for fairness
          const birdR = birdX + BIRD_RADIUS - 3;
          const birdT = live.birdY - BIRD_RADIUS + 3;
          const birdB = live.birdY + BIRD_RADIUS - 3;
          const pipeL = pipe.x;
          const pipeR = pipe.x + PIPE_WIDTH;
          const gapTop = pipe.gapY - PIPE_GAP / 2;
          const gapBot = pipe.gapY + PIPE_GAP / 2;
          const overlapX = birdR > pipeL && birdL < pipeR;
          if (overlapX && (birdT < gapTop || birdB > gapBot)) {
            hitPipe = true;
            break;
          }
        }

        if (hitCeiling || hitGround || hitPipe) {
          const finalScore = live.score;
          setStatus("gameover");
          submitHighRef.current(finalScore);
          onGameOverRef.current?.({ score: finalScore, won: false });
        }
      } else {
        last = now; // reset last so no huge dt burst after unpause
      }

      draw();
    };

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

  // Keyboard handling
  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;
      if (e.key === " " || e.key === "ArrowUp" || e.key === "w" || e.key === "W") {
        e.preventDefault();
        flap();
      } else if (e.key === "Enter") {
        e.preventDefault();
        if (statusRef.current !== "playing") flap();
      }
    };
    // 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);
    return () => {
      target?.removeEventListener("keydown", onKey as EventListener);
    };
  }, [flap, captureGlobalKeys]);

  // Touch / click — tap/click anywhere on canvas flaps
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    const onTap = (e: TouchEvent) => {
      e.preventDefault();
      flap();
    };
    el.addEventListener("touchstart", onTap, { passive: false });
    return () => el.removeEventListener("touchstart", onTap);
  }, [flap]);

  // Canvas click
  const handleCanvasClick = React.useCallback(() => {
    flap();
  }, [flap]);

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

  return (
    <div
      ref={wrapperRef}
      tabIndex={0}
      role="application"
      aria-label="Flappy bird 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 badge row */}
      <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"
          aria-live="polite"
          aria-atomic="true"
        >
          Score {score}
        </span>
        <span className="text-muted-foreground tabular-nums">Best {high}</span>
      </div>

      {/* Canvas wrapper */}
      <div
        ref={playfieldRef}
        className="relative aspect-[3/4] w-full overflow-hidden rounded-lg border bg-background"
      >
        <canvas
          ref={canvasRef}
          className="absolute inset-0 block h-full w-full cursor-pointer touch-none"
          onClick={handleCanvasClick}
        />

        {/* Idle / Game-over 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">Flappy</p>
                  <p className="text-muted-foreground text-sm">
                    Space, ↑, or tap to flap.
                  </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>
        )}

        {/* Aria-live region (hidden visually, readable by screen readers) */}
        <div
          aria-live="polite"
          aria-atomic="true"
          className="sr-only"
        >
          {status === "gameover"
            ? `Game over. You scored ${score}.${score >= high && score > 0 ? " New best!" : ""}`
            : status === "idle"
              ? "Flappy bird. Press Space or tap to start."
              : `Score ${score}`}
        </div>
      </div>
    </div>
  );
}

export default Flappy;

More from gamekitui

Browse all components in this library →