shadimage
gamekitui
componentpongsource: ok · hit

Pong

The original. Player vs CPU. First to 7. W/S or arrow keys.

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

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

Files1 · source cached 10/9/2026, 4:12:25 PM

registry/games/pong.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 drawing space — all physics/layout/drawing happen in this coordinate
// system; the canvas is scaled to fit its container while preserving aspect.
const LOGW = 360;
const LOGH = 240;

const PADDLE_W = 8;
const PADDLE_H = 50;
const PADDLE_RADIUS = 4;
const BALL_SIZE = 10;
const BALL_RADIUS = 5;
const PADDLE_MARGIN = 14;
const MAX_SCORE = 7;

// Ball speed in px/s at normal motion
const BALL_SPEED_NORMAL = 220;
const BALL_SPEED_REDUCED = 80;
const CPU_SPEED_NORMAL = 170; // px/s
const CPU_SPEED_REDUCED = 60;

/* -------------------------------------------------------------------------- */
/*                              Game state (refs)                              */
/* -------------------------------------------------------------------------- */

interface BallState {
  x: number;
  y: number;
  vx: number;
  vy: number;
}

interface GameState {
  playerY: number;
  cpuY: number;
  ball: BallState;
  playerScore: number;
  cpuScore: number;
  pointFlash: number; // timestamp of last point, for flash
  pointWinner: "player" | "cpu" | null;
}

function initBall(towardPlayer: boolean, speed: number): BallState {
  const angle = (Math.random() * 0.6 - 0.3); // -0.3 to 0.3 radians
  const vx = towardPlayer ? -speed * Math.cos(angle) : speed * Math.cos(angle);
  const vy = speed * Math.sin(angle);
  return { x: LOGW / 2, y: LOGH / 2, vx, vy };
}

function initGameState(speed: number): GameState {
  return {
    playerY: LOGH / 2 - PADDLE_H / 2,
    cpuY: LOGH / 2 - PADDLE_H / 2,
    ball: initBall(Math.random() > 0.5, speed),
    playerScore: 0,
    cpuScore: 0,
    pointFlash: 0,
    pointWinner: null,
  };
}

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

export interface PongProps {
  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 Pong({
  className,
  width,
  paused = false,
  autoFocus = true,
  captureGlobalKeys = true,
  persistHighScore: _persistHighScore,
  onScoreChange,
  onGameOver,
  onStart,
}: PongProps) {
  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 as React.RefObject<HTMLElement | null>);
  const reduce = usePrefersReducedMotion();

  const [status, setStatus] = React.useState<"idle" | "playing" | "gameover">("idle");
  const [playerScore, setPlayerScore] = React.useState(0);
  const [cpuScore, setCpuScore] = React.useState(0);
  const [winner, setWinner] = React.useState<"player" | "cpu" | null>(null);
  const [offscreen, setOffscreen] = React.useState(false);

  // Mutable refs the rAF loop reads 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);
  const gameStateRef = React.useRef<GameState | null>(null);
  // Track pointer drag y for player paddle
  const pointerYRef = React.useRef<number | null>(null);
  // Track pressed keys
  const keysRef = React.useRef<Set<string>>(new Set());

  statusRef.current = status;
  pausedRef.current = paused;
  offscreenRef.current = offscreen;
  themeRef.current = theme;
  reduceRef.current = reduce;

  // Announce score changes
  const [announcement, setAnnouncement] = React.useState("");

  // 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);
    };
  }, []);

  const start = React.useCallback(() => {
    const speed = reduceRef.current ? BALL_SPEED_REDUCED : BALL_SPEED_NORMAL;
    gameStateRef.current = initGameState(speed);
    setPlayerScore(0);
    setCpuScore(0);
    setWinner(null);
    setStatus("playing");
    setAnnouncement("Game started");
    onStart?.();
    onScoreChange?.(0);
    // Focus the wrapper so keyboard controls work after the Start button unmounts.
    wrapperRef.current?.focus();
  }, [onScoreChange, onStart]);

  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,
  // then scale the transform so all drawing happens in LOGW×LOGH 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 = w * dpr;
      canvas.height = 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 loop
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    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 = (gs: GameState | null) => {
      const t = themeRef.current;

      // Clear (transform already maps logical space onto the device canvas)
      ctx.clearRect(0, 0, LOGW, LOGH);

      // Background
      ctx.fillStyle = t.background || "#000";
      ctx.fillRect(0, 0, LOGW, LOGH);

      // Border
      ctx.strokeStyle = t.border || "rgba(255,255,255,0.2)";
      ctx.lineWidth = 1;
      ctx.strokeRect(0.5, 0.5, LOGW - 1, LOGH - 1);

      // Dashed center line
      const now = performance.now();
      const flashActive = gs && (now - gs.pointFlash) < 400;
      const midLineColor = flashActive
        ? t.primary || "#ff0"
        : t["muted-foreground"] || "rgba(255,255,255,0.15)";
      ctx.strokeStyle = midLineColor;
      ctx.lineWidth = 1;
      ctx.setLineDash([6, 6]);
      ctx.beginPath();
      ctx.moveTo(LOGW / 2, 4);
      ctx.lineTo(LOGW / 2, LOGH - 4);
      ctx.stroke();
      ctx.setLineDash([]);

      if (!gs) return;

      // Player paddle (left) — primary
      drawRoundRect(
        PADDLE_MARGIN,
        gs.playerY,
        PADDLE_W,
        PADDLE_H,
        PADDLE_RADIUS,
        t.primary || "#fff",
      );

      // CPU paddle (right) — muted-foreground (visible on both light/dark, distinct from the primary player)
      drawRoundRect(
        LOGW - PADDLE_MARGIN - PADDLE_W,
        gs.cpuY,
        PADDLE_W,
        PADDLE_H,
        PADDLE_RADIUS,
        t["muted-foreground"] || "#888",
      );

      // Ball — primary (rounded square)
      drawRoundRect(
        gs.ball.x - BALL_SIZE / 2,
        gs.ball.y - BALL_SIZE / 2,
        BALL_SIZE,
        BALL_SIZE,
        BALL_RADIUS,
        t.primary || "#fff",
      );
    };

    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
      last = now;

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

      if (active && gs) {
        const speed = reduceRef.current ? BALL_SPEED_REDUCED : BALL_SPEED_NORMAL;
        const cpuSpeed = reduceRef.current ? CPU_SPEED_REDUCED : CPU_SPEED_NORMAL;

        // Player paddle movement via keys
        const keys = keysRef.current;
        const paddleSpeed = cpuSpeed * 1.3; // player paddle speed
        if (keys.has("ArrowUp") || keys.has("w") || keys.has("W")) {
          gs.playerY = Math.max(0, gs.playerY - paddleSpeed * dt);
        }
        if (keys.has("ArrowDown") || keys.has("s") || keys.has("S")) {
          gs.playerY = Math.min(LOGH - PADDLE_H, gs.playerY + paddleSpeed * dt);
        }

        // Player paddle movement via pointer
        const ptrY = pointerYRef.current;
        if (ptrY !== null) {
          const targetY = ptrY - PADDLE_H / 2;
          const clamped = Math.max(0, Math.min(LOGH - PADDLE_H, targetY));
          // Snap to pointer position (within capped speed)
          const diff = clamped - gs.playerY;
          const maxMove = paddleSpeed * dt;
          if (Math.abs(diff) <= maxMove) {
            gs.playerY = clamped;
          } else {
            gs.playerY += Math.sign(diff) * maxMove;
          }
        }

        // CPU paddle AI — track ball with capped speed
        const cpuCenter = gs.cpuY + PADDLE_H / 2;
        const diff = gs.ball.y - cpuCenter;
        const maxMove = cpuSpeed * dt;
        if (Math.abs(diff) > 2) {
          gs.cpuY += Math.sign(diff) * Math.min(Math.abs(diff), maxMove);
          gs.cpuY = Math.max(0, Math.min(LOGH - PADDLE_H, gs.cpuY));
        }

        // Move ball
        gs.ball.x += gs.ball.vx * dt;
        gs.ball.y += gs.ball.vy * dt;

        // Wall bounce (top/bottom)
        if (gs.ball.y - BALL_SIZE / 2 < 0) {
          gs.ball.y = BALL_SIZE / 2;
          gs.ball.vy = Math.abs(gs.ball.vy);
        }
        if (gs.ball.y + BALL_SIZE / 2 > LOGH) {
          gs.ball.y = LOGH - BALL_SIZE / 2;
          gs.ball.vy = -Math.abs(gs.ball.vy);
        }

        // Player paddle collision (left)
        const playerLeft = PADDLE_MARGIN;
        const playerRight = PADDLE_MARGIN + PADDLE_W;
        if (
          gs.ball.vx < 0 &&
          gs.ball.x - BALL_SIZE / 2 <= playerRight &&
          gs.ball.x + BALL_SIZE / 2 >= playerLeft &&
          gs.ball.y + BALL_SIZE / 2 >= gs.playerY &&
          gs.ball.y - BALL_SIZE / 2 <= gs.playerY + PADDLE_H
        ) {
          gs.ball.x = playerRight + BALL_SIZE / 2;
          // Angle based on hit position (-1 to 1 relative to paddle center)
          const hitPos = (gs.ball.y - (gs.playerY + PADDLE_H / 2)) / (PADDLE_H / 2);
          const angle = hitPos * (Math.PI / 4); // max 45 degrees
          const currentSpeed = Math.hypot(gs.ball.vx, gs.ball.vy);
          const newSpeed = Math.max(currentSpeed, speed);
          gs.ball.vx = newSpeed * Math.cos(angle);
          gs.ball.vy = newSpeed * Math.sin(angle);
        }

        // CPU paddle collision (right)
        const cpuLeft = LOGW - PADDLE_MARGIN - PADDLE_W;
        const cpuRight = LOGW - PADDLE_MARGIN;
        if (
          gs.ball.vx > 0 &&
          gs.ball.x + BALL_SIZE / 2 >= cpuLeft &&
          gs.ball.x - BALL_SIZE / 2 <= cpuRight &&
          gs.ball.y + BALL_SIZE / 2 >= gs.cpuY &&
          gs.ball.y - BALL_SIZE / 2 <= gs.cpuY + PADDLE_H
        ) {
          gs.ball.x = cpuLeft - BALL_SIZE / 2;
          const hitPos = (gs.ball.y - (gs.cpuY + PADDLE_H / 2)) / (PADDLE_H / 2);
          const angle = hitPos * (Math.PI / 4);
          const currentSpeed = Math.hypot(gs.ball.vx, gs.ball.vy);
          const newSpeed = Math.max(currentSpeed, speed);
          gs.ball.vx = -(newSpeed * Math.cos(angle));
          gs.ball.vy = newSpeed * Math.sin(angle);
        }

        // Score check
        let scored = false;
        if (gs.ball.x < 0) {
          // CPU scores
          gs.cpuScore += 1;
          gs.pointFlash = performance.now();
          gs.pointWinner = "cpu";
          scored = true;
          const newCpuScore = gs.cpuScore;
          const newPlayerScore = gs.playerScore;
          setCpuScore(newCpuScore);
          onScoreChange?.(newPlayerScore);
          setAnnouncement(`CPU scores. Player ${newPlayerScore}, CPU ${newCpuScore}`);

          if (gs.cpuScore >= MAX_SCORE) {
            setStatus("gameover");
            setWinner("cpu");
            setAnnouncement("Game over. CPU wins!");
            onGameOver?.({ score: gs.playerScore, won: false });
            gameStateRef.current = gs;
          } else {
            gs.ball = initBall(true, speed); // serve toward player (they lost)
          }
        } else if (gs.ball.x > LOGW) {
          // Player scores
          gs.playerScore += 1;
          gs.pointFlash = performance.now();
          gs.pointWinner = "player";
          scored = true;
          const newPlayerScore = gs.playerScore;
          const newCpuScore = gs.cpuScore;
          setPlayerScore(newPlayerScore);
          onScoreChange?.(newPlayerScore);
          setAnnouncement(`Player scores. Player ${newPlayerScore}, CPU ${newCpuScore}`);

          if (gs.playerScore >= MAX_SCORE) {
            setStatus("gameover");
            setWinner("player");
            setAnnouncement("Game over. You win!");
            onGameOver?.({ score: gs.playerScore, won: true });
            gameStateRef.current = gs;
          } else {
            gs.ball = initBall(false, speed); // serve toward CPU (they lost)
          }
        }

        if (scored) {
          // Reset paddle positions gently
          gs.playerY = LOGH / 2 - PADDLE_H / 2;
          gs.cpuY = LOGH / 2 - PADDLE_H / 2;
        }
      } else if (!active) {
        // Nothing
      }

      draw(gameStateRef.current);
    };

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

  // Keyboard
  React.useEffect(() => {
    const onKeyDown = (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 gameKeys = new Set(["ArrowUp", "ArrowDown", "w", "s", "W", "S"]);
      if (gameKeys.has(e.key)) {
        e.preventDefault();
        keysRef.current.add(e.key);
        if (statusRef.current !== "playing") start();
      } else if (e.key === " " || e.key === "Enter") {
        e.preventDefault();
        if (statusRef.current !== "playing") start();
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      keysRef.current.delete(e.key);
    };
    // 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", onKeyDown as EventListener);
    target?.addEventListener("keyup", onKeyUp as EventListener);
    return () => {
      target?.removeEventListener("keydown", onKeyDown as EventListener);
      target?.removeEventListener("keyup", onKeyUp as EventListener);
    };
  }, [start, captureGlobalKeys]);

  // Pointer / touch drag for player paddle
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const getRelativeY = (clientY: number) => {
      const rect = canvas.getBoundingClientRect();
      return ((clientY - rect.top) / rect.height) * LOGH;
    };

    const onPointerDown = (e: PointerEvent) => {
      canvas.setPointerCapture(e.pointerId);
      pointerYRef.current = getRelativeY(e.clientY);
      if (statusRef.current !== "playing") start();
    };
    const onPointerMove = (e: PointerEvent) => {
      if (e.buttons === 0) return;
      pointerYRef.current = getRelativeY(e.clientY);
    };
    const onPointerUp = () => {
      pointerYRef.current = null;
    };

    canvas.addEventListener("pointerdown", onPointerDown);
    canvas.addEventListener("pointermove", onPointerMove);
    canvas.addEventListener("pointerup", onPointerUp);
    canvas.addEventListener("pointercancel", onPointerUp);
    return () => {
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointercancel", onPointerUp);
    };
  }, [start]);

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

  return (
    <div
      ref={wrapperRef}
      tabIndex={0}
      role="application"
      aria-label="Pong 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"
        >
          You {playerScore}
        </span>
        <span className="text-muted-foreground text-xs">First to {MAX_SCORE}</span>
        <span className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums">
          CPU {cpuScore}
        </span>
      </div>

      {/* aria-live region */}
      <div aria-live="polite" className="sr-only">
        {announcement}
      </div>

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

        {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">
                    {winner === "player" ? "You win!" : "CPU wins"}
                  </p>
                  <p className="text-muted-foreground text-sm">
                    {playerScore} – {cpuScore}
                  </p>
                </>
              ) : (
                <>
                  <p className="font-semibold text-lg">Pong</p>
                  <p className="text-muted-foreground text-sm">
                    W/S or Arrow keys to move. Drag on mobile.
                  </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>
    </div>
  );
}

export default Pong;

More from gamekitui

Browse all components in this library →