TL;DR For other developers
- Source images: the three current maps as hi-res overhead PNGs are in this r/WarDogs post. Start there; that's what we started from.
- What we make from them: a 2048px WebP, a 4096px WebP, a 480px JPEG thumbnail, and a 256px tile pyramid to zoom level 6 (16384px), built with two
sharpscripts inapps/web/scripts/. Ask and we'll share the scripts and the generated files. - Coordinates: everything on the map is stored as normalised
[0,1]x/y on the square source image, so a point means the same thing at every zoom level and every asset size. Map to metres yourself if you need range: the images carry no scale metadata. - Elevation: none. Flat overhead captures only. An artillery tool needs its own heightmap.
- Terms: the captures are Bulkhead's game assets shared by the community; wardogs.tech is unofficial and credits the post. Do the same.
01 Overview
Every built-in map (zesty, bakurani, ozeti) goes through two independent Node scripts, both using sharp, both run by hand from a developer machine — neither is wired into CI or a build step:
scripts/optimize-maps.mjs— right-sizes the source PNG into web-ready WebP files that ship in the repo underapps/web/public/maps/.scripts/tile-maps.mjs— cuts the same source into a full-resolution 256px tile pyramid and uploads it to Vercel Blob, for the zoomed-in view.
A third script, scripts/generate-map.mjs, has nothing to do with real maps — it procedurally renders default.jpg, the placeholder shown when a room has no map at all (see Source requirements).
02 What the pipeline produces
Per built-in map, five things exist: four files in the repo and one pyramid in Blob storage. Sizes below are the actual current files in apps/web/public/maps/.
| File | Spec | Actual size | Used by |
|---|---|---|---|
| <id>.webp | 2048px wide, WebP q80 | 867 KB – 1.3 MB | Base layer in MapCanvas.tsx — paints first, and stays mounted underneath the tile pyramid permanently as the fallback for any gap: missing pyramid, a 404'd tile, or tiles still loading. |
| <id>-hd.webp | 4096px wide, WebP q80 | 2.6 MB – 4.7 MB | Progressive upgrade image — but only for a map without a tile pyramid. See Gotchas: all three built-in maps have pyramids, so this file is currently dead weight for them. |
| <id>-thumb.jpg | 480px wide, JPEG q70 | 35 – 58 KB | Map-picker thumbnail (create-room-flow.tsx, room-controls.tsx). |
| tiles/<id>/{z}/{x}/{y}.webp | 256px tiles, WebP q80, levels 0–6 | 5,461 tiles/map | Full-resolution zoom, rendered by TileLayer.tsx. Lives on Vercel Blob, not in git. |
| <id>_map.png | Source, 16384–32768px square | not committed | Read once by both scripts from a local directory you pass with --src. Never ships. |
The no-map fallback, default.jpg, is separate from all of this: a single 2200×2200 JPEG (~118 KB), procedurally generated, with no HD variant, no thumbnail, and no tile pyramid. It's what a room shows before a commander picks or uploads a map.
03 Source image requirements
There's no written spec for a source map image — the requirements below are inferred from what the scripts assume and what public/maps/SOURCE.md documents:
- Format: PNG. Both scripts hardcode the filename pattern
<id>_map.png. - Resolution: at least 16384px square to fill the level-6 pyramid at native resolution.
tile-maps.mjs's comments note bakurani's real source is exactly 16384px (no downscale needed) while zesty's and ozeti's are 32768px and get downscaled to 16384 before tiling. Anything smaller than 16384px still works —tileOneMap()only resizes down, viaresize(target, target, { fit: "fill" }), never up. - Aspect: square. The resize call above uses
fit: "fill"to force the target square — a non-square source would be stretched, not letterboxed. - Where the three maps came from: the community post "Hi-res images of all 3 current maps" on r/WarDogs. They are overhead captures of the game's own maps, shared for community tools; wardogs.tech is unofficial and credits the post (
public/maps/SOURCE.md).default.jpgis a procedural placeholder generated bygenerate-map.mjs, owed to nobody.
04 The pipeline, step by step
Both scripts read from the same source directory independently — order between them doesn't matter, but a source PNG has to exist first for either to do anything.
-
Right-size the web images.
# --src is the folder holding the source PNGs node scripts/optimize-maps.mjs "C:/path/to/dir/with/*_map.png"
For each of
zesty,bakurani,ozeti: resizes the source to 2048px and 4096px wide (WebP, quality 80,effort: 5) and a 480px JPEG thumbnail (quality 70), all viasharpwithwithoutEnlargement: true. Writes directly intoapps/web/public/maps/. A handful of resizes per map — the script has no timing instrumentation, but this is seconds, not minutes, on typical hardware. -
Cut and upload the tile pyramid.
node scripts/tile-maps.mjs \ [--maps=zesty,bakurani,ozeti] # default: all three [--max-level=6] # default; 16384px top level --src="<folder with the source PNGs>" [--concurrency=16] # parallel Blob uploads [--quality=80] # tile WebP quality [--tmp=<dir>] [--keep-local]
Requires
BLOB_READ_WRITE_TOKENin the environment (copy it fromapps/web/.env.local). Per map: downscales the source to256 × 2^maxLevelpx if it's larger (a no-op for a source already at or under that size), then runssharp(...).webp({ quality }).tile({ size: 256, layout: "google" })to write a Google-style pyramid to a temp directory, then uploads every tile to Vercel Blob attiles/<id>/<z>/<x>/<y>.webpthrough a fixed-concurrency pool (default 16 at once), retrying a failed upload up to 4 times with exponential backoff.It's resumable: before uploading, it lists what's already at that Blob prefix and skips any tile whose path already exists, so a re-run after a partial failure only uploads what's missing. Local tiling itself is noted in the script's own log line as taking "a minute" for a 16384px pyramid; the network upload of ~5,461 tiles/map is the slower, connection-dependent part. When it's done it prints a
tiles: { baseUrl, maxLevel, tileSize }block per map to paste intopackages/shared/src/index.ts. -
(Unrelated) Regenerate the fallback.
node scripts/generate-map.mjs
Rewrites
public/maps/default.jpgfrom a deterministic (seeded) procedural SVG — no source image needed, no tiles, no HD variant. Only touches that one file.
05 How the client renders it
Coordinate system
Everything drawn on the map — strokes, arrows, markers, danger-zone radii — is stored as a normalized Point { x, y } in [0, 1], clamped by clampPoint(). Stroke width and marker radius are stored as fractions of map width, not pixels. That's what makes drawings line up at every zoom level: they're defined relative to the map, not to any specific image's pixel size.
For a tiled built-in map, MapCanvas.tsx pins the map-space size to a constant, TILE_NATURAL_SIZE = 2048, instead of reading it off the loaded <img>'s natural dimensions — deliberately, so the coordinate system never shifts depending on which resolution asset happens to have loaded. The tile grid is defined in the same units: at level L there's a 2^L × 2^L grid of tiles, each covering 2048 / 2^L map-space units. At level 3, a 256-unit tile is 1:1 with its 256px file at scale 1; at level 6 (32-unit tiles), 1:1 is scale 8.
Zoom levels & tile addressing
TileLayer.tsx picks a pyramid level from the current view scale:
L = clamp(round(log2(scale * mapWidth / tileSize)), 0, maxLevel)
It then computes which tiles at that level intersect the current viewport (plus a 1-tile margin) and requests them at ${baseUrl}/${z}/${x}/${y}.webp. The previous level stays mounted underneath the new target level until every tile the new level needs has loaded (or errored) — so switching zoom never flashes blank space, it just cross-fades once the sharper tiles are actually ready. A tile that's already browser-cached and .complete the instant its <img> mounts is checked directly, since some browsers don't fire a load event for it.
On disk, sharp's layout: "google" writes tiles as <z>/<y>/<x>.webp — row before column, the opposite of the usual "z/x/y" shorthand. tile-maps.mjs re-keys every tile to z/x/y when uploading to Blob, which is the order the client actually iterates and requests in.
HD swap
For a map without a tile pyramid, MapCanvas.tsx preloads the -hd.webp (4096px) in a background Image() and swaps the visible <img src> to it once loaded, so the first paint is the lighter 2048px file. This step is explicitly skipped when a tile pyramid exists (if (tileInfo) return;) — see Gotchas for what that means today.
Caching
Tiles are uploaded to Vercel Blob with cacheControlMaxAge: 31536000 (one year). Combined with the resumable upload skipping any path that already exists, that means re-tiling a map with the same tile paths won't actually replace a tile a client (or CDN) has already cached — a real re-tile needs the old Blob objects deleted first. The base/HD/thumb WebP and JPEG files are plain static files under apps/web/public/maps/; no headers() rule in next.config.ts or apps/web/vercel.json sets an explicit Cache-Control for them, so they get whatever default Next/Vercel applies to /public assets.
06 Adding a new built-in map — checklist
- Source it. Get a square PNG at least 16384px on a side, named
<id>_map.png, in the scripts' source directory. - Register it in both scripts. Add
{ id, file }toALL_MAPSin bothoptimize-maps.mjsandtile-maps.mjs— they're two separate hardcoded arrays with no shared config, easy to update one and forget the other. - Run
node scripts/optimize-maps.mjsto produce<id>.webp,<id>-hd.webp,<id>-thumb.jpginapps/web/public/maps/. - Run
node scripts/tile-maps.mjs --maps=<id>(needsBLOB_READ_WRITE_TOKEN) to cut and upload the pyramid. Copy the printedtiles: { baseUrl, maxLevel, tileSize }block. - Add the map to
packages/shared/src/index.ts: extend theMapIdunion and add aMapInfoentry toMAPSwith theurl/hdUrl/thumbpaths and the pastedtilesblock. - Commit the four repo files (
<id>.webp,-hd.webp,-thumb.jpg) — the tile pyramid lives only in Blob, not in git. - Verify it shows up: the map picker in
create-room-flow.tsxandroom-controls.tsxboth map over theMAPSarray automatically, andPOST /api/roomsacceptsmap=<id>viamapById()— nothing else to wire.
07 Custom maps uploaded by a commander
Any commander can replace a room's map at any time: Room Controls → "Upload map image" (MapUpload.tsx), gated on can(me, "upload_map").
- Limits. 8 MB hard cap, checked twice — client-side in
MapUpload.tsxbefore the request goes out, and server-side inPOST /api/rooms/:roomId/map(413 if somehow exceeded anyway). The file input'sacceptsuggests png/jpg/webp, but the server only checksfile.type.startsWith("image/")— any image MIME type actually passes. - Auth. The commander's token (from
getSession().commanderToken) is sent with the upload and re-verified server-side against the room's hashed token before anything is accepted. - Storage. Uploaded via
@vercel/blob'sput()tomaps/<roomId>-<timestamp>.<ext>, public access. The returned URL is written to both KV (setMapUrl) and the room's Liveblocksstorage.meta.mapUrl, so it replaces the map for everyone in the room immediately and survives a reload. - Why they're never tiled.
tilesForUrl()andhdVariantOf()both look up by exact match against the fixedMAPSarray — a custom upload's Blob URL is never in that array, so it always getsnull: no pyramid, no progressive HD swap, no thumbnail. It renders as one flat image at whatever resolution was uploaded, and its natural pixel size (read from the<img>'sonLoad) becomes that room's map-space coordinate system, instead of the fixed 2048-unit space tiled maps use. This is presumably why the 8 MB cap exists: there's no tiling to fall back on, so the whole file has to be small enough to load and pan at native resolution as-is. - Reverting. There's no explicit "remove custom map" action — picking any built-in map from the same picker in Room Controls sets
meta.mapUrlback to that map'surl, which is the way back.
08 Gotchas
- The HD swap is currently dead code for every built-in map. All three (
zesty,bakurani,ozeti) have a tile pyramid, andMapCanvas.tsxskips the 4096px HD preload/swap entirely whenever a pyramid exists. The-hd.webpfiles still get generated and shipped (2.6–4.7 MB each) but nothing currently loads them for a built-in map. They'd only matter again for a built-in map added without tiling, or if tiling were ever turned off. - The on-disk tile layout is row-then-column, not x-then-y.
sharp'slayout: "google"writes<z>/<y>/<x>.webplocally — the opposite of the "z/x/y" shorthand the URLs use after re-keying.tile-maps.mjs's own header comment calls this out as "verified empirically," i.e. found by trial and error, not documented anywhere upstream. - Re-tiling a map doesn't overwrite old tiles. The upload step skips any Blob path that already exists (that's what makes it resumable), so replacing a map's source image and re-running
tile-maps.mjssilently keeps every old tile that shares a path with a new one — old and new tiles only differ if the pyramid shape (max level) changes. Deleting the oldtiles/<id>/prefix from Blob first is necessary for an actual re-tile, and this script doesn't do that for you. next.config.ts's imageremotePatterns(for*.public.blob.vercel-storage.com) doesn't apply to any of this. Every map image in the app — base, HD, thumb, and every tile — is rendered through a plain<img>tag, notnext/image. Confirmed by grep: nonext/imageimport anywhere undersrc/features/map. The remote pattern config exists for Blob URLs in general but isn't exercised by the map pipeline.- Two scripts, two hardcoded map lists.
optimize-maps.mjsandtile-maps.mjseach define their ownALL_MAPSarray with the same three entries. Nothing keeps them in sync — adding a map to one and forgetting the other is a straightforward way to end up with a WebP file and no tile pyramid, or vice versa. - Provenance is one Reddit post. The three source PNGs come from a community post, recorded in
SOURCE.md. No elevation data exists in any of them; they are flat overhead images. - Blob cleanup for expired rooms is unconfirmed. Room records expire from KV 24 hours after last activity (
ROOM_TTL_S), but no code was found in this repo that deletes a custom map's Blob object when its room expires — the KV record disappears, the image may not. Flagged, not confirmed either way.
09 Reference
Constants pulled directly from the source, with the file each one comes from.
| Constant | Value | From |
|---|---|---|
| TILE_SIZE | 256px | scripts/tile-maps.mjs |
| --max-level (default) | 6 → 16384px pyramid top | scripts/tile-maps.mjs (parseArgs) |
| tiles per map, levels 0–6 | 5,461 | scripts/tile-maps.mjs header comment |
| tile Cache-Control | max-age=31536000 (1 yr) | scripts/tile-maps.mjs (put() call) |
| --quality (tile WebP, default) | 80 | scripts/tile-maps.mjs |
| --concurrency (default) | 16 | scripts/tile-maps.mjs |
| base image width / quality | 2048px, WebP q80 | scripts/optimize-maps.mjs |
| HD image width / quality | 4096px, WebP q80 | scripts/optimize-maps.mjs |
| thumb width / quality | 480px, JPEG q70 | scripts/optimize-maps.mjs |
| TILE_NATURAL_SIZE | 2048 map-space units | src/features/map/MapCanvas.tsx |
| zoom level formula | clamp(round(log2(scale·mapWidth/tileSize)), 0, maxLevel) | src/features/map/TileLayer.tsx (computeLevel) |
| marker/stroke coordinates | normalized Point, [0,1] | packages/shared/src/index.ts (Point, clampPoint) |
| DEFAULT_STROKE_WIDTH | 0.003 (fraction of map width) | packages/shared/src/index.ts |
| STROKE_TTL_MS | 10 minutes | packages/shared/src/index.ts |
| grid overlay | 8 cols × 5 rows, A–H / 1–5 | src/features/map/GridOverlay.tsx |
| custom upload cap | 8 MB (8 × 1024 × 1024 bytes) | src/app/api/rooms/[roomId]/map/route.ts, src/features/layout/MapUpload.tsx |
| default.jpg size | 2200×2200 JPEG q82 (mozjpeg) | scripts/generate-map.mjs |
| ROOM_TTL_S | 24 hours (refreshed on activity) | packages/shared/src/index.ts |