Snake
The classic snake game in three looks: classic, funky, and pixelated.
experimental
Try it
Score: 0Best 0
Snake
Press Start, or an arrow key on the board.
How it works
- Steer with the arrow keys or WASD on the focused board, swipe on touch screens, or use the on-screen pad. Space pauses.
- The rules live in src/lib/games/snake.ts as pure functions: walls and your own body end the game, and up to two turns are buffered between ticks.
- Classic, Funky, and Pixelated are only skins over the same game state, so you can switch looks mid-game.
- It speeds up a little with every food, and your best score is remembered in this browser.
Source
src/components/demos/SnakeLab.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, 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 { createGame, step, tickMs, turn, type Direction, type Point, type SnakeGame } from "@/lib/games/snake";
export type SnakeStyle = "classic" | "funky" | "pixel";
const STYLES: { id: SnakeStyle; label: string }[] = [
{ id: "classic", label: "Classic" },
{ id: "funky", label: "Funky" },
{ id: "pixel", label: "Pixelated" },
];
const SIZE = 16;
const KEYS: Record<string, Direction> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
w: "up",
s: "down",
a: "left",
d: "right",
};
const FUNKY_TONES = ["bg-lime", "bg-pink", "bg-blue", "bg-violet", "bg-orange"];
/** Where a cell sits on the board, as percentages so the board can scale. */
const place = (p: Point): CSSProperties => ({
left: `${(p.x / SIZE) * 100}%`,
top: `${(p.y / SIZE) * 100}%`,
width: `${100 / SIZE}%`,
height: `${100 / SIZE}%`,
});
/** Eye positions for the funky head, so it looks where it's going. */
const EYES: Record<Direction, string> = {
up: "items-start justify-between px-0.5 pt-0.5",
down: "items-end justify-between px-0.5 pb-0.5",
left: "flex-col items-start justify-between py-0.5 pl-0.5",
right: "flex-col items-end justify-between py-0.5 pr-0.5",
};
function Segment({ point, index, game, look }: { point: Point; index: number; game: SnakeGame; look: SnakeStyle }) {
const head = index === 0;
if (look === "classic") {
return (
<span className="absolute p-px" style={place(point)}>
<span className="block size-full bg-ink" />
</span>
);
}
if (look === "pixel") {
return (
<span
className={cn("absolute pixel-bevel", head ? "bg-pink" : index % 2 ? "bg-violet" : "bg-blue")}
style={place(point)}
/>
);
}
return (
<span className="absolute p-px" style={place(point)}>
<span
className={cn(
"flex size-full rounded-full border-2 border-ink",
head ? cn("bg-lime", EYES[game.direction]) : FUNKY_TONES[index % FUNKY_TONES.length],
)}
>
{head && (
<>
<span className="block size-1 rounded-full bg-ink" />
<span className="block size-1 rounded-full bg-ink" />
</>
)}
</span>
</span>
);
}
function Food({ point, look }: { point: Point; look: SnakeStyle }) {
if (look === "classic") {
return (
<span className="absolute grid place-items-center" style={place(point)}>
<span className="block size-1/2 rotate-45 bg-ink" />
</span>
);
}
if (look === "pixel") {
return <span className="absolute bg-orange pixel-bevel" style={place(point)} />;
}
return (
<span className="absolute grid place-items-center" style={place(point)}>
<span className="block size-4/5 rounded-full border-2 border-ink bg-pink motion-safe:animate-bounce" />
</span>
);
}
const BOARD: Record<SnakeStyle, string> = {
classic: "bg-lime-soft",
funky: "bg-paper bg-dots",
pixel: "bg-paper-sunken bg-pixel-grid",
};
export function SnakeLab({ tick }: { tick?: number }) {
// Deterministic first render; food is only placed randomly after it's eaten.
const [game, setGame] = useState<SnakeGame>(() => createGame(SIZE));
const [look, setLook] = useState<SnakeStyle>("classic");
const [best, saveBest] = useBestScore("snake");
const board = useRef<HTMLDivElement>(null);
const swipe = useRef<{ x: number; y: number } | null>(null);
const statusId = useId();
// The game loop: one step per tick while playing; faster as the score grows.
useEffect(() => {
if (game.status !== "playing") return;
const id = setInterval(() => setGame((g) => step(g, cryptoInt)), tick ?? tickMs(game.score));
return () => clearInterval(id);
}, [game.status, game.score, tick]);
useEffect(() => {
if (game.status === "over" || game.status === "won") saveBest(game.score);
}, [game.status, game.score, saveBest]);
const playing = game.status === "playing";
function start() {
setGame((g) => (g.status === "paused" ? { ...g, status: "playing" } : createGame(SIZE, "playing")));
board.current?.focus();
}
function pause() {
setGame((g) => (g.status === "playing" ? { ...g, status: "paused" } : g));
}
function steer(direction: Direction) {
setGame((g) => (g.status === "playing" ? turn(g, direction) : g));
}
function onKeyDown(e: KeyboardEvent) {
const direction = KEYS[e.key] ?? KEYS[e.key.toLowerCase()];
if (direction) {
e.preventDefault();
if (game.status === "ready") start();
steer(direction);
} else if (e.key === " ") {
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)) < 24) 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}.`
: game.status === "won"
? `You filled the board! Score ${game.score}.`
: game.status === "paused"
? "Paused."
: game.status === "ready"
? "Press Start, or an arrow key on the board."
: `Score ${game.score}`;
const score = look === "pixel" ? String(game.score).padStart(4, "0") : String(game.score);
return (
<div className="flex w-full flex-col items-center gap-4">
<div role="group" aria-label="Style" className="flex border-2 border-ink shadow-brutal-sm">
{STYLES.map((s) => (
<button
key={s.id}
type="button"
aria-pressed={look === s.id}
onClick={() => setLook(s.id)}
className={cn(
"cursor-pointer px-3 py-1.5 font-mono text-xs font-bold transition-colors duration-150 not-first:border-l-2 not-first:border-ink",
look === s.id ? "bg-lime text-on-bright" : "bg-paper-raised hover:bg-lime-soft",
)}
>
{s.label}
</button>
))}
</div>
<div className="flex w-full max-w-72 items-baseline justify-between font-mono text-xs uppercase">
<span>
{look === "pixel" ? "Score " : "Score: "}
<span className="font-bold tabular-nums">{score}</span>
</span>
<span className="text-ink-muted">
Best <span className="font-bold tabular-nums">{best}</span>
</span>
</div>
<div
ref={board}
tabIndex={0}
role="group"
aria-label="Snake board. Arrow keys or WASD to steer, Space to pause."
aria-describedby={statusId}
data-style={look}
onKeyDown={onKeyDown}
onPointerDown={(e) => (swipe.current = { x: e.clientX, y: e.clientY })}
onPointerUp={onPointerUp}
onPointerCancel={() => (swipe.current = null)}
style={{ "--cells": SIZE } as CSSProperties}
className={cn(
"relative aspect-square w-full max-w-72 touch-none overflow-hidden border-2 border-ink shadow-brutal select-none",
BOARD[look],
)}
>
<Food point={game.food} look={look} />
{game.snake.map((p, i) => (
<Segment key={i} point={p} index={i} game={game} look={look} />
))}
{!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 === "won" ? "You win!" : game.status === "paused" ? "Paused" : "Snake"}
</p>
<Button size="sm" onClick={start}>
{game.status === "paused" ? <Play aria-hidden /> : game.status === "ready" ? <Play aria-hidden /> : <RotateCcw 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>
{/* Touch and mouse controls; the keyboard works on the focused board. */}
<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>
);
}