← Back
Canvas

Snake Game

Classic snake game on a 20×20 canvas grid with D-pad controls and keyboard support.

Score: 0Press Start

canvas 2D · setInterval game loop · 20×20 grid · arrow keys

SnakeGame.tsx169 lines
"use client";

import { useEffect, useRef, useState } from "react";

const GRID = 20;
const CELL = 14;
const W = GRID * CELL;

type P = { x: number; y: number };
type Dir = "up" | "down" | "left" | "right";
const OPP: Record<Dir, Dir> = { up: "down", down: "up", left: "right", right: "left" };

function randFood(snake: P[]): P {
  let p: P;
  do { p = { x: Math.floor(Math.random() * GRID), y: Math.floor(Math.random() * GRID) }; }
  while (snake.some((s) => s.x === p.x && s.y === p.y));
  return p;
}

export default function SnakeGame() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const stateRef = useRef({
    snake: [{ x: 10, y: 10 }] as P[],
    dir: "right" as Dir,
    next: "right" as Dir,
    food: { x: 15, y: 10 } as P,
    running: false,
    score: 0,
  });
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const [score, setScore] = useState(0);
  const [status, setStatus] = useState<"idle" | "running" | "over">("idle");

  function render() {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;
    const s = stateRef.current;
    ctx.fillStyle = "#09090b";
    ctx.fillRect(0, 0, W, W);

    // grid dots
    ctx.fillStyle = "rgba(255,255,255,0.04)";
    for (let x = 0; x < GRID; x++)
      for (let y = 0; y < GRID; y++)
        ctx.fillRect(x * CELL + CELL / 2 - 0.5, y * CELL + CELL / 2 - 0.5, 1, 1);

    // food
    ctx.fillStyle = "#f43f5e";
    ctx.beginPath();
    ctx.arc(s.food.x * CELL + CELL / 2, s.food.y * CELL + CELL / 2, CELL / 2 - 2, 0, Math.PI * 2);
    ctx.fill();

    // snake
    s.snake.forEach((seg, i) => {
      const alpha = Math.max(0.3, 1 - (i / s.snake.length) * 0.7);
      ctx.fillStyle = i === 0 ? `rgba(167,139,250,1)` : `rgba(139,92,246,${alpha})`;
      ctx.fillRect(seg.x * CELL + 1, seg.y * CELL + 1, CELL - 2, CELL - 2);
    });
  }

  function tick() {
    const s = stateRef.current;
    s.dir = s.next;
    const head = { ...s.snake[0] };
    if (s.dir === "right") head.x++;
    else if (s.dir === "left")  head.x--;
    else if (s.dir === "up")    head.y--;
    else head.y++;

    if (head.x < 0 || head.x >= GRID || head.y < 0 || head.y >= GRID ||
        s.snake.some((seg) => seg.x === head.x && seg.y === head.y)) {
      clearInterval(intervalRef.current!);
      s.running = false;
      setStatus("over");
      return;
    }

    s.snake.unshift(head);
    if (head.x === s.food.x && head.y === s.food.y) {
      s.score++;
      setScore(s.score);
      s.food = randFood(s.snake);
    } else {
      s.snake.pop();
    }
    render();
  }

  function start() {
    clearInterval(intervalRef.current!);
    const s = stateRef.current;
    s.snake = [{ x: 10, y: 10 }];
    s.dir = "right"; s.next = "right";
    s.food = { x: 15, y: 10 };
    s.score = 0; s.running = true;
    setScore(0); setStatus("running");
    intervalRef.current = setInterval(tick, 130);
    render();
  }

  function move(dir: Dir) {
    const s = stateRef.current;
    if (s.running && s.dir !== OPP[dir]) s.next = dir;
  }

  useEffect(() => {
    render();
    function onKey(e: KeyboardEvent) {
      const map: Record<string, Dir> = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" };
      const d = map[e.key];
      if (d) { move(d); e.preventDefault(); }
    }
    window.addEventListener("keydown", onKey);
    return () => { clearInterval(intervalRef.current!); window.removeEventListener("keydown", onKey); };
  }, []);

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex items-center justify-between w-full max-w-[280px]">
        <span className="text-sm font-mono text-zinc-400">
          Score: <span className="text-zinc-900 dark:text-zinc-50 font-bold">{score}</span>
        </span>
        {status === "over" && <span className="text-sm font-medium text-red-500">Game Over</span>}
        {status === "idle" && <span className="text-xs text-zinc-400">Press Start</span>}
      </div>

      <canvas
        ref={canvasRef}
        width={W}
        height={W}
        className="rounded-xl border border-zinc-800 cursor-default"
      />

      {/* D-pad */}
      <div className="grid grid-cols-3 gap-1">
        {([
          [null, "up", null],
          ["left", null, "right"],
          [null, "down", null],
        ] as (Dir | null)[][]).map((row, ri) =>
          row.map((dir, ci) =>
            dir ? (
              <button
                key={`${ri}-${ci}`}
                onPointerDown={(e) => { e.preventDefault(); move(dir); }}
                className="w-10 h-10 rounded-lg bg-zinc-800 hover:bg-zinc-700 active:scale-90 text-white text-base flex items-center justify-center transition-all select-none"
              >
                {{ up: "↑", down: "↓", left: "←", right: "→" }[dir]}
              </button>
            ) : <div key={`${ri}-${ci}`} className="w-10 h-10" />
          )
        )}
      </div>

      <button
        onClick={start}
        className="px-6 py-2 rounded-full text-sm font-medium bg-violet-600 hover:bg-violet-700 text-white transition-colors"
      >
        {status === "idle" ? "Start" : "Restart"}
      </button>

      <p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600">
        canvas 2D · setInterval game loop · {GRID}×{GRID} grid · arrow keys
      </p>
    </div>
  );
}

More in Canvas