cartridge.cafe · open source world

BASE · 2D MOBILE

a complete little platformer, born working — fork it and carve away what your vision doesn't need

THE VISION

A bright toy-box afternoon: warm blue sky to cream horizon, soft green hills in two parallax bands, one golden sun upper-right. Focal: the orange roundling avatar mid-lower frame. Wood-and-grass platforms rise in a climbable zigzag. Mood: friendly, sunlit (meanLum ~120). First frame: the roundling on green ground, six gold coins marking the path up.

HOW TO PLAY

A / D or tap left/right — walk W / SPACE or tap the sky — jump R — restart Collect every coin. Stomp the critter; touching it costs a heart. This is a BASE: fork it and carve away what your vision does not need — worldData.baseManifest names every part and what is safe to remove.

built by: Claude Opus 4.8
5 visual shaders · 0 shader modules · 10 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 · base_atmo


// BASE ATMOSPHERE — sky/hills/sun + the dark underworld below the ground line.
// GEOMETRY IS NOT PAINTED HERE: ground and platforms are FIELDS the engine
// draws, so the pixels you stand on ARE the hitboxes (pixels=hitbox law,
// Sep 14). No private camera offsets either — viewbox() speaks for the ONE
// engine camera (worldData.__camera), so paint can never drift from physics.
fn visual_base_atmo(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let vb = viewbox();
  let wp = vb.xy + uv * vb.zw;                    // grid space, y down
  let sky = wp.y / 1024.0;
  var col = mix(vec3f(0.45, 0.68, 0.95), vec3f(0.85, 0.90, 0.98), sky);   // day sky
  // far hills (parallax)
  let h1 = 700.0 + 60.0 * sin(wp.x * 0.008 + 1.0);
  if (wp.y > h1) { col = mix(col, vec3f(0.55, 0.75, 0.62), 0.8); }
  let h2 = 800.0 + 40.0 * sin(wp.x * 0.013 + 4.0);
  if (wp.y > h2) { col = mix(col, vec3f(0.42, 0.65, 0.48), 0.9); }
  // sun + soft cloud band
  let sun = length(wp - vec2f(470.0, 130.0));
  col += vec3f(1.0, 0.9, 0.6) * exp(-sun * sun * 0.0004) * 0.6;
  col += vec3f(1.0) * 0.10 * smoothstep(30.0, 0.0, abs(wp.y - 240.0 - 30.0 * sin(wp.x * 0.01 + time * 0.1)));
  // below the ground line lives the dark: any gap in the ground fields (THE
  // PIT) shows this — absence of ground IS the chasm, no lanes needed.
  if (wp.y > 940.0) {
    col = mix(vec3f(0.10, 0.07, 0.12), vec3f(0.02, 0.01, 0.03), (wp.y - 940.0) / 84.0);
  }
  col = max(col, vec3f(0.06));                              // never-dark floor
  return vec4f(col, 1.0);
}

visual · base_actors


// BASE ACTORS — the avatar + population + fx overlay, drawn from the
// whiteboard alone (u0-3 player, u5 flash, u7 state, pop() entities). No
// private camera/shake offsets: viewbox() is the one engine camera, so the
// avatar is always painted exactly where physics says it stands.
fn visual_base_actors(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let vb = viewbox();
  let wp = vb.xy + uv * vb.zw;
  var col = vec4f(0.0);
  // ── entities from the population buffer: kind 0 = coin, 1 = critter ──
  for (var i = 0; i < 32; i++) {
    if (i >= popCount()) { break; }
    let e = pop(i);
    let d = wp - e.xy;
    if (dot(d, d) > 2500.0) { continue; }
    if (e.z < 0.5) {                                   // COIN: spinning disc
      let spin = abs(cos(time * 4.0 + e.w));
      let cd = length(vec2f(d.x / max(spin, 0.15), d.y));
      if (cd < 13.0) {
        let rim = smoothstep(13.0, 9.0, cd);
        col = vec4f(mix(vec3f(0.85, 0.65, 0.1), vec3f(1.0, 0.9, 0.4), rim), 1.0);
      }
    } else {                                            // CRITTER: bristly blob
      let wob = 1.0 + 0.15 * sin(time * 9.0 + e.w);
      if (length(vec2f(d.x, d.y * wob)) < 16.0) {
        col = vec4f(0.55, 0.25, 0.55, 1.0);
        if (d.y < -8.0) { col = vec4f(0.9, 0.9, 0.95, 1.0); }   // eye band
      }
    }
  }
  // ── THE AVATAR: a roundling with a face, u0/u1 pos, u2 facing, u3 hop ──
  let pp = vec2f(uni(0), uni(1));
  let pd = wp - pp;
  let squash = 1.0 + uni(3) * 0.25;
  let body = length(vec2f(pd.x / 1.15, (pd.y + 14.0) * squash)) - 16.0;
  if (body < 0.0) {
    var c = mix(vec3f(0.95, 0.55, 0.25), vec3f(1.0, 0.75, 0.45), clamp(-pd.y * 0.03, 0.0, 1.0));
    let eye = vec2f(pd.x - uni(2) * 6.0, pd.y + 18.0);
    if (length(eye - vec2f(4.0, 0.0)) < 2.6 || length(eye + vec2f(4.0, 0.0)) < 2.6) { c = vec3f(0.1); }
    col = vec4f(c, 1.0);
  }
  // fx: hit flash overlay (u5), win/lose tint (u7: 1 won gold, 2 over dusk)
  if (uni(5) > 0.01) { col = vec4f(mix(col.rgb, vec3f(1.0, 0.3, 0.2), uni(5) * 0.6), max(col.a, uni(5) * 0.35)); }
  if (uni(7) > 1.5) { col = vec4f(mix(col.rgb, vec3f(0.2, 0.1, 0.3), 0.4), max(col.a, 0.25)); }
  else if (uni(7) > 0.5) { col = vec4f(mix(col.rgb, vec3f(1.0, 0.85, 0.3), 0.3), max(col.a, 0.18)); }
  return col;
}

visual · base_plat


// BASE PLATFORM — drawn INSIDE its own field rect: the engine's field IS the
// hitbox, so these pixels can never lie about where you can stand. uv is
// field-local (-1..1, y down). Carve or move the FIELD to reshape the level.
fn visual_base_plat(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let v = uv.y * 0.5 + 0.5;                                 // 0 top .. 1 bottom
  var col = mix(vec3f(0.55, 0.42, 0.30), vec3f(0.40, 0.30, 0.22), v);
  if (v < 0.23) { col = vec3f(0.50, 0.70, 0.35); }          // grassy lip = the standable face
  return vec4f(col, 1.0);
}

visual · base_ground


// BASE GROUND — same law as platforms: the field rect is the truth. params.x =
// this field's width in grid px (cosmetic tick spacing only — geometry lives
// on the field itself, never duplicated here).
fn visual_base_ground(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let v = uv.y * 0.5 + 0.5;
  var col = mix(vec3f(0.35, 0.55, 0.30), vec3f(0.25, 0.4, 0.22), v);
  let px = (uv.x * 0.5 + 0.5) * max(params.x, 1.0);
  col += vec3f(0.08) * step(0.5, fract(px * 0.02));         // turf ticks
  return vec4f(col, 1.0);
}

visual · base_bg


fn visual_base_bg(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let g = uv.y * 0.5 + 0.5;
  var col = mix(vec3f(0.075, 0.085, 0.115), vec3f(0.028, 0.033, 0.05), g);
  col += vec3f(0.05, 0.06, 0.09) * (0.5 + 0.5 * sin(uv.x * 2.0 + uv.y * 3.0)) * 0.06;
  col += 0.010 * (vnoise(uv * 34.0) - 0.5);
  // BLANK: no horizon line (Galen, Aug 31: "mobile base has a blue line — we need
  // a blank"). The old soft floor line at uv.y≈0.35 was baked into every born
  // world's backdrop and read as a stray blue line; a blank base is a clean
  // neutral ground the builder paints over, nothing to remove first.
  return vec4f(col, 1.0);
}

— STEP HOOKS (JAVASCRIPT) —

hook · player

base-2d-mobile: player

// PLAYER (load-bearing) — reads input (keys + touch), writes INTENT only.
// Physics integrates it. Carving this removes the player's will, not gravity.
const wd = sim.worldData;
wd.__fixedStep = 1/60; if (wd.__seed === undefined) wd.__seed = 11;
const inp = wd.input || {}; const ptr = inp.pointer || {};
if (!wd.__p) wd.__p = { x: 288, y: 900, vx: 0, vy: 0, face: 1, ground: false, hop: 0, intent: { ax: 0, jump: false } };
const P = wd.__p;
let ax = inp.moveX || 0;
let jump = !!(wd.key_w || wd.key_space || (inp.moveY || 0) < -0.5);
// touch: tap left/right half walks; tap upper third jumps
if (ptr.down) {
  if ((ptr.y || 0) < 340) jump = true;
  else ax = (ptr.x || 288) < P.x - 20 ? -1 : (ptr.x || 288) > P.x + 20 ? 1 : 0;
}
P.intent.ax = Math.max(-1, Math.min(1, ax));
P.intent.jump = jump;
if (ax !== 0) P.face = ax > 0 ? 1 : -1;

hook · world

by claude-code

// PHYSICS (load-bearing, the 'world' slot) — gravity + integration + landing
// on STANDABLE FIELDS. The engine's fields are the ONE geometry: what it
// draws is exactly what you stand on (pixels ≡ hitbox, Galen's law Sep 14).
// No shadow tables — reshape the level by moving/resizing/carving fields.
const wd = sim.worldData;
if (!wd.__phys) wd.__phys = { g: 1500 };
const P = wd.__p; if (!P) return;
const dt2 = Math.min(dt, 1/30);
const I = P.intent || { ax: 0, jump: false };
P.vx += (I.ax * 900 - P.vx * 6) * dt2;
P.vy += wd.__phys.g * dt2;
if (I.jump && P.ground) { P.vy = -640; P.ground = false; (wd.__ev = wd.__ev || []).push({ t: 'jump' }); }
P.x += P.vx * dt2; P.y += P.vy * dt2;
if (P.x < 20) { P.x = 20; P.vx = 0; } if (P.x > 556) { P.x = 556; P.vx = 0; }
P.ground = false;
// land on any standable rect FIELD: top face only, feet within the drawn span
for (const f of sim.fields.values()) {
  // properties arrives as a plain object from the bridge/eye but as a MAP from
  // the browser sim (field.properties is a Map) — read both or the browser
  // sees no standables and the player falls through the world (Sep 14 bug)
  const props = f.properties || {};
  const standable = typeof props.get === 'function' ? props.get('standable') : props.standable;
  if (!standable || f.shapeType !== 'rect') continue;
  const sc = (f.transform && f.transform.scale) || 1;
  const w = (f.w || 0) * sc, top = (f.transform.y || 0) - ((f.h || 0) * sc) * 0.5;
  // slop = the avatar's drawn half-width (18): ANY visible overlap with the
  // slab supports you — you can never fall while pixels say you're standing
  if (P.vy >= 0 && Math.abs(P.x - f.transform.x) < w * 0.5 + 18 && P.y >= top - 2 && P.y <= top + Math.max(14, P.vy * dt2 + 6)) {
    P.y = top; P.vy = 0; P.ground = true;
  }
}
P.hop = P.ground ? Math.max(0, (P.hop || 0) - dt2 * 6) : Math.min(1, (P.hop || 0) + dt2 * 4);
if (P.y > 1100) { (wd.__ev = wd.__ev || []).push({ t: 'fell' }); P.x = 288; P.y = 900; P.vx = 0; P.vy = 0; }

hook · entities

base-2d-mobile: entities (pixels=hitbox rewire)

// ENTITIES (removable) — coins to collect + one patrolling critter. A coin
// stays COLLECTED until restart (no respawn farming — Galen, Sep 14); taking
// the last one emits 'allcoins' (the win itself belongs to rules).
const wd = sim.worldData;
if (!wd.__ents) wd.__ents = {
  coins: [{x:150,y:770},{x:430,y:660},{x:200,y:530},{x:440,y:400},{x:170,y:280},{x:288,y:910}].map(c => ({ ...c, got: false })),
  critter: { x: 380, y: 940, dir: 1 },
};
const E = wd.__ents; const P = wd.__p;
const dt2 = Math.min(dt, 1/30);
for (const c of E.coins) {
  if (c.got) continue;
  if (P && Math.hypot(P.x - c.x, P.y - 20 - c.y) < 26) {
    c.got = true; (wd.__ev = wd.__ev || []).push({ t: 'coin' });
    if (E.coins.every(k => k.got)) wd.__ev.push({ t: 'allcoins' });
  }
}
const K = E.critter;
K.x += K.dir * 60 * dt2;
if (K.x < 300) { K.x = 300; K.dir = 1; } if (K.x > 500) { K.x = 500; K.dir = -1; }
if (P && Math.hypot(P.x - K.x, P.y - K.y) < 28) {
  if (P.vy > 100) { (wd.__ev = wd.__ev || []).push({ t: 'stomp' }); K.x = 100 + (sim.rand ? sim.rand() : 0.5) * 380; P.vy = -450; }
  else (wd.__ev = wd.__ev || []).push({ t: 'hit' });
}

hook · rules

base-2d-mobile: rules (pixels=hitbox rewire)

// RULES (removable) — coins, lives, win/lose, restart. Consumes the event
// list; with no producers it idles. state: 0 play · 1 won · 2 over. The win
// is entities' 'allcoins' — every DISTINCT coin once, never farmed.
const wd = sim.worldData;
if (!wd.__rules) wd.__rules = { coins: 0, lives: 3, state: 0, iframe: 0 };
const R = wd.__rules;
const dt2 = Math.min(dt, 1/30);
R.iframe = Math.max(0, R.iframe - dt2);
for (const ev of (wd.__ev || [])) {
  if (R.state !== 0) break;
  if (ev.t === 'coin') R.coins += 1;
  if (ev.t === 'allcoins') R.state = 1;
  if ((ev.t === 'hit' || ev.t === 'fell') && R.iframe <= 0) { R.lives -= 1; R.iframe = 1.2; if (R.lives <= 0) R.state = 2; }
}
const restart = wd.key_r || (wd.__uiClickT && wd.__uiClickT !== R.lastClick && wd.__uiClick === 'restart');
if (restart) { R.lastClick = wd.__uiClickT || 0;
  if (R.state !== 0 || wd.key_r) { R.coins = 0; R.lives = 3; R.state = 0; R.iframe = 0;
    if (wd.__p) { wd.__p.x = 288; wd.__p.y = 900; wd.__p.vx = 0; wd.__p.vy = 0; }
    if (wd.__ents) { for (const c of wd.__ents.coins) c.got = false; } } }

hook · hud

by claude-code

// HUD (removable) — solver-banded UI from rules state. Anchors are WORLD-RECT
// fractions (wx/wy 0..1): true bands on the world's own footprint on ANY grid shape — the portrait
// lesson: gy anchors live on the world square, vx/vy live on the screen.
const wd = sim.worldData;
const R = wd.__rules;
const total = (wd.__ents && wd.__ents.coins) ? wd.__ents.coins.length : 0;
const rev = R ? (R.coins * 100 + R.lives + R.state * 10000) : -1;
if (rev === wd.__hudRev) return;
wd.__hudRev = rev;
if (!R) { wd.ui = { rev: 0, root: [] }; return; }
wd.ui = { rev,
  root: [
    { id: 'lives', kind: 'panel', anchor: { wx: 0.03, wy: 0.015 }, align: 'tl', glass: true, pad: 7,
      children: [{ kind: 'text', text: '♥'.repeat(Math.max(0, R.lives)) + '·'.repeat(Math.max(0, 3 - R.lives)), fontSize: 14, color: '#ff6b6b' }] },
    { id: 'score', kind: 'panel', anchor: { wx: 0.97, wy: 0.015 }, align: 'tr', glass: true, pad: 7,
      children: [{ kind: 'text', text: 'COINS ' + R.coins + (total ? '/' + total : ''), fontSize: 12, color: '#ffd27f' }] },
    ...(R.state === 1 ? [
      { id: 'won', kind: 'panel', anchor: { wx: 0.5, wy: 0.3 }, align: 'tc', glass: true, pad: 10,
        children: [{ kind: 'text', text: 'ALL COINS! YOU WIN', fontSize: 15, color: '#ffe28a' }] },
      { id: 'again', kind: 'panel', anchor: { wx: 0.5, wy: 0.88 }, align: 'bc', glass: true, pad: 8,
        children: [{ id: 'restart', kind: 'button', text: 'PLAY AGAIN', click: 'restart' }] }] : []),
    ...(R.state === 2 ? [
      { id: 'over', kind: 'panel', anchor: { wx: 0.5, wy: 0.3 }, align: 'tc', glass: true, pad: 10,
        children: [{ kind: 'text', text: 'OUT OF HEARTS', fontSize: 15, color: '#ff8866' }] },
      { id: 'again', kind: 'panel', anchor: { wx: 0.5, wy: 0.88 }, align: 'bc', glass: true, pad: 8,
        children: [{ id: 'restart', kind: 'button', text: 'TRY AGAIN', click: 'restart' }] }] : []),
  ] };

hook · camera

base-2d-mobile: camera (pixels=hitbox rewire)

// CAMERA (removable) — a gentle vertical look-ahead through the ENGINE'S OWN
// camera (worldData.__camera): one coordinate owner, so the view and the
// hitboxes can never disagree. The engine smooths and clamps whatever we ask.
const wd = sim.worldData;
const P = wd.__p; if (!P) { delete wd.__camera; return; }
wd.__camera = { x: 288, y: Math.max(312, Math.min(512, P.y - 220)) };

hook · fx

base-2d-mobile: fx (pixels=hitbox rewire)

// FX (removable) — juice from events: the hit/goal FLASH. Positional shake was
// carved ON PURPOSE: a paint offset is a lie about where things are (pixels ≡
// hitbox). If your vision wants shake, nudge worldData.__camera instead.
const wd = sim.worldData;
if (!wd.__fx) wd.__fx = { flash: 0 };
const F = wd.__fx;
const dt2 = Math.min(dt, 1/30);
F.flash = Math.max(0, F.flash - dt2 * 2.5);
for (const ev of (wd.__ev || [])) {
  if (ev.t === 'hit' || ev.t === 'fell') F.flash = 1;
  if (ev.t === 'coin') F.flash = Math.max(F.flash, 0.25);
}

hook · save

by claude-code

// SAVE (removable) — best score persists with the world's save state.
const wd = sim.worldData;
const R = wd.__rules;
if (R && (wd.__best === undefined || R.coins > wd.__best)) wd.__best = R.coins;

hook · audio

by claude-code

// AUDIO (removable) — events → sound ids the engine plays if sounds exist.
const wd = sim.worldData;
for (const ev of (wd.__ev || [])) {
  if (ev.t === 'coin') wd.__play_sound = { id: 'coin' };
  if (ev.t === 'jump') wd.__play_sound = { id: 'jump' };
  if (ev.t === 'hit' || ev.t === 'fell') wd.__play_sound = { id: 'hurt' };
}

hook · uplink

base-2d-mobile: uplink (pixels=hitbox rewire)

// UPLINK (load-bearing) — the ONE writer of the whiteboard + population.
// Slimmer now: geometry (ground/platforms/pit) is FIELDS the engine draws —
// no geometry lanes. Lanes: u0-3 player, u5 flash, u7 rules state. Runs LAST.
const wd = sim.worldData;
const U = new Array(64).fill(0);
const P = wd.__p || { x: 288, y: 900, face: 1, hop: 0 };
U[0] = P.x; U[1] = P.y; U[2] = P.face || 1; U[3] = P.hop || 0;
U[5] = wd.__fx ? wd.__fx.flash : 0;
U[7] = wd.__rules ? wd.__rules.state : 0;
wd.gpuUniforms = U;
const POP = [];
if (wd.__ents) {
  for (const c of wd.__ents.coins) if (!c.got) POP.push(c.x, c.y, 0, (c.x + c.y) * 0.01);
  const K = wd.__ents.critter; if (K) POP.push(K.x, K.y - 14, 1, K.x * 0.05);
}
wd.gpuPopulation = POP;
wd.__ev = [];   // the event list is per-tick; uplink (last) clears it