Internal How the wardogs.tech map pipeline works today. Code wins over docs if they disagree — this page is checked against it. Dev hub →
WARDOGS.TECH Internal · Map pipeline

How We Make an HD Map

A source image goes in; a 2048px base image, a 4096px HD image, a thumbnail, and a 16384px tile pyramid come out. This is that pipeline, the file formats it produces, and how the client turns it back into a zoomable battle map where every drawing lines up at every zoom level.

Tile size256px
Max level6 (16384px)
Built-in maps3 (zesty, bakurani, ozeti)
Custom upload cap8 MB

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 sharp scripts in apps/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 under apps/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/.

FileSpecActual sizeUsed by
<id>.webp2048px wide, WebP q80867 KB – 1.3 MBBase 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.webp4096px wide, WebP q802.6 MB – 4.7 MBProgressive 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.jpg480px wide, JPEG q7035 – 58 KBMap-picker thumbnail (create-room-flow.tsx, room-controls.tsx).
tiles/<id>/{z}/{x}/{y}.webp256px tiles, WebP q80, levels 0–65,461 tiles/mapFull-resolution zoom, rendered by TileLayer.tsx. Lives on Vercel Blob, not in git.
<id>_map.pngSource, 16384–32768px squarenot committedRead 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, via resize(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.jpg is a procedural placeholder generated by generate-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.

  1. 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 via sharp with withoutEnlargement: true. Writes directly into apps/web/public/maps/. A handful of resizes per map — the script has no timing instrumentation, but this is seconds, not minutes, on typical hardware.

  2. 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_TOKEN in the environment (copy it from apps/web/.env.local). Per map: downscales the source to 256 × 2^maxLevel px if it's larger (a no-op for a source already at or under that size), then runs sharp(...).webp({ quality }).tile({ size: 256, layout: "google" }) to write a Google-style pyramid to a temp directory, then uploads every tile to Vercel Blob at tiles/<id>/<z>/<x>/<y>.webp through 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 into packages/shared/src/index.ts.

  3. (Unrelated) Regenerate the fallback.
    node scripts/generate-map.mjs

    Rewrites public/maps/default.jpg from 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

  1. Source it. Get a square PNG at least 16384px on a side, named <id>_map.png, in the scripts' source directory.
  2. Register it in both scripts. Add { id, file } to ALL_MAPS in both optimize-maps.mjs and tile-maps.mjs — they're two separate hardcoded arrays with no shared config, easy to update one and forget the other.
  3. Run node scripts/optimize-maps.mjs to produce <id>.webp, <id>-hd.webp, <id>-thumb.jpg in apps/web/public/maps/.
  4. Run node scripts/tile-maps.mjs --maps=<id> (needs BLOB_READ_WRITE_TOKEN) to cut and upload the pyramid. Copy the printed tiles: { baseUrl, maxLevel, tileSize } block.
  5. Add the map to packages/shared/src/index.ts: extend the MapId union and add a MapInfo entry to MAPS with the url/hdUrl/thumb paths and the pasted tiles block.
  6. Commit the four repo files (<id>.webp, -hd.webp, -thumb.jpg) — the tile pyramid lives only in Blob, not in git.
  7. Verify it shows up: the map picker in create-room-flow.tsx and room-controls.tsx both map over the MAPS array automatically, and POST /api/rooms accepts map=<id> via mapById() — 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.tsx before the request goes out, and server-side in POST /api/rooms/:roomId/map (413 if somehow exceeded anyway). The file input's accept suggests png/jpg/webp, but the server only checks file.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's put() to maps/<roomId>-<timestamp>.<ext>, public access. The returned URL is written to both KV (setMapUrl) and the room's Liveblocks storage.meta.mapUrl, so it replaces the map for everyone in the room immediately and survives a reload.
  • Why they're never tiled. tilesForUrl() and hdVariantOf() both look up by exact match against the fixed MAPS array — a custom upload's Blob URL is never in that array, so it always gets null: 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>'s onLoad) 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.mapUrl back to that map's url, 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, and MapCanvas.tsx skips the 4096px HD preload/swap entirely whenever a pyramid exists. The -hd.webp files 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's layout: "google" writes <z>/<y>/<x>.webp locally — 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.mjs silently 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 old tiles/<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 image remotePatterns (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, not next/image. Confirmed by grep: no next/image import anywhere under src/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.mjs and tile-maps.mjs each define their own ALL_MAPS array 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.

ConstantValueFrom
TILE_SIZE256pxscripts/tile-maps.mjs
--max-level (default)6 → 16384px pyramid topscripts/tile-maps.mjs (parseArgs)
tiles per map, levels 0–65,461scripts/tile-maps.mjs header comment
tile Cache-Controlmax-age=31536000 (1 yr)scripts/tile-maps.mjs (put() call)
--quality (tile WebP, default)80scripts/tile-maps.mjs
--concurrency (default)16scripts/tile-maps.mjs
base image width / quality2048px, WebP q80scripts/optimize-maps.mjs
HD image width / quality4096px, WebP q80scripts/optimize-maps.mjs
thumb width / quality480px, JPEG q70scripts/optimize-maps.mjs
TILE_NATURAL_SIZE2048 map-space unitssrc/features/map/MapCanvas.tsx
zoom level formulaclamp(round(log2(scale·mapWidth/tileSize)), 0, maxLevel)src/features/map/TileLayer.tsx (computeLevel)
marker/stroke coordinatesnormalized Point, [0,1]packages/shared/src/index.ts (Point, clampPoint)
DEFAULT_STROKE_WIDTH0.003 (fraction of map width)packages/shared/src/index.ts
STROKE_TTL_MS10 minutespackages/shared/src/index.ts
grid overlay8 cols × 5 rows, A–H / 1–5src/features/map/GridOverlay.tsx
custom upload cap8 MB (8 × 1024 × 1024 bytes)src/app/api/rooms/[roomId]/map/route.ts, src/features/layout/MapUpload.tsx
default.jpg size2200×2200 JPEG q82 (mozjpeg)scripts/generate-map.mjs
ROOM_TTL_S24 hours (refreshed on activity)packages/shared/src/index.ts