Skip to content
← writing

A blocks grid you can play snake on

react
game
interactive
frontend

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:

const d = DELTA[dir];
const head = {
  row: (snake[0].row + d.row + rows) % rows,
  col: (snake[0].col + d.col + cols) % cols,
};

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:

import { createPublicClient, webSocket } from "viem";
 
const client = createPublicClient({ transport: webSocket("wss://your-rpc") });
 
<BlockSnake
  subscribe={(onBlock) =>
    client.watchBlocks({
      onBlock: (b) =>
        onBlock({ height: Number(b.number), txs: b.transactions.length }),
    })
  }
/>;

No viem? It's a raw eth_subscribe to newHeads:

const ws = new WebSocket("wss://your-rpc");
ws.onopen = () =>
  ws.send(JSON.stringify({ id: 1, method: "eth_subscribe", params: ["newHeads"] }));
ws.onmessage = (e) => {
  const head = JSON.parse(e.data).params?.result;
  if (head) onBlock({ height: parseInt(head.number, 16) });
};
return () => ws.close();

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.

From explorer.b3.fun, a project I work on at B3. Grab the component on the Block Snake page.

Ask your agent to implement this

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.

Paste into Claude Code, Codex, Cursor, or any agent. view raw .md download source .zip