How should you store a big tilemap?

Keep the flat array and narrow it. Dropping tile ids from Int32 to Uint8 took a 4096x4096 terrain map from 64 MB to 16 MB and made random reads 3.3x faster, while hand-rolled RLE came out 1.23x larger than the plain array once both were gzipped. Measured across dense, sparse and layered maps at thre

How should you store a big tilemap?

Keep the flat array and make it narrower: dropping tile ids from Int32Array to Uint8Array cut a 4096x4096 terrain map from 64 MB to 16 MB of RAM and made random reads 3.3x faster (8.45 ns to 2.58 ns), while the clever alternative everyone reaches for — hand-rolled run-length encoding — came out 1.23x larger than the plain array once both were gzipped. The only thing worth adding on top is chunking, for streaming and patches. A quadtree was the worst option measured, on every axis.

Hardware: Apple M3, 16 GB, macOS 26.4.1 (build 25E253). Node v23.5.0, built-ins only — no packages, no engine, no GPU, no network. gzip is zlib.gzipSync at Node's default level 6 unless stated; timings are medians of 5–9 runs under normal desktop load. This is JavaScript on a laptop; C# in Unity or Rust on a server will give different absolute numbers. Byte counts are exact and reproduce from the seed; the ratios are what transfers.

The three maps, all generated deterministically at 256x256, 1024x1024 and 4096x4096:

  • Dense terrain — 100% filled, ids 1–48. Six biomes from a two-octave value-noise field (lattice cells at N/16 and N/64, smoothstep bilinear). Inside a biome the tile is the biome's base 80% of the time and one of 7 autotile variants otherwise, by a per-coordinate hash. That variant noise is the point: real terrain is not made of perfect runs.
  • Sparse dungeon — id 0 empty, 1–12 otherwise. N²/4000 non-overlapping rooms 5–16 tiles a side, ringed with wall variants, seeded with 2–4 props, joined by L corridors. Fill: 7.5% at 256, 8.4% at 1024, 15.3% at 4096.
  • Layered — three co-sized layers: ground (ids 1–16, 100% filled), decoration (ids 100–140, 10.5%), collision (0/1, 21.5%).

The short answer

  • A flat typed array is the right tilemap data structure for almost every map. On a dense 1024x1024 map it was the smallest in memory (1,048,576 B as Uint8Array), the smallest gzipped (295,232 B), and 41x faster on random reads than RLE.
  • The cheapest win is the integer width, and it is free. Int32 to Uint8 is exactly 4x memory — 64 MB against 16 MB at 4096x4096 — and random reads went from 8.45 ns to 2.58 ns. The ceiling you buy: 255 distinct tile ids for Uint8, 65,535 for Uint16.
  • gzip already finds your runs. Flat Uint8 gzipped to 4,345,469 B on the 4096x4096 terrain map; hand-rolled RLE gzipped to 5,351,368 B — 1.23x worse. RLE only won on the sparse dungeon, and only by 1.45x.
  • Editing one tile is where the clever formats die. Flat array: 4.5 ns. Chunked: 7.1 ns. Sparse Map: 75.8 ns. RLE: 386.8 microseconds. Quadtree: 18.7 milliseconds, because one edit invalidates every byte offset in the buffer.
  • Chunking is for streaming and patching, not for size. 32x32 chunks cost 14% more total gzipped bytes on the 4096x4096 map, but one 1920x1080 screen needed 3,124 gzipped bytes instead of 5,075,870 — 1,625x less to get the first frame up.
Hand-rolled RLE gzips 1.23x larger than simply gzipping the flat array

Which tilemap data structure wins on a dense map?

Dense terrain, 1024x1024, 1,048,576 tiles, ids 1–48. Memory is the exact byteLength of the backing typed arrays, except the sparse Map row — a heap delta measured across global.gc(), varying a few KB between runs. Random read is 300,000 pre-generated coordinates through each structure's get(x, y); decode expands the structure back into a flat Uint16Array.

Representation Memory (B) Gzipped (B) Random read Full decode Edit 1 tile
Int32Array flat 4,194,304 397,609 2.9 ns 0.06 ms 1.6 ns
Uint16Array flat 2,097,152 346,245 4.7 ns 0.03 ms 4.5 ns
Uint8Array flat 1,048,576 295,232 4.6 ns 0.03 ms 4.5 ns
RLE, 395,048 runs 1,588,384 365,355 190.0 ns 6.59 ms 386.8 µs
Chunked 32x32, 1024/1024 kept 2,101,248 358,336 9.9 ns 2.55 ms 7.1 ns
Quadtree, 991,889 nodes 6,447,275 3,391,172 95.6 ns 53.61 ms 18.7 ms
Sparse Map, 1,048,576 entries 29,367,192 2,114,672 105.4 ns 15.98 ms 75.8 ns

The flat byte array wins every column, and the two structures with the best reputations — RLE and the quadtree — are the two worst rows.

The ordering between the three flat widths is not meaningful at this size — a 1024x1024 map fits in cache at every width, so those columns measure V8's bounds check, not memory. The width section below runs at a size where it matters.

Now the sparse dungeon, same size, 8.4% filled — the case that is supposed to break the flat array:

Representation Memory (B) Gzipped (B) Random read Edit 1 tile
Uint8Array flat 1,048,576 26,613 4.8 ns 4.4 ns
RLE, 56,620 runs 234,672 22,079 45.7 ns 97.2 µs
Chunked 32x32, 614/1024 kept 1,261,568 29,089 13.3 ns 11.6 ns
Quadtree, 148,717 nodes 966,657 472,440 55.1 ns 14.6 ms
Sparse Map, 88,298 entries 3,671,408 166,373 32.4 ns 21.8 ns

RLE finally wins both size columns, by 4.5x in memory, which is real. But look at the flat array's gzipped size: 26,613 bytes. The whole map is a 26 KB download either way. You would trade a 22,000x edit-cost cliff (97.2 µs against 4.4 ns) for 4.5 KB.

The layered map follows its ground layer. All three layers at 4096x4096 — 50,331,648 tiles — came to 50,331,648 B as Uint8Array and 5,957,206 B gzipped, against 201,326,592 B and 8,256,447 B as Int32Array. The decoration layer at 10.5% fill needs no different structure from the ground layer at 100%.

Does gzip make RLE pointless?

Mostly yes, and it is the most useful finding here. Flat Uint8Array against the same map hand-encoded as RLE (uint16 count + uint16 value pairs), both gzipped:

Map Flat Uint8 gz RLE gz RLE / flat at level 9
dense 1024x1024 295,232 365,355 1.24x worse 285,354 vs 352,722
layered 1024x1024 (ground) 201,280 240,217 1.19x worse 187,778 vs 227,394
dense 4096x4096 4,345,469 5,351,368 1.23x worse 4,184,024 vs 5,148,357
layered 4096x4096 (ground) 2,980,417 3,539,356 1.19x worse 2,760,386 vs 3,324,390
sparse 1024x1024 26,613 22,079 0.83x better 21,568 vs 21,317
sparse 4096x4096 665,994 459,321 0.69x better 371,826 vs 390,038

On every dense map, gzipping the plain array beat gzipping your RLE. DEFLATE's LZ77 stage is already a run finder: a repeated tile is a back-reference of distance 1, and Huffman coding spends a fraction of a bit on it. Encoding the runs yourself replaces a stream gzip compresses very well with 4-byte records whose count fields are high-entropy noise.

RLE does win on the sparse maps, whose fields of zero collapse to a handful of records before gzip sees them. But note the level-9 column: that advantage inverts at 4096 — 371,826 bytes flat against 390,038 for RLE. Turn the compressor up and it finds everything RLE did, plus more.

The rule: do not hand-roll RLE for a format that will be transport-compressed. If your levels go over HTTP with Content-Encoding: gzip, sit in a zip, or ride in a Unity AssetBundle, the runs are already being found. Same effect on JSON APIs in gzip or brotli for a JSON API: the compressor setting mattered more than the payload cleverness.

What integer width should tile ids use?

Each width measured in its own Node process, direct array indexing, 2,000,000 random reads, dense terrain:

Size Width Memory (B) Gzipped (B) Random read Sequential scan Max tile id
1024x1024 Uint8 1,048,576 295,232 1.28 ns 0.61 ms 255
1024x1024 Uint16 2,097,152 346,245 1.33 ns 0.62 ms 65,535
1024x1024 Int32 4,194,304 397,609 1.39 ns 0.61 ms 2,147,483,647
4096x4096 Uint8 16,777,216 4,345,469 2.58 ns 9.72 ms 255
4096x4096 Uint16 33,554,432 5,075,870 7.64 ns 9.94 ms 65,535
4096x4096 Int32 67,108,864 5,834,888 8.45 ns 9.91 ms 2,147,483,647

Three things fall out. Memory is exactly 4x from Int32 to Uint8, always. gzip recovers most of the waste on disk but none of it in RAM — 5,834,888 to 4,345,469 bytes is only 1.34x, against 4x resident. And the random-read cost is a cliff, not a slope: identical at 1024x1024 where every width fits in cache, then 3.3x apart at 4096x4096, where 16 MB sits at the edge of the M3's shared P-core L2 and 64 MB is far outside it. Sequential scanning is unaffected at 0.58–0.59 ns per tile in all six rows — the prefetcher hides the width.

Int32 is the default in far too many level formats, because it is what a C# int[] gives you and what JSON.parse hands back. Almost no game has more than 255 tile ids per layer; if yours does, Uint16 at 65,535 still will. Reserve the top of that range for flags and stay in 16 bits.

Is a sparse dictionary worth it for a mostly-empty map?

No — a JS Map of non-empty tiles cost more memory than storing every tile. On the 8.4%-filled dungeon, 88,298 entries occupied a 3,671,408 byte heap delta: 41.6 bytes per stored tile, 3.5 per tile of map, against 1.0 for the flat Uint8Array that stores the empty 91.6% too. On the dense map, 28.0 bytes per tile — 28x the flat array.

That is boxed hash-map entries in a managed runtime; a C# Dictionary<int,int> will be similar. A sparse dictionary is for maps unbounded in extent, not maps that are merely mostly empty. If you know the bounds, a flat byte array is the smaller sparse structure.

Is a quadtree ever worth it?

Not for tilemaps with autotile variation. Serialised into one byte buffer — 3 bytes per uniform leaf, 17 per internal node, as compact as the structure gets — it was still 6,447,275 bytes for a 1024x1024 map, 6.15 bytes per tile, worse than Int32Array, and gzipped to 3,391,172 bytes, 11.5x worse than the flat array. Reads were 95.6 ns against 4.6 ns, and one edit means rewriting the whole buffer, because splitting a leaf shifts every offset after it: 18.7 ms.

The 20% autotile variants are why: a quadtree pays 17 bytes for every node that fails to be uniform, and one differing tile in a 2x2 block blocks collapse all the way up. On the sparse dungeon it did beat the flat array in memory — 966,657 bytes against 1,048,576 — but still gzipped to 472,440, 17.8x worse than the flat array's 26,613: a tree of byte offsets is incompressible where a field of zeros is not. Quadtrees are for spatial queries, not tile storage.

When is chunking worth it?

When you need to stream, or to patch. The whole 4096x4096 dense map is 33,554,432 raw bytes and 5,075,870 gzipped (799 ms to gzip, 36 ms to gunzip). Chunked, each chunk gzipped separately, against a 1920x1080 viewport at 32 px tiles (60x34 tiles, worst-case alignment):

Chunk Chunks Index (B) Sum of chunk gz vs whole-map gz Chunks per screen Screen raw (B) Screen gz (B)
16x16 65,536 262,144 7,931,588 1.56x 20 10,240 2,434
32x32 16,384 65,536 5,762,667 1.14x 9 18,432 3,124
64x64 4,096 16,384 5,208,302 1.03x 4 32,768 5,287
128x128 1,024 4,096 5,013,208 0.99x 4 131,072 19,786
256x256 256 1,024 5,005,898 0.99x 4 524,288 77,417

32x32 is the sweet spot engines converged on for a reason. It costs 14% more total download than one blob and buys a 1,625x smaller first fetch — 3,124 bytes to draw a screen instead of 5,075,870. Resident memory follows: a 5x5 chunk cache (the 3x3 visible plus a margin) holds 51,200 bytes, against 33,554,432 for the whole map decoded.

Below 32 the per-chunk gzip overhead takes over (16x16 pays 1.56x total: a 512-byte payload has no dictionary to build); above 64 you fetch a quarter-megabyte to draw one screen. On the sparse dungeon chunking is free in both directions — 12,484 of 16,384 chunks were non-empty and the chunk gzips summed to 0.81x the whole-map gzip, because dropping empty chunks entirely beat compressing their zeros.

What does a JSON level file cost?

Most tools ship JSON. Dense terrain, 4096x4096:

Format Bytes Gzipped Parse time Bytes/tile
binary Uint8 16,777,216 4,345,469 0 ms (zero-copy view) 1.00
binary Uint16 33,554,432 5,075,870 0 ms (zero-copy view) 2.00
JSON flat int array 48,197,156 5,556,410 125.51 ms 2.87
JSON array-of-rows 48,205,348 5,565,998 150.78 ms 2.87
JSON base64 of raw 44,739,302 6,109,457 20.64 ms 2.67
JSON base64+zlib (Tiled) 6,767,891 5,092,036 49.61 ms 0.40

Plain JSON costs 2.87x the bytes of a Uint8 binary and 125 ms of main-thread parse that a typed-array view over a Buffer never spends. But gzip narrows the transfer gap to 1.28x — JSON's verbosity compresses away — so the case against JSON levels is parse time and peak memory, not download. Same asymmetry at larger scale in parsing huge JSON.

Tiled's base64 + zlib layer encoding is the compromise worth copying: still a readable JSON document with one opaque data string, 6.8 MB instead of 48 MB, decoded in 49.61 ms.

How many bytes is one tile edit?

Compact tilemap formats are catastrophically slow to edit — RLE is 86,000x a flat array

The largest ratio here, and what multiplayer and undo need. Against the 4096x4096 map, gzipped whole at 5,075,870 bytes:

Edits Delta, scattered Delta, clustered Dirty 32x32 chunks gz (scattered / clustered)
1 26 B 26 B 326 / 322
10 81 B 80 B 3,473 / 956
100 537 B 444 B 35,425 / 1,278
1,000 4,734 B 3,494 B 342,013 / 1,278
10,000 46,221 B 31,342 B 2,644,178 / 1,278

One tile edit is 26 gzipped bytes against 5,075,870 — 195,000x. Six raw bytes: a uint32 index and a uint16 value. Even 10,000 scattered edits are 46 KB, under 1% of the map.

Chunk-granular sync is the wrong default for scattered edits — 10,000 dirtied 7,519 chunks and 2.6 MB, half the map — and the right one for clustered edits, where the same count touched 4 chunks and 1,278 bytes. An undo stack wants tile deltas; a terraforming brush wants dirty chunks. Send whichever is smaller; you know both sizes first.

What this does not measure

Rendering. Nothing here touches a GPU or Unity's Tilemap component, whose own storage will dominate what you see in a build. These are the costs underneath. The ratios — 4x for integer width, 1.23x against hand-rolled RLE, 86,000x for an RLE edit, 1,625x for chunked streaming — are what carries across languages. For the other half of a 2D pipeline, see how much a sprite atlas actually saves you, where the algorithm everyone implements also loses.

Check it yourself

One file, no dependencies, nothing written to disk. Run it once per map: V8 shares inline-cache feedback between closures made at the same source location, so timing both in one process makes the second look slow.

node --expose-gc tilemap-bench.mjs 1024 dense
node --expose-gc tilemap-bench.mjs 1024 sparse
node --expose-gc --max-old-space-size=8192 tilemap-bench.mjs 4096 dense
#!/usr/bin/env node
// How should you store a big tilemap? — one file, no dependencies, Node 18+.
import zlib from 'node:zlib';

const N = Number(process.argv[2] || 1024);
const ONLY = process.argv[3];                    // 'dense' or 'sparse'
const gz = (b, l = 6) => zlib.gzipSync(b, { level: l }).length;
const med = a => a.slice().sort((x, y) => x - y)[a.length >> 1];
const ms = (fn, r = 5) => { const t = []; for (let i = 0; i < r; i++) { const s = process.hrtime.bigint(); fn(); t.push(Number(process.hrtime.bigint() - s) / 1e6); } return med(t); };
const P = (s, n) => String(s).padStart(n);

const rng = a => () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const hash2 = (x, y, s) => { let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(s, 0x9e3779b1); h ^= h >>> 15; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; return (h >>> 0) / 4294967296; };
function noise(n, cell, seed) {
  const g = Math.ceil(n / cell) + 2, r = rng(seed), lat = new Float32Array(g * g), out = new Float32Array(n * n);
  for (let i = 0; i < lat.length; i++) lat[i] = r();
  for (let y = 0; y < n; y++) { const fy = y / cell, y0 = fy | 0, ty = fy - y0, sy = ty * ty * (3 - 2 * ty);
    for (let x = 0; x < n; x++) { const fx = x / cell, x0 = fx | 0, tx = fx - x0, sx = tx * tx * (3 - 2 * tx);
      const a = lat[y0 * g + x0], b = lat[y0 * g + x0 + 1], c = lat[(y0 + 1) * g + x0], d = lat[(y0 + 1) * g + x0 + 1];
      out[y * n + x] = (a + (b - a) * sx) * (1 - sy) + (c + (d - c) * sx) * sy; } }
  return out;
}
// DENSE TERRAIN: 6 biomes, 80% base tile + 7 autotile variants. ids 1..48, 100% filled.
function dense(n, seed = 1234) {
  const lo = noise(n, Math.max(8, n / 16), seed), hi = noise(n, Math.max(4, n / 64), seed + 77), t = new Uint16Array(n * n);
  for (let i = 0; i < t.length; i++) { const b = Math.min(5, (lo[i] * .75 + hi[i] * .25) * 6 | 0), r = hash2(i % n, i / n | 0, seed);
    t[i] = b * 8 + 1 + (r < .8 ? 0 : 1 + ((r - .8) / .2 * 7 | 0)); }
  return t;
}
// SPARSE DUNGEON: n*n/4000 rooms + walls + props, serpentine L corridors. ids 0..12.
function dungeon(n, seed = 4321) {
  const t = new Uint16Array(n * n), r = rng(seed), rooms = [], target = Math.round(n * n / 4000);
  const put = (x, y, v) => { if (x >= 0 && y >= 0 && x < n && y < n && !t[y * n + x]) t[y * n + x] = v; };
  for (let i = 0; i < target * 4 && rooms.length < target; i++) {
    const w = 5 + (r() * 12 | 0), h = 5 + (r() * 12 | 0), x = 1 + (r() * (n - w - 2) | 0), y = 1 + (r() * (n - h - 2) | 0);
    if (rooms.some(q => x < q.x + q.w + 2 && x + w + 2 > q.x && y < q.y + q.h + 2 && y + h + 2 > q.y)) continue;
    rooms.push({ x, y, w, h });
  }
  for (const q of rooms) {
    for (let y = q.y; y < q.y + q.h; y++) for (let x = q.x; x < q.x + q.w; x++) t[y * n + x] = 1;
    for (let x = q.x - 1; x <= q.x + q.w; x++) { put(x, q.y - 1, 2 + (r() * 4 | 0)); put(x, q.y + q.h, 2 + (r() * 4 | 0)); }
    for (let y = q.y - 1; y <= q.y + q.h; y++) { put(q.x - 1, y, 2 + (r() * 4 | 0)); put(q.x + q.w, y, 2 + (r() * 4 | 0)); }
    for (let k = 0; k < 2 + (r() * 3 | 0); k++) t[(q.y + (r() * q.h | 0)) * n + q.x + (r() * q.w | 0)] = 7 + (r() * 6 | 0);
  }
  const band = Math.max(32, Math.round(n / 8));
  rooms.sort((p, q) => (Math.floor(p.y / band) - Math.floor(q.y / band)) || (Math.floor(p.y / band) % 2 === 0 ? p.x - q.x : q.x - p.x));
  for (let i = 1; i < rooms.length; i++) {
    const a = rooms[i - 1], b = rooms[i], ax = a.x + (a.w >> 1), ay = a.y + (a.h >> 1), bx = b.x + (b.w >> 1), by = b.y + (b.h >> 1);
    for (let x = Math.min(ax, bx); x <= Math.max(ax, bx); x++) { t[ay * n + x] = 1; put(x, ay - 1, 2); put(x, ay + 1, 2); }
    for (let y = Math.min(ay, by); y <= Math.max(ay, by); y++) { t[y * n + bx] = 1; put(bx - 1, y, 2); put(bx + 1, y, 2); }
  }
  return t;
}

const flat = (L, C) => { const a = new C(L.length); a.set(L); return {
  bytes: a.byteLength, get: (x, y) => a[y * N + x], set: (x, y, v) => { a[y * N + x] = v; },
  ser: () => Buffer.from(a.buffer, a.byteOffset, a.byteLength), decode: out => { out.set(a); } }; };

function rle(L) {                                    // uint16 count + uint16 value
  const c = [], v = []; let run = 1;
  for (let i = 1; i <= L.length; i++) { if (i < L.length && L[i] === L[i - 1] && run < 65535) { run++; continue; } c.push(run); v.push(L[i - 1]); run = 1; }
  let counts = Uint16Array.from(c), values = Uint16Array.from(v);
  const rowRun = new Uint32Array(N), rowTile = new Uint32Array(N);
  const idx = () => { let t = 0, r = 0; for (let y = 0; y < N; y++) { const w = y * N; while (t + counts[r] <= w) { t += counts[r]; r++; } rowRun[y] = r; rowTile[y] = t; } };
  idx();
  return {
    get runs() { return counts.length; },
    get bytes() { return counts.byteLength + values.byteLength + rowRun.byteLength + rowTile.byteLength; },
    get: (x, y) => { let r = rowRun[y], t = rowTile[y]; const w = y * N + x; while (t + counts[r] <= w) { t += counts[r]; r++; } return values[r]; },
    set(x, y, val) {                                  // O(runs): a split shifts everything after it
      const w = y * N + x; let r = rowRun[y], t = rowTile[y];
      while (t + counts[r] <= w) { t += counts[r]; r++; }
      if (values[r] === val) return;
      const b = w - t, a = t + counts[r] - w - 1, ex = (b > 0) + (a > 0);
      const nc = new Uint16Array(counts.length + ex), nv = new Uint16Array(nc.length);
      nc.set(counts.subarray(0, r)); nv.set(values.subarray(0, r)); let k = r;
      if (b > 0) { nc[k] = b; nv[k] = values[r]; k++; }
      nc[k] = 1; nv[k] = val; k++;
      if (a > 0) { nc[k] = a; nv[k] = values[r]; k++; }
      nc.set(counts.subarray(r + 1), k); nv.set(values.subarray(r + 1), k);
      counts = nc; values = nv; idx();
    },
    ser() { const b = Buffer.allocUnsafe(counts.length * 4); for (let i = 0; i < counts.length; i++) { b.writeUInt16LE(counts[i], i * 4); b.writeUInt16LE(values[i], i * 4 + 2); } return b; },
    decode(out) { let o = 0; for (let i = 0; i < counts.length; i++) { out.fill(values[i], o, o + counts[i]); o += counts[i]; } },
  };
}

function chunked(L, C = 32) {
  const cw = N / C, nc = cw * cw, ar = C * C, index = new Int32Array(nc).fill(-1); let used = 0;
  for (let cy = 0; cy < cw; cy++) for (let cx = 0; cx < cw; cx++) { let e = true;
    for (let y = 0; y < C && e; y++) { const o = (cy * C + y) * N + cx * C; for (let x = 0; x < C; x++) if (L[o + x]) { e = false; break; } }
    if (!e) index[cy * cw + cx] = used++; }
  const data = new Uint16Array(used * ar);
  for (let cy = 0; cy < cw; cy++) for (let cx = 0; cx < cw; cx++) { const s = index[cy * cw + cx]; if (s < 0) continue;
    for (let y = 0; y < C; y++) data.set(L.subarray((cy * C + y) * N + cx * C, (cy * C + y) * N + cx * C + C), s * ar + y * C); }
  return { used, nc, bytes: data.byteLength + index.byteLength,
    get: (x, y) => { const s = index[(y / C | 0) * cw + (x / C | 0)]; return s < 0 ? 0 : data[s * ar + (y % C) * C + (x % C)]; },
    set: (x, y, v) => { const s = index[(y / C | 0) * cw + (x / C | 0)]; if (s >= 0) data[s * ar + (y % C) * C + (x % C)] = v; },
    ser() { const b = Buffer.allocUnsafe(index.byteLength + data.byteLength);
      Buffer.from(index.buffer).copy(b, 0); Buffer.from(data.buffer).copy(b, index.byteLength); return b; },
    decode(out) { out.fill(0); for (let cy = 0; cy < cw; cy++) for (let cx = 0; cx < cw; cx++) for (let y = 0; y < C; y++) for (let x = 0; x < C; x++) out[(cy * C + y) * N + cx * C + x] = this.get(cx * C + x, cy * C + y); } };
}

function quadtree(L) {                     // leaf [0][u16] = 3 B, node [1][u32 x4] = 17 B
  let buf = Buffer.allocUnsafe(N * N * 9 + 64), pos = 0, leaves = 0, nodes = 0;
  const leaf = v => { const p = pos; buf[pos] = 0; buf.writeUInt16LE(v, pos + 1); pos += 3; leaves++; return p; };
  const inner = (a, b, c, d) => { const p = pos; buf[pos] = 1; buf.writeUInt32LE(a, pos + 1); buf.writeUInt32LE(b, pos + 5); buf.writeUInt32LE(c, pos + 9); buf.writeUInt32LE(d, pos + 13); pos += 17; nodes++; return p; };
  const build = (x, y, s) => {
    if (s === 1) { const v = L[y * N + x]; return [leaf(v), v]; }
    const h = s >> 1, a = build(x, y, h), b = build(x + h, y, h), c = build(x, y + h, h), d = build(x + h, y + h, h);
    if (a[1] >= 0 && a[1] === b[1] && b[1] === c[1] && c[1] === d[1]) { pos = a[0]; leaves -= 4; return [leaf(a[1]), a[1]]; }
    return [inner(a[0], b[0], c[0], d[0]), -1];
  };
  const root = build(0, 0, N)[0], bytes = pos, out = Buffer.allocUnsafe(bytes);
  buf.copy(out, 0, 0, bytes); buf = null;
  return { bytes, count: leaves + nodes,
    get: (x, y) => { let o = root, s = N, ox = 0, oy = 0;
      while (out[o] === 1) { const h = s >> 1, q = (y >= oy + h ? 2 : 0) + (x >= ox + h ? 1 : 0);
        o = out.readUInt32LE(o + 1 + q * 4); if (q & 1) ox += h; if (q & 2) oy += h; s = h; }
      return out.readUInt16LE(o + 1); },
    ser: () => out, decode(o2) { for (let i = 0; i < N * N; i++) o2[i] = this.get(i % N, i / N | 0); } };
}

function sparseMap(L) {
  const m = new Map();
  for (let i = 0; i < L.length; i++) if (L[i]) m.set(i, L[i]);
  return { size: m.size, bytes: -1, get: (x, y) => m.get(y * N + x) || 0,
    set: (x, y, v) => v ? m.set(y * N + x, v) : m.delete(y * N + x),
    ser() { const b = Buffer.allocUnsafe(m.size * 6); let o = 0; for (const [k, v] of m) { b.writeUInt32LE(k, o); b.writeUInt16LE(v, o + 4); o += 6; } return b; },
    decode(out) { out.fill(0); for (const [k, v] of m) out[k] = v; } };
}

const heap = () => { if (global.gc) { global.gc(); global.gc(); } return process.memoryUsage().heapUsed; };
const NR = 300000, r0 = rng(20260831), RX = new Int32Array(NR), RY = new Int32Array(NR);
for (let i = 0; i < NR; i++) { RX[i] = r0() * N | 0; RY[i] = r0() * N | 0; }
const out = new Uint16Array(N * N);

for (const [mapName, L] of [['dense terrain', dense(N)], ['sparse dungeon', dungeon(N)]]) {
  if (ONLY && !mapName.startsWith(ONLY)) continue;
  let nz = 0, mx = 0; for (let i = 0; i < L.length; i++) { if (L[i]) nz++; if (L[i] > mx) mx = L[i]; }
  console.log(`\n### ${mapName} ${N}x${N} — ${(nz / L.length * 100).toFixed(1)}% filled, max tile id ${mx}`);
  console.log('representation        memory_B    gzip_B  rnd_read_ns  decode_ms   edit_1_tile      detail');
  const cases = [['Int32Array', flat(L, Int32Array), ''], ['Uint16Array', flat(L, Uint16Array), ''], ['Uint8Array', flat(L, Uint8Array), '']];
  const rl = rle(L); cases.push(['RLE', rl, () => `${rl.runs} runs`]);
  const ch = chunked(L, 32); cases.push(['Chunked 32x32', ch, () => `${ch.used}/${ch.nc} chunks kept`]);
  const qt = quadtree(L); cases.push(['Quadtree', qt, () => `${qt.count} nodes`]);
  const h0 = heap(); const sp = sparseMap(L); const h1 = heap();
  sp.bytes = h1 - h0; cases.push(['Sparse Map', sp, () => `${sp.size} entries (heap delta)`]);

  for (const [k, r, det] of cases) {
    let s = 0, gen = 0;
    const gzb = gz(r.ser());                       // BEFORE the edit loop scrambles the map
    const read = () => { for (let i = 0; i < NR; i++) s += r.get(RX[i], RY[i]); };
    read(); read();
    const rd = ms(read, 7) * 1e6 / NR;
    r.decode(out); const dc = ms(() => r.decode(out), 5);
    let ed;
    if (k === 'RLE') { const K = 500, q = rle(L);
      ed = `${(ms(() => { gen++; for (let i = 0; i < K; i++) q.set(RX[i], RY[i], (i + gen * 13) % 40 + 1); }, 3) * 1000 / K).toFixed(1)} us`;
    } else if (k === 'Quadtree') { ed = `${ms(() => quadtree(L), 3).toFixed(1)} ms rebuild`;
    } else { const K = 100000;
      ed = `${(ms(() => { gen++; for (let i = 0; i < K; i++) r.set(RX[i], RY[i], (i + gen * 13) % 40 + 1); }, 5) * 1e6 / K).toFixed(1)} ns`; }
    console.log(`${k.padEnd(20)} ${P(r.bytes, 9)} ${P(gzb, 9)} ${P(rd.toFixed(1), 12)} ${P(dc.toFixed(2), 10)} ${P(ed, 13)}      ${det ? det() : ''}  [${s % 97}]`);
  }
  const f8 = Buffer.from(Uint8Array.from(L).buffer), rr = rle(L).ser();
  console.log(`  gzip check: flat Uint8 gz=${gz(f8)}  RLE gz=${gz(rr)}  ratio=${(gz(rr) / gz(f8)).toFixed(2)}x   (level 9: ${gz(f8, 9)} vs ${gz(rr, 9)})`);
}

console.log(`\n### tile-id width, dense terrain ${N}x${N}`);
console.log('width   memory_B     gzip_B   max_tile_id');
const D = dense(N);
for (const [n, C, ceil] of [['Uint8', Uint8Array, 255], ['Uint16', Uint16Array, 65535], ['Int32', Int32Array, 2147483647]]) {
  const a = new C(D.length); a.set(D);
  console.log(`${n.padEnd(7)} ${P(a.byteLength, 9)} ${P(gz(Buffer.from(a.buffer, a.byteOffset, a.byteLength)), 10)} ${P(ceil, 13)}`);
}

const u16 = new Uint16Array(D), raw = Buffer.from(u16.buffer);
const jf = JSON.stringify({ width: N, height: N, data: Array.from(D) });
const jt = JSON.stringify({ width: N, height: N, compression: 'zlib', data: zlib.deflateSync(raw).toString('base64') });
console.log(`\n### JSON vs binary, dense terrain ${N}x${N}`);
console.log(`binary Uint16 raw         ${P(raw.length, 10)} B   gzip ${P(gz(raw), 9)} B   parse 0 ms (zero-copy view)`);
console.log(`JSON flat int array       ${P(Buffer.byteLength(jf), 10)} B   gzip ${P(gz(Buffer.from(jf)), 9)} B   parse ${ms(() => JSON.parse(jf), 5).toFixed(2)} ms`);
console.log(`JSON base64+zlib (Tiled)  ${P(Buffer.byteLength(jt), 10)} B   gzip ${P(gz(Buffer.from(jt)), 9)} B   parse ${ms(() => zlib.inflateSync(Buffer.from(JSON.parse(jt).data, 'base64')), 5).toFixed(2)} ms`);

const wgz = gz(raw), d1 = Buffer.allocUnsafe(6); d1.writeUInt32LE(12345, 0); d1.writeUInt16LE(9, 4);
console.log(`\n### one edit to a ${N}x${N} map`);
console.log(`whole map gzipped ${wgz} B   |   6-byte delta gzipped ${gz(d1)} B   |   ratio ${(wgz / gz(d1)).toFixed(0)}x`);

On the M3 above, node --expose-gc tilemap-bench.mjs 1024 dense prints:

### dense terrain 1024x1024 — 100.0% filled, max tile id 48
representation        memory_B    gzip_B  rnd_read_ns  decode_ms   edit_1_tile      detail
Int32Array             4194304    397609          2.7       0.06        1.6 ns        [83]
Uint16Array            2097152    346245          4.9       0.03        4.6 ns        [83]
Uint8Array             1048576    295232          5.1       0.03        4.7 ns        [83]
RLE                    1588384    365355        190.1       6.70      409.4 us      395048 runs  [83]
Chunked 32x32          2101248    358336         10.3       2.54        7.1 ns      1024/1024 chunks kept  [83]
Quadtree               6447275   3391172        104.4      51.00 25.8 ms rebuild      991889 nodes  [83]
Sparse Map            29364168   2114672        104.4       7.29       84.5 ns      1048576 entries (heap delta)  [83]
  gzip check: flat Uint8 gz=295232  RLE gz=365355  ratio=1.24x   (level 9: 285354 vs 352722)

### tile-id width, dense terrain 1024x1024
width   memory_B     gzip_B   max_tile_id
Uint8     1048576     295232           255
Uint16    2097152     346245         65535
Int32     4194304     397609    2147483647

### JSON vs binary, dense terrain 1024x1024
binary Uint16 raw            2097152 B   gzip    346245 B   parse 0 ms (zero-copy view)
JSON flat int array          3012249 B   gzip    377659 B   parse 8.26 ms
JSON base64+zlib (Tiled)      461703 B   gzip    347481 B   parse 2.34 ms

### one edit to a 1024x1024 map
whole map gzipped 346245 B   |   6-byte delta gzipped 26 B   |   ratio 13317x

The byte counts are exact and will match on any machine; the timings will not. The ordering is the finding.