Home Terrain Forge Pattern Layers & Pixel Editor

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:

  1. 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.
  2. 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.
  3. 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").
  4. 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 off ForgeLayerParams (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 from mode: wall/mode: roof, never from mode: 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 by t (0..1). paletteAt(palette, t) linearly interpolates HSV between the bracketing stops (hue interpolated the short way around the circle).
  • generateTile's per-pixel mix value (engine.ts:411) — currently only used as a jitter offset around one hue — becomes the ramp lookup key: paletteAt(activePalette, mix). A preset's existing baseH/baseS/baseV ± hJ/sJ/vJ collapses 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 reposition t, 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), mirroring recipe_path/sanitize_id in 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 inline palette?: ForgePalette (one-off); preset stays 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, generateTile is unchanged (including auto scatterDetail / stone / vein). When present, auto scatter/stone/vein are suppressed and only enabled stack layers composite over the base fill (review lock-in #7). Water ripple_scale / specular_density remain on ForgeLayerParams.
  • Compositing function compositeLayer(base: TilePixels, layer: ForgeLayer, size, seed) -> TilePixels lives in the new patterns.ts, dispatching on layer.type to the Workstream A primitive.
  • pixel_brush is the one layer type that isn't procedural — its params is just { decal_path_or_id, density, scale_jitter, rotation_jitter } and compositing calls the existing bakeMotif (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:

  1. 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 (like drawZoom, png.ts:67-74) with click/drag-to-paint, a small palette swatch row (seeded from the recipe's active ForgePalette, 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.
  2. Backing store: the brush is a TilePixels the whole time it's being edited (same struct generateTile returns) — paint operations just mutate data directly, no new pixel format.
  3. Persistence: on "Save brush", export via existing tileToPngDataUrldataUrlToBase64 (png.ts:22-29) and POST to the existing upload_decal endpoint (terrain_forge.rs:470) — brushes land in assets/gfx/sprites/decals/ exactly like uploaded or Gemini-generated decals, with a brush_ naming prefix so the decal picker can group them separately. No schema or API change needed here at all.
  4. Using a brush: the resulting decal path becomes pixel_brush layer params (decal_path_or_id) in the layer stack (Workstream C), or can still be used the old way directly as a mode: motif recipe's decal — brushes are just decals, so both paths work.
  5. 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 on layer.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 a pixel_brush layer's "Edit brush" button or from Motif mode's decal picker ("+ Draw new").
  • terrain_forge.tsx keeps 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/gfx runtime 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)

  1. 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.
  2. Palette system (Workstream B) — ramp type + paletteAt, exact legacy jitter path preserved when no palette set, palette editor UI, saved .palette.yaml endpoints. Ships independently; from_existing can later emit multi-stop ramps.
  3. Thin UI panel extract (Workstream E start) before stack UI — empty shells for layers/palette/brush panels (lock-in #13).
  4. 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.
  5. Pixel brush editor (Workstream D) — depends on 2 and 4; Motif mode unchanged as primary decal path (lock-in #9).
  6. 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, or branching_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_brush layer 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.yaml recipes regenerate pixel-identical output (flatland-terrain-forge generate before/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).