Skip to content

Showcase: Procedural map

A fantasy map that draws itself: a 10×10 parchment grid charts in row by row, terrain clusters into named forests, mountains, villages, and water bodies, and a single road is autotiled from a start marker to a destination arrow with a dead-end spur or two. Every notable place gets a seeded name — settlements, regions, and battle sites — lettered onto the parchment in ink. Then a red dashed adventure line traces the road, retracting cleanly out of each dead-end it explores — losing a battle there (a shield that crossfades to a cross), arriving at a campfire, and reseeding a fresh, different map. It is the odd one out of the five: not a live simulation but a seeded procedural generator, and the repo’s first raster pipeline — the one showcase that decodes image assets through Skia and the one that renders text.

A small footprint: GameLoop (@flare-engine/core) for the pause-correct clock, SeededRNG (@flare-engine/math) for reproducible generation — driving both the geometry and the place-name generator — and the shared post-FX surface, used here only for its WebGL context lifecycle + context-loss resilience, not its bloom. The full CanvasKit build also decodes IM Fell English (the antique 1600s “old map” serif) from a .woff2 into a Typeface/Font for the labels. Everything that makes it a map is yours: the road generator, the autotiler, the terrain clustering, the seeded naming table, the adventure-line traversal, and the raster tile pipeline.

Every other showcase is pure vector (drawCircle/drawLine). The Map decodes PNGs. The Kenney tiles are fetched same-origin from public/cartography/ and decoded once via kit.MakeImageFromEncoded into a module-scoped SkImage cache:

const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
const img = kit.MakeImageFromEncoded(bytes); // lazy — survives context loss, one decode per PNG

The cache is deliberately never per-instance: a governor re-create or dev restart reuses the already-decoded textures (deleting one would pull a GPU texture out from under the next instance). Because create() is synchronous but the fetch is async, the renderer paints the dark table + a parchment bed until the cache warms, then reveals.

A single drawTile helper places any 128px sprite into a cell with rotation and mirroring — the road/arrow tiles rotate (0/90/180/270°), every terrain sprite only mirrors:

sc.save();
sc.translate(cx, cy);
if (qt) sc.rotate(qt * 90, 0, 0);
if (mirror) sc.scale(-1, 1);
sc.drawImageRectOptions(img, src, dst, FilterMode.Linear, MipmapMode.None, paint);
sc.restore();

grid.ts is the pure procgen core (bun-tested, zero-import but for the RNG). The road is carved by a biased self-avoiding walk from a pathEnd start toward an arrowHead end. The endpoints are placed on opposite edges and in opposite perpendicular thirds — a diagonal span — and a coverage gate rejects any carve that touches fewer than three of the four quadrants, so the road crosses the map instead of hugging one half. A simple-path rule (a step is only allowed if the new cell touches the existing road at its parent only) keeps it from clumping into loops. Backtracking handles dead-ends; 1–2 short spurs are grafted onto interior cells as battle dead-ends. A flood-fill validator then asserts the road is a single simple path (a tree: edges === cells − 1), rejecting degenerate maps.

The sprite for each road cell is chosen by edge-mask autotiling — a 4-bit neighbor mask (N=1,E=2,S=4,W=8) indexes a frozen 16-entry table of {sprite, rotation}, so five base tiles cover every junction:

// mask 5 (N|S) → straight r0 · 3 (N|E) → corner r0 · 7 (N|E|S) → split r0 · 15 → crossing · 1 (N) → end r0
const mask = (N ? 1 : 0) | (E ? 2 : 0) | (S ? 4 : 0) | (W ? 8 : 0);
const { name, qt } = MASK_TILE[mask];

Rotation is free, so the tiling is a pure function of the boolean road grid — automatically deterministic. The end cell is overridden to arrowHead, rotated to point off the map edge.

Decorations are placed so they read as regions, not confetti. grid.ts seeds a few biome origins, flood-grows each into a contiguous zone with a falloff, then fills by biome: forests self-attract, villages hug the road (via a distance-to-road field), mountains stay contiguous. Water comes in two forms. A sea grows as a solid cluster and renders as the Kenney wave texture recolored to ink via a SrcIn blend — monochrome hatch lines on the parchment, no color, the way antique maps drew open water. Separately, 1–2 lakes are placed as discrete features: the 2:1 lake sprite drawn across two cells at its natural aspect (no squish) and mirrored left/right. Road cells and the start/end clearance are excluded, and ~half the board is left empty — so the map breathes. The largest few regions (the sea always), plus both settlements and every battle site, are then named: a seeded syllable table composes “Ashford”, “Blackwood”, “Frost Peaks”, “The Cursed Keep”, “Sea of Mor”, drawn in the antique IM Fell serif with a cream halo so they stay legible over terrain.

At generation time a DFS over the road tree bakes an explicit ordered route: it detours into each dead-end spur (recording a battle stop), backtracks, and descends the trunk to the end last. Because consecutive route cells are adjacent, arc-length is just the step count, so the traveler’s position is a simple d(t) advanced by the pause-correct clock.

The line itself is drawn not from the raw Euler walk — that would overdraw itself on the way back out of a spur — but from the simple tree-path START→traveler. A BFS parent pointer (rooted at START) lets the renderer walk from whichever route endpoint is nearer to START, then stroke a final partial segment out to the exact traveler point. As the traveler retreats from a dead-end, that “nearer” endpoint slides back down the trunk, so the spur line retracts behind it — no self-overlap, and the campfire view is the clean trunk with every explored dead-end erased. And the line follows the road art: the tiles draw their centerline through each cell’s center, so the line passes through every cell center — arcing through a pathCorner tile (a quadratic forced through the center, a ~0.35·tile sweep) and running straight everywhere else (including the straight arms of split/crossing junctions). The curve model was calibrated against the actual Kenney pixels — sampled, it lands on a drawn road edge 0% of the time, staying dead-centre in the corridor instead of cutting corners. It is stroked with an animated PathEffect.MakeDash — marching ants that keep crawling during the battle dwells. At each dead-end the shield crossfades to a cross (the battle is always lost); at the end a campfire fades in and holds three seconds before a hash-stepped seed generates the next, different map.

  1. The first raster pipeline: MakeImageFromEncoded + a module-scoped SkImage cache + a rotate/mirror drawTile, plus an IM Fell .woff2Font for labels — all drawn opaque to the on-screen surface (no bloom).
  2. A seeded self-avoiding-walk road with a flood-fill validator — one simple path + dead-end spurs.
  3. Edge-mask autotiling — five sprites, a 16-entry table, a pure function of the road grid.
  4. Clustered terrain (forests / mountains / villages / an ink-hatched sea / 1–2 mirrored lake sprites) with seeded place names, leaving the board breathing.
  5. A retracting DFS adventure line (simple tree-path START→traveler) with a dashed reveal, crossfading dead-end battles, and a hash-stepped reseed.

No live simulation — a seeded generator and an asset pipeline instead. The road carver + autotiler + the seeded naming table are the seed of a planned @flare-engine/procgen (its tile side already ships as @flare-engine/tilemap, a generic Tiled/LDtk culled renderer). And the decode/cache + drawTile + the Font label pass are exactly what the now-shipping @flare-engine/render-web Skia backend provides — its createPostFxSurface / createWebSurface / loadCanvasKit are the published form of the CanvasKit surface this hero still runs its own, more context-loss-resilient precursor of.