52 — Terrain Forge: pattern layers, color ramps, and an in-browser pixel brush editor
Status: ready to test (Workstreams A–D shipped)
Decisions: additive schema only (existing .forge.yaml recipes keep working unmodified); new pattern types live in a reorderable layer_stack array instead of replacing the fixed layers knob block; pixel brushes are ordinary decal PNGs authored in-browser and baked at save time — no new runtime overlay system; palettes are a reusable saved asset, not just a per-recipe HSV triple.
Review lock-ins (post plan-52 review)
| # | Decision |
|---|---|
| 6 | Typed layer params. Discriminated union on layer.type (or string | number | boolean bags with per-type schema branches) — not Record<string, number>. course_grid needs a string pattern id; pixel_brush needs decal_path_or_id: string. |
| 7 | Stack replaces auto-scatter. No layer_stack → today’s generateTile path unchanged. With layer_stack → suppress automatic scatterDetail / stone / vein (migrate into explicit stack entries if needed). Never run both “auto” and stack detail passes. |
| 8 | Legacy palette shim = exact formula. h/s/v = base ± (mix-0.5)*2*jitter stays the no-palette path; do not approximate it as a 2-stop short-way HSV lerp. Pixel-identical regen requires golden fixtures. |
| 9 | Motif vs pixel_brush. Motif mode keeps motif + bakeMotif as the primary decal path. pixel_brush is a Base/From-existing stack layer that reuses motif scatter math; Motif does not grow a competing second decal UI in v1. |
| 10 | Masks. Cut generic ForgeLayerMask from v1 schema until 1–2 concrete masks are specified; omit the blank object. |
| 11 | Palettes are YAML. Saved palettes live as assets/gfx/sprites/forge/palettes/{id}.palette.yaml (not .json), matching forge recipe convention. |
| 12 | Solo is UI-only. enabled is persisted; solo/hide-others is transient editor state. |
| 13 | UI split before stack UI. Extract thin terrain_forge_layers.tsx / palette / brush panel shells before the first reorderable stack UI lands (not after the 2k-line plugin grows further). |
| 14 | Goldens early. Per-primitive deterministic PNG checksum fixtures land with Workstream A (TS + Rust), not only at exit. |
1. Problem
Terrain Forge's Base mode (tools/content-admin/web/src/terrain_forge/engine.ts) — the mode every hand-authored ground tile (grass, dirt, bog, hill, …) ultimately comes from — has exactly one visual vocabulary:
- One blended 3-octave value-noise field (
generateTile, engine.ts:404-422) picks a per-pixel HSV jitter around a single preset base hue/sat/val. This is the entire "base color" story — 12 fixed presets (PRESETS, engine.ts:101-261), no custom palette, no multi-color ramps. scatterDetail(engine.ts:308-332) is the only detail primitive: randomly-positioned soft circles ("blobs") of a second jittered color, alpha-blended over the base.stoneLayer(engine.ts:334-350) is the same blob function with different constants. This is exactly the "dot pattern" the request calls out — every texture family reduces to circles at some density/radius, because that is the only shape function that exists.veinLayer(engine.ts:352-367) is a threshold on a second noise field — cracks, but undirected and non-branching (just "noise value above X gets darker").- The UI's entire Layers panel (terrain_forge.tsx:920-1060) is five fixed sliders/toggles (
density_mul,hue_mul,val_mul, stone layer + density, vein layer + amount, water-only ripple/specular) hung directly offForgeLayerParams(types.ts:38-49) — not a real stack. There is no way to add a second independent detail pass, reorder passes, blend two colors non-uniformly, or mask a pattern to part of the tile.
Meanwhile two siloed subsystems already solve pieces of what's missing, just not for terrain base tiles:
- Wall/roof forge (
wall.ts,roof.ts) has real repeating-pattern math —wallPatternShadeAt/course-grid brick/ashlar/cobble/plank shading computed per-pixel from a wrapped grid position (wall.ts:74-157) — but this shading function is hard-wired to masonry/plank shapes and only reachable frommode: wall/mode: roof, never frommode: base. - Motif (
motif.ts) already does exactly what a "small pixel pattern, tiled" primitive needs:stampDecalWrapped(motif.ts:33-74) scatters an arbitrary RGBA image toroidally so it tiles seamlessly with jittered scale/rotation/density. The gap isn't the scatter math — it's that the decal itself must already exist as an uploaded PNG, a picked sprite sheet, or a Gemini generation; there is no way to draw a small motif by hand inside content-admin.
Net effect: designers get convincing brick walls and shingle roofs (structured, layered patterns) but only speckle/static for grass, dirt, sand, forest floor, bog, moss — the terrain that covers most of the map.
2. Locked decisions
| # | Decision |
|---|---|
| 1 | Backward compatible. The ~30 existing assets/gfx/sprites/forge/*.forge.yaml recipes and their layers: {density_mul, ...} block keep working unchanged — new capability is an additive optional layer_stack: ForgeLayer[] array, not a replacement of ForgeLayerParams. Recipes with no layer_stack render exactly as today. |
| 2 | Pixel brushes are baked, not runtime. A hand-drawn brush is exported to an ordinary PNG and saved through the existing decal upload path (upload_decal, terrain_forge.rs:470) — it becomes a normal motif-style decal. No new file format, no new runtime rendering path; crates/gfx never has to know a tile's texture came from a hand-drawn brush vs. an uploaded PNG vs. Gemini. |
| 3 | Pattern primitives are shared, not duplicated. The wall/roof course-grid shading math is generalized into a primitive usable from any layer (base terrain included), not copy-pasted a third time. |
| 4 | Palettes are a reusable saved asset, not just three sliders per recipe. A designer builds a "bog" ramp once (4–5 color stops) and reuses it across the bog base tile, its corner sets, and its motif decals — same pattern as road_type/preset being a shared named thing today, not a one-off per recipe. |
| 5 | Every new capability ships to both runtimes. Anything added to engine.ts/wall.ts-style TS modules gets a matching addition in tools/flatland-terrain-forge/src/gen.rs in the same build step (existing project convention — headless generate must be able to reproduce any saved recipe). Layer types that are pure raster (a baked pixel brush) need no Rust mirror since they're just pixels already on disk by the time generate reads the recipe; only new procedural layer types (patterns, ramps) need a Rust port. |
3. Workstream A — Shared pattern-primitive library
Goal: pull the "shade at (x, y) on a wrapped grid" idea out of wall.ts/roof.ts and give it enough primitives that base terrain stops being limited to circles.
New module: tools/content-admin/web/src/terrain_forge/patterns.ts (TS) + a matching pattern.rs section in tools/flatland-terrain-forge/src/gen.rs. Each pattern is a pure function (x, y, size, params) -> shade | color contribution, same shape as wallPatternShadeAt (wall.ts:74) and pattern_shade_at (gen.rs:863) but decoupled from WallParams/masonry semantics.
Primitives to add (each toroidally wrapped so tiles stay seamless — reuse the wrap/wrapMod helpers already in wall.ts/gen.rs):
| Primitive | What it draws | Reuses |
|---|---|---|
speckle |
Today's scatterDetail circles, promoted to a named primitive (no behavior change) |
drawBlob as-is |
organic_blob |
Same scatter, but blob shape is a wobbled polygon (angle-varying radius via a small per-instance noise sample) instead of a perfect circle — kills the "dot" look without needing hand art | New: wobbledRadiusAt(angle, seed) |
streaks |
Short directional line-segments (grass blades, wood grain, scratch marks) — anisotropic version of speckle: same scatter loop, elongated ellipse stamp with a jittered orientation |
drawBlob's alpha math, generalized to an ellipse test |
voronoi_cells |
Cracked-earth / cobble / lichen-patch cells from a jittered point grid + nearest/second-nearest distance (F1/F2) — cell interiors get a per-cell color jitter, cell edges get a mortar-style shade | New; same "jittered point grid" trick wrappedNoise's lattice already sets up, generalized to 2D distance instead of bilinear interpolation |
branching_cracks |
Real forking cracks (mud/dry-lakebed/rock) instead of veinLayer's raw noise threshold — midpoint-displacement line growth from N seed points, each step splits with small probability, wrapped at tile edges |
New, reuses makeRng |
course_grid |
Generalizes wallPatternShadeAt's brick/ashlar/cobble/plank/siding grid math for terrain use (e.g. flagstone paths, tilled-field furrows, plank floors as ground cover) |
Extracted from wall.ts |
Each primitive returns a shade delta + optional color override, matching the existing applyShade/blendTo vocabulary in wall.ts (wall.ts:15-48) so layer compositing (Workstream C) can treat every primitive uniformly.
4. Workstream B — Palette / gradient-map color system
Goal: replace "one base hue + jitter" with real multi-color control, while keeping today's 12 presets as built-in defaults so nothing regresses.
- New type
ForgePalette = { stops: Array<{ t: number; h: number; s: number; v: number }> }— 2–6 stops sorted byt(0..1).paletteAt(palette, t)linearly interpolates HSV between the bracketing stops (hue interpolated the short way around the circle). generateTile's per-pixelmixvalue (engine.ts:411) — currently only used as a jitter offset around one hue — becomes the ramp lookup key:paletteAt(activePalette, mix). A preset's existingbaseH/baseS/baseV ± hJ/sJ/vJcollapses to a legacy 2-stop palette generated on the fly, so old recipes render pixel-identical (locked decision #1).- Palette editor UI (new panel, e.g.
terrain_forge_palette.tsx): stop list with color swatches, drag to repositiont, add/remove stop, and an eyedropper that samples a pixel from the live tiled preview canvas (drawTiledPreview's canvas already has the pixels, png.ts:48-65) or from an uploaded reference photo into a stop — useful for matching a hand-painted reference tile's colors. - Saved palette library: new content-admin endpoint pair for
assets/gfx/sprites/forge/palettes/{id}.palette.yaml(YAML, review lock-in #11), mirroringrecipe_path/sanitize_idin terrain_forge.rs:175-195 so a palette built for one recipe is pickable from any other recipe's palette dropdown, not copy-pasted. - Recipe gains an optional
palette_ref?: string(saved palette id) or inlinepalette?: ForgePalette(one-off);presetstays as the fallback when neither is set.
5. Workstream C — Layer stack (generalizing ForgeLayerParams)
Goal: turn the five fixed Layers-panel toggles into an ordered, editable stack — add, remove, reorder, solo/hide, per-layer opacity and blend mode — using the primitives from Workstream A and colors from Workstream B.
- New type in
types.ts(discriminated params per review lock-in #6):type ForgeLayer = | { id: string; type: "speckle"; enabled: boolean; opacity: number; blend_mode: BlendMode; seed: number; palette_ref?: string; params: SpecklePatternParams } | { id: string; type: "organic_blob"; /* … */ params: OrganicBlobParams } | { id: string; type: "streaks"; /* … */ params: StreaksParams } | { id: string; type: "voronoi_cells"; /* … */ params: VoronoiParams } | { id: string; type: "branching_cracks"; /* … */ params: BranchingCracksParams } | { id: string; type: "course_grid"; /* … */ params: CourseGridParams } // includes pattern: WallPatternId | { id: string; type: "pixel_brush"; /* … */ params: PixelBrushParams }; // includes decal_path_or_id: string ForgeRecipe.layer_stack?: ForgeLayer[]— optional, additive per locked decision #1. When absent,generateTileis unchanged (including autoscatterDetail/ stone / vein). When present, auto scatter/stone/vein are suppressed and only enabled stack layers composite over the base fill (review lock-in #7). Waterripple_scale/specular_densityremain onForgeLayerParams.- Compositing function
compositeLayer(base: TilePixels, layer: ForgeLayer, size, seed) -> TilePixelslives in the newpatterns.ts, dispatching onlayer.typeto the Workstream A primitive. pixel_brushis the one layer type that isn't procedural — itsparamsis just{ decal_path_or_id, density, scale_jitter, rotation_jitter }and compositing calls the existingbakeMotif(motif.ts:77-96) unchanged. This is what makes Workstream D cheap: the stack doesn't need new scatter math for hand-drawn brushes, just a layer type that points at one.
6. Workstream D — In-browser pixel brush editor
Goal: let a designer draw a small tileable motif (a blade of grass, a pebble, a leaf, a moss fleck, a crack fork) directly in content-admin instead of needing an uploaded PNG or a Gemini prompt, then use it as a pixel_brush layer.
This is the most "new" piece of work but rides entirely on infrastructure that already exists:
- Editor UI (new component,
tools/content-admin/web/src/plugins/pixel_brush_editor.tsx): an N×N grid (8×8 / 16×16 / 32×32, size picker) rendered at a fixed on-screen pixel scale (likedrawZoom, png.ts:67-74) with click/drag-to-paint, a small palette swatch row (seeded from the recipe's activeForgePalette, Workstream B, plus transparent/eraser), and a toroidal-wrap preview: the 4×4 tiled preview canvas already used for recipes (drawTiledPreview, png.ts:48-65) reused live against the brush's own pixels so the artist can see seams while drawing, not just after baking. - Backing store: the brush is a
TilePixelsthe whole time it's being edited (same structgenerateTilereturns) — paint operations just mutatedatadirectly, no new pixel format. - Persistence: on "Save brush", export via existing
tileToPngDataUrl→dataUrlToBase64(png.ts:22-29) and POST to the existingupload_decalendpoint (terrain_forge.rs:470) — brushes land inassets/gfx/sprites/decals/exactly like uploaded or Gemini-generated decals, with abrush_naming prefix so the decal picker can group them separately. No schema or API change needed here at all. - Using a brush: the resulting decal path becomes
pixel_brushlayer params (decal_path_or_id) in the layer stack (Workstream C), or can still be used the old way directly as amode: motifrecipe's decal — brushes are just decals, so both paths work. - Symmetry/tiling aids (nice-to-have, still cheap given the grid is just array indices): mirror-X/mirror-Y paint (write both
(x,y)and(size-1-x,y)), and a "wrap paint" toggle that also writes the opposite-edge pixel when painting within N px of a border, so brushes are seam-safe by construction rather than by luck.
Not in scope for v1: undo/redo history beyond a simple linear stack, layers within the brush itself (the brush is one flat RGBA image — it becomes a layer in the terrain's stack, it doesn't need its own sub-stack), animation (brushes are static; motif's existing frames/fps handling is untouched).
7. Workstream E — UI restructuring
terrain_forge.tsx is already 2023 lines with all mode panels inline (terrain_forge.tsx). Adding a reorderable layer list, per-layer type-specific controls, a palette editor, and a pixel brush editor into that one file is the main scaling risk of this plan, not the engine work. Split before adding, not after:
terrain_forge_layers.tsx— the layer stack panel (list, drag-reorder, add/remove, per-type control renderer dispatching onlayer.type).terrain_forge_palette.tsx— palette stop editor + saved-palette picker (used from the Layers panel and from the base Preset panel).pixel_brush_editor.tsx— the drawing grid (Workstream D), opened as a modal/side-panel from apixel_brushlayer's "Edit brush" button or from Motif mode's decal picker ("+ Draw new").terrain_forge.tsxkeeps recipe CRUD, mode switching, the live tiled/zoom preview, and Save — and imports the three panels above for the parts of each mode that need them (Base/From-existing/Motif all gain a layer stack; Road/Corner/Wall/Roof are out of scope for v1, see §9).
8. Recipe schema additions
schemas/terrain-forge.schema.json gains (all optional, nothing existing changes type):
"palette_ref": { "type": "string" },
"palette": {
"type": "object",
"properties": {
"stops": {
"type": "array",
"items": {
"type": "object",
"required": ["t", "h", "s", "v"],
"properties": {
"t": { "type": "number", "minimum": 0, "maximum": 1 },
"h": { "type": "number" }, "s": { "type": "number" }, "v": { "type": "number" }
}
}
}
}
},
"layer_stack": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "type", "enabled", "opacity", "blend_mode", "seed", "params"],
"properties": {
"id": { "type": "string" },
"type": {
"type": "string",
"enum": ["speckle", "organic_blob", "streaks", "voronoi_cells", "branching_cracks", "course_grid", "pixel_brush"]
},
"enabled": { "type": "boolean" },
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"blend_mode": { "type": "string", "enum": ["normal", "multiply", "screen", "overlay", "add"] },
"seed": { "type": "integer" },
"palette_ref": { "type": "string" },
"params": { "type": "object", "description": "Per-type knobs; validated by type discriminant (not a bare number map)." }
}
}
}
(mask omitted in v1 — review lock-in #10.)
9. Out of scope for v1
- Extending the layer stack to Road/Corner/Wall/Roof modes — those already have their own bespoke pattern systems (wang connectivity, masonry course grids) that would need a separate compositing model; only Base/From-existing/Motif gain the stack initially.
- Any change to
crates/gfxruntime rendering — everything here bakes to pixels at Save time, same as today. - Multi-layer brushes / brush animation / brush libraries shared across projects (single-project
assets/gfx/sprites/decals/is enough for v1). - Automatic seam-healing or AI upscaling of hand-drawn brushes — the wrap-paint aid (§6.5) is the only seam assistance.
10. Build order (incremental, each step independently shippable)
- Pattern primitive library (Workstream A) + Rust parity + per-primitive golden checksums in the same step. Exercised via
GenerateOpts.debug_pattern(TS) / Rust unit tests — no recipe schema/UI yet. - Palette system (Workstream B) — ramp type +
paletteAt, exact legacy jitter path preserved when no palette set, palette editor UI, saved.palette.yamlendpoints. Ships independently;from_existingcan later emit multi-stop ramps. - Thin UI panel extract (Workstream E start) before stack UI — empty shells for layers/palette/brush panels (lock-in #13).
- Layer stack schema + compositor (Workstream C), wired to primitives + palettes, no brush type yet. Stack present ⇒ no auto scatter (lock-in #7). Basic add/remove/reorder list.
- Pixel brush editor (Workstream D) — depends on 2 and 4; Motif mode unchanged as primary decal path (lock-in #9).
- Rust CLI parity for steps 2 and 4 ships in the same PR as each TS step (locked decision #5).
11. Exit criteria
- A base-mode recipe can express at least one non-circular detail pattern (
organic_blob,streaks,voronoi_cells, orbranching_cracks) stacked over the base noise fill, with visibly different silhouettes from today's uniform dot scatter at the same density. - A designer can build a 4+ stop color ramp, save it as a named palette, and reuse it from a second unrelated recipe without re-entering colors.
- A designer can draw a small (e.g. 12×12) tileable pixel motif in-browser, save it, and scatter it across a base tile via a
pixel_brushlayer with density/scale/rotation jitter — with no visible seam at the motif's own tile-wrap boundary at default settings. - All ~30 existing
assets/gfx/sprites/forge/*.forge.yamlrecipes regenerate pixel-identical output (flatland-terrain-forge generatebefore/after diff) with zero edits — confirms additive-only schema. flatland-terrain-forge generate --recipe <one using layer_stack>(Rust CLI) produces the same tile as the content-admin browser preview for at least one recipe per new pattern type — confirms Rust parity, not just browser-only capability.- Layers panel supports reorder, per-layer enable/disable, and opacity without a full page reload of the recipe (live preview updates on every change, matching today's slider responsiveness).