Block Snake
A grid of streaming blocks that's also a playable snake game — the blocks are the food. Arrow keys / WASD / touch pad, wrap-around walls, grows on eat. Rebuilt from the explorer.b3.fun hero.
the blocks are the food — hit play
Usage
import { BlockSnake } from "~/components/games/block-snake";<BlockSnake rows={8} cols={13} speed={170} />
The blocks are the food
On explorer.b3.fun the homepage hero is a grid of dots where each new block on the chain lights up a cell. The fun part: you can play snake on it, and the lit blocks are what you eat. I pulled that idea out into a standalone toy. Here it streams synthetic "blocks" into random cells on a timer; in the real thing those are live blocks.
The game is a tiny state machine on an interval. The head moves one cell per tick in the current direction, walls wrap with a modulo, and eating a block prepends a segment instead of dropping the tail. Direction changes are guarded so you can't reverse into yourself, and a thick rounded SVG line joins the segments so it reads as a snake rather than a trail of dots.
// wrap-around move + grow-on-eat, every `speed` msconst d = DELTA[dir];const head = {row: (snake[0].row + d.row + rows) % rows, // walls wrapcol: (snake[0].col + d.col + cols) % cols,};if (snake.length > 2 && hits(head, snake.slice(0, -1))) return gameOver();const ate = food && head.row === food.row && head.col === food.col;return ate ? [head, ...snake] : [head, ...snake.slice(0, -1)];
Props
| prop | type | default | description |
|---|---|---|---|
| rows | number | 8 | Grid rows. |
| cols | number | 13 | Grid columns. |
| speed | number | 170 | Snake step interval (ms); lower is faster. |
| accent | string | blue | Block + grid color (any CSS color). |
| subscribe | (onBlock) => cleanup | synthetic | Plug in a real block source (WebSocket / polling); omit for the demo stream. |
From explorer.b3.fun, a project I work on at B3.
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useCallback, useEffect, useRef, useState } from "react";import { Joystick, Command } from "lucide-react";import { cn } from "~/lib/utils";type Dir = "UP" | "DOWN" | "LEFT" | "RIGHT";interface Cell {row: number;col: number;}interface BlockDot extends Cell {height: number;txs: number;born: number;}const OPPOSITE: Record<Dir, Dir> = {UP: "DOWN",DOWN: "UP",LEFT: "RIGHT",RIGHT: "LEFT",};const DELTA: Record<Dir, Cell> = {UP: { row: -1, col: 0 },DOWN: { row: 1, col: 0 },LEFT: { row: 0, col: -1 },RIGHT: { row: 0, col: 1 },};const KEYS: Record<string, Dir> = {ArrowUp: "UP",ArrowDown: "DOWN",ArrowLeft: "LEFT",ArrowRight: "RIGHT",w: "UP",s: "DOWN",a: "LEFT",d: "RIGHT",};export interface IncomingBlock {height: number;txs?: number;}export interface BlockSnakeProps {rows?: number;cols?: number;/** Snake step interval in ms (lower = faster). */speed?: number;/** Accent color for blocks + grid (any CSS color). */accent?: string;/*** Plug in a real block source (WebSocket / polling). Receives a callback to* push each new block; return a cleanup fn. Omit it and the grid streams* synthetic blocks so the demo is self-contained.*/subscribe?: (onBlock: (b: IncomingBlock) => void) => (() => void) | void;className?: string;}/*** A grid of streaming "blocks" you can play snake on — the blocks are the food.* Arrow keys / WASD / on-screen pad, wrap-around walls, grows on eat. Rebuilt,* self-contained, from the explorer.b3.fun hero.*/export function BlockSnake({rows = 8,cols = 13,speed = 170,accent = "hsl(217 91% 60%)",subscribe,className,}: BlockSnakeProps) {const wrapRef = useRef<HTMLDivElement>(null);const [cell, setCell] = useState(40); // measured px size of one grid cellconst [blocks, setBlocks] = useState<BlockDot[]>([]);const [hover, setHover] = useState<number | null>(null);const [active, setActive] = useState(false);const [snake, setSnake] = useState<Cell[]>([]);const [food, setFood] = useState<Cell | null>(null);const [score, setScore] = useState(0);const [over, setOver] = useState(false);const dirRef = useRef<Dir>("RIGHT");const heightRef = useRef(Math.floor(1_200_000 + Math.random() * 800_000));// ── measure the cell size ────────────────────────────────────────────────useEffect(() => {const el = wrapRef.current;if (!el) return;const measure = () => setCell(el.clientWidth / cols);measure();const ro = new ResizeObserver(measure);ro.observe(el);return () => ro.disconnect();}, [cols]);const occupied = useCallback((r: number, c: number, list: Cell[]) =>list.some((s) => s.row === r && s.col === c),[],);// Place an incoming block on a free cell (dedupe by height, cap, expire old).const pushBlock = useCallback((height: number, txs: number) => {setBlocks((prev) => {const fresh = prev.filter((b) => Date.now() - b.born < 6500);if (fresh.some((b) => b.height === height)) return fresh;if (fresh.length >= Math.floor(rows * cols * 0.32)) return fresh;for (let i = 0; i < 24; i++) {const row = Math.floor(Math.random() * rows);const col = Math.floor(Math.random() * cols);if (!occupied(row, col, fresh)) {return [{ row, col, height, txs, born: Date.now() }, ...fresh];}}return fresh;});},[rows, cols, occupied],);// ── stream blocks in (real source via `subscribe`, else synthetic) ──────────useEffect(() => {// expire old blocks even when none are arrivingconst expire = setInterval(() => {setBlocks((prev) => {const f = prev.filter((b) => Date.now() - b.born < 6500);return f.length === prev.length ? prev : f;});}, 1000);let stop: (() => void) | void;if (subscribe) {stop = subscribe((b) =>pushBlock(b.height, b.txs ?? Math.floor(Math.random() * 240) + 1),);} else {const tick = () =>pushBlock(heightRef.current++, Math.floor(Math.random() * 240) + 1);tick();const id = setInterval(tick, 900);stop = () => clearInterval(id);}return () => {clearInterval(expire);stop?.();};}, [subscribe, pushBlock]);// ── snake game loop ────────────────────────────────────────────────────────useEffect(() => {if (!active) return;const id = setInterval(() => {setSnake((prev) => {if (!prev.length) return prev;const d = DELTA[dirRef.current];const head = {row: (prev[0].row + d.row + rows) % rows,col: (prev[0].col + d.col + cols) % cols,};// self-collisionif (prev.length > 2 && occupied(head.row, head.col, prev.slice(0, -1))) {setActive(false);setOver(true);return prev;}const ate = food && head.row === food.row && head.col === food.col;const next = ate ? [head, ...prev] : [head, ...prev.slice(0, -1)];if (ate) {setScore((s) => s + 10);// next food = a current block not under the snakesetBlocks((bl) => {const candidates = bl.filter((b) => !occupied(b.row, b.col, next),);const pick =candidates[Math.floor(Math.random() * candidates.length)];setFood(pick ? { row: pick.row, col: pick.col } : null);return bl;});}return next;});}, speed);return () => clearInterval(id);}, [active, food, rows, cols, speed, occupied]);// ── input ──────────────────────────────────────────────────────────────────const turn = useCallback((d: Dir) => {if (d !== OPPOSITE[dirRef.current]) dirRef.current = d;}, []);useEffect(() => {if (!active) return;const onKey = (e: KeyboardEvent) => {if (e.key === "Escape") return setActive(false);const d = KEYS[e.key];if (d) {e.preventDefault();turn(d);}};window.addEventListener("keydown", onKey);return () => window.removeEventListener("keydown", onKey);}, [active, turn]);function start() {const mid = { row: Math.floor(rows / 2), col: Math.floor(cols / 2) };dirRef.current = "RIGHT";const first =blocks.find((b) => !(b.row === mid.row && b.col === mid.col)) ?? null;setSnake([mid]);setFood(first ? { row: first.row, col: first.col } : null);setScore(0);setOver(false);setActive(true);}const center = (n: number) => (n + 0.5) * cell;const isFoodCell = (b: BlockDot) =>active && food?.row === b.row && food?.col === b.col;return (<divclassName={cn("relative select-none", className)}style={{ "--snake-accent": accent } as React.CSSProperties}><divref={wrapRef}className="snake-grid relative w-full overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(0_0%_4%)]"style={{aspectRatio: `${cols} / ${rows}`,backgroundSize: `${cell}px ${cell}px`,backgroundPosition: `${cell / 2}px ${cell / 2}px`,}}>{/* streaming blocks */}{blocks.map((b) => (<divkey={b.height}className="absolute -translate-x-1/2 -translate-y-1/2"style={{ left: center(b.col), top: center(b.row) }}onMouseEnter={() => !active && setHover(b.height)}onMouseLeave={() => setHover(null)}><spanclassName={cn("snake-block", isFoodCell(b) && "is-food")}style={{width: cell * 0.4,height: cell * 0.4,background: isFoodCell(b) ? "#f97316" : accent,}}/>{hover === b.height && !active && (<span className="snake-tip">#{b.height} · {b.txs} txs</span>)}</div>))}{/* food (always visible while playing, even if its block expired) */}{active && food && (<spanclassName="snake-block is-food absolute -translate-x-1/2 -translate-y-1/2"style={{left: center(food.col),top: center(food.row),width: cell * 0.4,height: cell * 0.4,background: "#f97316",}}/>)}{/* snake */}{active && (<><svg className="pointer-events-none absolute inset-0 h-full w-full">{snake.slice(0, -1).map((seg, i) => {const nxt = snake[i + 1];let x2 = center(nxt.col);let y2 = center(nxt.row);if (Math.abs(nxt.col - seg.col) > 1)x2 += (nxt.col > seg.col ? -cols : cols) * cell;if (Math.abs(nxt.row - seg.row) > 1)y2 += (nxt.row > seg.row ? -rows : rows) * cell;return (<linekey={i}x1={center(seg.col)}y1={center(seg.row)}x2={x2}y2={y2}stroke="rgba(255,255,255,0.85)"strokeWidth={Math.max(2, cell * 0.18)}strokeLinecap="round"/>);})}</svg>{snake.map((s, i) => (<spankey={i}className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-white"style={{left: center(s.col),top: center(s.row),width: cell * 0.42 * Math.max(0.55, 1 - i * 0.06),height: cell * 0.42 * Math.max(0.55, 1 - i * 0.06),opacity: Math.max(0.5, 1 - i * 0.04),}}/>))}</>)}{/* HUD */}{active && (<div className="absolute right-3 top-3 flex items-center gap-2 font-mono text-xs"><span className="rounded-md bg-black/60 px-2.5 py-1 text-white backdrop-blur">score {score}</span><buttontype="button"onClick={() => setActive(false)}className="flex items-center gap-1 rounded-md border border-white/20 bg-black/60 px-2 py-1 text-white backdrop-blur"><Command className="size-3" /> esc</button></div>)}{/* idle / game-over CTA */}{!active && (<div className="absolute bottom-3 left-1/2 -translate-x-1/2"><buttontype="button"onClick={start}className="inline-flex items-center gap-2 rounded-lg bg-[var(--snake-accent)] px-4 py-2 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.03] active:translate-y-px"><Joystick className="size-4" />{over ? `Play again · scored ${score}` : "Play snake"}</button></div>)}{/* touch dpad */}{active && (<div className="absolute bottom-3 left-1/2 grid -translate-x-1/2 grid-cols-3 gap-1 sm:hidden"><span /><DPad onPress={() => turn("UP")}>↑</DPad><span /><DPad onPress={() => turn("LEFT")}>←</DPad><DPad onPress={() => turn("DOWN")}>↓</DPad><DPad onPress={() => turn("RIGHT")}>→</DPad></div>)}</div><p className="mt-3 text-center font-mono text-xs text-[hsl(var(--muted-foreground))]">{active? "arrow keys / WASD · eat the blocks · walls wrap": "the blocks are the food — hit play"}</p></div>);}function DPad({children,onPress,}: {children: React.ReactNode;onPress: () => void;}) {return (<buttontype="button"onTouchStart={(e) => {e.preventDefault();onPress();}}onClick={onPress}className="flex size-9 items-center justify-center rounded-md border border-white/20 bg-black/60 text-white backdrop-blur">{children}</button>);}
/* ── Block snake (streaming blocks grid + snake game), from explorer.b3.fun ── */.snake-grid {background-image:linear-gradient(to right, hsl(0 0% 100% / 0.05) 1px, transparent 1px),linear-gradient(to bottom, hsl(0 0% 100% / 0.05) 1px, transparent 1px);}.snake-block {display: block;border-radius: 50%;box-shadow: 0 0 0 1px hsl(0 0% 100% / 0.15);animation: snake-pulse 2.4s ease-in-out infinite;}.snake-block.is-food {box-shadow: 0 0 14px 2px rgba(249, 115, 22, 0.7);animation: snake-food 0.9s ease-in-out infinite;}.snake-tip {position: absolute;left: 50%;bottom: calc(100% + 6px);transform: translateX(-50%);white-space: nowrap;border-radius: 6px;border: 1px solid hsl(var(--border));background: hsl(var(--card));padding: 3px 8px;font-family: var(--font-mono);font-size: 0.65rem;color: hsl(var(--foreground));z-index: 50;}@keyframes snake-pulse {0%, 100% { opacity: 0.55; transform: scale(0.9); }50% { opacity: 1; transform: scale(1.05); }}@keyframes snake-food {0%, 100% { transform: scale(1); }50% { transform: scale(1.25); }}@media (prefers-reduced-motion: reduce) {.snake-block { animation: none; }}