cartridge.cafe · open source world

juggling head game

HOW TO PLAY

MOUSE — move both hands at the bottom Heads start in the air. Hit them with the TOP of your hands (breakout-style) to launch upward. Each launch adds to your juggle streak. A head that hits the floor respawns at the centre and resets the streak. Beat your best consecutive juggles.

built by: Claude (Cursor)
2 visual shaders · 0 shader modules · 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 · juggle


fn jn_rot2(a: f32) -> mat2x2<f32> {
  let s = sin(a);
  let c = cos(a);
  return mat2x2<f32>(vec2f(c, s), vec2f(-s, c));
}

fn jn_hash21(p: vec2f) -> f32 {
  var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
  p3 = p3 + dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

fn jn_mask(d: f32, aa: f32) -> f32 {
  return 1.0 - smoothstep(-aa, aa, d);
}

fn jn_sdSegment(p: vec2f, a: vec2f, b: vec2f) -> f32 {
  let pa = p - a;
  let ba = b - a;
  let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}

fn jn_lineSeg(p: vec2f, a: vec2f, b: vec2f, w: f32) -> f32 {
  let d = jn_sdSegment(p, a, b);
  return 1.0 - smoothstep(w, w + 2.2, d);
}

fn visual_juggle(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let pc = vec2f(256.0 + uv.x * 256.0, 256.0 + uv.y * 256.0);
  let handL = vec2f(uni(0), uni(1));
  let handRgt = vec2f(uni(2), uni(3));
  let head0 = vec2f(uni(4), uni(5));
  let head1 = vec2f(uni(6), uni(7));
  let head2 = vec2f(uni(8), uni(9));
  let heads = array<vec2f, 3>(head0, head1, head2);
  let vivid = vec3f(1.0, 0.0, 0.06);

  var col = vec3f(0.09, 0.08, 0.11);
  let gx = i32(clamp(floor(pc.x * 19.0 / 512.0), 0.0, 18.0));
  let gy = i32(clamp(floor(pc.y * 12.0 / 512.0), 0.0, 11.0));
  let gix = gy * 19 + gx;
  if (uni(28 + gix) > 0.12) {
    col = vivid;
  }

  col += vec3f(0.02, 0.015, 0.02) * jn_hash21(pc * 0.08);

  let floorY = 486.0;
  let floorBand = 1.0 - smoothstep(floorY - 2.0, floorY + 8.0, pc.y);
  col = mix(col, vec3f(0.16, 0.12, 0.11), floorBand * 0.85);

  let bodyC = vec2f(uni(26), uni(27));
  let shL = vec2f(uni(22), uni(23));
  let shR = vec2f(uni(24), uni(25));
  let elL = vec2f(uni(18), uni(19));
  let elR = vec2f(uni(20), uni(21));

  let chestC = bodyC + vec2f(0.0, -14.0);
  let chestD = length((pc - chestC) / vec2f(72.0, 38.0)) - 1.0;
  let pelvisD = length((pc - bodyC) / vec2f(58.0, 48.0)) - 1.0;
  let torso = clamp(jn_mask(chestD, 1.6) + jn_mask(pelvisD, 1.5), 0.0, 1.0);
  let torsoTop = step(pc.y, bodyC.y - 38.0);
  col = mix(col, vec3f(0.30, 0.26, 0.32), torso * torsoTop * 0.94);
  let collar = 1.0 - smoothstep(0.010, 0.028, abs(pc.y - (bodyC.y - 36.0)));
  col += vec3f(0.40, 0.36, 0.42) * collar * (1.0 - smoothstep(38.0, 44.0, abs(pc.x - bodyC.x))) * 0.42;

  let armCol = vec3f(0.42, 0.36, 0.40);
  let shPad = jn_mask(length(pc - shL) - 11.0, 1.4) + jn_mask(length(pc - shR) - 11.0, 1.4);
  let uaL = jn_lineSeg(pc, shL, elL, 10.0);
  let faL = jn_lineSeg(pc, elL, handL, 8.5);
  let uaR = jn_lineSeg(pc, shR, elR, 10.0);
  let faR = jn_lineSeg(pc, elR, handRgt, 8.5);
  col = mix(col, armCol, clamp(shPad + uaL + faL + uaR + faR, 0.0, 1.0) * 0.92);

  let hRad = 26.0;
  let headR = 17.0;

  for (var hi = 0; hi < 3; hi++) {
    let hpos = heads[hi];
    let hd = length(pc - hpos) - headR;
    let hmask = jn_mask(hd, 1.2);
    let skin = vec3f(0.96, 0.78, 0.62);
    col = mix(col, skin, hmask * 0.95);
    let eyeL = jn_mask(length(pc - (hpos + vec2f(-6.0, -3.0))) - 3.2, 0.8);
    let eyeR = jn_mask(length(pc - (hpos + vec2f(6.0, -3.0))) - 3.2, 0.8);
    col = mix(col, vec3f(0.12, 0.08, 0.08), max(eyeL, eyeR));
    let mouthD = length((pc - hpos) / vec2f(8.0, 5.0) - vec2f(0.0, 2.5)) - 1.0;
    col = mix(col, vec3f(0.55, 0.12, 0.12), jn_mask(mouthD, 0.6) * 0.7);
    col += vec3f(1.0, 0.35, 0.2) * (1.0 - smoothstep(0.0, 3.0, abs(hd))) * 0.22;
  }

  let gdL = length(pc - handL) - hRad;
  let gmaskL = jn_mask(gdL, 1.1);
  col = mix(col, vec3f(0.88, 0.72, 0.58), gmaskL);
  col += vec3f(1.0, 0.9, 0.75) * (1.0 - smoothstep(0.0, 2.5, abs(gdL))) * 0.18;
  let gdR = length(pc - handRgt) - hRad;
  let gmaskR = jn_mask(gdR, 1.1);
  col = mix(col, vec3f(0.88, 0.72, 0.58), gmaskR);
  col += vec3f(1.0, 0.9, 0.75) * (1.0 - smoothstep(0.0, 2.5, abs(gdR))) * 0.18;

  return vec4f(col, 1.0);
}

visual · juice_plate

fn visual_juice_plate(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  return vec4f(0.0);
}

— STEP HOOKS (JAVASCRIPT) —

hook · juggle

revert uniform faces
const wd = sim.worldData;
const inp = wd.input || { pointer: {} };
const ptr = inp.pointer || {};

wd.__resets = ['__g'];
if (wd.__fresh) delete wd.__fresh;

function spawnHeadInAir(head, slot) {
  const offsets = [-44, 0, 44];
  const off = offsets[slot] || 0;
  head.x = 256 + off;
  head.y = 188 + slot * 18;
  head.vx = off * 0.55;
  head.vy = -(105 + slot * 18);
}

if (!wd.__g) {
  wd.__g = {
    body: { x: 256, y: 452 },
    handL: { x: 170, y: 430 },
    handR: { x: 342, y: 430 },
    heads: [{ x: 0, y: 0, vx: 0, vy: 0 }, { x: 0, y: 0, vx: 0, vy: 0 }, { x: 0, y: 0, vx: 0, vy: 0 }],
    lastH: [
      { x: 212, y: 188 },
      { x: 256, y: 206 },
      { x: 300, y: 224 },
    ],
    elbowL: { x: 200, y: 410 },
    elbowR: { x: 312, y: 410 },
    bestRed: 0,
  };
  for (let i = 0; i < 3; i++) spawnHeadInAir(wd.__g.heads[i], i);
}
const G = wd.__g;

if (!G.body) G.body = { x: 256, y: 452 };
if (!G.handL) G.handL = { x: 170, y: 430 };
if (!G.handR) G.handR = { x: 342, y: 430 };
if (!G.elbowL) G.elbowL = { x: 200, y: 410 };
if (!G.elbowR) G.elbowR = { x: 312, y: 410 };
if (!G.heads || G.heads.length < 3) {
  G.heads = [{ x: 0, y: 0, vx: 0, vy: 0 }, { x: 0, y: 0, vx: 0, vy: 0 }, { x: 0, y: 0, vx: 0, vy: 0 }];
  for (let i = 0; i < 3; i++) spawnHeadInAir(G.heads[i], i);
}
if (!G.lastH) G.lastH = G.heads.map((h) => ({ x: h.x, y: h.y }));

const GW = 19;
const GH = 12;
const GCELLS = GW * GH;

if (G.bestRed == null) {
  G.bestRed = G.bestStreak != null ? G.bestStreak : 0;
}
if (G.bestRed > GCELLS) G.bestRed = 0;

if (!G.grid || G.grid.length !== GCELLS) {
  G.grid = new Array(GCELLS).fill(0);
}

function countInkCells(g) {
  let n = 0;
  for (let i = 0; i < g.length; i++) {
    if (g[i] > 0) n += 1;
  }
  return n;
}

function clearPaint(g) {
  for (let i = 0; i < g.length; i++) g[i] = 0;
}

function stampDisk(g, x, y, radius) {
  const cx = (x / 512) * GW;
  const cy = (y / 512) * GH;
  const rx = (radius / 512) * GW + 0.55;
  const ry = (radius / 512) * GH + 0.55;
  const x0 = Math.max(0, Math.floor(cx - rx));
  const x1 = Math.min(GW - 1, Math.ceil(cx + rx));
  const y0 = Math.max(0, Math.floor(cy - ry));
  const y1 = Math.min(GH - 1, Math.ceil(cy + ry));
  for (let iy = y0; iy <= y1; iy++) {
    for (let ix = x0; ix <= x1; ix++) {
      const dx = (ix + 0.5) / GW - x / 512;
      const dy = (iy + 0.5) / GH - y / 512;
      if (dx * dx + dy * dy <= (radius / 512) * (radius / 512) * 2.8) {
        g[iy * GW + ix] = 1;
      }
    }
  }
}

function stampSeg(g, ax, ay, bx, by, radius) {
  const dist = Math.hypot(bx - ax, by - ay);
  const steps = Math.max(2, Math.ceil(dist / 6));
  for (let k = 0; k <= steps; k++) {
    const t = k / steps;
    stampDisk(g, ax + (bx - ax) * t, ay + (by - ay) * t, radius);
  }
}

function playPunchSplat() {
  wd.__play_sound = [
    { frequency: 520, duration: 0.05, volume: 0.22, type: 'triangle' },
    { frequency: 280, duration: 0.07, volume: 0.14, type: 'sine' },
  ];
}

function playDeathSplat() {
  wd.__play_sound = [
    { frequency: 72, duration: 0.42, volume: 0.58, type: 'sawtooth' },
    { frequency: 48, duration: 0.55, volume: 0.5, type: 'square' },
    { frequency: 110, duration: 0.18, volume: 0.32, type: 'triangle' },
  ];
}

const pdt = Math.min(dt, 0.05);
const handR = 26;
const headR = 17;
const floorY = 486;
const minX = 24;
const maxX = 488;
const minY = 40;

const mx = ptr.x != null ? ptr.x : 256;
const my = ptr.y != null ? ptr.y : 400;

const handY = Math.max(395, Math.min(472, my));
const spread = 72 + (handY - 395) * 0.08;
const tgtLx = Math.max(minX + handR, Math.min(mx - spread * 0.55, 248));
const tgtRx = Math.max(264, Math.min(mx + spread * 0.55, maxX - handR));
const smooth = Math.min(1, 14 * pdt);

G.handL.x += (tgtLx - G.handL.x) * smooth;
G.handL.y += (handY - G.handL.y) * smooth;
G.handR.x += (tgtRx - G.handR.x) * smooth;
G.handR.y += (handY - G.handR.y) * smooth;

const body = G.body;
const shoulderL = { x: body.x - 48, y: body.y - 48 };
const shoulderR = { x: body.x + 48, y: body.y - 48 };
const L1 = 58;
const L2 = 52;

function solveIK(shoulder, hand, bend) {
  const dx = hand.x - shoulder.x;
  const dy = hand.y - shoulder.y;
  let d = Math.hypot(dx, dy);
  const maxReach = L1 + L2 - 2;
  const minReach = Math.abs(L1 - L2) + 2;
  d = Math.max(minReach, Math.min(maxReach, d));
  const base = Math.atan2(dy, dx);
  const c2 = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
  const knee = Math.acos(Math.max(-1, Math.min(1, c2)));
  const a1 = base + bend * knee;
  return {
    x: shoulder.x + Math.cos(a1) * L1,
    y: shoulder.y + Math.sin(a1) * L1,
  };
}

G.elbowL = solveIK(shoulderL, G.handL, 1);
G.elbowR = solveIK(shoulderR, G.handR, -1);

const grav = 520 * pdt;

function bouncePaddleTop(head, hand) {
  const dx = head.x - hand.x;
  const paddleTop = hand.y - handR * 0.78;
  const halfW = handR * 1.12;

  if (Math.abs(dx) > halfW + headR) return false;
  if (head.vy <= 0) return false;
  if (head.y < paddleTop - headR - 6) return false;
  if (head.y > hand.y + handR * 0.25) return false;

  head.y = paddleTop - headR - 0.5;
  const lift = 300 + Math.min(220, head.vy * 0.45);
  head.vy = -lift;
  head.vx = dx * 4.5 + head.vx * 0.55;
  return true;
}

for (let i = 0; i < G.heads.length; i++) {
  const head = G.heads[i];
  head.vy += grav;
  head.x += head.vx * pdt;
  head.y += head.vy * pdt;
  head.vx *= 1 - 0.28 * pdt;

  if (head.x < minX + headR) {
    head.x = minX + headR;
    head.vx = Math.abs(head.vx) * 0.82;
  }
  if (head.x > maxX - headR) {
    head.x = maxX - headR;
    head.vx = -Math.abs(head.vx) * 0.82;
  }
  if (head.y < minY + headR) {
    head.y = minY + headR;
    head.vy = Math.abs(head.vy) * 0.75;
  }

  if (bouncePaddleTop(head, G.handL)) playPunchSplat();
  if (bouncePaddleTop(head, G.handR)) playPunchSplat();

  if (head.y >= floorY - headR) {
    const roundScore = countInkCells(G.grid);
    if (roundScore > G.bestRed) G.bestRed = roundScore;
    playDeathSplat();
    clearPaint(G.grid);
    spawnHeadInAir(head, i);
    G.lastH[i].x = head.x;
    G.lastH[i].y = head.y;
  }
}

const heads = G.heads;
const last = G.lastH;

for (let i = 0; i < 3; i++) {
  stampSeg(G.grid, last[i].x, last[i].y, heads[i].x, heads[i].y, 11);
  stampDisk(G.grid, heads[i].x, heads[i].y, 12);
}

const redNow = countInkCells(G.grid);
if (redNow > G.bestRed) G.bestRed = redNow;

const u = [
  G.handL.x,
  G.handL.y,
  G.handR.x,
  G.handR.y,
  heads[0].x,
  heads[0].y,
  heads[1].x,
  heads[1].y,
  heads[2].x,
  heads[2].y,
  0,
  0,
  last[0].x,
  last[0].y,
  last[1].x,
  last[1].y,
  last[2].x,
  last[2].y,
  G.elbowL.x,
  G.elbowL.y,
  G.elbowR.x,
  G.elbowR.y,
  shoulderL.x,
  shoulderL.y,
  shoulderR.x,
  shoulderR.y,
  body.x,
  body.y,
];

for (let i = 0; i < GCELLS; i++) u.push(G.grid[i]);

wd.gpuUniforms = u;

for (let i = 0; i < 3; i++) {
  last[i].x = heads[i].x;
  last[i].y = heads[i].y;
}

const scoreLine = 'PAINT ' + redNow + ' / ' + GCELLS;
const bestLine = 'BEST ' + G.bestRed;

wd.hud = [
  {
    id: 'score',
    type: 'text',
    x: '0',
    y: '70px',
    width: '100%',
    textAlign: 'center',
    text: scoreLine + '\n' + bestLine,
    fontSize: '30px',
    color: '#ff4444',
  },
];