---
title: "A blocks grid you can play snake on"
description: "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."
date: "2026-05-30"
tags: ["react", "game", "interactive", "frontend"]
---

The homepage hero on [explorer.b3.fun](https://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):

## 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:

```ts
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:

```ts
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.

```tsx
  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:

```tsx

const client = createPublicClient({ transport: webSocket("wss://your-rpc") });

  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`:

```ts
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:

```ts
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](https://explorer.b3.fun), a project I work on at B3. Grab
the component on the [Block Snake](/components/block-snake) page.
