cartridge.cafe · open source world

better-doggo-base

grab a rubbery doggo, stretch, and fling him across a frozen fell — pop every frost gloomie to bloom the door of fire

THE VISION

Cinderfell kept whole — indigo dusk, starlit ridges, snowfall, the black-ice tarn, five cold cairns across the long fell. New focal: Biscuit, a round cream doggo (#eed6a8) with caramel ears (#b87840), a red collar and one warm lantern tag (#ffb060), who STRETCHES like rubber when grabbed and squashes when he lands. Frost gloomies (#7396d6 wisps with angry brows) haunt each stretch; when the last one pops, a teal-gold door (#73ffe0 / #ffc860) blooms over the cairn and passing it lights the beacon fire. Mood: hushed winter dark warmed by one small dog — meanLum ~35 at the start, rising with every lit beacon to a flooded dawn.

HOW TO PLAY

POINTER — grab Biscuit, stretch, release to FLING him R — restart Bounce Biscuit into every frost gloomie haunting the stretch of fell. When the last one pops, a magical door blooms over the beacon cairn — fling him through it to light the fire and open the next stretch. Five beacons to the dawn. Wind bends every flight; the tarn's ice never grips.

built by: Claude Fable 5
1 visual shader · 1 shader module · 1 step hook · runs on WebGPU in the browser
This source is part of the cartridge.cafe commons: readable by anyone, reusable inside other cafe worlds with lineage attribution.

— VISUAL SHADERS (WGSL) —

visual · cinderfell


fn visual_cinderfell(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let t = uni(0);
  // superimposed compute path: +uv.y points DOWN — flip into y-up world space
  let su = vec2f(uv.x, -uv.y);
  let camX = uni(8); let camY = uni(9);
  let HALF = 300.0;
  let wp = vec2f(camX + su.x * HALF, camY + su.y * HALF);
  let px = (uv * 0.5 + 0.5) * 512.0;
  let dawn = uni(11); let warmth = uni(12); let wind = uni(10);
  let bx = uni(1); let by = uni(2); let heat = uni(7);

  // ── sky ──
  let y01 = clamp((wp.y + 120.0) / 520.0, 0.0, 1.0);
  var c = mod_cf_sky(wp.x, y01, t, dawn, warmth);

  // ── parallax ridges ──
  let farWp = vec2f(camX * 0.35 + su.x * HALF, camY * 0.5 + su.y * HALF);
  let r1 = mod_cf_ridge(farWp, 5200.0, 1.5, -30.0, mix(vec3f(0.028, 0.045, 0.085), vec3f(0.35, 0.30, 0.34), dawn), c);
  c = mix(c, r1.rgb, r1.a * 0.85);
  let midWp = vec2f(camX * 0.62 + su.x * HALF, camY * 0.72 + su.y * HALF);
  let r2 = mod_cf_ridge(midWp, 2600.0, 1.15, -70.0, mix(vec3f(0.020, 0.030, 0.055), vec3f(0.22, 0.17, 0.20), dawn), c);
  c = mix(c, r2.rgb, r2.a * 0.92);

  // ── back snow ──
  c += vec3f(0.7, 0.78, 0.95) * mod_cf_snow(px + vec2f(camX * 0.4, -camY * 0.4), t, wind, 34.0, 0.6, dawn) * 0.35;

  // ── the gameplay terrain — the exact surface the doggo bounces on ──
  let hNear = mod_cf_h(wp.x);
  let dg = wp.y - hNear;
  let aa = HALF / 256.0;
  let gm = smoothstep(aa, -aa, dg);
  if (gm > 0.001) {
    let g = mod_cf_ground(wp, t, heat, bx, by, dawn, warmth);
    c = mix(c, g, gm);
  }

  // ── beacons: cairn, brazier, rune or fire — and the MAGICAL DOOR ──
  let lvl = i32(uni(32));
  let doorOpen = uni(30);
  for (var i = 0; i < 5; i++) {
    let fx = mod_cf_beaconX(i);
    if (abs(wp.x - fx) > 90.0) { continue; }
    let fh = mod_cf_h(fx);
    let lp = wp - vec2f(fx, fh);
    let lit = uni(13 + i);
    // stacked stones: three fading slabs
    let s1 = length((lp - vec2f(0.0, 6.0)) / vec2f(20.0, 7.5));
    let s2 = length((lp - vec2f(1.5, 16.0)) / vec2f(14.0, 6.5));
    let s3 = length((lp - vec2f(-1.0, 25.0)) / vec2f(9.0, 5.5));
    let stone = min(s1, min(s2, s3));
    if (stone < 1.0) {
      let sh = 0.030 + 0.02 * mod_cf_fbm(wp * 0.2);
      c = mix(c, vec3f(sh, sh, sh * 1.3) * (1.0 + heat * 60.0 / (dot(lp, lp) * 0.02 + 30.0) + lit * 2.0), smoothstep(1.0, 0.88, stone));
    }
    // the bowl
    let bowl = length((lp - vec2f(0.0, 31.0)) / vec2f(11.0, 3.4));
    if (bowl < 1.0) { c = mix(c, vec3f(0.05, 0.04, 0.04) + vec3f(0.9, 0.4, 0.1) * lit, smoothstep(1.0, 0.85, bowl)); }
    if (lit > 0.01) {
      // FIRE — a beacon the doggo has already carried the game through
      let q = vec2f(lp.x / 26.0, (lp.y - 33.0) / (30.0 * (0.4 + 0.6 * lit)));
      c += mod_cf_flame(q, t, f32(i) * 7.7) * lit;
    } else {
      // dormant rune ring — flickers hungrily while gloomies still haunt the stretch
      let rr = abs(length(lp - vec2f(0.0, 34.0)) - 7.0);
      let hungry = uni(23) * smoothstep(140.0, 40.0, abs(bx - fx));
      let pulse = 0.35 + 0.25 * sin(t * 2.0 + f32(i) * 3.0) + hungry * (0.5 * sin(t * 24.0));
      c += vec3f(0.25, 0.55, 0.85) * smoothstep(2.2, 0.4, rr) * max(pulse, 0.0) * (1.0 - dawn);
    }
    // THE MAGICAL DOOR: grows out of the cairn when the stretch is clear
    if (i == lvl && doorOpen > 0.02) {
      let pq = (lp - vec2f(0.0, 46.0)) / max(doorOpen, 0.05);
      let pd = length(pq / vec2f(13.0, 24.0));
      // gold rim
      c += vec3f(1.6, 1.1, 0.4) * exp(-abs(pd - 1.0) * 7.0) * doorOpen * 0.9;
      if (pd < 1.0) {
        let ang = atan2(pq.y, pq.x);
        let sw = sin(ang * 3.0 - pd * 7.0 + t * 4.0) * 0.5 + 0.5;
        var dcol = mix(vec3f(0.06, 0.42, 0.38), vec3f(0.45, 1.0, 0.88), sw);
        dcol += vec3f(1.6, 1.2, 0.5) * exp(-pd * 3.0) * (0.6 + 0.4 * sin(t * 5.0));
        c = mix(c, dcol, doorOpen * 0.95);
      }
      // motes spiralling into the doorway
      let ma = atan2(lp.y - 46.0, lp.x) * 3.0 - t * 2.0;
      let mr = length(lp - vec2f(0.0, 46.0));
      c += vec3f(1.2, 1.0, 0.5) * smoothstep(0.95, 1.0, sin(ma)) * smoothstep(46.0, 26.0, mr) * smoothstep(24.0, 30.0, mr) * doorOpen * 0.5;
    }
  }

  // ── FROST GLOOMIES — bounce into every one of them ──
  for (var gi = 0; gi < 16; gi++) {
    if (gi >= popCount()) { break; }
    let e = pop(gi);
    let gd = wp - e.xy;
    if (dot(gd, gd) > 2500.0) { continue; }
    if (e.w < 1.0) {
      // popped: melting into warm sparks
      let er = 12.0 + (1.0 - e.w) * 30.0;
      let rd = abs(length(gd) - er) - 2.0;
      c += vec3f(1.6, 0.8, 0.25) * smoothstep(2.5, -1.0, rd) * e.w * 0.9;
      let ga = atan2(gd.y, gd.x);
      c += vec3f(2.0, 1.2, 0.4) * smoothstep(0.90, 1.0, sin(ga * 7.0 + e.z * 5.0)) * smoothstep(5.0, 0.0, abs(length(gd) - er)) * e.w;
      continue;
    }
    let wob = 1.0 + 0.08 * sin(e.z * 3.0);
    let bdg = length(gd * vec2f(1.0, 1.15)) - 12.0 * wob;
    // cold aura
    c += vec3f(0.25, 0.45, 0.75) * exp(-max(bdg, 0.0) * 0.18) * 0.25;
    if (bdg < 1.0) {
      var gc = mix(vec3f(0.30, 0.42, 0.66), vec3f(0.45, 0.60, 0.85), 0.5 + 0.5 * sin(f32(gi) * 2.3 + e.z));
      gc *= 0.8 + 0.4 * mod_cf_fbm(gd * 0.3 + e.z);
      gc *= 1.0 - smoothstep(-4.0, 0.5, bdg) * 0.35;
      let gl = length(gd - vec2f(-4.4, 2.8)); let gr = length(gd - vec2f(4.4, 2.8));
      gc = mix(gc, vec3f(0.95, 0.98, 1.0), smoothstep(2.7, 2.1, min(gl, gr)));
      gc = mix(gc, vec3f(0.05, 0.08, 0.16), smoothstep(1.3, 0.8, min(length(gd - vec2f(-4.0, 2.4)), length(gd - vec2f(4.8, 2.4)))));
      let b1 = sdSegment(gd, vec2f(-7.0, 7.0), vec2f(-1.8, 4.6)) - 1.0;
      let b2 = sdSegment(gd, vec2f(7.0, 7.0), vec2f(1.8, 4.6)) - 1.0;
      gc = mix(gc, vec3f(0.06, 0.10, 0.2), smoothstep(0.4, -0.4, min(b1, b2)));
      let fr = sdSegment(gd, vec2f(-2.6, -4.6), vec2f(2.6, -4.2)) - 0.9;
      gc = mix(gc, vec3f(0.06, 0.10, 0.2), smoothstep(0.4, -0.4, fr));
      c = mix(c, gc, smoothstep(1.0, -0.5, bdg));
    }
  }

  // ── THE DOGGO ──
  let dogP = vec2f(bx, by);
  let grabbed = uni(25);
  let anch = vec2f(uni(26), uni(27));
  let face = uni(34);
  let amb = mix(vec3f(0.55, 0.66, 0.90), vec3f(1.15, 0.98, 0.85), dawn);
  let lp0 = wp - dogP;
  let bd0 = length(lp0);
  // the collar lantern's warmth — small, alive
  let breathe = 0.9 + 0.1 * sin(t * 3.4);
  c += vec3f(1.3, 0.6, 0.2) * (heat * breathe * 18.0 / (bd0 * bd0 * 0.10 + 20.0));

  var bodyd = 100000.0;
  if (grabbed > 0.5) {
    // rubbery stretch: a tapered capsule from the grab anchor to the dragged pup
    let ab = dogP - anch;
    let abl = max(dot(ab, ab), 0.0001);
    let hseg = clamp(dot(wp - anch, ab) / abl, 0.0, 1.0);
    bodyd = length(wp - anch - ab * hseg) - mix(7.0, 14.0, hseg);
  } else {
    // squash & stretch along the motion
    let sxs = max(uni(36), 0.4);
    let q0 = rot2(-uni(35)) * lp0;
    bodyd = length(vec2f(q0.x / sxs, q0.y * sxs)) - 14.0;
  }

  if (bodyd < 26.0) {
    let cream = vec3f(0.93, 0.84, 0.66);
    let caramel = vec3f(0.72, 0.47, 0.24);
    // ears + tail behind the body
    let earDrop = clamp(uni(5) / 700.0, -1.0, 1.0) * 2.5;
    let e1 = length((lp0 - vec2f(-9.0, 11.0 + earDrop)) * vec2f(1.5, 0.85)) - 7.0;
    let e2 = length((lp0 - vec2f(9.0, 11.5 + earDrop)) * vec2f(1.5, 0.85)) - 7.0;
    let wag = sin(t * 10.0) * 2.5;
    let tl = length(lp0 - vec2f(-face * 15.0, 2.0 + wag * 0.4)) - 4.5;
    let acc = min(min(e1, e2), tl);
    c = mix(c, caramel * amb * (1.0 + heat * 0.6), smoothstep(1.0, -0.5, acc));
    if (bodyd < 1.2) {
      var bc = cream;
      bc = mix(bc, vec3f(1.0, 0.95, 0.80), smoothstep(0.0, -10.0, lp0.y) * 0.35);
      bc = mix(bc, caramel, smoothstep(6.5, 5.0, length(lp0 - vec2f(6.0 * face, 3.5))));
      bc *= amb * 1.18;
      bc *= 1.0 - smoothstep(-4.0, 0.5, bodyd) * 0.30;
      bc += vec3f(1.2, 0.6, 0.25) * heat * 0.20;
      c = mix(c, bc, smoothstep(1.2, -0.4, bodyd));
      // face — derpy on purpose
      let inb = smoothstep(0.5, -0.5, bodyd);
      let lean = clamp(uni(4) / 1000.0, -1.0, 1.0) * 3.0;
      let fp = dogP + vec2f(lean + face * 1.5, 2.0);
      var look = vec2f(face * 1.2, -0.3);
      let cur = vec2f(uni(37), uni(38));
      if (grabbed > 0.5) { look = normalize(anch - dogP + vec2f(0.001, 0.0)) * 1.7; }
      else if (abs(uni(4)) + abs(uni(5)) > 60.0) { look = normalize(vec2f(uni(4), uni(5))) * 1.5; }
      else if (length(cur - dogP) < 150.0) { look = normalize(cur - dogP + vec2f(0.001, 0.0)) * 1.6; }
      let eLp = wp - (fp + vec2f(-5.5, 3.0));
      let eRp = wp - (fp + vec2f(6.0, 2.6));
      c = mix(c, vec3f(0.99, 0.99, 0.97), smoothstep(4.3, 3.6, length(eLp)) * inb);
      c = mix(c, vec3f(0.99, 0.99, 0.97), smoothstep(3.5, 2.8, length(eRp)) * inb);
      c = mix(c, vec3f(0.10, 0.07, 0.05), smoothstep(2.0, 1.3, length(eLp - look - vec2f(0.5, -0.4))) * inb);
      c = mix(c, vec3f(0.10, 0.07, 0.05), smoothstep(1.8, 1.1, length(eRp - look * 0.35 - vec2f(-0.9, 0.5))) * inb);
      // muzzle + nose
      c = mix(c, vec3f(1.0, 0.96, 0.85) * amb, smoothstep(5.6, 4.7, length((wp - (fp + vec2f(0.5, -3.2))) * vec2f(0.9, 1.15))) * inb);
      c = mix(c, vec3f(0.16, 0.10, 0.08), smoothstep(2.3, 1.6, length((wp - (fp + vec2f(0.5, -1.6))) * vec2f(0.85, 1.2))) * inb);
      // tongue — flops harder with speed
      let flop = sin(t * 9.0) * (1.0 + uni(20) * 1.5);
      let tq = wp - (fp + vec2f(1.8 + flop * 0.5, -7.5 - flop * 0.6));
      let td = sdRoundedBox(tq, vec2f(2.3, 3.6), 2.0);
      c = mix(c, vec3f(0.95, 0.45, 0.50), smoothstep(0.6, -0.4, td) * inb);
      // collar + lantern tag
      let cq = wp - (dogP + vec2f(0.0, -8.5));
      let cd = sdRoundedBox(cq, vec2f(11.0, 1.8), 1.5);
      c = mix(c, vec3f(0.45, 0.10, 0.14) * amb + vec3f(0.20, 0.02, 0.02), smoothstep(0.5, -0.5, max(cd, bodyd - 1.0)));
      let tagd = length(wp - (dogP + vec2f(0.0, -11.5))) - 2.2;
      c = mix(c, vec3f(2.0, 1.2, 0.4), smoothstep(0.5, -0.5, tagd));
    }
  }

  // cursor halo — a soft firefly marking where your hand is on the fell
  let curP = vec2f(uni(37), uni(38));
  let curD = length(wp - curP);
  c += vec3f(0.9, 0.85, 0.7) * exp(-curD * curD / 60.0) * 0.40 * (1.0 - grabbed) * uni(24);
  c += vec3f(1.0, 0.8, 0.4) * smoothstep(1.6, 0.0, abs(curD - 13.0 - 2.0 * sin(t * 3.0))) * 0.12 * (1.0 - grabbed) * uni(24);

  // grab anchor + fling-direction hint
  if (grabbed > 0.5) {
    let ad = abs(length(wp - anch) - 6.0) - 1.0;
    c += vec3f(0.9, 0.95, 1.0) * smoothstep(0.8, -0.4, ad) * 0.6;
    let dir = anch - dogP;
    let dl = length(dir);
    if (dl > 4.0) {
      let nd = dir / dl;
      let along = dot(wp - anch, nd);
      let perp = abs(dot(wp - anch, vec2f(-nd.y, nd.x)));
      let dotm = smoothstep(0.5, 0.2, abs(fract(along / 14.0) - 0.5));
      let hint = step(0.0, along) * step(along, dl * 1.4) * smoothstep(2.2, 0.8, perp) * dotm;
      c += vec3f(1.4, 0.9, 0.4) * hint * uni(28) * 0.8;
    }
  }

  // sparks trail a fast pup — the ember heart it inherited
  let stoke = uni(20);
  if (stoke > 0.02 && bd0 < 90.0 && bd0 > 14.0) {
    let vx = uni(4);
    let back = vec2f(lp0.x + sign(vx) * bd0 * 0.5, lp0.y - bd0 * 0.18);
    let sp = mod_cf_hash(floor(back * 0.5 + vec2f(0.0, t * 9.0)));
    c += vec3f(2.2, 1.1, 0.25) * smoothstep(0.93, 0.995, sp) * stoke * smoothstep(90.0, 20.0, bd0);
  }

  // ── front snow, wind-bent, heaviest before dawn ──
  c += vec3f(0.85, 0.9, 1.05) * mod_cf_snow(px + vec2f(camX * 1.6, -camY * 1.6), t, wind, 60.0, 1.0, dawn) * 0.8;
  c += vec3f(0.7, 0.75, 0.9) * mod_cf_snow(px + vec2f(camX * 1.0, -camY), t + 40.0, wind, 45.0, 0.8, dawn) * 0.5;

  // intro fade from black
  c *= uni(24);
  return vec4f(c, 1.0);
}

— SHADER MODULES —

module · cf_lib


// ── noise kit ──
fn mod_cf_hash(p: vec2f) -> f32 {
  return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
}
fn mod_cf_vnoise(p: vec2f) -> f32 {
  let i = floor(p); let f = fract(p);
  let u = f * f * (3.0 - 2.0 * f);
  return mix(mix(mod_cf_hash(i), mod_cf_hash(i + vec2f(1.0, 0.0)), u.x),
             mix(mod_cf_hash(i + vec2f(0.0, 1.0)), mod_cf_hash(i + vec2f(1.0, 1.0)), u.x), u.y);
}
fn mod_cf_fbm(p: vec2f) -> f32 {
  var v = 0.0; var a = 0.5; var q = p;
  for (var i = 0; i < 4; i++) {
    v += a * mod_cf_vnoise(q);
    q = q * 2.03 + vec2f(17.0, 9.2);
    a *= 0.5;
  }
  return v;
}
fn mod_cf_sm01(t: f32) -> f32 { let c = clamp(t, 0.0, 1.0); return c * c * (3.0 - 2.0 * c); }

// ── THE TERRAIN — the one truth. Mirrored exactly in the JS hook. ──
fn mod_cf_h(x: f32) -> f32 {
  var h = 90.0 * sin(x * 0.0021 + 1.7)
        + 55.0 * sin(x * 0.0047 + 0.4)
        + 26.0 * sin(x * 0.0089 + 3.1)
        + 11.0 * sin(x * 0.0173 + 5.2);
  h = mix(h, 10.0, exp(-(x * x) / 25600.0));                       // the start pad
  let tm = mod_cf_sm01((x - 1840.0) / 120.0) * (1.0 - mod_cf_sm01((x - 2320.0) / 120.0));
  h = mix(h, -30.0, tm * 0.92);                                    // the frozen tarn
  h += 130.0 * exp(-((x - 3050.0) * (x - 3050.0)) / 22500.0);      // the scarp
  // hearth terraces: flat ground under each beacon, so a rolling iron ball can
  // actually PARK for the handoff (levels precomputed from the base curve)
  h = mix(h, 19.5, 0.9 * exp(-((x - 620.0) * (x - 620.0)) / 6400.0));
  h = mix(h, -56.0, 0.9 * exp(-((x - 1520.0) * (x - 1520.0)) / 6400.0));
  h = mix(h, -31.7, 0.9 * exp(-((x - 2290.0) * (x - 2290.0)) / 6400.0));
  h = mix(h, 54.1, 0.9 * exp(-((x - 3390.0) * (x - 3390.0)) / 6400.0));
  h = mix(h, -33.2, 0.9 * exp(-((x - 4250.0) * (x - 4250.0)) / 6400.0));
  return h;
}
fn mod_cf_ice(x: f32) -> f32 {
  return mod_cf_sm01((x - 1840.0) / 120.0) * (1.0 - mod_cf_sm01((x - 2320.0) / 120.0));
}
fn mod_cf_dh(x: f32) -> f32 { return (mod_cf_h(x + 2.0) - mod_cf_h(x - 2.0)) * 0.25; }

fn mod_cf_beaconX(i: i32) -> f32 {
  if (i == 0) { return 620.0; }
  if (i == 1) { return 1520.0; }
  if (i == 2) { return 2290.0; }
  if (i == 3) { return 3390.0; }
  return 4250.0;
}

// ── sky: deep dusk → dawn. y01: 0 horizon, 1 zenith. ──
fn mod_cf_sky(x: f32, y01: f32, t: f32, dawn: f32, warmth: f32) -> vec3f {
  let y = clamp(y01, 0.0, 1.0);
  // dusk: indigo zenith, cold teal band, a dying amber seam where the sun sank
  let zenD = vec3f(0.010, 0.016, 0.045);
  let horD = vec3f(0.055, 0.10, 0.16);
  var c = mix(horD, zenD, pow(y, 0.62));
  c += vec3f(0.30, 0.12, 0.03) * exp(-y * 7.0) * (0.35 + 0.2 * warmth) * (1.0 - dawn);
  // dawn floods from the east (the direction of travel — the fire you lit)
  let sunY = mix(-0.14, 0.16, dawn);
  let sd = length(vec2f((x - 4250.0) * 0.00042, (y - sunY) * 1.35));
  let dawnZen = vec3f(0.18, 0.32, 0.55);
  let dawnHor = vec3f(1.05, 0.55, 0.22);
  let cDawn = mix(dawnHor, dawnZen, pow(y, 0.55));
  c = mix(c, cDawn, dawn);
  c += vec3f(1.35, 0.85, 0.45) * exp(-sd * 5.5) * dawn * 1.6;      // glow
  c += vec3f(2.6, 1.9, 1.2) * smoothstep(0.05, 0.012, sd) * dawn;  // the disc
  // stars — round points in isotropic cells (aniso cells smear them to dashes)
  let sp = vec2f(x, y01 * 470.0) * 0.24;
  let sid = floor(sp);
  let s = mod_cf_hash(sid);
  let soff = (vec2f(mod_cf_hash(sid + 3.0), mod_cf_hash(sid + 9.0)) - 0.5) * 0.6;
  let sdd = length(fract(sp) - 0.5 - soff);
  let tw = 0.55 + 0.45 * sin(t * (1.5 + s * 3.0) + s * 40.0);
  c += vec3f(0.75, 0.82, 1.0) * smoothstep(0.965, 0.998, s) * smoothstep(0.30, 0.04, sdd) * tw * y * (1.0 - dawn) * 0.9;
  return c;
}

// ── flame: a living fbm tongue in a unit box (0..1, y up from the base) ──
fn mod_cf_flame(q: vec2f, t: f32, seed: f32) -> vec3f {
  if (q.y < -0.05 || q.y > 1.25 || abs(q.x) > 0.55) { return vec3f(0.0); }
  let wob = mod_cf_fbm(vec2f(q.x * 3.0 + seed, q.y * 2.6 - t * 2.2 + seed)) - 0.5;
  let xx = q.x + wob * 0.38 * q.y;
  let width = 0.30 * (1.0 - q.y * 0.78);
  let body = smoothstep(width, width * 0.25, abs(xx)) * smoothstep(1.18, 0.55, q.y) * smoothstep(-0.04, 0.10, q.y);
  let core = smoothstep(width * 0.5, 0.0, abs(xx)) * smoothstep(0.75, 0.1, q.y);
  return vec3f(1.60, 0.55, 0.12) * body * 1.4 + vec3f(2.6, 1.9, 0.9) * core;
}

// ── snow: one drifting layer; call per depth. wind bends the fall. ──
fn mod_cf_snow(px: vec2f, t: f32, wind: f32, scale: f32, speed: f32, dawn: f32) -> f32 {
  let drift = vec2f(t * (wind * 0.35 * speed), t * 46.0 * speed);
  let g = (px + drift) / scale;
  let id = floor(g);
  let f = fract(g) - 0.5;
  let r = mod_cf_hash(id);
  if (r < 0.82) { return 0.0; }
  let off = vec2f(mod_cf_hash(id + 7.0) - 0.5, mod_cf_hash(id + 13.0) - 0.5) * 0.6;
  let d = length(f - off);
  let tw = 0.7 + 0.3 * sin(t * 3.0 + r * 50.0);
  return smoothstep(0.10, 0.02, d) * tw * (1.0 - dawn * 0.85);
}

// ── terrain shading: material by height/slope, warm light from fires ──
fn mod_cf_ground(wp: vec2f, t: f32, heat: f32, bx: f32, by: f32, dawn: f32, warmth: f32) -> vec3f {
  let hh = mod_cf_h(wp.x);
  let depth = hh - wp.y;                       // how far below the surface
  let slope = abs(mod_cf_dh(wp.x));
  let ice = mod_cf_ice(wp.x);
  // base materials: frost-grass on the gentle, dark basalt on the steep
  let rockN = mod_cf_fbm(wp * 0.030);
  let fineN = mod_cf_fbm(wp * 0.14);
  var mat = mix(vec3f(0.055, 0.075, 0.075), vec3f(0.035, 0.036, 0.046), mod_cf_sm01(slope * 1.9 - 0.15));
  mat *= 0.75 + 0.5 * rockN;
  // snow cap: on gentle ground, hugging the surface
  let snowBand = mod_cf_sm01(1.0 - slope * 2.1) * mod_cf_sm01(1.0 - depth * 0.05) * (0.55 + 0.45 * fineN);
  mat = mix(mat, vec3f(0.42, 0.47, 0.56), snowBand * 0.85 * (1.0 - ice));
  // the tarn: black ice, glassy, faint teal depths
  let iceCol = mix(vec3f(0.020, 0.05, 0.065), vec3f(0.10, 0.20, 0.24), smoothstep(3.0, 0.0, depth));
  mat = mix(mat, iceCol * (0.8 + 0.4 * fineN), ice);
  // surface line brightens, depths fall to dark
  mat *= mix(1.0, 0.25, mod_cf_sm01(depth * 0.02));
  mat *= 1.0 + 1.4 * exp(-depth * 0.40);
  // ambient: cold dusk → warm dawn
  var c = mat * mix(vec3f(0.45, 0.60, 0.85), vec3f(1.15, 0.95, 0.80), dawn) * (0.9 + warmth * 0.25);
  // the cinder's own light — the whole point of carrying it
  let bd = distance(wp, vec2f(bx, by));
  c += mat * vec3f(1.5, 0.75, 0.28) * (heat * 130.0 / (bd * bd * 0.02 + 30.0)) * exp(-depth * 0.10);
  // every lit beacon is a hearth
  for (var i = 0; i < 5; i++) {
    let lit = uni(13 + i);
    if (lit > 0.01) {
      let fx = mod_cf_beaconX(i);
      let fp = vec2f(fx, mod_cf_h(fx) + 34.0);
      let fd = distance(wp, fp);
      let flick = 0.85 + 0.15 * sin(t * 9.0 + f32(i) * 11.0);
      c += mat * vec3f(1.5, 0.8, 0.3) * (lit * flick * 200.0 / (fd * fd * 0.02 + 40.0)) * exp(-depth * 0.10);
    }
  }
  // ice glint under any light
  c += vec3f(0.5, 0.8, 0.9) * ice * smoothstep(1.2, 0.0, depth) * (0.10 + 0.25 * smoothstep(120.0, 0.0, abs(wp.x - bx)) * heat);
  return c;
}

// ── a far ridge line (parallax) — same parent terrain, remixed ──
fn mod_cf_ridge(wp: vec2f, xo: f32, amp: f32, base: f32, col: vec3f, sky: vec3f) -> vec4f {
  let h = mod_cf_h(wp.x * 0.52 + xo) * amp + base;
  let d = wp.y - h;
  let m = smoothstep(2.0, -2.0, d);
  let fade = mix(col, sky, mod_cf_sm01((wp.y - h) * -0.004));   // haze toward its own depths
  return vec4f(fade, m);
}

— STEP HOOKS (JAVASCRIPT) —

hook · cinderfell_core

DOGGO ON THE FELL: cinderfell terrain+wind physics kept; pointer grab/stretch/fling, gloomies, beacon doors, dawn
const wd = sim.worldData
if (wd.__cf) { delete wd.__cf }
wd.__resets = ['__dg', '__trig']
if (wd.__fresh) { delete wd.__fresh }

// ── THE TERRAIN — mirrored exactly from mod_cf_h in cf_lib. One truth. ──
const sm01 = t => { const c = Math.max(0, Math.min(1, t)); return c * c * (3 - 2 * c) }
const mixN = (a, b, m) => a + (b - a) * m
const terrainH = x => {
  let h = 90 * Math.sin(x * 0.0021 + 1.7)
        + 55 * Math.sin(x * 0.0047 + 0.4)
        + 26 * Math.sin(x * 0.0089 + 3.1)
        + 11 * Math.sin(x * 0.0173 + 5.2)
  h = mixN(h, 10, Math.exp(-(x * x) / 25600))
  const tm = sm01((x - 1840) / 120) * (1 - sm01((x - 2320) / 120))
  h = mixN(h, -30, tm * 0.92)
  h += 130 * Math.exp(-((x - 3050) * (x - 3050)) / 22500)
  h = mixN(h, 19.5, 0.9 * Math.exp(-((x - 620) * (x - 620)) / 6400))
  h = mixN(h, -56.0, 0.9 * Math.exp(-((x - 1520) * (x - 1520)) / 6400))
  h = mixN(h, -31.7, 0.9 * Math.exp(-((x - 2290) * (x - 2290)) / 6400))
  h = mixN(h, 54.1, 0.9 * Math.exp(-((x - 3390) * (x - 3390)) / 6400))
  h = mixN(h, -33.2, 0.9 * Math.exp(-((x - 4250) * (x - 4250)) / 6400))
  return h
}
const iceAt = x => sm01((x - 1840) / 120) * (1 - sm01((x - 2320) / 120))
const dH = x => (terrainH(x + 2) - terrainH(x - 2)) * 0.25
const DOORS = [620, 1520, 2290, 3390, 4250]

// frost gloomies per stretch of the fell: [x, floatOff, patrolRange, patrolSpeed]
const FOES = [
  [[240, 0, 50, 1.2], [420, 0, 70, 1.6], [150, 80, 30, 2.0]],
  [[800, 0, 60, 1.4], [1010, 90, 40, 2.2], [1230, 0, 70, 1.7], [1430, 0, 40, 1.3]],
  [[1690, 0, 80, 2.0], [1900, 60, 50, 2.4], [2060, 0, 90, 2.2], [2210, 0, 40, 1.5]],
  [[2480, 0, 70, 1.5], [2760, 100, 45, 2.3], [3040, 0, 60, 1.8], [3170, 70, 40, 2.5], [3300, 0, 50, 1.6]],
  [[3550, 0, 60, 1.6], [3720, 90, 45, 2.4], [3900, 0, 80, 1.9], [4060, 60, 40, 2.6], [4160, 0, 40, 1.4]],
]
const buildFoes = lv => (FOES[lv] || []).map((f, i) => ({ x0: f[0], off: f[1], rng: f[2], sp: f[3], ph: i * 2.13, x: f[0], st: 1 }))

if (!wd.__dg || wd.__dg.v !== 2) wd.__dg = {
  v: 2, t: 0, lv: 0,
  x: 0, y: terrainH(0) + 20, vx: 0, vy: 0, roll: 0, grounded: 0, airT: 0,
  camX: 0, camY: 40, intro: 0,
  lit: [0, 0, 0, 0, 0], litAnim: [0, 0, 0, 0, 0], dawn: 0, warmth: 0, dawnSung: false,
  grab: 0, ax: 0, ay: 0, sx: 0, sy: 0, ptrWasDown: false,
  stoke: 0, landP: 0, squishA: 0, flings: 0, door: 0, doorSung: false,
  foes: buildFoes(0),
  gustT: 3, gustA: 0, gustSign: -1, gustLife: 0, wind: 0,
}
const G = wd.__dg
const step = Math.min(dt, 1 / 30)
G.t += step
G.intro = Math.min(1, G.intro + step * 0.5)
const snd = []

sim.defineChapters(['THE COLD', 'FIRST LIGHT', 'THE TARN', 'THE SCARP', 'THE LAST BEACON', 'DAWN'])

// ── wind: a slow body + gusts that arrive like weather (kept from cinderfell) ──
G.gustT -= step
if (G.gustT <= 0) {
  G.gustT = 7 + (Math.sin(G.t * 7.13) * 0.5 + 0.5) * 9
  G.gustA = 120 + (Math.sin(G.t * 3.7) * 0.5 + 0.5) * 90
  G.gustSign = Math.sin(G.t * 1.93) > 0.25 ? 1 : -1
  G.gustLife = 2.6
}
G.gustLife = Math.max(0, (G.gustLife || 0) - step)
const gustEnv = sm01(G.gustLife / 0.6) * sm01((2.6 - G.gustLife) / 0.6)
G.wind = Math.sin(G.t * 0.10) * 26 + Math.sin(G.t * 0.043 + 2) * 20 + G.gustSign * G.gustA * gustEnv

// ── pointer → world space (camera at camX,camY; screen half-width 300, y flips) ──
const ptr = (wd.input && wd.input.pointer) || {}
const pDown = ptr.down !== undefined ? !!ptr.down : !!wd.mouse_down
const pgx = ptr.x !== undefined ? ptr.x : (wd.mouse_x || 256)
const pgy = ptr.y !== undefined ? ptr.y : (wd.mouse_y || 256)
const pwx = G.camX + (pgx / 256 - 1) * 300
const pwy = G.camY - (pgy / 256 - 1) * 300
const pPressed = ptr.pressed || (pDown && !G.ptrWasDown)
G.ptrWasDown = pDown

// ── grab (dragstart) · stretch (drag) · fling (dragend) ──
// press ANYWHERE grabs the pup; the stretch is relative to where you pressed,
// so no precision aiming at a small bouncy dog is ever needed
const RAD = 14, MAXSTRETCH = 150, FLINGK = 6.8
if (pPressed && !G.grab) {
  G.grab = 1; G.ax = G.x; G.ay = G.y; G.px0 = pwx; G.py0 = pwy; G.vx = 0; G.vy = 0
  snd.push({ frequency: 300, duration: 0.06, volume: 0.10, type: 'triangle' })
}
if (G.grab) {
  if (pDown) {
    let sx = pwx - G.px0, sy = pwy - G.py0
    const sl = Math.hypot(sx, sy)
    if (sl > MAXSTRETCH) { sx *= MAXSTRETCH / sl; sy *= MAXSTRETCH / sl }
    G.sx = sx; G.sy = sy
    G.x = G.ax + sx; G.y = G.ay + sy
  } else {
    // dragend: fling from the LAST dragged stretch — release events can
    // arrive without coordinates, so never recompute from the pointer here
    G.grab = 0
    G.x = G.ax; G.y = G.ay
    if (Math.hypot(G.sx, G.sy) > 16) {
      G.vx = -G.sx * FLINGK; G.vy = -G.sy * FLINGK
      G.flings++
      const pw = Math.min(1, Math.hypot(G.sx, G.sy) / MAXSTRETCH)
      snd.push({ frequency: 160 + pw * 180, duration: 0.18, volume: 0.20, type: 'triangle' })
    } else { G.vx = 0; G.vy = 0 }
    G.sx = 0; G.sy = 0
  }
}

// ── physics: semi-implicit Euler vs the height field (kept; bouncier for a rubbery pup) ──
const GRAV = 780, REST = 0.5, VMAX = 1150
if (!G.grab) {
  G.vy -= GRAV * step
  G.vx += (G.wind - G.vx) * (G.grounded ? 0.05 : 0.12) * step
  if (G.vx > VMAX) G.vx = VMAX; else if (G.vx < -VMAX) G.vx = -VMAX
  if (G.vy > 1400) G.vy = 1400; else if (G.vy < -1400) G.vy = -1400
  G.x += G.vx * step
  G.y += G.vy * step

  const h = terrainH(G.x)
  G.grounded = 0
  const pen = (h + RAD) - G.y
  if (pen > 0) {
    const s = dH(G.x)
    const inv = 1 / Math.sqrt(1 + s * s)
    const nx = -s * inv, ny = inv
    G.x += nx * pen * ny; G.y += ny * pen
    const vn = G.vx * nx + G.vy * ny
    if (vn < 0) {
      const impact = -vn
      G.vx -= nx * vn * (1 + REST); G.vy -= ny * vn * (1 + REST)
      if (impact > 120 && G.airT > 0.15) {
        G.landP = Math.min(1, impact / 480)
        G.squishA = Math.atan2(ny, nx)
        snd.push({ frequency: 60 + Math.min(50, impact * 0.07), duration: 0.14, volume: Math.min(0.28, impact / 1500), type: 'sine' })
      }
    }
    const tx = ny, ty = -nx
    let vt = G.vx * tx + G.vy * ty
    const mu = mixN(2.6, 0.12, iceAt(G.x))
    vt *= Math.exp(-mu * step)
    const vn2 = G.vx * nx + G.vy * ny
    G.vx = tx * vt + nx * vn2; G.vy = ty * vt + ny * vn2
    G.grounded = 1
    G.airT = 0
  } else { G.airT += step }
  G.roll += (G.vx / RAD) * step
}
G.landP = Math.max(0, G.landP - step * 2.5)

// ── frost gloomies: patrol, decay, and get bounced into ──
let alive = 0
for (const e of G.foes) {
  if (e.st >= 1) {
    e.ph += step * e.sp
    e.x = e.x0 + Math.sin(e.ph) * e.rng
    e.y = terrainH(e.x) + 13 + e.off + (e.off > 0 ? Math.sin(G.t * 2 + e.ph) * 8 : 0)
    alive++
  } else if (e.st > 0) { e.st -= step * 2 }
}
if (!G.grab) {
  for (const e of G.foes) {
    if (e.st < 1) continue
    const ddx = G.x - e.x, ddy = G.y - e.y
    const dd = Math.hypot(ddx, ddy)
    if (dd < RAD + 13) {
      const spd = Math.hypot(G.vx, G.vy)
      const nx = ddx / (dd || 1), ny = ddy / (dd || 1)
      if (spd > 90) {
        e.st = 0.999
        const vn = G.vx * nx + G.vy * ny
        if (vn < 0) { G.vx -= 1.6 * vn * nx; G.vy -= 1.6 * vn * ny }
        G.vy += 90
        G.landP = Math.max(G.landP, 0.6)
        G.squishA = Math.atan2(ny, nx)
        snd.push({ frequency: 660, duration: 0.10, volume: 0.18, type: 'square' })
        snd.push({ frequency: 990, duration: 0.16, volume: 0.12, type: 'sine' })
      } else {
        G.x = e.x + nx * (RAD + 13); G.y = e.y + ny * (RAD + 13)
      }
    }
  }
}

// ── the magical door: opens on the beacon cairn when the stretch is clear ──
const doorX = G.lv < 5 ? DOORS[G.lv] : 4600
const doorH = terrainH(doorX)
if (G.lv < 5 && alive === 0) {
  G.door = Math.min(1, G.door + step * 1.2)
  if (G.door >= 1 && !G.doorSung) {
    G.doorSung = true
    snd.push({ frequency: 392, duration: 0.5, volume: 0.20, type: 'sine' })
    snd.push({ frequency: 494, duration: 0.6, volume: 0.16, type: 'sine' })
    snd.push({ frequency: 588, duration: 0.9, volume: 0.13, type: 'sine' })
  }
}
const doorOpen = G.door >= 0.99
let passed = false
if (G.lv < 5 && doorOpen && G.x > doorX + 6 && G.y < doorH + 78) {
  passed = true
  const i = G.lv
  G.lit[i] = 1
  try { if (sim.trigger('cf_lit_' + i, true)) sim.completeChapter() } catch (e) {}
  snd.push({ frequency: 523, duration: 0.5, volume: 0.20, type: 'sine' })
  snd.push({ frequency: 659, duration: 0.7, volume: 0.15, type: 'sine' })
  snd.push({ frequency: 784, duration: 1.0, volume: 0.12, type: 'sine' })
  G.lv++
  G.door = 0
  G.doorSung = false
  G.foes = buildFoes(G.lv)
}
if (!passed && !G.grab) {
  // soft walls hold the fell; a closed (or overshot) door holds the stretch
  if (G.x < -120) { G.x = -120; G.vx = Math.max(0, G.vx) }
  if (G.lv < 5) {
    if (!doorOpen && G.x > doorX - 26) { G.x = doorX - 26; G.vx = -Math.abs(G.vx) * 0.5 }
    else if (doorOpen && G.x > doorX - 4 && G.y >= doorH + 78) { G.x = doorX - 4; G.vx = -Math.abs(G.vx) * 0.5 }
  }
  if (G.x > 4600) { G.x = 4600; G.vx = Math.min(0, G.vx) }
  if (G.y < -400) { G.y = terrainH(G.x) + RAD + 2; G.vy = 0 }
}

// ── dawn rises with the lit beacons ──
let litCount = 0
for (let i = 0; i < 5; i++) {
  if (G.lit[i]) litCount++
  G.litAnim[i] = mixN(G.litAnim[i], G.lit[i], 1 - Math.exp(-2.2 * step))
}
G.warmth = mixN(G.warmth, litCount / 5, 1 - Math.exp(-0.8 * step))
const finale = litCount === 5
G.dawn = mixN(G.dawn, finale ? 1 : 0, 1 - Math.exp(-0.28 * step))
if (finale && !G.dawnSung) {
  G.dawnSung = true
  snd.push({ frequency: 196, duration: 2.2, volume: 0.2, type: 'sine' })
  snd.push({ frequency: 294, duration: 2.2, volume: 0.16, type: 'sine' })
  snd.push({ frequency: 392, duration: 2.6, volume: 0.14, type: 'sine' })
  snd.push({ frequency: 588, duration: 3.0, volume: 0.10, type: 'sine' })
}

// ── styling signals for the shader ──
const spd = Math.hypot(G.vx, G.vy)
G.stoke = mixN(G.stoke, sm01((spd - 250) / 400), 1 - Math.exp(-4 * step))
const heat = 0.5 + G.stoke * 0.3
let scaleAlong = 1 + Math.min(spd / 1400, 0.30)
let scaleAng = Math.atan2(G.vy, G.vx)
if (G.landP > 0.05) { scaleAlong = 1 - 0.45 * G.landP; scaleAng = G.squishA }
if (G.grab) { scaleAlong = 1; scaleAng = 0 }
if (Math.abs(G.vx) > 25) G.face = Math.sign(G.vx)
if (G.face === undefined) G.face = 1
const hungry = (G.lv < 5 && alive > 0 && Math.abs(G.x - doorX) < 140) ? 1 : 0

// ── camera: terrain-weighted, leads the motion (kept from cinderfell) ──
const lookX = G.x + G.vx * 0.30
const lookY = mixN(G.y, terrainH(G.x), 0.72) + 85
G.camX += (Math.max(-40, Math.min(4520, lookX)) - G.camX) * (1 - Math.exp(-3.2 * step))
G.camY += (lookY - G.camY) * (1 - Math.exp(-2.6 * step))

// pin the canvas field
for (const f of sim.fields.values()) {
  if ((f.name || '') === 'Cinderfell') { const T = f.transform; T.x = 256; T.y = 256; T.vx = 0; T.vy = 0 }
}

// ── publish the whiteboard ──
const U = new Array(40).fill(0)
U[0] = G.t; U[1] = G.x; U[2] = G.y; U[3] = G.roll
U[4] = G.vx; U[5] = G.vy; U[6] = G.grounded; U[7] = heat
U[8] = G.camX; U[9] = G.camY; U[10] = G.wind; U[11] = G.dawn
U[12] = G.warmth
for (let i = 0; i < 5; i++) U[13 + i] = G.litAnim[i]
U[18] = 0; U[19] = G.landP; U[20] = G.stoke
U[21] = finale ? 1 : 0; U[22] = iceAt(G.x); U[23] = hungry
U[24] = G.intro
U[25] = G.grab; U[26] = G.ax; U[27] = G.ay
U[28] = G.grab ? Math.min(1, Math.hypot(G.sx, G.sy) / MAXSTRETCH) : 0
U[29] = doorX; U[30] = G.door; U[31] = alive
U[32] = G.lv; U[33] = G.flings; U[34] = G.face
U[35] = scaleAng; U[36] = scaleAlong
U[37] = pwx; U[38] = pwy
wd.gpuUniforms = U

const P = []
for (const e of G.foes) { if (e.st > 0) P.push(e.x, e.y, e.ph, e.st) }
wd.gpuPopulation = P

wd.hud = [
  { id: 'dg_hud', type: 'text', x: '2%', y: '2%',
    text: 'BEACON ' + Math.min(G.lv + 1, 5) + '/5 · GLOOMIES ' + alive + ' · FLINGS ' + G.flings,
    fontSize: '14px', color: '#ffd9a0' },
]
if (finale) wd.hud.push({ id: 'dg_win', type: 'text', x: '22%', y: '38%',
  text: 'DAWN — GOOD DOGGO!', fontSize: '30px', color: '#ffe9c0' })
else if (doorOpen) wd.hud.push({ id: 'dg_go', type: 'text', x: '30%', y: '8%',
  text: '✦ the door is open ✦', fontSize: '15px', color: '#8ef0d8' })
if (snd.length) wd.__play_sound = snd