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.
— 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
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