Skip to content
← components

Block Snake

new

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

speed
130
accent

Usage

example.tsx
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.

loop.ts
// wrap-around move + grow-on-eat, every `speed` ms
const d = DELTA[dir];
const head = {
row: (snake[0].row + d.row + rows) % rows, // walls wrap
col: (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

proptypedefaultdescription
rowsnumber8Grid rows.
colsnumber13Grid columns.
speednumber170Snake step interval (ms); lower is faster.
accentstringblueBlock + grid color (any CSS color).
subscribe(onBlock) => cleanupsyntheticPlug in a real block source (WebSocket / polling); omit for the demo stream.

From explorer.b3.fun, a project I work on at B3.

The 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, …).

components/games/block-snake.tsx
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 cell
const [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 arriving
const 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-collision
if (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 snake
setBlocks((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 (
<div
className={cn("relative select-none", className)}
style={{ "--snake-accent": accent } as React.CSSProperties}
>
<div
ref={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) => (
<div
key={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)}
>
<span
className={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 && (
<span
className="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 (
<line
key={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) => (
<span
key={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>
<button
type="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">
<button
type="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 (
<button
type="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>
);
}
app/styles/app.css
/* ── 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; }
}