cartridge.cafe · open source world
CINDERFELL
HOW TO PLAY
← → or A D — roll. The ground grips; the ice does not. SPACE — leap. The jump fires along the slope, so hills aim you. You are the last cinder of a dead hearth. Five beacons stand cold on the long fell east. A beacon only catches from a cinder that still burns — speed stokes your ember, the wind starves it. But fire passes only to a steady hand: come in hot, then STOP. Light all five. Bring the dawn. (hold R — winter returns)
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
// ── noise kit ──
fn cfv_hash(p: vec2f) -> f32 {
return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
}
fn cfv_vnoise(p: vec2f) -> f32 {
let i = floor(p); let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(mix(cfv_hash(i), cfv_hash(i + vec2f(1.0, 0.0)), u.x),
mix(cfv_hash(i + vec2f(0.0, 1.0)), cfv_hash(i + vec2f(1.0, 1.0)), u.x), u.y);
}
fn cfv_fbm(p: vec2f) -> f32 {
var v = 0.0; var a = 0.5; var q = p;
for (var i = 0; i < 4; i++) {
v += a * cfv_vnoise(q);
q = q * 2.03 + vec2f(17.0, 9.2);
a *= 0.5;
}
return v;
}
fn cfv_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 cfv_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 = cfv_sm01((x - 1840.0) / 120.0) * (1.0 - cfv_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 cfv_ice(x: f32) -> f32 {
return cfv_sm01((x - 1840.0) / 120.0) * (1.0 - cfv_sm01((x - 2320.0) / 120.0));
}
fn cfv_dh(x: f32) -> f32 { return (cfv_h(x + 2.0) - cfv_h(x - 2.0)) * 0.25; }
fn cfv_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 cfv_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 = cfv_hash(sid);
let soff = (vec2f(cfv_hash(sid + 3.0), cfv_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 cfv_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 = cfv_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 cfv_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 = cfv_hash(id);
if (r < 0.82) { return 0.0; }
let off = vec2f(cfv_hash(id + 7.0) - 0.5, cfv_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 cfv_ground(wp: vec2f, t: f32, heat: f32, bx: f32, by: f32, dawn: f32, warmth: f32) -> vec3f {
let hh = cfv_h(wp.x);
let depth = hh - wp.y; // how far below the surface
let slope = abs(cfv_dh(wp.x));
let ice = cfv_ice(wp.x);
// base materials: frost-grass on the gentle, dark basalt on the steep
let rockN = cfv_fbm(wp * 0.030);
let fineN = cfv_fbm(wp * 0.14);
var mat = mix(vec3f(0.055, 0.075, 0.075), vec3f(0.035, 0.036, 0.046), cfv_sm01(slope * 1.9 - 0.15));
mat *= 0.75 + 0.5 * rockN;
// snow cap: on gentle ground, hugging the surface
let snowBand = cfv_sm01(1.0 - slope * 2.1) * cfv_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, cfv_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 = cfv_beaconX(i);
let fp = vec2f(fx, cfv_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 cfv_ridge(wp: vec2f, xo: f32, amp: f32, base: f32, col: vec3f, sky: vec3f) -> vec4f {
let h = cfv_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, cfv_sm01((wp.y - h) * -0.004)); // haze toward its own depths
return vec4f(fade, m);
}
// ── distant volcano: animated silhouette, lava, plume, and ejecta ──
fn cfv_volcano(q: vec2f, t: f32, dawn: f32) -> vec4f {
// q is stable screen/parallax space, y up. The mountain lives on the eastern horizon.
let p = q - vec2f(0.43, -0.20);
let ax = abs(p.x);
let flankNoise = (cfv_fbm(vec2f(p.x * 7.0 + 13.0, 4.0)) - 0.5) * 0.055;
let coneTop = 0.48 - ax * 1.15 + flankNoise;
let mountain = smoothstep(0.012, -0.012, p.y - coneTop)
* smoothstep(0.68, 0.02, ax);
let craterCut = smoothstep(0.055, 0.015, length(vec2f(p.x * 1.8, p.y - 0.475)));
let bodyMask = mountain * (1.0 - craterCut * 0.50);
let rock = cfv_fbm(p * vec2f(9.0, 13.0) + vec2f(0.0, 1.0));
var rockCol = mix(vec3f(0.022, 0.018, 0.026), vec3f(0.090, 0.043, 0.036), rock);
rockCol = mix(rockCol, vec3f(0.12, 0.075, 0.065), dawn * 0.38);
// Several branching lava paths track the cone's slope and pulse independently.
let flowY = clamp((0.46 - p.y) / 0.68, 0.0, 1.0);
let river1 = abs(p.x + 0.025 + 0.060 * sin(flowY * 10.0 + t * 0.26));
let river2 = abs(p.x - 0.18 * flowY - 0.018 * sin(flowY * 16.0 - t * 0.20));
let river3 = abs(p.x + 0.23 * flowY + 0.022 * sin(flowY * 13.0 + 2.0));
let lava = (smoothstep(0.020, 0.004, river1)
+ smoothstep(0.014, 0.003, river2) * 0.75
+ smoothstep(0.012, 0.003, river3) * 0.60)
* smoothstep(-0.10, 0.04, flowY) * bodyMask;
let pulse = 0.82 + 0.18 * sin(t * 4.7 + flowY * 18.0);
var col = rockCol * bodyMask;
col += vec3f(3.8, 0.62, 0.055) * lava * pulse;
col += vec3f(2.8, 0.24, 0.025) * exp(-abs(p.y - coneTop) * 75.0) * bodyMask * 0.20;
// Hot vent and rolling ash: layered noisy lobes drift east in the high wind.
let vent = vec2f(p.x, p.y - 0.49);
let ventGlow = exp(-dot(vent / vec2f(0.17, 0.10), vent / vec2f(0.17, 0.10)) * 2.2);
col += vec3f(4.5, 1.0, 0.10) * ventGlow * (0.72 + 0.28 * sin(t * 6.0));
var smoke = 0.0;
for (var i = 0; i < 5; i++) {
let fi = f32(i);
let rise = fract(t * (0.035 + fi * 0.002) + fi * 0.193);
let center = vec2f(0.03 + rise * rise * 0.26 + 0.035 * sin(t * 0.7 + fi * 2.1),
0.54 + rise * 0.62);
let size = vec2f(0.12 + rise * 0.19, 0.10 + rise * 0.15);
let d = length((p - center) / size);
let ragged = cfv_fbm((p - center) * 18.0 + vec2f(fi * 8.0, -t * 0.12));
smoke += smoothstep(1.08 + ragged * 0.28, 0.58, d) * (1.0 - rise * 0.55);
}
let smokeMask = clamp(smoke * 0.48, 0.0, 0.82);
let smokeCol = mix(vec3f(0.075, 0.064, 0.080), vec3f(0.20, 0.13, 0.12), ventGlow * 0.8);
col = mix(col, smokeCol, smokeMask);
// Ballistic embers above the crater; sparse, bright, and bloom-friendly.
let ep = floor((p + vec2f(t * 0.035, -t * 0.16)) * vec2f(72.0, 55.0));
let er = cfv_hash(ep);
let emberZone = smoothstep(0.90, 0.35, abs(p.x - 0.08))
* smoothstep(1.20, 0.48, p.y) * smoothstep(0.43, 0.56, p.y);
let embers = smoothstep(0.975, 0.998, er) * emberZone;
col += vec3f(5.2, 1.25, 0.14) * embers;
let coverage = clamp(bodyMask + smokeMask + ventGlow * 0.65 + embers, 0.0, 1.0);
return vec4f(col, coverage);
}
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 = cfv_sky(wp.x, y01, t, dawn, warmth);
// ── distant erupting volcano, behind every ridge and the gameplay plane ──
let volcanoQ = vec2f(su.x + camX * 0.000025, su.y + camY * 0.00012);
let volcano = cfv_volcano(volcanoQ, t, dawn);
c = mix(c, volcano.rgb, volcano.a);
// ── parallax ridges (parent module remixes; each hazier than the last) ──
let farWp = vec2f(camX * 0.35 + su.x * HALF, camY * 0.5 + su.y * HALF);
let r1 = cfv_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);
let midWp = vec2f(camX * 0.62 + su.x * HALF, camY * 0.72 + su.y * HALF);
let r2 = cfv_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);
// ── back snow (falls behind the near ground) ──
c += vec3f(0.7, 0.78, 0.95) * cfv_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 ball collides with ──
let hNear = cfv_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 = cfv_ground(wp, t, heat, bx, by, dawn, warmth);
c = mix(c, g, gm);
}
// ── beacons: cairn, brazier, rune or fire ──
for (var i = 0; i < 5; i++) {
let fx = cfv_beaconX(i);
if (abs(wp.x - fx) > 90.0) { continue; }
let fh = cfv_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 * cfv_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 — parent flame module, sized by how lit it is
let q = vec2f(lp.x / 26.0, (lp.y - 33.0) / (30.0 * (0.4 + 0.6 * lit)));
c += cfv_flame(q, t, f32(i) * 7.7) * lit;
} else {
// dormant rune ring — hungry flicker when a cold cinder is near
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 CINDER ──
let lp = wp - vec2f(bx, by);
let bd = length(lp);
let R = 14.0;
// ember glow — HDR, breathes with heat, bloom does the rest
let breathe = 0.9 + 0.1 * sin(t * 3.4);
c += vec3f(1.6, 0.62, 0.15) * (heat * breathe * 55.0 / (bd * bd * 0.06 + 22.0));
if (bd < R + 1.2) {
// iron crust, rotated by the roll — cracks leak the core
let roll = uni(3);
let ca = cos(roll); let sa = sin(roll);
let rp = vec2f(lp.x * ca - lp.y * sa, lp.x * sa + lp.y * ca);
let crack = cfv_fbm(rp * 0.16 + 3.7);
let vein = smoothstep(0.46, 0.52, crack) * smoothstep(0.60, 0.54, crack);
var bc = vec3f(0.030, 0.028, 0.032) * (0.7 + 0.6 * cfv_fbm(rp * 0.5));
bc += vec3f(2.6, 0.85, 0.16) * vein * (0.15 + heat * 1.6) * breathe;
let coreG = smoothstep(R * 0.55, 0.0, bd);
bc += vec3f(2.2, 0.7, 0.12) * coreG * heat * 0.7;
// cold rim from the sky, warm rim from travel direction
let rim = smoothstep(R - 3.0, R, bd);
bc += vec3f(0.25, 0.35, 0.55) * rim * (1.0 - dawn * 0.5);
c = mix(c, bc, smoothstep(R + 1.2, R - 1.2, bd));
}
// sparks when the ember is being stoked — streaks trailing the motion
let stoke = uni(20);
if (stoke > 0.02 && bd < 90.0 && bd > R) {
let vx = uni(4);
let back = vec2f(lp.x + sign(vx) * bd * 0.5, lp.y - bd * 0.18);
let sp = cfv_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, bd);
}
// ── front snow, wind-bent, heaviest before dawn ──
c += vec3f(0.85, 0.9, 1.05) * cfv_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) * cfv_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_lib_hash(p: vec2f) -> f32 {
return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
}
fn mod_cf_lib_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_lib_hash(i), mod_cf_lib_hash(i + vec2f(1.0, 0.0)), u.x),
mix(mod_cf_lib_hash(i + vec2f(0.0, 1.0)), mod_cf_lib_hash(i + vec2f(1.0, 1.0)), u.x), u.y);
}
fn mod_cf_lib_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_lib_vnoise(q);
q = q * 2.03 + vec2f(17.0, 9.2);
a *= 0.5;
}
return v;
}
fn mod_cf_lib_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_lib_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_lib_sm01((x - 1840.0) / 120.0) * (1.0 - mod_cf_lib_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_lib_ice(x: f32) -> f32 {
return mod_cf_lib_sm01((x - 1840.0) / 120.0) * (1.0 - mod_cf_lib_sm01((x - 2320.0) / 120.0));
}
fn mod_cf_lib_dh(x: f32) -> f32 { return (mod_cf_lib_h(x + 2.0) - mod_cf_lib_h(x - 2.0)) * 0.25; }
fn mod_cf_lib_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_lib_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_lib_hash(sid);
let soff = (vec2f(mod_cf_lib_hash(sid + 3.0), mod_cf_lib_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_lib_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_lib_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_lib_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_lib_hash(id);
if (r < 0.82) { return 0.0; }
let off = vec2f(mod_cf_lib_hash(id + 7.0) - 0.5, mod_cf_lib_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_lib_ground(wp: vec2f, t: f32, heat: f32, bx: f32, by: f32, dawn: f32, warmth: f32) -> vec3f {
let hh = mod_cf_lib_h(wp.x);
let depth = hh - wp.y; // how far below the surface
let slope = abs(mod_cf_lib_dh(wp.x));
let ice = mod_cf_lib_ice(wp.x);
// base materials: frost-grass on the gentle, dark basalt on the steep
let rockN = mod_cf_lib_fbm(wp * 0.030);
let fineN = mod_cf_lib_fbm(wp * 0.14);
var mat = mix(vec3f(0.055, 0.075, 0.075), vec3f(0.035, 0.036, 0.046), mod_cf_lib_sm01(slope * 1.9 - 0.15));
mat *= 0.75 + 0.5 * rockN;
// snow cap: on gentle ground, hugging the surface
let snowBand = mod_cf_lib_sm01(1.0 - slope * 2.1) * mod_cf_lib_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_lib_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_lib_beaconX(i);
let fp = vec2f(fx, mod_cf_lib_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_lib_ridge(wp: vec2f, xo: f32, amp: f32, base: f32, col: vec3f, sky: vec3f) -> vec4f {
let h = mod_cf_lib_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_lib_sm01((wp.y - h) * -0.004)); // haze toward its own depths
return vec4f(fade, m);
}
// ── distant volcano: animated silhouette, lava, plume, and ejecta ──
fn mod_cf_lib_volcano(q: vec2f, t: f32, dawn: f32) -> vec4f {
// q is stable screen/parallax space, y up. The mountain lives on the eastern horizon.
let p = q - vec2f(0.43, -0.20);
let ax = abs(p.x);
let flankNoise = (mod_cf_lib_fbm(vec2f(p.x * 7.0 + 13.0, 4.0)) - 0.5) * 0.055;
let coneTop = 0.48 - ax * 1.15 + flankNoise;
let mountain = smoothstep(0.012, -0.012, p.y - coneTop)
* smoothstep(0.68, 0.02, ax);
let craterCut = smoothstep(0.055, 0.015, length(vec2f(p.x * 1.8, p.y - 0.475)));
let bodyMask = mountain * (1.0 - craterCut * 0.50);
let rock = mod_cf_lib_fbm(p * vec2f(9.0, 13.0) + vec2f(0.0, 1.0));
var rockCol = mix(vec3f(0.022, 0.018, 0.026), vec3f(0.090, 0.043, 0.036), rock);
rockCol = mix(rockCol, vec3f(0.12, 0.075, 0.065), dawn * 0.38);
// Several branching lava paths track the cone's slope and pulse independently.
let flowY = clamp((0.46 - p.y) / 0.68, 0.0, 1.0);
let river1 = abs(p.x + 0.025 + 0.060 * sin(flowY * 10.0 + t * 0.26));
let river2 = abs(p.x - 0.18 * flowY - 0.018 * sin(flowY * 16.0 - t * 0.20));
let river3 = abs(p.x + 0.23 * flowY + 0.022 * sin(flowY * 13.0 + 2.0));
let lava = (smoothstep(0.020, 0.004, river1)
+ smoothstep(0.014, 0.003, river2) * 0.75
+ smoothstep(0.012, 0.003, river3) * 0.60)
* smoothstep(-0.10, 0.04, flowY) * bodyMask;
let pulse = 0.82 + 0.18 * sin(t * 4.7 + flowY * 18.0);
var col = rockCol * bodyMask;
col += vec3f(3.8, 0.62, 0.055) * lava * pulse;
col += vec3f(2.8, 0.24, 0.025) * exp(-abs(p.y - coneTop) * 75.0) * bodyMask * 0.20;
// Hot vent and rolling ash: layered noisy lobes drift east in the high wind.
let vent = vec2f(p.x, p.y - 0.49);
let ventGlow = exp(-dot(vent / vec2f(0.17, 0.10), vent / vec2f(0.17, 0.10)) * 2.2);
col += vec3f(4.5, 1.0, 0.10) * ventGlow * (0.72 + 0.28 * sin(t * 6.0));
var smoke = 0.0;
for (var i = 0; i < 5; i++) {
let fi = f32(i);
let rise = fract(t * (0.035 + fi * 0.002) + fi * 0.193);
let center = vec2f(0.03 + rise * rise * 0.26 + 0.035 * sin(t * 0.7 + fi * 2.1),
0.54 + rise * 0.62);
let size = vec2f(0.12 + rise * 0.19, 0.10 + rise * 0.15);
let d = length((p - center) / size);
let ragged = mod_cf_lib_fbm((p - center) * 18.0 + vec2f(fi * 8.0, -t * 0.12));
smoke += smoothstep(1.08 + ragged * 0.28, 0.58, d) * (1.0 - rise * 0.55);
}
let smokeMask = clamp(smoke * 0.48, 0.0, 0.82);
let smokeCol = mix(vec3f(0.075, 0.064, 0.080), vec3f(0.20, 0.13, 0.12), ventGlow * 0.8);
col = mix(col, smokeCol, smokeMask);
// Ballistic embers above the crater; sparse, bright, and bloom-friendly.
let ep = floor((p + vec2f(t * 0.035, -t * 0.16)) * vec2f(72.0, 55.0));
let er = mod_cf_lib_hash(ep);
let emberZone = smoothstep(0.90, 0.35, abs(p.x - 0.08))
* smoothstep(1.20, 0.48, p.y) * smoothstep(0.43, 0.56, p.y);
let embers = smoothstep(0.975, 0.998, er) * emberZone;
col += vec3f(5.2, 1.25, 0.14) * embers;
let coverage = clamp(bodyMask + smokeMask + ventGlow * 0.65 + embers, 0.0, 1.0);
return vec4f(col, coverage);
}
— STEP HOOKS (JAVASCRIPT) —
hook · cinderfell_core
CINDERFELL: ball physics vs shared height field, wind, ember heat, beacons, dawn
try {
const wd = sim.worldData
if (!wd.__cf || wd.__cf.v !== 1) wd.__cf = {
v: 1, t: 0, x: 0, y: 60, vx: 0, vy: 0, roll: 0, heat: 1,
grounded: 0, camX: 0, camY: 40, lit: [0, 0, 0, 0, 0], litAnim: [0, 0, 0, 0, 0],
dawn: 0, warmth: 0, jumpP: 0, landP: 0, stoke: 0, hungry: 0, intro: 0,
gustT: 3, gustA: 0, gustSign: -1, wind: 0, wasDown: false, airT: 0
}
const G = wd.__cf
// worldData persists — a session that dies mid-keypress leaves key_*=true
// forever (and the state RESTORE can resurrect it over any one-shot clear).
// Watchdog: a key "held" with no fresh press-pulse (_n unchanged) for 8s is
// a ghost, not a finger. Clear it. Real holds re-press; scripts tap shorter.
if (!G.kw) G.kw = {}
const step = Math.min(dt, 1 / 30)
for (const k of ['key_left', 'key_right', 'key_up', 'key_down', 'key_space', 'key_a', 'key_d', 'key_w']) {
const n = wd[k + '_n'] || 0
const w = G.kw[k] || (G.kw[k] = { n: -1, t: 0 })
if (!wd[k]) { w.n = n; w.t = 0; continue }
if (n !== w.n) { w.n = n; w.t = 0; continue } // fresh press — legit
w.t += step
if (w.t > 8) { wd[k] = false; w.t = 0 }
}
G.t += step
G.intro = Math.min(1, G.intro + step * 0.5)
// ── 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)
// hearth terraces — MUST mirror mod_cf_h exactly
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 BEACONS = [620, 1520, 2290, 3390, 4250]
sim.defineChapters(['THE COLD', 'FIRST LIGHT', 'THE TARN', 'THE SCARP', 'THE LAST BEACON', 'DAWN'])
// ── wind: a slow body + gusts that arrive like weather ──
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 // mostly a headwind
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
// ── input ──
const L = !!(wd.key_left || wd.key_a), Rt = !!(wd.key_right || wd.key_d)
const jumpKey = !!(wd.key_space || wd.key_up || wd.key_w)
const dir = (Rt ? 1 : 0) - (L ? 1 : 0)
// ── physics: semi-implicit Euler against the height field ──
const RAD = 14, GRAV = 780, REST = 0.20, VMAX = 460
G.vy -= GRAV * step
// drive: torque along the ground when grounded, faint air control aloft
const ice = iceAt(G.x)
const driveA = G.grounded ? mixN(900, 260, ice) : 240
G.vx += dir * driveA * step
// wind couples to the ball — hardest when airborne (nothing to grip)
G.vx += (G.wind - G.vx) * (G.grounded ? 0.05 : 0.28) * step
// an iron ball has a terminal roll — past it, crests become launch ramps
if (G.vx > VMAX) G.vx = VMAX; else if (G.vx < -VMAX) G.vx = -VMAX
G.x += G.vx * step
G.y += G.vy * step
const h = terrainH(G.x)
const wasGrounded = G.grounded
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 // surface normal (y-up)
G.x += nx * pen * ny; G.y += ny * pen // push out along the normal
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)
wd.__play_sound = [{ frequency: 52 + Math.min(40, impact * 0.06), duration: 0.16, volume: Math.min(0.3, impact / 1400), type: 'sine' }]
}
}
// tangential friction — the tarn barely takes any
const tx = ny, ty = -nx
let vt = G.vx * tx + G.vy * ty
const mu = mixN(2.6, 0.12, ice) * (dir !== 0 ? 0.35 : 1) // driving fights friction
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
// jump: an impulse along the slope's NORMAL — hills aim your leap
if (jumpKey && !G.wasDown) {
G.vx += nx * 310; G.vy += ny * 310
G.grounded = 0
G.jumpP = 1
wd.__play_sound = [{ frequency: 84, duration: 0.15, volume: 0.2, type: 'triangle' }]
}
} else { G.airT += step }
G.wasDown = jumpKey
// soft walls hold the fell
if (G.x < -120) { G.x = -120; G.vx = Math.max(0, G.vx) }
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 } // never lost
G.roll += (G.vx / RAD) * step
G.jumpP = Math.max(0, G.jumpP - step * 3)
G.landP = Math.max(0, G.landP - step * 2.5)
// ── the ember: wind starves it, speed stokes it, beacons restore it ──
const speed = Math.abs(G.vx)
const stokeIn = sm01((speed - 150) / 260)
G.stoke = mixN(G.stoke, stokeIn, 1 - Math.exp(-4 * step))
// three currents: wind starves, speed stokes, and STILLNESS rekindles — a
// resting cinder's crust insulates, so going cold is never a dead end
const rest = (G.grounded && speed < 80) ? 0.014 : 0
G.heat = Math.max(0.12, Math.min(1,
G.heat - step * (0.010 + 0.030 * gustEnv) + (stokeIn * 0.06 + rest) * step))
// ── beacons ──
G.hungry = 0
let litCount = 0
for (let i = 0; i < 5; i++) {
const bx = BEACONS[i]
const near = Math.abs(G.x - bx) < 60 && Math.abs(G.y - (terrainH(bx) + 34)) < 90
if (!G.lit[i] && near) {
// fire passes only to a steady hand: burn hot, but STOP to give it
if (G.heat > 0.3 && Math.abs(G.vx) < 150 && G.grounded) {
G.lit[i] = 1
G.heat = 1
wd.__play_sound = [
{ frequency: 392, duration: 0.5, volume: 0.22, type: 'sine' },
{ frequency: 494, duration: 0.6, volume: 0.18, type: 'sine' },
{ frequency: 588, duration: 0.9, volume: 0.15, type: 'sine' },
]
try { if (sim.trigger('cf_lit_' + i, true)) sim.completeChapter() } catch (e) {}
} else G.hungry = 1 // the rune flickers: too cold, or moving too fast to hand over
}
if (G.lit[i]) { G.heat = Math.max(G.heat, Math.abs(G.x - bx) < 120 ? 0.9 : G.heat); 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
wd.__play_sound = [
{ frequency: 196, duration: 2.2, volume: 0.2, type: 'sine' },
{ frequency: 294, duration: 2.2, volume: 0.16, type: 'sine' },
{ frequency: 392, duration: 2.6, volume: 0.14, type: 'sine' },
{ frequency: 588, duration: 3.0, volume: 0.10, type: 'sine' },
]
}
// ── hold R two seconds: the fell goes cold again (replayable demo) ──
G.resetT = (wd.key_r ? (G.resetT || 0) + step : 0)
if (G.resetT > 2) {
G.lit = [0, 0, 0, 0, 0]; G.litAnim = [0, 0, 0, 0, 0]
G.dawn = 0; G.warmth = 0; G.heat = 1; G.dawnSung = false; G.resetT = 0
G.x = 0; G.y = terrainH(0) + 20; G.vx = 0; G.vy = 0
wd.__trig = null
wd.__play_sound = [{ frequency: 110, duration: 0.6, volume: 0.15, type: 'sine' }]
}
// ── camera: leads the motion, breathes with the hill ──
const lookX = G.x + G.vx * 0.30
// the camera belongs to the FELL, not the ball — terrain-weighted, so leaps
// rise on screen instead of dragging the whole horizon up with them
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(32).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] = G.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] = G.jumpP; U[19] = G.landP; U[20] = G.stoke
U[21] = finale ? 1 : 0; U[22] = iceAt(G.x); U[23] = G.hungry
U[24] = G.intro
wd.gpuUniforms = U
if (wd.cfDebug) wd.hud = [{ id: 'cf_dbg', type: 'text', x: '1%', y: '97%',
text: 'x=' + Math.round(G.x) + ' vx=' + Math.round(G.vx) + ' heat=' + G.heat.toFixed(2) + ' lit=' + G.lit.join(''),
color: '#050505', fontSize: '2px' }] // invisible on camera, readable by a script
else if (wd.hud && wd.hud.length && wd.hud[0] && wd.hud[0].id === 'cf_dbg') wd.hud = []
} catch (e) { /* the fell keeps its weather */ }