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.
What the engine provides here
Section titled “What the engine provides here”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.
The first raster pipeline
Section titled “The first raster 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 PNGThe 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();Roads: carve, validate, autotile
Section titled “Roads: carve, validate, autotile”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 r0const 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.
Terrain that clusters
Section titled “Terrain that clusters”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.
The adventure line
Section titled “The adventure line”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.
- The first raster pipeline:
MakeImageFromEncoded+ a module-scopedSkImagecache + a rotate/mirrordrawTile, plus an IM Fell.woff2→Fontfor labels — all drawn opaque to the on-screen surface (no bloom). - A seeded self-avoiding-walk road with a flood-fill validator — one simple path + dead-end spurs.
- Edge-mask autotiling — five sprites, a 16-entry table, a pure function of the road grid.
- Clustered terrain (forests / mountains / villages / an ink-hatched sea / 1–2 mirrored lake sprites) with seeded place names, leaving the board breathing.
- 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.