Read the full writeup at https://seangeng.com/writing/a-blocks-grid-you-can-play-snake-on.md and implement it in my project.
It covers: A blocks grid you can play snake on. The explorer.b3.fun hero is a grid of live blocks, and you can play snake on it where the blocks are the food. Here's that idea pulled out into a standalone, dependency-free toy.
Requirements:
- Follow the technique/approach exactly as described in the writeup.
- Adapt names, colors, and styling to my project's existing conventions.
- If it's a component, make it reusable with sensible props and TypeScript types.
- Keep it accessible: semantic HTML, keyboard support, and respect prefers-reduced-motion.
- When done, tell me which files you created or changed and how to use it.
The homepage hero on explorer.b3.fun is a grid of
dots where each new block on the chain lights up a cell. At some point someone
added a "play a game" button, and the grid turns into snake, with the lit
blocks as the food. It's the kind of pointless detail I love. I pulled it out
into a standalone version (the blocks here are synthetic, but otherwise it's the
same toy):
the blocks are the food, hit play
It's a tiny state machine on a timer
The whole game is a setInterval that moves a head cell once per tick. Walls
wrap with a modulo, so going off one edge comes back the other side:
Eating grows the snake by not dropping the tail that tick; otherwise it does:
const ate = food && head.row === food.row && head.col === food.col;return ate ? [head, ...snake] : [head, ...snake.slice(0, -1)];
Self-collision (once you're longer than two) ends it, and direction changes are
guarded against a 180, since reversing straight into your own neck is the most
common way to lose to the input handler instead of the game.
The two details that make it read right
First, the food comes from the blocks. A separate timer streams blocks into
random empty cells and expires them after a few seconds, so the board is always
alive. When you eat one, the next food is picked from whatever blocks are
currently on screen, which keeps the game and the ambient animation as one
system instead of two.
Second, the snake is drawn as a thick rounded SVG line through the segment
centers, not just a row of dots. With wrap-around that means clamping the line
when a segment jumps an edge, but it's worth it. A connected line reads as a
snake; a trail of circles reads as a loading spinner.
No game engine, no canvas, no animation library, just grid math and a couple of
intervals. Sizing comes from one ResizeObserver measuring the cell width so
everything positions in pixels off the same number.
Streaming real blocks in
The demo invents blocks on a timer so it works anywhere. The real explorer
feeds it actual chain blocks, and the component leaves a seam for that: an
optional subscribe prop. You get a callback to push each block and return a
cleanup function. Leave it off and you get the synthetic stream.
<BlockSnake subscribe={(onBlock) => { // ...wire up your source, call onBlock per block... return () => {/* cleanup */}; }}/>
WebSocket is the right default: subscribe once and get pushed every new
block. With viem that's watchBlocks, which uses the node's subscription under
the hood:
Polling is the fallback when you only have HTTP (or an explorer API).
Poll the latest height every couple of seconds and only push when it changes,
so you don't re-add the same block:
let last = 0;const id = setInterval(async () => { const b = await fetch("/api/latest-block").then((r) => r.json()); if (b.height !== last) { last = b.height; onBlock({ height: b.height, txs: b.txs }); }}, 2000);return () => clearInterval(id);
Either way the component does the rest: it dedupes by height, caps how many
blocks live on the grid at once, and expires them after a few seconds, so a
fast chain won't flood the board. Whatever you push becomes both the ambient
animation and, the moment you hit play, the food.