How spatiotemporal tiles work
Map tiles solved "the world is too big to send" by cutting space into a pyramid. STT applies the same cut to time: every tile is also sliced into time buckets, packed into static files, and streamed like video — buffered ahead of a playhead, animated entirely on the GPU. This page is the whole system, end to end.
Four stages, no servers
One Rust CLI turns timestamped rows into an archive of static files; one TypeScript reader streams them into any renderer. Between the two there is only an object store — nothing computes per request.
Rows with geometry + time
- GeoParquet / Parquet — WKB, GeoArrow or lon/lat columns
- PostGIS — --postgres · table or SQL, reprojected server-side
- DuckDB — --duckdb · scans CSV, GeoJSON & Parquet too
Every row: a point, line or polygon in WGS84 plus start (and optional end) timestamps.
stt-build — the Rust tiler
- Normalize time — ISO 8601 / unix → milliseconds
- Tile in space — Web-Mercator z/x/y across the zoom range
- Cut in time — each tile splits into buckets (default 1 h)
- Encode — Arrow columns; the schema is hoisted, not repeated
- Compress + dedup — zstd per blob, blake3 collapses identical bytes
- Pack — blobs packed into 64 MiB objects + a directory
cargo install spatiotemporal-tiles
A folder of static files
- manifest.json — tiny + mutable — names the current build
- index/<blake3>.sttd — the tile directory, immutable
- packs/<blake3>.sttp — tile blobs, immutable, range-read
Any object store behind any CDN. No tile server, no database, cache-forever URLs.
Streamed like video
- @poopdeck.gl/core — range requests, off-thread Arrow decode
- @poopdeck.gl/playback — clock + buffering governor
- GPU time gate — two uniforms per frame, zero rebuilds
- Four renderers — deck.gl · MapLibre · Three (WebGPU) · Cesium
npm i @poopdeck.gl/layers
Tiles cut in space and time
A classic tile is addressed by (z, x, y). An STT tile adds a fourth coordinate: the start of its time bucket. That one extra axis is what makes a map scrubbable — the viewer fetches only the buckets around the playhead, for only the tiles in view.
Deeper: the time axis has zoom levels too
--temporal-lod "1d@8,30d@4" bakes aggregate tiers whose buckets are strict multiples of the base bucket; each directory entry records its own temporal_bucket_ms, so the reader dispatches per zoom with no special cases. Dense-then-quiet datasets can instead use --adaptive-temporal N, which sizes windows by feature count rather than the clock.
Deeper: long-lived features live in one blob — the reader looks back
An interval feature is written once, in the bucket of its start time — never copied into every bucket it spans. So rendering time T means fetching the bucket that contains T and looking back to earlier buckets that still hold live features.
The naive alternative duplicates each feature into every bucket it overlaps, so a long-lived trip, storm or vessel track is written N times and the archive balloons. The look-back avoids that: store once, pay a bounded read.
cover_t_min is a tight lower covering bound recorded per directory entry — the 08:00 bucket advertises 08:20, so the reader knows exactly how far back to walk and stops once an earlier bucket's newest feature has already ended. When it is None (repacked or pre-covering archives) the reader falls back to time_start: correct, but it looks all the way back to the bucket start.
Deeper: trips are clipped at tile seams — the crossing vertex is interpolated in time
A trajectory carries a timestamp per vertex. When it crosses a tile boundary the build clips it at the border and splits it into one self-contained sub-path per tile. The crossing point becomes a new border vertex whose position lands on the edge and whose time is interpolated between the two real vertices — so each tile owns a complete little polyline with continuous times.
Edge case: a segment whose longitude jumps more than 180° straddles the dateline. Left whole, the clamped tile walk draws it the long way and bakes a globe-spanning sliver into every column between — so it is split at the wrap first. Dateline wrap → split, so a line never smears across the globe.
Clipping runs once, at build — clip_trajectory during per-tile placement — so every tile ships a self-contained ClippedSegment and the viewer never fetches a neighbour to finish a partial trip. The load-bearing detail is that the border vertex carries an interpolated time, not just a position: interpolate_timestamp gives the left tile's last frame and the right tile's first frame the same clock, to the millisecond. Without it, trips flash and teleport at tile borders.
Three files, three round trips
Millions of tiles would mean millions of URLs. Instead the archive is a handful of immutable, content-addressed objects: a directory that knows where every tile lives, and packs read by HTTP range. Anything that can serve a static file can serve STT.
Three kinds of file — one mutable, two immutable
A new build writes new hashed objects and flips one pointer in manifest.json — deploys are atomic and additive, and every heavy object is cacheable forever. To hand the dataset to someone rather than serve it, stt-bundle pack folds the tree into one .sttb file and back out again, re-verifying every content address on the way.
Cold start = three round trips
After that, panning, zooming and scrubbing only issue range reads into immutable packs — there is no per-tile URL and no server doing work.
The directory: 52 bytes per leaf up front, ~1 byte per tile inside
One object holds every tile address in the archive, and it has to stay small enough to fetch cold. Two mechanisms do that: a root page that rules whole leaves out before they're downloaded, and a body that exploits how little consecutive tile keys differ.
Above: it prunes before anything is fetched
The root page carries one 52-byte descriptor per leaf — zoom range, geo bbox and time span. Only leaves overlapping the query are downloaded.
Below: sorted keys plus run-length encoding
Entries sort by (zoom, hilbert(x, y), time) — the 2D walk drawn in the next figure — so consecutive keys differ by almost nothing and delta-code down to about a byte each.
| entry key (delta-coded) | features | blob run (written once per run) |
|---|---|---|
| z7 · h2841 · 08:00 | 214 | run ×3 → pack 0 @ 0, 41 kBthree identical hours — blob columns written once |
| z7 · h2841 · 09:00 | 214 | |
| z7 · h2841 · 10:00 | 214 | |
| z7 · h2841 · 11:00 | 890 | run ×1 → pack 0 @ 41 kB, 96 kBoffset stores 0 — it follows the previous blob |
| z7 · h2842 · 08:00 | 3 102 | run ×1 → pack 0 @ 137 kB, 210 kBnext Hilbert cell — still sequential |
| z7 · h2842 · 09:00 | 2 977 | run ×1 → pack 0 @ 347 kB, 205 kB |
This is the temporal analogue of PMTiles collapsing identical ocean tiles across space: because the writer dedups byte-identical blobs, a cell that doesn't change across consecutive buckets costs one run instead of N entries' worth of blob columns. Tens of thousands of tiles index in a few hundred kilobytes.
Deeper: laying the space-time cube down in a line
A blob's address has three axes — (x, y) on the map plus a time bucket — but a pack is one flat byte string. --blob-ordering chooses the space-filling walk that linearizes the cube. Pick a walk, then play a real map+time gesture and watch which bytes it touches: the walk decides how many range reads the gesture costs.
Fixed viewport, the playhead runs forward — the classic playback loop. Each visible tile wants its whole timeline in one pull, so the time-deep spatial walk (and the 3D generalist) read it as a single run; time-major seeks on every tick.
1 range read for the play gesture under hilbert3 — watch the brackets fuse as it plays, before gap-coalescing merges the near-misses too.
| walk | seeks | play | pan | pan+play | zoom+play | click |
|---|---|---|---|---|---|---|
| spatial | 15 | 1 | 16 | 10 | 12 | 1 |
| hilbert3 | 0 | 1 | 4 | 5 | 3 | 2 |
| time-major | 3 | 4 | 1 | 6 | 3 | 4 |
Range reads per gesture. Notice the cube walk never spikes — 1·4·5·3·2, no catastrophes — while the specialists each blow up on the gesture they ignore.
--blob-ordering auto — the default — reads the archive's occupied space-vs-time extent and picks: time-deep datasets (a buoy's four-year track over a few cells) get spatial, because playback wants each cell's whole timeline in one pull; balanced or space-heavy datasets (a day of global flights) get the hilbert3 generalist. To decide from measurement instead of shape, build with --blob-ordering measured or audit an existing archive with stt-optimize order-audit, which simulates the range-read cost of each walk over the real directory. Either way the directory index keeps its own (zoom, hilbert(x,y), t) sort — the knob permutes bytes inside packs, never keys.
Deeper still: the same walk on real archives
The cube above is idealized — every cell full. Real archives are sparse and lopsided, and that shape is exactly what --blob-ordering auto reads to pick a walk. Switch datasets and watch the winner flip: a buoy's multi-year track over a few cells wants spatial; a day of flights wants the 3D-Hilbert generalist. Density is sampled from each archive's directory.
Loading density profile…
Packs — dedup by content, fetch by range
Inside a tile
Each tile is columnar Apache Arrow — the layout GPUs and analytics engines already speak. Three required columns describe points, paths and polygons in time; optional columns layer on trips, per-vertex values, splats and pre-aggregation.
A tile is a sectioned frame — and its schema isn't in it
Arrow schemas are dataset-constant, so shipping one per tile pays for the same bytes tens of thousands of times. Instead each layer carries a 16-byte blake3 reference into a schema table embedded in manifest.json; only genuinely per-tile metadata rides in TILE_META. Splitting geometry from properties lets a reader decode a tile's shape without touching its columns. Every section pads to 8 bytes and the IPC writer is pinned to 8-byte buffer alignment, so column buffers hand to the GPU without copying.
…and the batches are columns
A tile of simple events costs three columns — most event data is instantaneous, so end_time isn't written at all. Trips, splats, flow corridors and summary tiers are the same container with more columns switched on; there is no separate format per layer type.
Deeper: how the numbers shrink
Every quantization is opt-in and ships its own inverse, so the reader reconstructs real values on decode and nothing downstream knows the wire was smaller. Each one also declares itself in manifest.capabilities — a reader that doesn't implement it refuses the dataset at open rather than silently drawing integer grid indices as degrees.
lon = x0 + q · sx
Vertices snap to one grid anchored at the world corner (−180°, −90°), constant across the dataset, sized to your ground precision. Because every tile shares it, identical geometry stays byte-identical — a per-tile grid would give the same point different indices per tile and defeat blob dedup (measured +61%). Affine lives in stt:quant.
f64 8 B → int 4 Bt = origin + q · step
Trajectory timestamps become UInt16 step counts, widening to UInt32 when a tile's span needs it and falling back to exact Int64 past the --vertex-time-precision ceiling.
i64 8 B → u16 2 B / vertexvalue = o + q · s
--quantize-attrs-auto maps every Float64 property onto 65 536 levels of its own range; --quantize-attr z=0.05 pins one column to a fixed precision instead. The per-column affine rides in TILE_META.qa.
f64 8 B → u16 2 B / valueIntegers also compress better: small deltas and repeated values give zstd far more to work with than float mantissas, so the wire savings compound beyond the raw width cut. On dense LiDAR archives the combination has measured 4–6× end-to-end.
Fetched bytes decode on a worker pool; two cache tiers skip the work entirely
Decoding one tile inline is ~5–20 ms of decompress + tableFromIPC + buffer extraction — a whole frame gone. So each fetched slice goes to the least-pending worker instead, and the decoded typed arrays are transferred back rather than copied; the render loop never touches the work. The cache tiers are why a second visit is instant: RAM skips the network, OPFS skips the network and the zstd step. And because the OPFS key is the directory's content address, a redeploy mints new keys and stale tiles simply stop being found — no walk-and-delete.
Streamed like video
The runtime treats sim-time the way a video player treats seconds: buffer a runway ahead of the playhead, gate the clock on it, and never let the picture outrun the data. Rendering stays cheap because animation is a shader, not a data update.
The governor buffers sim-time like a video player buffers seconds
The governor is a small state machine — it degrades before it freezes
A gate also passes when the missing bytes are predicted to arrive within the wall time the current runway buys — so a cold seek on a fast link starts almost instantly instead of demanding seconds of resident sim-time up front.
If a gate can't pass within maxStartWaitMs (~8 s), the governor stops waiting and pins the playhead to the data frontier, advancing at arrival rate — motion never lurches, and it re-tightens the moment the network recovers.
The governor never touches the network itself — it sees the loader only through a small BufferSource seam (getBufferedRunway, estimateCost, flushPrefetch, setAnimationState), which is why @poopdeck.gl/playback stays zero-dependency and renderer-agnostic.
Per frame, the GPU alpha-gates every feature — buffers never rebuild
Per-feature (and per-vertex) times upload once as attributes. Each frame updates a handful of uniforms and the vertex shader gates alpha; fully hidden vertices collapse to a degenerate position so they cost no fragments. Times are relativized against a per-tile offset before upload, keeping f32 attributes millisecond-accurate (relative windows cap at 2²⁴ ms ≈ 4.6 h). The identical gate math runs as deck.gl GLSL, MapLibre GLSL and Three TSL.
Deeper: four looks, one mechanism — alpha as a function of age
window
visible around the playhead, fade ramps at both edges
trail
alpha = 1 − age / trailLength — comet tails per vertex
wake
long low tail, bright head — size shrinks toward the tail too
cumulative
everything up to the playhead stays — build-up stories
Dashed line = the playhead. Switching modes changes a uniform, not the data — the same buffers render as a sliding window, a comet trail, a vessel wake or an accumulating record. A separate injection can also map age to height, turning any dataset into a space-time cube.
The control loop — two packages, one seam
In deck.gl apps the controller rides deck's own frame loop (useDeckClock mirrors it onto context.userData.stt), so STT layers stay cached layer instances that simply redraw. MapLibre, Three and Cesium bridge their render loops the same way.
Deeper: many datasets, one playhead
The governor folds health over the required set only — combined runway is the minimum, completeness is the AND. The shared scheduler then splits the connection budget by deficit round robin (the lidar's weight 4 earns 4× the bandwidth) and orders requests earliest-deadline-first, where a request's deadline is its distance from the playhead.
When throughput can't sustain the requested speed, playback steps down this ladder immediately; it steps back up only after sustained headroom clears a deadband — asymmetry borrowed from video ABR, so the picture degrades fast and recovers calmly instead of oscillating.
What keeps it small and smooth
Every lever below is measured and recorded in the archive's own metadata, so the reader reconstructs exact values on decode. The lossless ones are on by default; anything that trades precision for bytes is opt-in. Together they compound to 4–6× smaller archives that still animate at 60 fps.
Smaller archives
Cut bytes at build time. The lossless levers run by default; the ones that trade precision for size are opt-in, exact to a declared precision, and undone on decode.
Coordinate quantization
Geometry stored as fixed-point integers on one world-anchored grid — the same grid for every tile, so identical geometry stays byte-identical and content-addressed dedup survives — at a chosen ground precision, a fraction of float64 with no visible loss.
--quantize-coords · stt:quantAttribute quantization
Every Float64 property maps onto 65 536 levels of its own range, plus a tiny affine to reconstruct real values on decode — or pin one column to a fixed precision.
--quantize-attrs-auto · TILE_META.qaCompact feature times
On by default: start_time becomes a UInt32 offset from the tile’s own t0, and end_time vanishes entirely when every feature is instantaneous. Measured a third of a point archive’s bytes.
TILE_META.st / .etShared schema templates
Arrow schemas are dataset-constant, so tiles reference one by 16-byte blake3 hash instead of repeating ~900 B of it each. Single-digit templates cover a whole dataset.
manifest.schemasPer-blob zstd
Every tile blob is an independent zstd frame, so the browser decodes with a ~30 kB pure-JS decoder — no WASM, no shared dictionary.
--zstd-level 1…22 · --publishContent-addressed dedup
Blobs are stored once, keyed by blake3. Byte-identical time buckets collapse — static or quiet periods cost almost nothing.
packs/<blake3>.sttpSimplify + pre-tessellate
Time-aware line simplification keeps the motion and drops the noise, at a latitude-corrected ground tolerance; polygons can bake their earcut triangles so clients skip tessellation.
--time-aware-simplify · --pre-tessellateFewer, smarter fetches
Move only the bytes the current viewport, zoom and playhead actually need.
Paged directory
The default: the tile index ships in pages, and 52-byte root descriptors prune whole leaves by zoom ∧ bbox ∧ time span before any fetch.
layout: "paged" · --page-entriesRange coalescing
Neighbouring blobs in a pack fuse into one HTTP range request when the gap is under 2 MiB — Hilbert ordering makes neighbours common.
HTTP 206 · coalesceGapBytesTemporal LOD
Coarser time buckets at low zooms: a continent view scrubs days per tile while street level streams hours.
--temporal-lod "1d@8,30d@4"Summary tiers
Pre-aggregated H3 or quadbin cells serve the overview zooms, so a hundred million points still open instantly.
--summary-tier quadbinZoom bands
Each feature carries a [min, max] zoom range — clustered overviews own the low zooms, full resolution appears as you dive.
--min/max-zoom-fieldTile budgets
Opt-in per-tile caps on bytes or features drop the lowest-importance rows to fit, and log exactly what was dropped where.
--maximum-tile-bytesSmoother frames
Keep the render thread idle: animation lives in the shader, decode lives in workers.
GPU time gate
Feature and vertex times upload once as attributes; each frame changes a few uniforms and the shader alpha-gates — no rebuilds, ever.
TimeFilterExtensionZero-copy GPU columns
Interleaved FixedSizeList columns (quaternions, colors, scales) bind to the GPU exactly as decoded — no main-thread repacking.
--vector-groupOff-thread decode + OPFS
Workers decompress and parse tiles off the UI thread; decoded tiles persist in the browser's private file system across visits.
createDefaultTileDecoderSpace-time values
A per-vertex × per-bucket value cube animates static geometry — streets breathing with hourly traffic while their shape ships once. Optionally UInt16-quantized, the only lever those columns have.
vertex_value_matrix · --quantize-vertex-valuesThe build tunes itself
The levers above are powerful and easy to misuse, so the toolchain ships its own analyst. It profiles the source, measures each lever on a real sample of the data, and recommends flags with a confidence grade — while a firewall guarantees it never throws data away to hit a size target unless you say so.
The pipeline gains a build-time analyst
stt-optimize wraps the tiler with a loop that runs entirely at build time — nothing here touches the serve path. analyze picks the zoom range and temporal bucket and runs the advisors; --auto folds the safe ones in (bare, it only fills zoom + bucket; --auto encode also applies the non-lossy byte levers); then inspect, diff and doctor turn the manual tuning passes this project kept re-running by hand into commands CI can gate on (diff --fail-on-growth, doctor --strict).
Measure, don't guess — every projection is a real encode
Instead of a size formula, the analyzer keeps a deterministic stride sample of real geometries and property values and pushes it through the production encoder plus zstd, then attributes cost by re-encoding each column alone. That is where a projection like −36% sample encode (measured) comes from — and how the doctor knows a near-incompressible id column, not the geometry, is eating the archive.
From analysis to advice — with a no-thinning firewall
five analyzers profile the source; four advisors turn that profile into flag recommendations, each carrying a measured projection and a confidence grade.
Byte-level, undone on decode. Folded into --auto encode and appended to the pasteable command, in advisor order. Advice that carries a real tradeoff (spatial ordering hurts pan) is flagged suggestion_only and steps out of both.
Degrades data. Reported loudly with its projection, but you add it by hand — never in --auto, never in the command.
This split is the project's no-thinning principle made mechanical: a build never silently throws data away to hit a size target. Quantization and per-tile budgets can shrink an archive a lot — so they stay a deliberate, per-dataset choice, and the default build is byte-identical whether or not you ran the analyzer.
The doctor — severity-ranked findings on the built tileset
| sev | finding (stable code) | what it smells | remediation |
|---|---|---|---|
| expensive-feature-ids | near-incompressible hash-like ids dominate the bytes | sequential ids / drop | |
| raw-f64-column | plain Float64 property columns worth quantizing | --quantize-attr | |
| missing-summary-tier | huge point dataset with no aggregated overview | --summary-tier quadbin | |
| unpaged-large | whole-load directory on a large tile count | layout: "paged" | |
| oversized-blobs | individual tiles past 1 MiB compressed | raise min-zoom / split | |
| z0-bomb | deep pyramid under a tiny geographic extent | clamp zoom range | |
| dead-columns | constant / all-null property columns | drop at source |
Each rule keys off numbers already measured for this tileset — directory stats and per-column costs from inspect — and cites them in its message; the doctor never re-encodes, so every delta is labelled (estimated from measured column costs). Run it with --strict and a fresh smell fails the build.
Deeper: the archive describes how to style itself
Every non-streaming build already bakes the cheap signals into manifest.json — the dominant layer kind and a suggested playback duration — so a client can open a strange archive and pick a clock with no config. --style-hints adds the expensive part: a full per-property profile with percentiles and a suggested colour domain.
"style_hints": {
"version": 1,
"layer_hint": "trips", // always baked
"suggested_playback_seconds": 45, // always baked
"properties": [ // --style-hints only
{ "name": "speed_kmh", "min": 0, "p50": 14.2, "p95": 31.6,
"p97": 34.0, "p99": 41.8, "max": 58, "suggested_domain": [0, 34] },
{ "name": "route_id", "cardinality": 214 }
]
}The domain clamps at p97, not max — one outlier must not dim the whole ramp. Values are sampled at a deterministic stride (capped per property), so the block is byte-identical across rebuilds of the same input, safe to content-address alongside everything else.
Crates and packages
The format lives in one Rust crate and one TypeScript package; everything else is a thin consumer. The same decoded tiles drive deck.gl, MapLibre, Three.js and Cesium — pick the renderer your app already uses.
Build time — the Rust crates
Alongside them: stt-wasm, the WebAssembly decoder, and stt-generate, the demo-dataset generator behind every showcase demo — it fetches real data, writes GeoParquet and shells out to stt-build.
Runtime — the npm packages (@poopdeck.gl)
One decoder, one clock: every renderer consumes the same decoded tiles from core. layers, three and react import the playback engine directly; MapLibre and Cesium take no dependency on it and drive from any clock through a small structural interface. An eighth package, /mcp, exposes the dataset catalog and docs to AI agents and depends on neither.
Who renders what — the capability matrix
| layer kind | deck | three | maplibre | cesium |
|---|---|---|---|---|
| point | ||||
| path | — | |||
| polygon | — | |||
| arc | ||||
| line | ||||
| icon | — | |||
| column | — | |||
| trips | ||||
| tripHeads | ||||
| boundingBox | — | — | ||
| surfel | — | ↳ point | ||
| heatmap | ↳ point | — | ||
| h3Summary | — | |||
| quadbinSummary | — | |||
| flowmap | ↳ line | |||
| flowCorridor | ↳ line | |||
| flowStroke | ↳ flowCorridor | ↳ line | ||
| isoLines | ↳ path | — | ↳ path | |
| ego | — | — | — | |
| text | ↳ icon | ↳ icon | ↳ icon | |
| mesh | ↳ boundingBox | — | ↳ boundingBox | |
| pointCloud | ↳ point | ↳ point | ↳ point | |
| hexbin | ↳ h3Summary | ↳ h3Summary |
| time-filter mode | deck | three | maplibre | cesium |
|---|---|---|---|---|
| window | ||||
| trail | ||||
| wake | ||||
| cumulative |
deck.gl is the reference backend and the only one with no gaps; Three (WebGPU/TSL) tracks it closest and adds the ego/cockpit kinds; MapLibre renders most of the catalog as native custom layers on the basemap; Cesium takes the movement kinds natively on a WGS84 globe and degrades the rest to a simpler kind rather than dropping them.
● native · ↳ falls back to a simpler kind · — unsupported. Generated from each backend's BackendDescriptor — the full, regenerated table lives in the spec.
One formula, every engine
This is the guarantee behind the package map above. The alpha math is defined once, as a framework-free CPU function in @poopdeck.gl/core — and then stated a second time, independently, as a branchless expression AST. Two derivations disagree wherever the spec is ambiguous, which one implementation can never reveal; that is how two out-of-contract inputs were found and pinned. Nothing here is machine-emitted. deck.gl, MapLibre and Three each hand-write the math in their own dialect, because each host wants it inlined its own way, and Cesium skips the shader entirely and runs the oracle on the CPU. What keeps them honest is a chain of assertions rather than a compiler: every backend keeps a JS reference of its shader math, tests pin that reference to both oracles numerically, and the shipped shader is locked to the reference. So a trail looks and times identically whether you draw it in deck.gl or on a Cesium globe — and changing the formula fails four test suites at once until every renderer follows.
Why it's built this way
Formats live or die on their constraints. These are the locked-in choices — each one traded away flexibility to buy something measurable, and each is written down in the spec so the trade stays visible.
Why static files instead of a tile server?
Tiling cost is paid once, at build. Serving is any object store behind any CDN — no capacity planning, nothing to fall over under load, and archives work offline or air-gapped. The entire runtime contract is HTTP GET plus range requests.
Static files — but can a tile be generated on the fly?
Yes, and it is the same tile. The offline tiler and the on-demand stt-serve share one EncoderConfig and per-tile encode path, so a dynamically served tile is byte-identical to what a batch build would have written — a live PostGIS or DuckDB query can back the map, and static hosting stays the default rather than the only mode.
Why Apache Arrow for the payload?
Columns arrive GPU-shaped and slice zero-copy, and the same schema opens in Rust, JS and Python with stock libraries. Everything the format adds rides in metadata rather than side files — the dataset-constant part in the schema itself (stt:quant), the per-tile part in the frame's own TILE_META section.
How does the format add features without breaking readers?
Two rules. Additive columns need no announcement — a reader that doesn't know part_offsets ignores it. But a feature that re-types an existing column (quantized coordinates become integers) would make an old reader silently misdecode rather than fail, so each one declares itself in manifest.capabilities and a reader that lacks it must refuse the dataset at open. Silent wrongness is the only failure mode the format treats as unacceptable.
Why zstd per blob, with no shared dictionary?
Every blob decodes alone, which is what makes random access real: any tile is readable from a single range read, the browser decoder is ~30 kB of pure JS, and caching never depends on fetching a dictionary first.
Why packs instead of a file per tile?
Millions of tiny objects make deploys, listings and cold caches slow. Packs target 64 MiB (blobs are never split), stay far under CDN object caps, upload in seconds and serve precise range reads — with gap coalescing fusing neighbours into one request.
Why blake3 content addressing?
One hash is simultaneously the dedup key, the cache key and the integrity check. Immutable URLs cache forever, a deploy is an atomic manifest pointer flip, stale objects age out via retention, and corruption is detectable end to end (crc32c guards each blob besides).
Why animate in the shader?
Rebuilding buffers per frame caps out orders of magnitude below what GPUs can draw. Attributes upload once; a frame is a handful of uniform writes — which is why a million features scrub at 60 fps and why scrubbing backwards is free.
Why four renderers over one kernel?
Teams already own a map stack. Decode, streaming, the clock and the definition of the gate math are shared; only the last inch is per-host — GLSL for deck.gl and MapLibre, TSL for Three's WebGPU path, a custom Appearance for Cesium — and conformance tests pin every one of those to the same CPU oracle.
Are builds reproducible?
Byte-for-byte, across processes. Blob order and directory order carry total tiebreaks, and the encoder assembles every metadata key from sorted maps that Arrow ≥59 then serializes in that order — so an unchanged dataset rebuilt in a fresh process re-derives identical content addresses, nothing re-uploads, and identical tiles across datasets share one physical object. (This was the format's one open gap under Arrow 54, whose writer emitted metadata in hash-map order; a formerly-#[ignore]d canary test now guards it.)
What happens if you never tune it?
Nothing is taken away. Every size budget and quantization lever is inert unless you opt in, so an untuned build is byte-identical to one you never analyzed — the no-thinning rule. Run --auto or --publish and any flag you set explicitly still wins (resolved from clap's ValueSource, not sentinels), while lossy advice is surfaced loudly but never applied for you.