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
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.
Int32toUint8is 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 forUint8, 65,535 forUint16. - gzip already finds your runs. Flat
Uint8gzipped 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.
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?
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.