cartridge.cafe · open source world
NOCTURNE DISTRICT
HOW TO PLAY
NOCTURNE DISTRICT — the city as a pinball table CLICK & HOLD — draw the ball to your hand · it grabs when it arrives DRAG & RELEASE — a real throw W / S — raise and lower the ball Red interceptors run the street at you. Smash them with a FAST ball: dull-red drifters weave · crimson dodgers juke your throws · blazing hunters burn for the line A slow held ball in traffic gets RAMMED out of your hand. Three hearts. Every ship that slips past costs one; three gone resets the night. Clear ten ships to end a wave — a heart comes back, and the street gets meaner.
1 visual shader · 0 shader modules · 2 step hooks · 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 · nc_city
const NC_CELL: f32 = 9.0;
struct NCHit { t: f32, kind: i32, n: vec3f, cell: vec2f };
fn nc_mode() -> f32 { return uni(15); } // 0 endless · 1 district
fn nc_isStreet(cell: vec2f) -> bool {
if (nc_mode() > 0.5) { // district: finite table, open beyond
if (cell.x < -5.5 || cell.x > 4.5 || cell.y < -5.5 || cell.y > 4.5) { return true; }
}
if (cell.x == 0.0) { return true; }
if (abs(cell.y - 5.0 * round(cell.y / 5.0)) < 0.5) { return true; }
return false;
}
fn nc_dims(cell: vec2f) -> vec3f {
let r = hash22(cell);
let h = 6.0 + r.x * 40.0;
let bw = NC_CELL * 0.5 * (0.72 + r.y * 0.20);
let bd = NC_CELL * 0.5 * (0.72 + fract(r.y * 7.31 + r.x) * 0.20);
return vec3f(h, bw, bd);
}
fn nc_ball() -> vec3f { return vec3f(uni(1), uni(2), uni(3)); }
fn nc_sphereT(ro: vec3f, rd: vec3f, c: vec3f, r: f32) -> f32 {
let oc = ro - c;
let b = dot(oc, rd);
let cc = dot(oc, oc) - r * r;
let h = b * b - cc;
if (h < 0.0) { return -1.0; }
return -b - sqrt(h);
}
fn nc_traceCity(ro: vec3f, rd: vec3f, maxd: f32, maxCells: i32) -> NCHit {
var best: NCHit;
best.t = maxd; best.kind = -1; best.n = vec3f(0.0, 1.0, 0.0); best.cell = vec2f(0.0, 0.0);
if (rd.y < -1.0e-4) {
let tg = -ro.y / rd.y;
if (tg > 0.0 && tg < best.t) { best.t = tg; best.kind = 0; best.n = vec3f(0.0, 1.0, 0.0); }
}
var cell = floor(vec2f(ro.x, ro.z) / NC_CELL);
let stepDir = sign(vec2f(rd.x, rd.z));
let cellMin = cell * NC_CELL;
var tMaxX = 1.0e9; var tMaxZ = 1.0e9;
var tDeltaX = 1.0e9; var tDeltaZ = 1.0e9;
if (abs(rd.x) > 1.0e-6) {
let bx = select(cellMin.x, cellMin.x + NC_CELL, rd.x > 0.0);
tMaxX = (bx - ro.x) / rd.x; tDeltaX = NC_CELL / abs(rd.x);
}
if (abs(rd.z) > 1.0e-6) {
let bz = select(cellMin.y, cellMin.y + NC_CELL, rd.z > 0.0);
tMaxZ = (bz - ro.z) / rd.z; tDeltaZ = NC_CELL / abs(rd.z);
}
var tEnter = 0.0;
for (var k = 0; k < maxCells; k = k + 1) {
if (!nc_isStreet(cell)) {
let dm = nc_dims(cell);
let c = vec3f((cell.x + 0.5) * NC_CELL, dm.x * 0.5, (cell.y + 0.5) * NC_CELL);
let half = vec3f(dm.y, dm.x * 0.5, dm.z);
let m = 1.0 / rd;
let nrm = m * (ro - c);
let kk = abs(m) * half;
let t1 = -nrm - kk;
let t2 = -nrm + kk;
let tN = max(max(t1.x, t1.y), t1.z);
let tF = min(min(t2.x, t2.y), t2.z);
if (tF >= tN && tF > 0.0) {
let th = select(tN, 0.001, tN < 0.0);
if (th < best.t) {
best.t = th; best.kind = 1; best.cell = cell;
best.n = -sign(rd) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz);
}
}
}
if (tMaxX < tMaxZ) { tEnter = tMaxX; tMaxX = tMaxX + tDeltaX; cell.x = cell.x + stepDir.x; }
else { tEnter = tMaxZ; tMaxZ = tMaxZ + tDeltaZ; cell.y = cell.y + stepDir.y; }
if (tEnter > best.t || tEnter > maxd) { break; }
}
return best;
}
fn nc_sky(rd: vec3f) -> vec3f {
let y = max(rd.y, 0.0);
var c = mix(vec3f(0.050, 0.060, 0.110), vec3f(0.008, 0.012, 0.035), pow(y, 0.5));
c += vec3f(0.28, 0.13, 0.05) * exp(-max(rd.y, 0.0) * 5.5);
let md = normalize(vec3f(-0.45, 0.42, 1.0));
let m = max(dot(rd, md), 0.0);
c += vec3f(0.90, 0.92, 0.98) * pow(m, 3000.0) * 3.0;
c += vec3f(0.30, 0.34, 0.44) * pow(m, 30.0) * 0.35;
if (rd.y > 0.05) {
let sp = rd.xz / (rd.y + 0.2);
let stars = hash21(floor(sp * 40.0));
c += vec3f(step(0.995, stars)) * 0.8 * smoothstep(0.05, 0.30, rd.y);
}
return c;
}
fn nc_facade(p: vec3f, n: vec3f, cell: vec2f) -> vec3f {
let baseCol = vec3f(0.018, 0.022, 0.035);
if (abs(n.y) > 0.55) { return vec3f(0.012, 0.016, 0.026); }
var u = p.z;
if (abs(n.z) > abs(n.x)) { u = p.x; }
let WU = 1.05; let WV = 1.35;
let cu = floor(u / WU);
let cv = floor((p.y - 0.6) / WV);
let fu = fract(u / WU);
let fv = fract((p.y - 0.6) / WV);
let inWin = step(0.18, fu) * step(fu, 0.82) * step(0.20, fv) * step(fv, 0.86) * step(0.6, p.y);
let r = hash21(cell * 7.0 + vec2f(cu * 1.3, cv * 2.1));
let lit = step(0.48, r);
let cyan = step(0.86, hash21(cell + vec2f(cu, cv) * 0.7));
let wc = mix(vec3f(1.70, 1.20, 0.62), vec3f(0.45, 1.50, 1.80), cyan);
return baseCol + wc * lit * inWin * 2.4;
}
fn nc_ballBody(flash: f32) -> vec3f {
return mix(vec3f(3.0, 1.7, 0.8), vec3f(7.0, 5.5, 4.0), flash);
}
fn nc_ballGlow(ro: vec3f, rd: vec3f, ball: vec3f, sceneT: f32, flash: f32) -> vec3f {
let oc = ball - ro;
let tca = dot(oc, rd);
if (tca < 0.0) { return vec3f(0.0); }
let d2x = dot(oc, oc) - tca * tca;
// occluded: a faint pulse bleeds through the towers — the toy is never lost
if (tca > sceneT + 2.0) { return vec3f(1.3, 0.55, 0.28) * exp(-d2x * 0.030) * (0.12 + flash * 0.3); }
let d2 = dot(oc, oc) - tca * tca;
let trail = ball - normalize(vec3f(uni(5), uni(6), uni(7)) + vec3f(1.0e-5)) * 2.4;
let oc2 = trail - ro;
let tca2 = dot(oc2, rd);
var g = exp(-d2 * 0.10) * (0.8 + flash * 1.6);
if (tca2 > 0.0 && tca2 < sceneT + 2.0) {
g += exp(-(dot(oc2, oc2) - tca2 * tca2) * 0.16) * 0.30;
}
return vec3f(1.7, 1.0, 0.5) * g;
}
fn nc_reflColor(ro: vec3f, rd: vec3f, ball: vec3f, flash: f32) -> array<vec3f, 2> {
let hb = nc_traceCity(ro, rd, 110.0, 20);
var sceneT = hb.t;
var c: vec3f;
let tc = nc_sphereT(ro, rd, ball, 1.3);
if (tc > 0.0 && tc < sceneT) {
c = nc_ballBody(flash);
sceneT = tc;
} else if (hb.kind == 1) {
let p = ro + rd * hb.t;
c = mix(nc_facade(p, hb.n, hb.cell), nc_sky(rd), 1.0 - exp(-hb.t * 0.02));
} else if (hb.kind == 0) {
c = vec3f(0.02, 0.022, 0.03);
} else {
c = nc_sky(rd);
}
return array<vec3f, 2>(c, nc_ballGlow(ro, rd, ball, sceneT, flash));
}
fn nc_rotY(p: vec3f, a: f32) -> vec3f {
let cc = cos(a); let ss = sin(a);
return vec3f(cc * p.x + ss * p.z, p.y, -ss * p.x + cc * p.z);
}
fn nc_rotZ(p: vec3f, a: f32) -> vec3f {
let cc = cos(a); let ss = sin(a);
return vec3f(cc * p.x - ss * p.y, ss * p.x + cc * p.y, p.z);
}
fn nc_shipSdf(q: vec3f) -> f32 {
// hull: a blade — sharp nose forward (-z), raked tail
let hull = (abs(q.x) * 1.5 + abs(q.y) * 2.6 + abs(q.z + 0.15) * 0.42) - 0.62;
var wq = q; wq.x = abs(wq.x);
let wing = max(max(wq.x - 1.5, abs(wq.y + 0.06 + wq.x * 0.10) - 0.045),
abs(wq.z - 0.45 - wq.x * 0.85) - max(0.5 - wq.x * 0.28, 0.05));
let fin = max(max(abs(q.x) - 0.05, abs(q.y - 0.35) - 0.34 + (q.z - 0.9) * 0.3), abs(q.z - 0.85) - 0.42);
return min(hull, min(wing, fin));
}
fn nc_shipHit(ro: vec3f, rd: vec3f, e: vec3f, yaw: f32, bank: f32, tmax: f32) -> f32 {
let oc = e - ro;
let tca = dot(oc, rd);
if (tca < 0.0 || tca > tmax + 3.0) { return -1.0; }
let d2 = dot(oc, oc) - tca * tca;
let R = 2.1;
if (d2 > R * R) { return -1.0; }
let thc = sqrt(R * R - d2);
var tt = max(tca - thc, 0.0);
let tEnd = tca + thc;
for (var st = 0; st < 22; st++) {
if (tt > tEnd || tt > tmax) { break; }
let q = nc_rotZ(nc_rotY(ro + rd * tt - e, yaw), bank);
let dd = nc_shipSdf(q);
if (dd < 0.02) { return tt; }
tt += max(dd * 0.85, 0.015);
}
return -1.0;
}
fn visual_nc_city(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
let p = vec2f(uv.x, -uv.y);
let t = time;
let district = nc_mode() > 0.5;
var camZ = uni(0);
if (camZ < 0.001) { camZ = t * 5.0; }
var ro: vec3f;
var look: vec3f;
if (district) { // street level, dollying down the path
ro = vec3f(4.5, 5.6, uni(26)); // the hook publishes the dolly (uni 26)
look = normalize(vec3f(0.0, -0.10, 1.0)); // hand math mirrors this exactly
} else { // the drive: low, forward, swaying
ro = vec3f(4.5 + sin(t * 0.08) * 1.2, 5.2 + sin(t * 0.20) * 0.30, camZ);
look = normalize(vec3f(sin(t * 0.05) * 0.12, -0.12, 1.0));
}
let rt = normalize(cross(vec3f(0.0, 1.0, 0.0), look));
let up = cross(look, rt);
var rd = normalize(rt * p.x + up * (p.y * 0.72) + look * 1.5);
rd = normalize(rd + vec3f(1.0e-4));
var ball = nc_ball();
if (ball.y < 0.01) { ball = vec3f(4.5, 2.0, camZ + 20.0); }
let flash = uni(4);
let horizon = nc_sky(vec3f(rd.x, max(rd.y, 0.02), rd.z));
var col = nc_sky(rd);
let hit = nc_traceCity(ro, rd, 220.0, 44);
var hitT = hit.t;
var kind = hit.kind;
let tc = nc_sphereT(ro, rd, ball, 1.3);
if (tc > 0.0 && tc < hitT) { hitT = tc; kind = 2; }
// interceptors — ships, not dots (pos uni 16.. · pose uni 28..)
var eHit = -1;
if (district) {
for (var ei = 0; ei < 3; ei++) {
let e = vec3f(uni(16 + ei * 3), uni(17 + ei * 3), uni(18 + ei * 3));
if (e.y < 0.01) { continue; }
let te = nc_shipHit(ro, rd, e, uni(28 + ei * 3), uni(29 + ei * 3), hitT);
if (te > 0.0 && te < hitT) { hitT = te; kind = 4; eHit = ei; }
}
}
if (kind == 4) {
let e = vec3f(uni(16 + eHit * 3), uni(17 + eHit * 3), uni(18 + eHit * 3));
let cf = uni(30 + eHit * 3);
let cls = i32(floor(cf));
let flare = fract(cf);
let q = nc_rotZ(nc_rotY(ro + rd * hitT - e, uni(28 + eHit * 3)), uni(29 + eHit * 3));
var trim = vec3f(2.0, 0.32, 0.16);
if (cls == 1) { trim = vec3f(2.6, 0.12, 0.10); }
if (cls == 2) { trim = vec3f(3.2, 0.55, 0.28); }
let e2 = vec2f(0.03, 0.0);
let nn = normalize(vec3f(
nc_shipSdf(q + e2.xyy) - nc_shipSdf(q - e2.xyy),
nc_shipSdf(q + e2.yxy) - nc_shipSdf(q - e2.yxy),
nc_shipSdf(q + e2.yyx) - nc_shipSdf(q - e2.yyx)));
let rim = pow(1.0 - abs(dot(nn, rd)), 2.0);
var sc = vec3f(0.015, 0.02, 0.03) + trim * rim * 0.65;
sc += trim * exp(-length(q - vec3f(0.0, 0.0, 0.95)) * 3.2) * (1.8 + flare * 4.0);
sc += trim * smoothstep(0.06, 0.0, abs(abs(q.x) - 1.05)) * smoothstep(0.5, 0.1, abs(q.z - 0.3)) * 0.8;
let blink = smoothstep(0.92, 1.0, sin(t * 5.0 + f32(eHit) * 2.3) * 0.5 + 0.5);
sc += vec3f(2.0) * blink * exp(-length(q - vec3f(0.0, 0.62, 0.9)) * 6.0);
col = sc;
} else if (kind == 2) {
let hp = ro + rd * hitT;
let nn = normalize(hp - ball);
let rim = pow(1.0 - max(dot(nn, -rd), 0.0), 2.0);
var bc = nc_ballBody(flash) + vec3f(1.2, 0.6, 0.25) * rim;
// too close to the eye, the ball turns to glass — it must never blind you
let fade = smoothstep(11.0, 4.5, hitT);
if (fade > 0.01) {
let ro2 = ro + rd * (hitT + 2.8);
let hit2 = nc_traceCity(ro2, rd, 200.0, 32);
var back = nc_sky(rd);
if (hit2.kind == 1) {
let hp2 = ro2 + rd * hit2.t;
back = nc_facade(hp2, hit2.n, hit2.cell);
back = mix(back, horizon, 1.0 - exp(-(hitT + 2.8 + hit2.t) * 0.014));
} else if (hit2.kind == 0) {
back = mix(vec3f(0.015, 0.017, 0.024), horizon, 0.5);
}
bc = mix(bc, back, fade * 0.8);
bc += vec3f(0.9, 0.5, 0.2) * rim * 0.35 * fade; // a warm glass edge stays
}
col = bc;
} else if (kind == 1) {
let hp = ro + rd * hitT;
var mcol = nc_facade(hp, hit.n, hit.cell);
mcol += nc_sky(hit.n) * 0.04;
col = mix(mcol, horizon, 1.0 - exp(-hitT * 0.014));
} else if (kind == 0) {
let hp = ro + rd * hitT;
let rip = vnoise(hp.xz * 0.8 + vec2f(0.0, t * 0.3)) - 0.5;
let rn = normalize(vec3f(rip * 0.08, 1.0, rip * 0.08));
let rrd = normalize(reflect(rd, rn) + vec3f(1.0e-4));
let refl = nc_reflColor(hp + rn * 0.05, rrd, ball, flash);
let fres = pow(1.0 - max(-rd.y, 0.0), 4.0);
var road = mix(vec3f(0.015, 0.017, 0.024), refl[0], clamp(fres * 0.7 + 0.15, 0.0, 1.0));
road += refl[1] * 0.65;
col = mix(road, horizon, 1.0 - exp(-hitT * 0.014));
}
col += nc_ballGlow(ro, rd, ball, hitT, flash);
if (district) {
// enemy glow + the red sting when one gets through
for (var gi = 0; gi < 3; gi++) {
let e = vec3f(uni(16 + gi * 3), uni(17 + gi * 3), uni(18 + gi * 3));
if (e.y < 0.01) { continue; }
let oc2 = e - ro;
let tca = dot(oc2, rd);
if (tca > 0.0 && tca < hitT + 3.0) {
let dsq = max(dot(oc2, oc2) - tca * tca, 0.0);
let cf2 = uni(30 + gi * 3);
var tr2 = vec3f(2.0, 0.32, 0.16);
if (i32(floor(cf2)) == 1) { tr2 = vec3f(2.6, 0.12, 0.10); }
if (i32(floor(cf2)) == 2) { tr2 = vec3f(3.2, 0.55, 0.28); }
col += tr2 * exp(-dsq * 0.55) * (0.22 + fract(cf2) * 0.5);
}
}
// a ship dies loud: an expanding shell in its own color
let bT = uni(40);
if (bT > 0.001) {
let bC = vec3f(uni(37), uni(38), uni(39));
var tr3 = vec3f(2.0, 0.32, 0.16);
if (i32(uni(41) + 0.5) == 1) { tr3 = vec3f(2.6, 0.12, 0.10); }
if (i32(uni(41) + 0.5) == 2) { tr3 = vec3f(3.2, 0.55, 0.28); }
let oc3 = bC - ro;
let tca3 = dot(oc3, rd);
if (tca3 > 0.0 && tca3 < hitT + 6.0) {
let dsq3 = max(dot(oc3, oc3) - tca3 * tca3, 0.0);
let shell = exp(-pow((sqrt(dsq3) - bT * 9.0) * 1.4, 2.0));
col += tr3 * shell * (1.0 - bT) * 2.4;
col += vec3f(3.0, 2.4, 1.6) * exp(-dsq3 * 2.0) * max(1.0 - bT * 3.0, 0.0) * 3.0;
}
}
// proximity alarm breathes at the bottom of the glass
col += vec3f(1.6, 0.12, 0.10) * uni(27) * pow(max(-p.y, 0.0), 2.0) * (0.6 + 0.4 * sin(t * 9.0));
col += vec3f(1.4, 0.06, 0.04) * uni(25) * pow(length(p), 2.0) * 0.8;
}
return vec4f(col, 1.0);
}— STEP HOOKS (JAVASCRIPT) —
hook · nc_core
Nocturne core — ROLLBACK ball watchdog (was re-centering ball to mid-screen); original ball physics restored + wd.ui glass hearts (meter pips) kept
try {
if (sim.worldData.__fresh) { delete sim.worldData.__fresh; delete sim.worldData.__ncB }
if (sim.worldData.__ncReset) { delete sim.worldData.__ncReset; delete sim.worldData.__ncB }
const wd = sim.worldData
if (!wd.__ncB || wd.__ncB.v !== 4) wd.__ncB = { v: 4, t: 0, alt: 3, valt: 0, flash: 0, red: 0, bounces: 0, kills: 0, through: 0, pvx: 0, pvy: 0, enemies: [null, null, null], spawnT: 2 }
const S = wd.__ncB
const pdt = Math.min(dt, 0.05)
const SND = [] // every voice this tick, flushed once at the end
for (const f of sim.fields.values()) {
const P = f.properties
const hx = P && (P.get ? P.get('hx') : P.hx)
if (typeof hx === 'number') {
f.transform.x = hx; f.transform.y = P.get ? P.get('hy') : P.hy
f.transform.vx = 0; f.transform.vy = 0
}
}
const ball = sim.fields.get('nc_ball_f')
if (ball) {
const T = ball.transform
if (!S.phys) S.phys = { x: 274, y: 320, vx: 30, vy: 20 }
const TB = S.phys
S.t += pdt
S.ramCd = Math.max(0, (S.ramCd || 0) - pdt)
// ── the dolly: down the street, then a slow pull back (150s round trip).
// One clock; the GPU reads it from uni(26) so eye and hand agree. ──
const cyc = (S.t % 150) / 150
const tri = cyc < 0.5 ? cyc * 2 : 2 - cyc * 2
const camWZ = -55 + 100 * tri
const camGY = camWZ * 4 + 256
const RO = [4.5, 5.6, camWZ], LOOK = [0, -0.09950, 0.99504], RT = [1, 0, 0], UP = [0, 0.99504, 0.09950]
const bh = S.alt + 1.3
const gx0 = wd.mouse_x, gy0 = wd.mouse_y
const downEdge = wd.mouse_down && !S.mHeld
S.mHeld = wd.mouse_down ? 1 : 0
// ── holding far ATTRACTS the ball; once it reaches your hand it is GRABBED
// and rides the cursor — release is a real throw ──
let hand = false
if (wd.mouse_down && typeof gx0 === 'number') {
const p2x = (gx0 / 256) - 1, p2y = 1 - (gy0 / 256)
const rd3 = [RT[0]*p2x + UP[0]*p2y*0.72 + LOOK[0]*1.5,
RT[1]*p2x + UP[1]*p2y*0.72 + LOOK[1]*1.5,
RT[2]*p2x + UP[2]*p2y*0.72 + LOOK[2]*1.5]
if (S.handBh === undefined) { S.handBh = Math.min(bh, 4.6) }
const tt2 = (S.handBh - RO[1]) / rd3[1]
if (tt2 > 0) {
hand = true
if (downEdge) SND.push({ frequency: 420, duration: 0.06, volume: 0.25, type: 'sine' })
const px2 = Math.max(255, Math.min(293, (RO[0] + rd3[0]*tt2) * 4 + 256))
const py2 = Math.max(camGY + 8, Math.min(486, (RO[2] + rd3[2]*tt2) * 4 + 256))
const dx = px2 - TB.x, dy = py2 - TB.y
const dd = Math.hypot(dx, dy) || 1
const cvx = (px2 - (S.hpx ?? px2)) / Math.max(pdt, 1e-4)
const cvy = (py2 - (S.hpy ?? py2)) / Math.max(pdt, 1e-4)
S.cvx = (S.cvx || 0) * 0.6 + cvx * 0.4
S.cvy = (S.cvy || 0) * 0.6 + cvy * 0.4
S.hpx = px2; S.hpy = py2
if ((S.ramCd || 0) <= 0 && (S.held || dd < 30)) {
S.held = 1
TB.x += (px2 - TB.x) * Math.min(1, pdt * 16)
TB.y += (py2 - TB.y) * Math.min(1, pdt * 16)
TB.vx = Math.max(-380, Math.min(380, S.cvx))
TB.vy = Math.max(-380, Math.min(380, S.cvy))
} else {
const pull = 1500 * Math.min(1, dd / 110)
TB.vx += (dx / dd) * pull * pdt
TB.vy += (dy / dd) * pull * pdt
TB.vx *= (1 - 1.7 * pdt); TB.vy *= (1 - 1.7 * pdt)
}
S.flash = Math.max(S.flash, 0.2)
}
} else if (S.mWas) {
SND.push({ frequency: 300, duration: 0.08, volume: 0.25, type: 'triangle' })
if (S.held && Math.hypot(TB.vx, TB.vy) > 90) S.free = 1
S.held = 0; S.hpx = undefined; S.hpy = undefined; S.cvx = 0; S.cvy = 0; S.handBh = undefined
}
S.mWas = wd.mouse_down ? 1 : 0
{
const sp2 = Math.hypot(TB.vx, TB.vy)
if (sp2 > 380) { TB.vx *= 380 / sp2; TB.vy *= 380 / sp2 }
}
const grabbed = hand
const dvx = TB.vx - S.pvx, dvy = TB.vy - S.pvy
if (dvx * dvx + dvy * dvy > 900) { S.flash = 1; S.bounces++ }
if (!grabbed) {
if (S.free) {
// thrown: momentum is yours; air and walls take it back gradually
TB.vx *= (1 - 0.22 * pdt); TB.vy *= (1 - 0.22 * pdt)
if (Math.hypot(TB.vx, TB.vy) < 70) S.free = 0
} else {
// leash: a soft spring toward the street ahead of the camera
TB.vx += ((274) - TB.x) * 0.9 * pdt + Math.sin(S.t * 0.9) * 6 * pdt
TB.vy += ((camGY + 60) - TB.y) * 0.35 * pdt
const sp = Math.hypot(TB.vx, TB.vy)
if (sp > 1 && sp < 45) { const k = 60 / sp; TB.vx *= k; TB.vy *= k }
if (sp >= 110) { const k = 110 / sp; TB.vx *= k; TB.vy *= k }
if (sp <= 1) { TB.vx = 60; TB.vy = 40 }
}
}
// ── altitude: W lifts, S sinks — your keys rule the sky, gravity rules the rest ──
const lift = (wd.key_w ? 1 : 0) - (wd.key_s ? 1 : 0)
if (lift !== 0) { S.valt += lift * 30 * pdt; S.pilot = 1.0 }
S.pilot = Math.max(0, (S.pilot || 0) - pdt)
if (S.pilot > 0 || grabbed) {
S.valt *= Math.exp(-2.5 * pdt) // a steady hand hovers
} else {
S.valt -= 9 * pdt
if (S.alt < 0.1 && Math.abs(S.valt) < 2.5) S.valt = 7
}
S.alt += S.valt * pdt
if (S.alt < 0) { S.alt = 0; S.valt = Math.abs(S.valt) * 0.9; S.flash = Math.max(S.flash, 0.7) }
if (S.alt > 5) { S.alt = 5; S.valt = Math.min(S.valt, 0) }
S.pvx = TB.vx; S.pvy = TB.vy
S.flash *= Math.exp(-4 * pdt)
// integrate
TB.x += TB.vx * pdt
TB.y += TB.vy * pdt
// ── the street holds the ball: canyon walls left/right, view range ahead ──
const rest = S.free ? 0.75 : 1
if (TB.x < 257) { TB.x = 257; TB.vx = Math.abs(TB.vx) * rest }
if (TB.x > 291) { TB.x = 291; TB.vx = -Math.abs(TB.vx) * rest }
const zlo = camGY + 8, zhi = Math.min(camGY + 175, 486)
if (TB.y < zlo) { TB.y = zlo; TB.vy = Math.abs(TB.vy) * rest }
if (TB.y > zhi) { TB.y = zhi; TB.vy = -Math.abs(TB.vy) * rest }
// towers stay solid
{
const RB = 6
for (const f of sim.fields.values()) {
const P = f.properties
const hx2 = P && (P.get ? P.get('hx') : P.hx)
if (typeof hx2 !== 'number' || f.id === 'nc_ball_f' || f.id === 'nc_city_f') continue
const hw = f.w / 2, hh3 = f.h / 2
const qx = Math.max(f.transform.x - hw, Math.min(TB.x, f.transform.x + hw))
const qy = Math.max(f.transform.y - hh3, Math.min(TB.y, f.transform.y + hh3))
const ddx = TB.x - qx, ddy = TB.y - qy
const d2 = ddx*ddx + ddy*ddy
if (d2 < RB*RB && d2 > 1e-6) {
const dd = Math.sqrt(d2)
const nx = ddx/dd, ny = ddy/dd
TB.x = qx + nx*RB; TB.y = qy + ny*RB
const vn = TB.vx*nx + TB.vy*ny
if (vn < 0) {
const kr = S.free ? 1.75 : 1.9
TB.vx -= kr*vn*nx; TB.vy -= kr*vn*ny
if (-vn > 40) { S.flash = 1; S.bounces++ }
}
}
}
}
// ── the interceptors: three ship classes run the street ──
// DRIFTER (cyan) weaves · DODGER (amber) jukes your throws ·
// HUNTER (magenta) guns for the camera line and burns hard near it
S.spawnT -= pdt
for (let i = 0; i < 3; i++) {
const E = S.enemies[i]
if (!E) {
if (S.spawnT <= 0) {
const heat = Math.min(1, (S.kills + S.through * 2) / 24)
const roll = Math.random()
const cls = roll < 0.24 + heat * 0.3 ? 2 : (roll < 0.6 ? 1 : 0)
S.enemies[i] = { x: 258 + Math.random() * 32, y: Math.min(camGY + 160 + Math.random() * 25, 484),
a: 1.6 + Math.random() * 2.2, sp: (cls === 2 ? 19 : 13) + Math.random() * 10,
ph: Math.random() * 6.28, cls, lvx: 0, flare: 0, dcd: 0, yaw: 0, bank: 0 }
S.spawnT = Math.max(1.6, 2.5 + Math.random() * 2.5 - heat * 1.2)
if (cls === 2) SND.push({ frequency: 68, duration: 0.35, volume: 0.11, type: 'sawtooth' })
}
continue
}
E.dcd = Math.max(0, E.dcd - pdt)
E.flare *= Math.exp(-3 * pdt)
let sp = E.sp
if (E.cls === 2) {
const near = Math.max(0, 1 - (E.y - camGY) / 120)
sp *= 1 + near * 1.3
E.flare = Math.max(E.flare, near * 0.8)
}
E.y -= sp * pdt
const wx2 = Math.sin(S.t * 1.3 + E.ph) * (E.cls === 0 ? 7 : 4)
if (E.cls >= 1 && E.dcd <= 0) {
const bd = Math.hypot(TB.x - E.x, TB.y - E.y)
const closing = ((E.x - TB.x) * TB.vx + (E.y - TB.y) * TB.vy) / Math.max(bd, 1)
if (bd < 34 && closing > 70) {
E.lvx = (TB.x > E.x ? -1 : 1) * (E.cls === 2 ? 55 : 75)
E.dcd = 1.1
E.flare = 1
SND.push({ frequency: 480, duration: 0.07, volume: 0.12, type: 'triangle' },
{ frequency: 700, duration: 0.09, volume: 0.10, type: 'triangle' })
}
}
E.lvx *= Math.exp(-2.2 * pdt)
E.x = Math.max(257, Math.min(291, E.x + (wx2 + E.lvx) * pdt))
E.yaw = Math.max(-0.6, Math.min(0.6, -(wx2 + E.lvx) / 55))
E.bank = Math.max(-0.8, Math.min(0.8, (wx2 + E.lvx) / 45))
const hd = Math.hypot(TB.x - E.x, TB.y - E.y)
if (hd < 11 && Math.abs(bh - E.a) < 2.6 && S.held && Math.hypot(TB.vx, TB.vy) <= 60) {
// camping in traffic: the ship RAMS the ball out of your hand — a trade
S.held = 0; S.free = 1; S.ramCd = 0.8
TB.vx = (TB.x - E.x) * 18 + E.lvx * 2
TB.vy = 160
S.flash = 1; S.red = Math.max(S.red || 0, 0.6)
SND.push({ frequency: 180, duration: 0.25, volume: 0.4, type: 'sawtooth' },
{ frequency: 120, duration: 0.35, volume: 0.3, type: 'square' })
S.enemies[i] = null
continue
}
if (hd < 10 && Math.abs(bh - E.a) < 2.6 && Math.hypot(TB.vx, TB.vy) > 60) {
S.kills++; S.flash = 1
S.boom = { x: E.x, a: E.a, y: E.y, t: 0, cls: E.cls }
SND.push({ frequency: 660, duration: 0.22, volume: 0.4, type: 'square' })
if (E.cls === 2) SND.push({ frequency: 880, duration: 0.3, volume: 0.22, type: 'square' },
{ frequency: 1320, duration: 0.4, volume: 0.15, type: 'sine' })
S.enemies[i] = null
continue
}
if (E.y < camGY + 6) {
S.through++; S.red = 1
SND.push({ frequency: 90, duration: 0.4, volume: 0.5, type: 'sawtooth' })
S.enemies[i] = null
if (S.through >= 3) {
SND.push({ frequency: 220, duration: 0.3, volume: 0.5, type: 'sawtooth' },
{ frequency: 110, duration: 0.6, volume: 0.5, type: 'sawtooth' },
{ frequency: 55, duration: 1.0, volume: 0.5, type: 'sine' })
wd.__ncReset = true
}
}
}
if (S.boom) { S.boom.t += pdt / 0.7; if (S.boom.t >= 1) S.boom = null }
// ── the district's voice: engine hum, street heartbeat, proximity sonar ──
S.audHum = (S.audHum || 0) - pdt
const spd = Math.hypot(TB.vx, TB.vy)
if (S.audHum <= 0) {
S.audHum = 0.34
SND.push({ frequency: 46 + spd * 0.45, duration: 0.3, volume: 0.045 + (spd / 380) * 0.07, type: 'sine' })
if (S.held) SND.push({ frequency: 92 + spd * 0.9, duration: 0.18, volume: 0.035, type: 'triangle' })
}
S.audBeat = (S.audBeat || 0) - pdt
if (S.audBeat <= 0) {
S.audBeat = 1.9
SND.push({ frequency: 41, duration: 0.5, volume: 0.05 + tri * 0.03, type: 'sine' })
}
let danger = 0, dCls = 0
for (const E of S.enemies) if (E) {
const d3 = Math.max(0, 1 - (E.y - (camGY + 6)) / 70)
if (d3 > danger) { danger = d3; dCls = E.cls }
}
S.danger = danger
// ── the crossing: survive to the end of the buildings — the real win ──
if (cyc > 0.495 && !S.crossed) {
S.crossed = 1
S.wave = (S.wave || 1) + 1
S.through = 0 // full hearts: you crossed the district
S.banner = { text: 'DISTRICT CROSSED - THE NIGHT IS YOURS', t: 4 }
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cafe:caption', {
detail: { text: 'district crossed - the night is yours', kind: 'tuned' } }))
SND.push({ frequency: 392, duration: 0.35, volume: 0.32, type: 'triangle' },
{ frequency: 523, duration: 0.4, volume: 0.30, type: 'triangle' },
{ frequency: 659, duration: 0.5, volume: 0.28, type: 'triangle' },
{ frequency: 1046, duration: 0.9, volume: 0.26, type: 'sine' })
}
if (cyc < 0.05 && S.crossed) { S.crossed = 0 }
// ── the night comes in waves: clear ten ships and the district yields a breath ──
if (S.kills >= (S.wave || 1) * 10) {
S.wave = (S.wave || 1) + 1
S.through = Math.max(0, S.through - 1)
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cafe:caption', {
detail: { text: 'WAVE ' + (S.wave - 1) + ' CLEARED - the district yields', kind: 'tuned' } }))
S.banner = { text: 'WAVE ' + (S.wave - 1) + ' CLEARED', t: 3 }
SND.push({ frequency: 523, duration: 0.3, volume: 0.3, type: 'triangle' },
{ frequency: 784, duration: 0.4, volume: 0.28, type: 'triangle' },
{ frequency: 1046, duration: 0.6, volume: 0.25, type: 'sine' })
}
S.audSon = (S.audSon || 0) - pdt
if (danger > 0.12 && S.audSon <= 0) {
S.audSon = 0.95 - danger * 0.75
SND.push({ frequency: dCls === 2 ? 1170 : (dCls === 1 ? 990 : 880), duration: 0.05,
volume: 0.045 + danger * 0.07, type: 'square' })
}
S.red = (S.red || 0) * Math.exp(-2.5 * pdt)
const hearts = Math.max(0, 3 - S.through)
if (S.banner) { S.banner.t -= pdt; if (S.banner.t <= 0) S.banner = null }
// UI through the SOLVER (wd.ui glass), never the deprecated wd.hud. Hearts
// are three meter pips (the ui glyph font is ASCII - a heart char renders as
// nothing). Re-declare only when content changes; rev bump = re-solve.
if (wd.hud) delete wd.hud
const uiSig = hearts + '|' + (S.wave || 1) + '|' + S.kills + '|' + (S.banner ? S.banner.text : '')
if (S.uiSig !== uiSig) {
S.uiSig = uiSig
S.uiRev = (S.uiRev || 0) + 1
wd.ui = { rev: S.uiRev, root: [
{ id: 'nc_status', kind: 'panel', anchor: { vx: 0, vy: 0 }, align: 'tl', dx: 12, dy: 12, glass: true, children: [
{ id: 'nc_hearts', kind: 'row', gap: 6, children: [0, 1, 2].map(i => (
{ id: 'nc_h' + i, kind: 'meter', value: i < hearts ? 1 : 0, hue: '#ff4a5e', w: 26 }
)) },
{ id: 'nc_wave', kind: 'text', text: 'WAVE ' + (S.wave || 1) + ' \u00b7 KILLS ' + S.kills, fontSize: 11, color: 'rgba(255,255,255,0.62)' },
] },
...(S.banner ? [{ id: 'nc_banner', kind: 'panel', anchor: { gx: 256, gy: 116 }, align: 'tc', glass: true, children: [
{ id: 'nc_banner_t', kind: 'text', text: S.banner.text, fontSize: 18, color: '#ffd682' },
] }] : []),
] }
}
// park the field at the ball's projected screen position (its true face)
{
const w3 = [(TB.x - 256) / 4, bh, (TB.y - 256) / 4]
const v3 = [w3[0]-RO[0], w3[1]-RO[1], w3[2]-RO[2]]
const f3 = v3[0]*LOOK[0] + v3[1]*LOOK[1] + v3[2]*LOOK[2]
S.esx = ((v3[0]*RT[0]+v3[1]*RT[1]+v3[2]*RT[2]) * 1.5 / f3 + 1) * 256
S.esy = (1 - (v3[0]*UP[0]+v3[1]*UP[1]+v3[2]*UP[2]) * 1.5 / (0.72 * f3)) * 256
T.x = S.esx; T.y = S.esy; T.vx = 0; T.vy = 0
}
const wx = (S.phys.x - 256) / 4
const wz = (S.phys.y - 256) / 4
const eu = []
const ep = []
for (let i = 0; i < 3; i++) {
const E = S.enemies[i]
eu.push(E ? (E.x - 256) / 4 : 0, E ? E.a : 0, E ? (E.y - 256) / 4 : 0)
ep.push(E ? E.yaw || 0 : 0, E ? E.bank || 0 : 0, E ? E.cls + Math.min(0.95, E.flare || 0) : 0)
}
const B3 = S.boom
wd.gpuUniforms = [0, wx, S.alt + 1.3, wz, S.flash, T.vx / 4, S.valt, T.vy / 4,
0, 0, 0, 0, 0, 0, 0, 1, ...eu, S.red, camWZ, S.danger || 0, ...ep,
B3 ? (B3.x - 256) / 4 : 0, B3 ? B3.a : 0, B3 ? (B3.y - 256) / 4 : 0,
B3 ? Math.min(0.999, B3.t) : 0, B3 ? B3.cls : 0]
if (SND.length) wd.__play_sound = SND
ball.name = 'Ball — ' + S.kills + ' down · ' + S.through + ' got through'
}
} catch (e) { /* keep the sim alive */ }
hook · nc_perf_probe
Frame-cadence probe at worldData.__perf.
try {
const wd = sim.worldData
if (!wd.__perf || wd.__perf.v !== 3) wd.__perf = { v: 3, n: 0, sum: 0, max: 0, o20: 0, o50: 0, fN: 0, fSum: 0, fMax: 0, fO20: 0, uN: 0 }
const P = wd.__perf
if (dt > 0 && dt < 1) {
P.n++; P.sum += dt
if (dt > P.max) P.max = dt
if (dt > 0.020) P.o20++
if (dt > 0.050) P.o50++
const foc = (typeof document !== 'undefined') && document.hasFocus()
if (foc) { P.fN++; P.fSum += dt; if (dt > P.fMax) P.fMax = dt; if (dt > 0.020) P.fO20++ }
else { P.uN++ }
P.fpsAll = Math.round(P.n / P.sum)
P.fpsFocused = P.fN > 5 ? Math.round(P.fN / P.fSum) : 0
P.fpsUnfocused = P.uN > 5 ? Math.round(P.uN / (P.sum - P.fSum)) : 0
P.pctUnfocused = Math.round(100 * P.uN / P.n)
}
} catch (e) { /* keep the sim alive */ }