TECHNICALAug 27, 2026

How I paint 100 million minesweeper tiles in the browser

You never paint 100 million tiles. You paint what the zoom can actually show, keep the world in bitboards, and leave React out of the frame loop.

World Sweeper started as a joke I took too far. What if Minesweeper was the actual planet? Not a 16x16 board. Earth. Zoom out and you get continents. Zoom in and you get the classic beveled tile, the number, the little flag.

The grid is 25,088 x 12,544. That's 315 million cells if you count ocean. Land, the tiles you can actually click, is 103,173,755. Call it a hundred million.

The first question I get from other frontend people is usually: WebGL? Instancing? A scene graph?

I used Canvas 2D. React draws the HUD. The world is two typed arrays and a hash function. The renderer never paints a hundred million of anything.

The version that would have melted the tab

If you put a hundred million divs in the DOM, the main thread is done.

MISSING PERSON REPORT

Name: Main Thread
Last seen: Attempting to render 100,000,000 <div> elements
Status: Presumed dead

Witnesses report that Main Thread was also seen running a 100-million-iteration fillRect() loop at 60 FPS.

If found, please do not block it.

If you fillRect every land cell every frame, you're running a 100-million iteration loop at 60Hz. That isn't a game. It's going to hitch, then freeze.

The rule I ended up with is the same one maps have used for a long time: never do more work than a screen can show, and never store more state than a click actually needs.

The world is not an array of objects

The naive cell looks like { x, y, isMine, revealed, flagged }. Multiply that by a hundred million and the tab gets huge, the GC never finishes, and you can't really play it.

What I actually keep in memory:

  • Land mask: 1 bit per cell. Ocean is 0, land is 1. About 37.5MB for the whole planet, water included.
  • Cell state: 2 bits per cell. Hidden, flagged, revealed, defused. About 75MB. This is the real bill.
  • Mines: not stored. hash32(seed, x, y) < density. Same seed, same planet, every time.
  • Treasure chests: same trick, different mixer.

That's the whole board. Two Uint8Arrays. Mines exist in the math, not in RAM. A mine bitboard would have been another 37MB for a question I can answer in a few multiplies.

typescript
export function getBit(buf: Uint8Array, i: number): boolean {
  return (buf[i >>> 3] & (1 << (i & 7))) !== 0;
}

export function getState(state: Uint8Array, i: number): number {
  const shift = (i & 3) << 1;
  return (state[i >>> 2]! >> shift) & 3;
}

export function mineRoll(seed: number, x: number, y: number): number {
  return hash32(hash32(x + 1) ^ hash32(Math.imul(y + 1, 374761393)) ^ seed);
}

First click is still fair. After you seed, a Chebyshev neighborhood around the click can never be a mine, even if the hash says it is. The hash didn't change. The lookup did.

How Earth becomes a bitboard

A Node script downloads Natural Earth land and lakes, scanline-fills the polygons onto the mask, and writes land.bin.gz plus a 2048x1024 overview PNG. Target was 100 million land pixels. Probe the land fraction at 2048x1024, solve for a 2:1 grid, snap to 256. Land came out at 103.2 million. Close enough that I kept it.

The browser fetches the gzipped mask. DecompressionStream inflates it on the main path so the first paint isn't waiting on a worker. A worker walks the bits afterwards and counts mines exactly, so the HUD isn't using a rounded density.

Three painters, one zoom

zoom is pixels per tile. Fit-to-screen on a laptop is about 0.07. Fully zoomed it is 44. You can read a number at 8. Bevels look decent at 16. Below 3, a tile is a fraction of a pixel, so drawing it as a tile is wasted work.

The switch is simple on purpose:

typescript
const vis = range.tw * range.th;
const useOverview = view.zoom < OVERVIEW_MAX_ZOOM || vis > MID_TILE_LIMIT;

if (useOverview && overview && overlay) {
  paintOverview(ctx, world, view, pal, overview, overlay, cssW, cssH);
  return;
}
if (view.zoom >= CLOSE_MIN_ZOOM && vis <= 40_000) {
  paintClose(ctx, world, view, pal, range, cssW, cssH, pings, fx);
  return;
}
paintMid(ctx, world, view, pal, mid, range, cssW, cssH, pings, now);

OVERVIEW_MAX_ZOOM is 3. MID_TILE_LIMIT is 400,000. Close-up also refuses to run if more than 40,000 tiles are on screen. Those numbers aren't sacred. They're the points where the cheaper painter still looks like the game and the fancier one starts to hitch.

Overview: two blits, planet-sized

Zoomed out, the world is a 2048x1024 texture of continents, stretched with imageSmoothingEnabled = false so it stays sharp instead of blurry. On top of that, a same-size overlay of what you've actually done: dug dirt, flags, defuses, pulse pings.

Pan and zoom are just where you draw those two images. Cost doesn't care how many cells exist. You're stretching two megapixel bitmaps.

When you dig a tile I don't rebuild the overlay. I map (x, y) onto the 2K texture, write four bytes, set a dirty flag. Next paint, one putImageData. The overlay is the only place revealed state is visible at world scale, so the close-up painter and the satellite view stay in sync.

typescript
function paintOverview(...) {
  fillOcean(ctx, pal, cssW, cssH);
  flushOverlay(overlay);
  ctx.imageSmoothingEnabled = false;
  const dw = world.cols * view.zoom;
  const dh = world.rows * view.zoom;
  ctx.drawImage(overview, view.x, view.y, dw, dh);
  ctx.drawImage(overlay.canvas, view.x, view.y, dw, dh);
}

Mid: one pixel per visible tile, then stretch

Close enough that a 2K texture looks blocky. Not close enough to draw numbers. So I only walk the visible rectangle, write one pixel per land cell into an offscreen canvas, and scale that up. Checkerboard grass, dug, flags, the exploded mine if you hit one.

Worst case is 400,000 pixels. That's smaller than a 1080p framebuffer. The CPU fills a small ImageData. The GPU does the stretch. Still Canvas 2D. Still no shaders.

typescript
function visibleRange(world, view, cssW, cssH) {
  const { zoom, x, y } = view;
  const c0 = Math.max(0, Math.floor(-x / zoom));
  const r0 = Math.max(0, Math.floor(-y / zoom));
  const c1 = Math.min(world.cols, Math.ceil((cssW - x) / zoom));
  const r1 = Math.min(world.rows, Math.ceil((cssH - y) / zoom));
  return { c0, r0, c1, r1, tw: c1 - c0, th: r1 - r0 };
}

That AABB is the whole game. If a tile isn't on screen it doesn't get a color, a sprite, or a number. I don't clip in shader space. I don't iterate the planet. I compute four integers.

Close: now it looks like Minesweeper

Bevels. A 2px grout. 16x16 sprites for the flag, the boom, the defuse kit, baked once into offscreen canvases and cached. Adjacent-mine counts in Press Start 2P, but only when zoom >= 8, because a 4px font isn't readable.

This path is the expensive one on purpose. It only runs when you can actually use the extra pixels. If I drew bevels at world scale it would hitch, and you still couldn't read the numbers.

Zoom that doesn't jump around

Linear zoom feels fine until it doesn't. At 0.07px per tile, adding 1 is a huge jump. At 40px per tile, adding 1 does nothing. Maps solved this a long time ago: multiply.

typescript
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, view.zoom * Math.exp(-dy * ZOOM_INTENSITY)));
const k = next / view.zoom;
viewRef.current = {
  zoom: next,
  x: px - (px - view.x) * k,
  y: py - (py - view.y) * k,
};

The world point under the cursor stays under the cursor. That's the whole reason it doesn't jump. ZOOM_INTENSITY is 0.0016 so a trackpad flick feels like a map, and a mouse wheel isn't too fast.

The camera lives in a ref. The wheel handler doesn't setState. requestAnimationFrame coalesces a burst of wheel events into one paint. If I put zoom in React state, every tick would reconcile a tree while I'm also trying to blit, and zoom would feel laggy.

React is the HUD, not the world

World sits in worldRef. Camera in viewRef. Overlay, mid buffer, pings, pulse wave: refs. The canvas paint path doesn't go through React render. React still owns HP, XP, kits, the skill menu. Different update rates for different jobs.

Cooldown labels tick at about 10Hz, not 60. Flood-fill patches the overlay as it goes, then asks for another frame if the queue isn't empty. The HUD can be a little stale. The board shouldn't.

If the thing changes 60 times a second, it isn't React state. It's a ref, and you paint it yourself.

Opening a huge empty area without freezing

Classic Minesweeper flood-fill is "recurse until you hit numbers." On this board a zero in the middle of a desert can uncover a country. Doing that on the click stack drops a lot of frames. Sometimes the tab just says Page Unresponsive.

The flood is a Uint32Array queue. It starts at a million slots, about 4MB, and doubles if a flood actually gets that big. Each frame I take a budget:

typescript
export function floodFrameBudget(remaining: number): number {
  if (remaining <= 0) return 0;
  const paced = Math.ceil(remaining / 45);
  return Math.min(remaining, Math.max(64, Math.min(paced, 12_000)));
}

Between 64 and 12,000 reveals per frame, paced so a huge basin takes about 45 frames instead of one. The fill animation is that budget. Overlay pixels land as we go, so the continent fills in instead of the UI locking.

What I still allocate

I reuse the mid canvas when the visible size hasn't changed. I don't reuse the ImageData. Mid mode still calls createImageData every frame. That buffer is viewport-sized, not planet-sized, so it doesn't blow the tab. It does show up as GC noise if you look at the profiler. First thing I'd fix: a pooled Uint8ClampedArray and new ImageData(buffer, w, h).

The overlay putImageData uploads the whole 2048x1024 even if you dug one tile. Eight megabytes. Fine at this size. A dirty rectangle would be cleaner.

The 75MB state buffer is paid up front for every cell, including ocean. Ocean doesn't need two bits. A sparse structure for revealed land would be smaller after a short run and more annoying to write. I paid RAM for simpler code. I'm okay with that at this scale. I wouldn't be okay with it at a billion.

Sprites are tiny and cached. Palette RGB tuples are computed once from CSS variables. The flood queue is the only structure that grows, and it grows in place.

What this is not

To be clear:

  • Not WebGL. Not WebGPU. Canvas 2D on purpose. LOD means you never submit a hundred million primitives, so the 2D context is enough.
  • Not a quadtree of chunks. The land mask is already compact. Chunks would help streaming from disk. They wouldn't help a mask that already fits in memory.
  • Not WASM, not SIMD, not a worker on the paint path. The worker counts mines. Painting stays on the main thread because the work per frame is small once you LOD.
  • Not zero-allocation. Mid mode still allocates, as I said above.

The actual lesson

A hundred million tiles sounds like a graphics problem. It was a data problem.

Once the world fits in two typed arrays, mines are a hash, and the camera only asks for what it can see, Canvas 2D is plenty. The browser was fine. The object graph was the part that wouldn't have worked.

Zoom isn't a special effect. It's a level-of-detail switch with a cursor-anchored multiply so it feels like a map. Memory isn't "I optimized the GC." Memory is refusing to allocate a tile object in the first place.

If you're about to render a ridiculous number of things in a browser, start with the refusals. What do you not store? What do you not draw? What does React not need to know about? The fancy renderer is usually the thing you do after those answers are boring.

More posts in the Writing section.