Super Pip: a Mario-style platformer with co-op (rebuild guide) 10OCT2026

40 levels in 10 worlds, coin blocks, power-ups and castles, for 1 to 4 players

Posted by on 10th Oct 2026

My own run-and-jump platformer in the spirit of the original Super Mario Bros. (Nintendo, 1985), with my own hero, enemies, levels, pixel art and music. Play it at https://games.503mail.com/pip/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.

What's in it

Pip (or Pop, Penny or Pax) runs and jumps through 40 levels in 10 worlds: Green Meadow, Sandy Dunes, Snowy Peaks, Moonlit Fields, Autumn Woods, Jungle Falls, Frozen Night, Sunset Cliffs, Crystal Caverns and Candy Clouds. That's 8 more levels than the original's 32. Each world has an outdoor level, an underground cave, a treetop level high above the ground, and a castle with lava, fire bars and a bridge over the lava where King Grumble waits. Grab the axe behind him (or hit him with 5 sparks) and the bridge drops him into the lava. ? blocks hold coins and power-ups: the Big Berry makes Pip big (big Pip can smash bricks), the Spark Flower lets him throw bouncing sparks, the Rainbow Star makes him invincible for a while, and a green berry is an extra life, as are 100 coins. The enemies are Grumbles (stomp them), Shellbacks whose shells can be kicked into other enemies (red ones turn at ledges, winged ones hop), Spinies (don't stomp those), snapping plants in pipes, cannons, lava embers and fire bars. Levels end at a flagpole (higher grab = more points) with a timer bonus, and there's a checkpoint halfway. Play Together: up to 4 players, each on their own tablet or PC, run through the same level at the same time. Coin blocks, broken bricks, enemies and power-ups are shared, so everyone sees the same thing, and anyone reaching the flag takes the whole team to the next level. A player who falls comes back next to a friend, with endless lives. The levels are made by a seeded generator, so a level is the same every time and on every tablet. Progress, character and options are saved in the browser. Signed in from BlockCraft: a kid who is signed in to BlockCraft on the same tablet doesn't have to type anything. The game reads BlockCraft's saved sign-in (same site, so same browser storage), adds a hero with their name in their own BlockCraft colour (the colour of their vehicles and castle flags), picks it, and uses that name in Play Together.

Controls

Keyboard: ◀ ▶ run, ▲ / Space / Z jump (hold for higher), X or Shift runs faster and throws sparks, P pause. A game controller works too. Touch: ◀ ▶ on the left, B (run / sparks) and A (jump) on the right, and II to pause. Auto run (on by default on touch screens) makes Pip always run fast, so kids only need ◀ ▶ and A. Easy turns off the timer and gives endless lives. Play Together: one player taps Play Together, then Start a new game, and gets a 4-letter game code. The others tap Play Together and Join that game in the list. When everyone is in, the first player picks a level and taps Start.

Rebuild, step by step

Super Pip is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Super Pip tile.

1. Make the game's folder

Proxmox host shell (Proxmox web UI → pve → Shell):

mkdir -p /root/nibbles/pip

2. Create the game file

Paste this into the Proxmox host shell. It writes /root/nibbles/pip/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):

cat > /root/nibbles/pip/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Super Pip</title>
<link rel="icon" type="image/png" sizes="32x32" href="/pip/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/pip/icon-192.png">
<link rel="apple-touch-icon" href="/pip/apple-touch-icon.png">
<meta property="og:title" content="Super Pip">
<meta property="og:image" content="https://games.503mail.com/pip/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#000;overflow:hidden;touch-action:none;color:#fff;
    font-family:"Lucida Console","Courier New",monospace;user-select:none;-webkit-user-select:none}
  #c{position:absolute;image-rendering:pixelated;image-rendering:crisp-edges}
  .ov{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
    gap:1.6vmin;background:rgba(0,0,0,.45);overflow:auto;padding:16px;box-sizing:border-box}
  .hide{display:none!important}
  h1{margin:0 0 1vmin;font-size:clamp(28px,10vmin,72px);color:#ffd84a;letter-spacing:.08em;
    text-shadow:4px 4px 0 #b4400c,8px 8px 0 #000}
  h2{margin:0;font-size:clamp(18px,5vmin,34px);color:#ffd84a}
  button,select,input{font:inherit;font-size:clamp(16px,3.4vmin,24px)}
  .big{min-width:min(70vw,360px);padding:1.4vmin 3vmin;background:#e8641c;color:#fff;border:3px solid #fff;
    border-radius:10px;box-shadow:0 4px 0 #6a2a08;cursor:pointer}
  .big:focus,.big:hover{background:#ff8a3c;outline:none}
  .big:disabled{background:#555;box-shadow:none}
  .row{display:flex;flex-wrap:wrap;gap:1.4vmin;justify-content:center;align-items:center}
  .ch{padding:1vmin 2vmin;border:3px solid #fff;border-radius:8px;color:#fff;cursor:pointer;background:#222}
  .ch[aria-pressed=true]{outline:4px solid #ffd84a}
  label{font-size:clamp(14px,3vmin,20px);display:flex;gap:6px;align-items:center}
  input[type=checkbox]{width:22px;height:22px}
  .lv{display:grid;grid-template-columns:auto repeat(4,auto);gap:1vmin;align-items:center}
  .lv button{padding:1vmin 2vmin;background:#2a4ab0;color:#fff;border:2px solid #fff;border-radius:6px}
  .lv button:disabled{background:#333;color:#888;border-color:#555}
  .lv span{font-size:clamp(12px,2.6vmin,18px);text-align:right;padding-right:1vmin}
  a{color:#9cf}
  #rooms{display:flex;flex-direction:column;gap:1vmin;min-width:min(80vw,420px)}
  #rooms div{display:flex;justify-content:space-between;align-items:center;gap:2vmin;background:#123;padding:1vmin 2vmin;border-radius:6px}
  #touch{position:absolute;inset:0;pointer-events:none}
  #touch.on{pointer-events:auto}
  .tb{position:absolute;border-radius:50%;background:rgba(255,255,255,.16);border:3px solid rgba(255,255,255,.45);
    color:rgba(255,255,255,.85);display:flex;align-items:center;justify-content:center;font-weight:bold;
    width:17vmin;height:17vmin;font-size:7vmin;bottom:5vmin}
  .tb.down{background:rgba(255,255,255,.45)}
  #tbL{left:4vmin}#tbR{left:25vmin}#tbB{right:25vmin;bottom:9vmin}#tbA{right:4vmin}
  #pauseBtn{position:absolute;top:1vmin;right:1vmin;width:9vmin;height:9vmin;font-size:4vmin;border-radius:8px;
    background:rgba(0,0,0,.35);color:#fff;border:2px solid rgba(255,255,255,.5)}
</style>
</head>
<body>
<canvas id="c" aria-label="Super Pip game screen"></canvas>
<div id="touch" class="hide">
  <div class="tb" id="tbL">&#9664;</div><div class="tb" id="tbR">&#9654;</div>
  <div class="tb" id="tbB">B</div><div class="tb" id="tbA">A</div>
</div>
<button id="pauseBtn" class="hide" aria-label="Pause">II</button>

<div id="menu" class="ov">
  <h1>SUPER PIP</h1>
  <div class="row" id="chars" role="group" aria-label="Pick your hero"></div>
  <button class="big" id="bPlay">Play</button>
  <button class="big" id="bMP">Play Together</button>
  <button class="big" id="bLevels">Choose Level</button>
  <div class="row">
    <label><input type="checkbox" id="oEasy"> Easy (no timer, endless lives)</label>
    <label><input type="checkbox" id="oRun"> Auto run</label>
    <label><input type="checkbox" id="oSnd"> Sound</label>
  </div>
  <div style="font-size:clamp(12px,2.6vmin,16px);text-align:center;max-width:90vw">
    Keys: arrows to move, Up/Space/Z jump, X run and throw sparks, P pause. Touch: buttons on screen.</div>
  <a href="/">All games</a>
</div>

<div id="levels" class="ov hide">
  <h2>Choose Level</h2>
  <div class="lv" id="lvGrid"></div>
  <button class="big" id="bLvBack">Back</button>
</div>

<div id="lobby" class="ov hide">
  <h2>Play Together</h2>
  <div id="lbStatus" role="status">Connecting...</div>
  <div class="row"><label>Your name <input id="lbName" maxlength="12" size="12"></label></div>
  <div id="lbSigned" class="hide" style="font-size:clamp(12px,2.6vmin,16px)">Signed in from BlockCraft on this tablet.</div>
  <div id="lbOut">
    <div class="row"><button class="big" id="bCreate">Start a new game</button></div>
    <div id="rooms" aria-label="Games you can join"></div>
  </div>
  <div id="lbIn" class="hide">
    <div id="lbRoom" style="text-align:center"></div>
    <div class="row" id="lbHost"><label>Level <select id="lbLevel"></select></label>
      <button class="big" id="bStartMP">Start</button></div>
  </div>
  <button class="big" id="bLbBack">Back</button>
</div>

<div id="pause" class="ov hide">
  <h2>Paused</h2>
  <button class="big" id="bResume">Resume</button>
  <button class="big" id="bRestart">Restart level</button>
  <button class="big" id="bQuit">Quit to menu</button>
</div>

<script>
'use strict';
// ===================================================================================
//  SUPER PIP - our own run-and-jump platformer (own characters, levels, art, music).
//  40 levels in 10 worlds, 1 player or up to 4 together over games.503mail.com/mp/ws.
// ===================================================================================
const $ = id => document.getElementById(id);
const TS = 16, VH = 240, ROWS = 15;
let VW = 384;
const cv = $('c'), ctx = cv.getContext('2d');
function resize() {
  const iw = innerWidth || 640, ih = innerHeight || 360, ar = iw / ih;
  VW = Math.max(256, Math.min(480, Math.round(VH * ar / 2) * 2));
  cv.width = VW; cv.height = VH; ctx.imageSmoothingEnabled = false;
  const s = Math.min(iw / VW, ih / VH);
  cv.style.width = (VW * s) + 'px'; cv.style.height = (VH * s) + 'px';
  cv.style.left = ((iw - VW * s) / 2) + 'px'; cv.style.top = ((ih - VH * s) / 2) + 'px';
}
addEventListener('resize', resize); resize();

function mulberry(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;

// ---------------- save ----------------
let SAVE = { un: 0, cur: 0, name: '', ch: 0, easy: false, run: isTouch, snd: true, best: 0 };
try { Object.assign(SAVE, JSON.parse(localStorage.getItem('superpip') || '{}')); } catch (e) {}
function save() { try { localStorage.setItem('superpip', JSON.stringify(SAVE)); } catch (e) {} }

// ---------------- audio ----------------
let AC = null, master = null;
function ac() {
  if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); master = AC.createGain(); master.gain.value = 0.22; master.connect(AC.destination); } catch (e) { return null; } }
  if (AC.state === 'suspended') AC.resume();
  return AC;
}
function tone(f0, f1, dur, type, vol, delay) {
  if (!SAVE.snd) return; const a = ac(); if (!a) return;
  const t = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
  o.type = type || 'square'; o.frequency.setValueAtTime(f0, t);
  if (f1) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
  g.gain.setValueAtTime(vol || 0.2, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
  o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.02);
}
let noiseBuf = null;
function noise(dur, vol, delay) {
  if (!SAVE.snd) return; const a = ac(); if (!a) return;
  if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate * 0.5, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
  const t = a.currentTime + (delay || 0), s = a.createBufferSource(), g = a.createGain();
  s.buffer = noiseBuf; g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
  s.connect(g); g.connect(master); s.start(t); s.stop(t + dur);
}
const SFX = {
  jump: () => tone(260, 620, 0.16, 'square', 0.14),
  jumpbig: () => tone(200, 480, 0.18, 'square', 0.14),
  coin: () => { tone(988, 0, 0.06, 'square', 0.12); tone(1319, 0, 0.3, 'square', 0.12, 0.06); },
  stomp: () => { tone(500, 120, 0.12, 'square', 0.2); },
  bump: () => tone(140, 90, 0.08, 'triangle', 0.35),
  brk: () => { noise(0.25, 0.3); tone(200, 60, 0.15, 'square', 0.1); },
  sprout: () => [392, 523, 659, 784].forEach((f, i) => tone(f, 0, 0.08, 'triangle', 0.25, i * 0.05)),
  up: () => [523, 659, 784, 1047, 784, 1047, 1319].forEach((f, i) => tone(f, 0, 0.07, 'square', 0.12, i * 0.05)),
  oneup: () => [659, 784, 1319, 1047, 1175, 1568].forEach((f, i) => tone(f, 0, 0.09, 'square', 0.12, i * 0.08)),
  fire: () => tone(900, 300, 0.07, 'square', 0.1),
  kick: () => tone(700, 300, 0.06, 'square', 0.18),
  hurt: () => [600, 450, 600, 450, 300].forEach((f, i) => tone(f, 0, 0.06, 'square', 0.12, i * 0.05)),
  die: () => [523, 494, 466, 392, 330, 262].forEach((f, i) => tone(f, 0, 0.14, 'square', 0.15, 0.25 + i * 0.13)),
  flag: () => tone(1200, 300, 0.9, 'square', 0.1),
  clear: () => [523, 659, 784, 1047, 0, 784, 1047].forEach((f, i) => f && tone(f, 0, 0.16, 'square', 0.13, i * 0.14)),
  boss: () => { tone(120, 60, 0.3, 'sawtooth', 0.2); },
  pause: () => { tone(660, 0, 0.08, 'square', 0.1); tone(990, 0, 0.1, 'square', 0.1, 0.09); }
};
function sfx(n) { if (SFX[n]) SFX[n](); }

// music: our own tunes. Each bar is 16 eighth notes; bass is 8 notes of 2 eighths per bar.
const NOTE = { C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11 };
function n2f(n) { const m = /^([A-G]#?)(\d)$/.exec(n); if (!m) return 0; return 440 * Math.pow(2, (NOTE[m[1]] + (+m[2] + 1) * 12 - 69) / 12); }
const SONGS = {
  over: { step: 0.13, lead: 'square', mel: 'C5:1 E5:1 G5:2 E5:1 G5:1 A5:2 G5:1 E5:1 C5:1 D5:1 E5:2 -:2 D5:1 F5:1 A5:2 F5:1 A5:1 B5:2 A5:1 F5:1 D5:1 E5:1 F5:2 -:2 E5:1 G5:1 C6:2 B5:1 A5:1 G5:2 A5:1 G5:1 E5:1 D5:1 C5:2 D5:2 E5:1 E5:1 D5:1 D5:1 C5:2 G4:2 A4:1 B4:1 C5:4 -:2',
    bass: 'C3 G3 C3 G3 C3 G3 C3 G3 D3 A3 D3 A3 G2 D3 G2 D3 C3 G3 A2 E3 F2 C3 F2 C3 G2 D3 G2 D3 C3 G3 C3 G3' },
  cave: { step: 0.15, lead: 'triangle', mel: 'A4:2 -:1 C5:1 E5:2 -:2 D5:1 C5:1 B4:2 -:4 G4:2 -:1 B4:1 D5:2 -:2 C5:1 B4:1 A4:2 -:4 A4:1 C5:1 E5:1 A5:1 G5:2 E5:2 F5:1 E5:1 D5:1 C5:1 B4:4 C5:1 B4:1 A4:1 G4:1 A4:4 E4:2 -:2 A4:4',
    bass: 'A2 A2 E2 E2 A2 A2 E2 E2 G2 G2 D2 D2 E2 E2 E2 E2 F2 F2 C3 C3 G2 G2 E2 E2 F2 F2 E2 E2 A2 A2 A2 A2' },
  sky: { step: 0.12, lead: 'square', mel: 'G5:2 E5:1 G5:1 C6:2 G5:2 A5:1 G5:1 F5:1 E5:1 D5:4 F5:2 D5:1 F5:1 B5:2 A5:2 G5:1 F5:1 E5:1 D5:1 C5:4 E5:1 F5:1 G5:2 E5:1 F5:1 G5:2 A5:2 B5:2 C6:4 B5:1 A5:1 G5:1 F5:1 E5:2 D5:2 C5:4 -:4',
    bass: 'C3 E3 G3 E3 C3 E3 G3 E3 G2 B2 D3 B2 C3 E3 G3 E3 A2 C3 E3 C3 F2 A2 C3 A2 G2 B2 D3 B2 C3 G2 C3 C3' },
  castle: { step: 0.13, lead: 'sawtooth', mel: 'E5:1 F5:1 E5:1 D#5:1 E5:2 B4:2 C5:1 B4:1 A#4:1 B4:1 E4:4 E5:1 F5:1 E5:1 D#5:1 E5:2 G5:2 F5:1 E5:1 D5:1 C5:1 B4:4',
    bass: 'E2 E2 E2 E2 F2 F2 E2 E2 E2 E2 E2 E2 D2 D2 B1 B1' },
  star: { step: 0.1, lead: 'square', mel: 'C5:1 C5:1 C5:1 D5:1 C5:1 C5:1 E5:1 -:1 D5:1 D5:1 D5:1 E5:1 D5:1 D5:1 F5:1 -:1',
    bass: 'C3 C3 G2 G2 D3 D3 G2 G2' },
  win: { step: 0.14, lead: 'square', mel: 'C5:2 E5:2 G5:2 C6:4 -:2 A5:2 B5:2 C6:8 -:8', bass: 'C3 C3 C3 C3 F2 F2 C3 C3 C3 C3 C3 C3 C3 C3 C3 C3' }
};
function parseSong(s) {
  const mel = s.mel.split(' ').map(t => { const [n, d] = t.split(':'); return [n === '-' ? 0 : n2f(n), +d]; });
  const bass = s.bass.split(' ').map(n => [n2f(n), 2]);
  return { step: s.step, lead: s.lead, tracks: [{ notes: mel, type: s.lead, vol: 0.07 }, { notes: bass, type: 'triangle', vol: 0.16 }] };
}
let music = null;
function playMusic(name) {
  stopMusic(); if (!SAVE.snd || !SONGS[name]) return; const a = ac(); if (!a) return;
  const s = parseSong(SONGS[name]);
  music = { name, s, pos: s.tracks.map(() => ({ i: 0, t: a.currentTime + 0.08 })), timer: setInterval(schedMusic, 60) };
  schedMusic();
}
function schedMusic() {
  if (!music || !AC) return; const now = AC.currentTime;
  music.s.tracks.forEach((tr, k) => {
    const p = music.pos[k];
    if (p.t < now - 0.5) p.t = now + 0.05;
    while (p.t < now + 0.25) {
      const [f, d] = tr.notes[p.i]; const dur = d * music.s.step;
      if (f) { const o = AC.createOscillator(), g = AC.createGain(); o.type = tr.type; o.frequency.value = f;
        g.gain.setValueAtTime(tr.vol, p.t); g.gain.setValueAtTime(tr.vol, p.t + dur * 0.7); g.gain.linearRampToValueAtTime(0.0001, p.t + dur * 0.95);
        o.connect(g); g.connect(master); o.start(p.t); o.stop(p.t + dur); }
      p.t += dur; p.i = (p.i + 1) % tr.notes.length;
    }
  });
}
function stopMusic() { if (music) { clearInterval(music.timer); music = null; } }

// ---------------- palettes ----------------
const PALS = {
  grass: { sky: '#6aa8ff', top: '#4cc24a', top2: '#2e8f2e', dirt: '#b9692e', dirt2: '#7d3f14', brick: '#c0602a', brick2: '#6a2a0a', hill: '#3c9c4c', hill2: '#64c46c', cloud: '#ffffff', bush: '#3aae4a', tree: '#48b848', trunk: '#d8b070' },
  desert: { sky: '#ffd28a', top: '#f0c868', top2: '#c89a40', dirt: '#d8a050', dirt2: '#a87028', brick: '#c8803a', brick2: '#7a4410', hill: '#e0a858', hill2: '#f0c878', cloud: '#fff6e0', bush: '#7aa83a', tree: '#a8c04a', trunk: '#c89a5a' },
  snow: { sky: '#a8d4ff', top: '#ffffff', top2: '#c0dcff', dirt: '#7ea8d8', dirt2: '#4e78a8', brick: '#6a9ad0', brick2: '#2a4a7a', hill: '#dceaff', hill2: '#ffffff', cloud: '#ffffff', bush: '#c8e2ff', tree: '#e8f4ff', trunk: '#8aa8c8' },
  night: { sky: '#0e1848', top: '#3aa060', top2: '#1e6a3a', dirt: '#7a4a2a', dirt2: '#4a2812', brick: '#9a4a2a', brick2: '#4a1e08', hill: '#18305a', hill2: '#244878', cloud: '#2c3a6c', bush: '#1e6040', tree: '#2e8a4e', trunk: '#8a6a4a', stars: 1 },
  autumn: { sky: '#ffbe8c', top: '#e0702c', top2: '#a8461a', dirt: '#8a502a', dirt2: '#5a2e12', brick: '#b8502a', brick2: '#64220a', hill: '#c8742e', hill2: '#e89a48', cloud: '#fff0e0', bush: '#d0582a', tree: '#e08a30', trunk: '#8a5a30' },
  jungle: { sky: '#7cd4c8', top: '#2aa83a', top2: '#14701e', dirt: '#6a4a2a', dirt2: '#40280e', brick: '#8a6a3a', brick2: '#4a3410', hill: '#1e8a3a', hill2: '#38b050', cloud: '#e8fff8', bush: '#1a9a30', tree: '#2ab840', trunk: '#a07a4a' },
  icenight: { sky: '#141e40', top: '#e0f0ff', top2: '#9ab8e0', dirt: '#4a6a9a', dirt2: '#2a4068', brick: '#5a7aaa', brick2: '#1e3050', hill: '#2a3a6a', hill2: '#3e5288', cloud: '#3a4a7a', bush: '#8aa8d0', tree: '#a8c8f0', trunk: '#6a7a9a', stars: 1 },
  dusk: { sky: '#c8506a', top: '#8a5ac8', top2: '#5a3088', dirt: '#5a3050', dirt2: '#381a30', brick: '#8a3a5a', brick2: '#3a1020', hill: '#7a2e58', hill2: '#a04a78', cloud: '#ffb0a0', bush: '#6a3a9a', tree: '#9a6ad8', trunk: '#6a4a5a' },
  crystal: { sky: '#1c1036', top: '#c47aff', top2: '#8a40d0', dirt: '#4e2a7e', dirt2: '#2e1450', brick: '#7a4ac8', brick2: '#2e1060', hill: '#36205e', hill2: '#4e2e86', cloud: '#5a4a9a', bush: '#a060e8', tree: '#c48aff', trunk: '#6a4a9a', stars: 1 },
  candy: { sky: '#ffd0ec', top: '#ff7ac0', top2: '#d04a90', dirt: '#f6e0b0', dirt2: '#d8a870', brick: '#ff9ad0', brick2: '#b04a80', hill: '#ffa8d8', hill2: '#ffc8e8', cloud: '#ffffff', bush: '#8ae0c0', tree: '#7ad8f0', trunk: '#ffe0a0' },
  cave: { sky: '#000000', top: '#4a8ad8', top2: '#2a5aa8', dirt: '#2a5a9a', dirt2: '#163a6a', brick: '#3a78c0', brick2: '#122a50', tree: '#3a78c0', trunk: '#22446a', dark: 1 },
  crystalcave: { sky: '#0a0414', top: '#b060f0', top2: '#7030b0', dirt: '#4a2080', dirt2: '#28104a', brick: '#7a40c0', brick2: '#280c50', tree: '#9a50e0', trunk: '#3a1a6a', dark: 1 },
  castle: { sky: '#000000', top: '#a8a8a8', top2: '#707070', dirt: '#8a8a8a', dirt2: '#4e4e4e', brick: '#9a9a9a', brick2: '#4a4a4a', tree: '#888', trunk: '#555', dark: 1 }
};
const OVER = ['grass', 'desert', 'snow', 'night', 'autumn', 'jungle', 'icenight', 'dusk', 'crystal', 'candy'];
const WORLD_NAMES = ['Green Meadow', 'Sandy Dunes', 'Snowy Peaks', 'Moonlit Fields', 'Autumn Woods', 'Jungle Falls', 'Frozen Night', 'Sunset Cliffs', 'Crystal Caverns', 'Candy Clouds'];
function themeOf(w, s) { if (s === 4) return 'castle'; if (s === 2) return w >= 8 ? 'crystalcave' : 'cave'; return OVER[w - 1]; }
function shade(hex, f) { const n = parseInt(hex.slice(1), 16); let r = n >> 16, g = n >> 8 & 255, b = n & 255;
  if (f > 0) { r += (255 - r) * f; g += (255 - g) * f; b += (255 - b) * f; } else { r *= 1 + f; g *= 1 + f; b *= 1 + f; }
  return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1).replace(/\..*/, '').padStart(6, '0'); }
function shadeI(hex, f) { const n = parseInt(hex.slice(1), 16); let r = n >> 16, g = n >> 8 & 255, b = n & 255;
  if (f > 0) { r += (255 - r) * f; g += (255 - g) * f; b += (255 - b) * f; } else { r *= 1 + f; g *= 1 + f; b *= 1 + f; }
  return 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')'; }

// ---------------- sprites ----------------
function mk(rows, pal) {
  const h = rows.length, w = Math.max(...rows.map(r => r.length));
  const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d');
  for (let y = 0; y < h; y++) for (let x = 0; x < rows[y].length; x++) { const k = rows[y][x]; if (k !== '.' && pal[k]) { g.fillStyle = pal[k]; g.fillRect(x, y, 1, 1); } }
  return c;
}
const PIP_STAND = [
  '................',
  '.....ccccc......',
  '....ccccccccc...',
  '....hhhssks.....',
  '...hshhsssks....',
  '...hshhssssss...',
  '...hhsssssss....',
  '.....ssssss.....',
  '....ccocco......',
  '...cccocccocc...',
  '..ssccooooccss..',
  '..ss.oyooyo.ss..',
  '....oooooooo....',
  '....ooo..ooo....',
  '...bbb....bbb...',
  '..bbbb....bbbb..'];
const LEGS = {
  stand: null,
  w1: ['....ooooooo.....', '...oooo.oooo....', '..bbb.....bbb...', '.bbbb......bbb..'],
  w2: ['....oooooo......', '.....oooo.......', '.....bbbbb......', '....bbbbbb......']
};
const PIP_JUMP_BODY = [
  '..s.ccocco......',
  '..scccocccocc.s.',
  '...ccoooooocccs.',
  '....oyooyoo.....',
  '....oooooooo....',
  '...oooo..ooo....',
  '..bbbb....bbb...',
  '..bbb......bbbb.'];
function pipFrame(name) {
  const r = PIP_STAND.slice();
  if (name === 'w1' || name === 'w2') { const l = LEGS[name]; for (let i = 0; i < 4; i++) r[12 + i] = l[i]; }
  if (name === 'jump') for (let i = 0; i < 8; i++) r[8 + i] = PIP_JUMP_BODY[i];
  return r;
}
const BIGMAP = [0, 1, 2, 2, 3, 4, 5, 5, 6, 6, 7, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15];
const CHARS = [
  { name: 'Pip', c: '#e8641c', o: '#3b2f8f' },
  { name: 'Pop', c: '#22b5a0', o: '#6b3a1a' },
  { name: 'Penny', c: '#e84a9a', o: '#3e3e52' },
  { name: 'Pax', c: '#f2c21b', o: '#2a7a3a' }];
// Signed in to BlockCraft on this tablet? It's the same site, so the same browser storage. Then the kid plays
// as themself: their BlockCraft name, and their BlockCraft colour (same picker BlockCraft uses for vehicles and flags).
const BC_PAINTS = ['#d23a32', '#2f6fd8', '#3fae3a', '#8932b8', '#f0c020', '#f07d19', '#f38baa', '#20b8b0'];
function bcPaint(n) { const s = n.toLowerCase(); let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return BC_PAINTS[(h >>> 0) % 8]; }
const BC = (() => { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg') || '{}'); if (c.signedIn && c.name) return { name: String(c.name).slice(0, 12), color: bcPaint(String(c.name)) }; } catch (e) {} return null; })();
if (BC) { CHARS.unshift({ name: BC.name, c: BC.color, o: shade(BC.color, -0.55), kid: true });
  if (SAVE.kid !== BC.name) { SAVE.kid = BC.name; SAVE.ch = 0; } else if (SAVE.kidCh !== true) SAVE.ch = 0; SAVE.kidCh = true; save(); }
else if (SAVE.kidCh) { SAVE.kidCh = false; SAVE.ch = Math.max(0, (SAVE.ch | 0) - 1); save(); }
if (!(SAVE.ch >= 0 && SAVE.ch < CHARS.length)) SAVE.ch = 0;
function myName() { return BC ? BC.name : (SAVE.name || CHARS[SAVE.ch].name); }
function charCol(ch) { return Array.isArray(ch) ? ch[0] : (CHARS[ch] || CHARS[0]).c; }
const STAR_COLS = ['#ff4040', '#ffd040', '#40e060', '#40a0ff'];
const sprCache = {};
function pipSprite(ch, pw, frame, starPhase) {
  const key = String(ch) + '|' + pw + '|' + frame + '|' + starPhase;
  if (sprCache[key]) return sprCache[key];
  const C = Array.isArray(ch) ? { c: ch[0], o: ch[1] } : CHARS[ch] || CHARS[0]; // remote players send their two colours
  let cap = C.c, out = C.o;
  if (pw === 2) { cap = '#ffffff'; out = C.c; }
  if (starPhase >= 0) { cap = STAR_COLS[starPhase]; out = STAR_COLS[(starPhase + 2) % 4]; }
  const pal = { c: cap, o: out, h: '#5a3210', s: '#f5c28e', k: '#000000', y: '#ffe060', b: '#5a3210' };
  let rows = pipFrame(frame);
  if (pw > 0) rows = BIGMAP.map(i => rows[i]);
  return sprCache[key] = mk(rows, pal);
}
const GRUMBLE = [
  '................',
  '.....kkkkkk.....',
  '...kkccccccckk..',
  '..kccccccccccck.',
  '.kcccwcccccwccck',
  '.kccccccccccccck',
  '.kkkkkkkkkkkkkkk',
  '..kbbbbbbbbbbbk.',
  '..kbwwkbbbwwkbk.',
  '..kbbwkbbbbwkbk.',
  '..kbbbbbbbbbbbk.',
  '..kbbbkkkkkbbbk.',
  '...kbbbbbbbbbk..',
  '....kkkkkkkkk...',
  '...fff....fff...',
  '..ffff....ffff..'];
const GRUMBLE2 = GRUMBLE.slice(0, 14).concat(['....fff..fff....', '...ffff..ffff...']);
const TURTLE = [
  '......kkk.......',
  '.....kyyyk......',
  '....kyywkyk.....',
  '....kyykkyk.....',
  '....kyyyyyk.....',
  '...kyyyyyyk.....',
  '...kyyyykk......',
  '....kkkyk.......',
  '......kyk.kkkk..',
  '.....kyykkggggk.',
  '.....kyykgwggggk',
  '......kkggggwggk',
  '.....kgggwgggggk',
  '....kggggggwgggk',
  '....kgwggggggggk',
  '....kggggwggggk.',
  '....kwwwwwwwwwk.',
  '.....kyyk.kyyk..',
  '....kyyyk.kyyyk.',
  '....kkkk..kkkk..'];
const TURTLE2 = TURTLE.slice(0, 17).concat(['......kyykyyk...', '.....kyyykyyyk..', '.....kkkkkkkk...']);
const SHELL = [
  '.....kkkkkk.....',
  '...kkggwgggkk...',
  '..kgggggwggggk..',
  '.kggwgggggwgggk.',
  '.kgggggwgggggwk.',
  '.kgwggggggwgggk.',
  '.kgggwggggggggk.',
  '..kwwwwwwwwwwk..',
  '..kyyyyyyyyyyk..',
  '...kkkkkkkkkk...'];
const SPR = {};
function buildSprites() {
  const gp = { k: '#000', c: '#7a3d12', b: '#e8b878', w: '#fff', f: '#3a1a05' };
  SPR.grumble = [mk(GRUMBLE, gp), mk(GRUMBLE2, gp)];
  SPR.king = [mk(GRUMBLE, { k: '#000', c: '#6a2a9a', b: '#f0c890', w: '#fff', f: '#2a0a3a' }), mk(GRUMBLE2, { k: '#000', c: '#6a2a9a', b: '#f0c890', w: '#fff', f: '#2a0a3a' })];
  SPR.kingHurt = mk(GRUMBLE, { k: '#fff', c: '#ff6060', b: '#fff', w: '#000', f: '#fff' });
  const tg = { k: '#000', y: '#f2d16b', w: '#fff', g: '#2f9e3a' }, tr = { k: '#000', y: '#f2d16b', w: '#fff', g: '#d8382a' };
  SPR.turtle = [mk(TURTLE, tg), mk(TURTLE2, tg)]; SPR.turtleR = [mk(TURTLE, tr), mk(TURTLE2, tr)];
  SPR.shell = mk(SHELL, tg); SPR.shellR = mk(SHELL, tr);
  SPR.spinyShell = mk(SHELL, { k: '#000', y: '#f2d16b', w: '#fff', g: '#e04020' });
}
buildSprites();
function blit(img, x, y, flip, w, h) {
  x = Math.round(x); y = Math.round(y); w = w || img.width; h = h || img.height;
  if (flip) { ctx.save(); ctx.translate(x + w, y); ctx.scale(-1, 1); ctx.drawImage(img, 0, 0, w, h); ctx.restore(); }
  else ctx.drawImage(img, x, y, w, h);
}

// ---------------- tiles ----------------
// G ground  B brick  ? coin block  P power block  N multi-coin brick  S star brick  1 1-up brick
// U used  H hard  p q l r pipe  Q cannon  T treetop  I trunk  D bridge  A axe  L lava  C coin  # invisible wall
const SOLID = new Set(['G', 'B', '?', 'P', 'N', 'S', '1', 'U', 'H', 'p', 'q', 'l', 'r', 'Q', 'T', 'D', '#']);
let TI = null, TI_THEME = '';
function cnv() { const c = document.createElement('canvas'); c.width = c.height = 16; return [c, c.getContext('2d')]; }
function buildTiles(pal) {
  const T = {}; let c, g;
  [c, g] = cnv(); g.fillStyle = pal.dirt; g.fillRect(0, 0, 16, 16); g.fillStyle = pal.dirt2;
  g.fillRect(0, 15, 16, 1); g.fillRect(15, 0, 1, 16); g.fillRect(0, 7, 9, 1); g.fillRect(8, 9, 8, 1); g.fillRect(8, 0, 1, 8); g.fillRect(4, 8, 1, 8); g.fillRect(11, 9, 1, 7);
  g.fillStyle = shadeI(pal.dirt, 0.25); g.fillRect(1, 1, 6, 1); g.fillRect(9, 10, 1, 4); g.fillRect(1, 9, 2, 1); T.G = c;
  [c, g] = cnv(); g.drawImage(T.G, 0, 0); g.fillStyle = pal.top; g.fillRect(0, 0, 16, 5); g.fillStyle = pal.top2;
  for (let i = 0; i < 16; i++) g.fillRect(i, 5, 1, (i * 7) % 3 === 0 ? 2 : (i % 2 ? 1 : 0));
  g.fillStyle = shadeI(pal.top, 0.35); g.fillRect(0, 0, 16, 1); T.Gt = c;
  [c, g] = cnv(); g.fillStyle = pal.brick; g.fillRect(0, 0, 16, 16); g.fillStyle = pal.brick2;
  for (let r = 0; r < 4; r++) { g.fillRect(0, r * 4 + 3, 16, 1); const o = r % 2 ? 4 : 0; g.fillRect(o, r * 4, 1, 3); g.fillRect(o + 8, r * 4, 1, 3); }
  g.fillStyle = shadeI(pal.brick, 0.3); g.fillRect(0, 0, 16, 1); T.B = c;
  const qmark = ['.kkkk.', 'kk..kk', '....kk', '...kk.', '..kk..', '..kk..', '......', '..kk..'];
  ['#f0a020', '#e09018', '#c87a10'].forEach((col, i) => {
    [c, g] = cnv(); g.fillStyle = '#8a4a00'; g.fillRect(0, 0, 16, 16); g.fillStyle = col; g.fillRect(1, 1, 14, 14);
    g.fillStyle = '#ffd890'; g.fillRect(1, 1, 14, 1); g.fillStyle = '#8a4a00'; [[2, 2], [13, 2], [2, 13], [13, 13]].forEach(p => g.fillRect(p[0], p[1], 1, 1));
    for (let y = 0; y < 8; y++) for (let x = 0; x < 6; x++) if (qmark[y][x] === 'k') { g.fillStyle = '#8a4a00'; g.fillRect(6 + x, 5 + y, 1, 1); g.fillStyle = '#fff'; g.fillRect(5 + x, 4 + y, 1, 1); }
    T['Q' + i] = c;
  });
  [c, g] = cnv(); g.fillStyle = '#5a2e10'; g.fillRect(0, 0, 16, 16); g.fillStyle = '#9a5a2a'; g.fillRect(1, 1, 14, 14);
  g.fillStyle = '#5a2e10'; [[2, 2], [13, 2], [2, 13], [13, 13]].forEach(p => g.fillRect(p[0], p[1], 1, 1)); T.U = c;
  const hb = pal.dark ? pal.brick : shade(pal.dirt, -0.05);
  [c, g] = cnv(); g.fillStyle = hb; g.fillRect(0, 0, 16, 16); g.fillStyle = shadeI(hb, 0.35); g.fillRect(0, 0, 16, 2); g.fillRect(0, 0, 2, 16);
  g.fillStyle = shadeI(hb, -0.45); g.fillRect(0, 14, 16, 2); g.fillRect(14, 0, 2, 16); g.fillStyle = shadeI(hb, -0.2); g.fillRect(5, 5, 6, 6); T.H = c;
  const pg = '#3cb44a', pl = '#a8f0a0', pd = '#16642a';
  [c, g] = cnv(); g.fillStyle = pd; g.fillRect(0, 0, 16, 16); g.fillStyle = pg; g.fillRect(1, 1, 15, 14); g.fillStyle = pl; g.fillRect(3, 1, 2, 14); g.fillRect(7, 1, 1, 14); T.p = c;
  [c, g] = cnv(); g.fillStyle = pd; g.fillRect(0, 0, 16, 16); g.fillStyle = pg; g.fillRect(0, 1, 15, 14); g.fillStyle = pd; g.fillRect(9, 1, 1, 14); g.fillRect(11, 1, 2, 14); T.q = c;
  [c, g] = cnv(); g.fillStyle = pd; g.fillRect(2, 0, 14, 16); g.fillStyle = pg; g.fillRect(3, 0, 13, 16); g.fillStyle = pl; g.fillRect(5, 0, 2, 16); g.fillRect(9, 0, 1, 16); T.l = c;
  [c, g] = cnv(); g.fillStyle = pd; g.fillRect(0, 0, 14, 16); g.fillStyle = pg; g.fillRect(0, 0, 13, 16); g.fillStyle = pd; g.fillRect(8, 0, 1, 16); g.fillRect(10, 0, 2, 16); T.r = c;
  [c, g] = cnv(); g.fillStyle = '#222'; g.fillRect(0, 0, 16, 16); g.fillStyle = '#555'; g.fillRect(0, 0, 16, 2); g.fillRect(0, 14, 16, 2);
  g.fillStyle = '#fff'; g.fillRect(5, 5, 6, 5); g.fillStyle = '#222'; g.fillRect(6, 7, 1, 1); g.fillRect(9, 7, 1, 1); g.fillRect(7, 10, 2, 2); T.Qc = c;
  const tc = pal.tree, tcd = shadeI(pal.tree, -0.35), tcl = shadeI(pal.tree, 0.35);
  ['l', 'm', 'r', 'o'].forEach(k => {
    [c, g] = cnv(); g.fillStyle = tcd; g.fillRect(0, 2, 16, 10); g.fillStyle = tc; g.fillRect(0, 2, 16, 8); g.fillStyle = tcl; g.fillRect(0, 2, 16, 2);
    if (k === 'l' || k === 'o') { g.clearRect(0, 2, 2, 2); g.clearRect(0, 10, 2, 2); g.fillStyle = tcd; g.fillRect(0, 4, 1, 6); }
    if (k === 'r' || k === 'o') { g.clearRect(14, 2, 2, 2); g.clearRect(14, 10, 2, 2); g.fillStyle = tcd; g.fillRect(15, 4, 1, 6); }
    T['T' + k] = c;
  });
  [c, g] = cnv(); g.fillStyle = pal.trunk; g.fillRect(3, 0, 10, 16); g.fillStyle = shadeI(pal.trunk, -0.3); g.fillRect(5, 0, 1, 16); g.fillRect(10, 0, 1, 16); T.I = c;
  [c, g] = cnv(); g.fillStyle = '#d07a2a'; g.fillRect(0, 0, 16, 6); g.fillStyle = '#8a4410'; g.fillRect(0, 5, 16, 1); g.fillRect(7, 0, 1, 5);
  g.fillStyle = '#bbb'; for (let i = 0; i < 16; i += 4) g.fillRect(i, 8 + (i % 8 ? 1 : 0), 3, 2); T.D = c;
  [c, g] = cnv(); g.fillStyle = '#8a5a2a'; g.fillRect(7, 2, 2, 14); g.fillStyle = '#ccc'; g.fillRect(9, 2, 5, 7); g.fillStyle = '#fff'; g.fillRect(9, 2, 5, 1); g.fillStyle = '#888'; g.fillRect(13, 2, 1, 7); T.A = c;
  for (let i = 0; i < 2; i++) { [c, g] = cnv(); g.fillStyle = '#e83a10'; g.fillRect(0, 0, 16, 16); g.fillStyle = '#ffa020';
    for (let x = 0; x < 16; x++) g.fillRect(x, 0, 1, 2 + Math.round(Math.sin((x + i * 4) * Math.PI / 4) * 1.5 + 1.5)); g.fillStyle = '#ffe060'; g.fillRect(i * 8 + 2, 1, 3, 1); T['L' + i] = c; }
  [10, 6, 2, 6].forEach((w, i) => { [c, g] = cnv(); g.fillStyle = '#a86a00'; g.fillRect(8 - w / 2 - 1, 2, w + 2, 12); g.fillStyle = '#ffd030'; g.fillRect(8 - w / 2, 3, w, 10);
    if (w > 4) { g.fillStyle = '#fff4a0'; g.fillRect(8 - w / 2 + 1, 4, 1, 7); } T['C' + i] = c; });
  TI = T;
}
function solid(c) { return SOLID.has(c); }

// ---------------- level generator ----------------
const EDEF = {
  walker: { w: 14, h: 14 }, turtle: { w: 14, h: 22 }, spiny: { w: 14, h: 14 }, plant: { w: 16, h: 24 }, bullet: { w: 14, h: 12 },
  boss: { w: 28, h: 30 }, flame: { w: 16, h: 8 }, ember: { w: 12, h: 12, local: 1 }, firebar: { w: 8, h: 8, local: 1 }, lift: { w: 48, h: 8, local: 1 }
};
function mkEnt(type, x, y, o) { const d = EDEF[type]; return Object.assign({ id: 0, type, x, y, w: d.w, h: d.h, vx: 0, vy: 0, dir: -1, st: 'walk', t: 0, alive: true, active: false, onG: false, pend: 0, local: !!d.local, hp: 5 }, o || {}); }

function genLevel(w, s) {
  const kind = s === 4 ? 'castle' : s === 2 ? 'cave' : s === 3 ? 'sky' : 'over';
  const rnd = mulberry(w * 7919 + s * 104729 + 13), R = (a, b) => a + Math.floor(rnd() * (b - a + 1)), ch = p => rnd() < p;
  const d = (w - 1) + (s - 1) * 0.35;
  const LEN = (kind === 'castle' ? 140 : 165) + w * 10, MAXW = LEN + 80;
  const T = []; for (let y = 0; y < ROWS; y++) T.push(new Array(MAXW).fill('.'));
  const L = { w: 0, t: T, kind, theme: themeOf(w, s), world: w, stage: s, ents: [], items: [], parts: [], pops: [], bumps: {}, multi: {}, changed: {},
    cannons: [], eid: 1, iid: 100000, frame: 0, midX: 0, pole: null, castleX: 0, bridge: null, axe: null, time: kind === 'castle' ? 300 : 400 };
  let gh = kind === 'castle' ? 3 : 2, x = 0;
  const ceil = kind === 'cave' || kind === 'castle' ? 2 : 0;
  const top = () => ROWS - gh;
  const set = (cx, cy, c) => { if (cy >= 0 && cy < ROWS && cx >= 0 && cx < MAXW) T[cy][cx] = c; };
  const fillCeil = cx => { for (let y = 0; y < ceil; y++) set(cx, y, 'G'); };
  const ground = cx => { for (let y = top(); y < ROWS; y++) set(cx, y, 'G'); fillCeil(cx); };
  const pit = cx => { fillCeil(cx); if (kind === 'castle') { set(cx, ROWS - 1, 'L'); set(cx, ROWS - 2, 'L'); } };
  const ent = (type, cx, bottomPx, o) => { const e = mkEnt(type, cx * 16 + 1, 0, o); e.y = bottomPx - e.h; e.id = L.eid++; L.ents.push(e); return e; };
  const pool = ['walker', 'walker']; if (d >= 0.7) pool.push('turtle'); if (d >= 2.5) pool.push('flyer'); if (d >= 3.5) pool.push('red'); if (d >= 5.5) pool.push('spiny'); if (d >= 7) pool.push('spiny', 'flyer', 'red');
  const enemy = (cx, row) => { const k = pool[R(0, pool.length - 1)], by = row * 16;
    if (k === 'walker') ent('walker', cx, by); else if (k === 'turtle') ent('turtle', cx, by); else if (k === 'red') ent('turtle', cx, by, { red: true });
    else if (k === 'flyer') ent('turtle', cx, by, { wing: true }); else ent('spiny', cx, by); };
  let lastPow = -25;
  const qChar = () => { if (x - lastPow > 28 && ch(0.55)) { lastPow = x; return 'P'; } return '?'; };
  const bChar = () => ch(0.07) ? 'N' : ch(0.025) ? 'S' : ch(0.02) ? '1' : 'B';
  const ep = (kind === 'castle' ? 0.12 : 0.45) + d * 0.035;
  function flat(n, p) {
    const sx = x; for (let i = 0; i < n; i++) ground(x + i);
    if (p && ch(p) && n >= 3) { const k = R(1, Math.min(3, 1 + Math.floor(d / 3))); const ex = sx + R(1, Math.max(1, n - 2));
      for (let i = 0; i < k; i++) { const cx = ex + i * 2; if (cx < sx + n) enemy(cx, top()); } }
    x += n;
  }
  const seg = {
    flat() { flat(R(4, 9), ep); },
    gap() { const gw = R(2, Math.min(4, 2 + Math.floor(d / 2.5))); for (let i = 0; i < gw; i++) pit(x + i);
      if (ch(0.3) && kind !== 'castle') for (let i = 0; i < gw; i++) set(x + i, top() - 3, 'C');
      if (kind === 'castle' && ch(0.6)) ent('ember', x + Math.floor(gw / 2), ROWS * 16 - 4, { base: ROWS * 16 - 16, t: R(0, 100) });
      x += gw; flat(R(3, 5), ep * 0.5); },
    pipe() { flat(R(2, 3), 0); const ph = R(2, 3), pt = top() - ph;
      ground(x); ground(x + 1); set(x, pt, 'p'); set(x + 1, pt, 'q'); for (let y = pt + 1; y < top(); y++) { set(x, y, 'l'); set(x + 1, y, 'r'); }
      if (d >= 1 && ch(0.25 + d * 0.06)) { const e = mkEnt('plant', x * 16 + 8, pt * 16); e.id = L.eid++; e.base = pt * 16; e.off = 24; e.t = R(0, 150); L.ents.push(e); }
      x += 2; flat(R(2, 5), ep); },
    blocks() { const n = R(7, 10), sx = x; flat(n, ep); const row = top() - 4, bx = sx + R(1, Math.max(1, n - 6)), pat = R(0, 3);
      if (row <= ceil + 1) return;
      if (pat === 0) set(bx + 2, row, qChar());
      else if (pat === 1) { for (let i = 0; i < 5; i++) set(bx + i, row, i % 2 ? qChar() : bChar()); if (row - 4 > ceil + 1 && ch(0.5)) set(bx + 2, row - 4, qChar()); }
      else if (pat === 2) { for (let i = 0; i < 4; i++) set(bx + i, row, i === 1 ? qChar() : bChar()); }
      else { for (let i = 0; i < 3; i++) set(bx + i + 1, row, '?'); if (row - 4 > ceil + 1) for (let i = 0; i < 3; i++) set(bx + i + 1, row - 4, 'C'); } },
    stairs() { const k = Math.max(2, Math.min(R(3, Math.min(7, 3 + Math.floor(d / 2))), top() - ceil - 5)); flat(1, 0);
      for (let i = 0; i < k; i++) { ground(x); for (let j = 0; j <= i; j++) set(x, top() - 1 - j, 'H'); x++; }
      if (ch(0.5)) { const gw = R(1, 2); for (let i = 0; i < gw; i++) pit(x + i); x += gw; }
      for (let i = 0; i < k; i++) { ground(x); for (let j = 0; j < k - i; j++) set(x, top() - 1 - j, 'H'); x++; }
      flat(R(2, 4), ep * 0.5); },
    step() { gh = clamp(gh + R(-2, 2), 2, kind === 'cave' || kind === 'castle' ? 5 : 6); flat(R(3, 7), ep); },
    coins() { const n = R(5, 8), sx = x; flat(n, 0); const two = ch(0.4); for (let i = 1; i < n - 1; i++) { set(sx + i, top() - 3, 'C'); if (two) set(sx + i, top() - 4, 'C'); } },
    plat() { const len = R(8, 12 + Math.floor(d)), end = x + len; let py = top(), px = x;
      const mat = kind === 'sky' ? 'T' : 'H';
      let pw = 4; // width of the platform we jump from: narrow ones get shorter, lower jumps
      for (;;) { const g = pw <= 2 ? 2 : R(2, 3); if (px + g + 2 >= end) break; for (let i = 0; i < g; i++) pit(px + i); px += g;
        const ny = clamp(py + R(pw <= 2 ? -2 : -3, 2), ceil + 5, ROWS - 3), wd = R(2, 4); pw = wd;
        for (let i = 0; i < wd; i++) { pit(px + i); set(px + i, ny, mat); if (mat === 'T' && i > 0 && i < wd - 1) for (let y = ny + 1; y < ROWS; y++) set(px + i, y, 'I'); }
        if (mat === 'T' && wd === 2) for (let y = ny + 1; y < ROWS; y++) set(px, y, 'I');
        if (wd >= 3 && ch(0.25 + d * 0.04)) enemy(px + 1, ny);
        py = ny; px += wd; }
      const g = pw <= 2 ? 2 : R(2, 3); for (let i = 0; i < g; i++) pit(px + i); px += g;
      if (top() < py - 3) gh = ROWS - (py - 3);
      x = px; flat(R(3, 5), ep * 0.5); },
    lift() { const len = R(5, 8); for (let i = 0; i < len; i++) pit(x + i);
      const e = mkEnt('lift', x * 16, top() * 16); e.id = L.eid++; e.x0 = x * 16; e.x1 = (x + len) * 16 - 48; e.y0 = top() * 16; e.per = R(220, 320); e.ph = R(0, 300); L.ents.push(e);
      x += len; flat(R(3, 5), ep * 0.5); },
    cannon() { const n = R(6, 9), sx = x; flat(n, ep * 0.5); const cx = sx + R(2, n - 3), h = ch(0.5) ? 2 : 1;
      for (let i = 1; i <= h; i++) set(cx, top() - i, 'Q'); L.cannons.push({ x: cx, y: top() - h, t: R(0, 150) }); },
    firebar() { const n = R(6, 9), sx = x; flat(n, 0); const bx = sx + Math.floor(n / 2), low = ch(0.5), by = low ? top() - 1 : top() - 4;
      set(bx, by, 'H'); const e = mkEnt('firebar', bx * 16 + 8, by * 16 + 8); e.id = L.eid++; e.len = d > 6 ? 6 : 5; e.ang = rnd() * 6.28; e.spd = (ch(0.5) ? 1 : -1) * (0.03 + d * 0.003); L.ents.push(e); }
  };
  const WT = {
    over: { flat: 5, gap: 4 + d * 0.3, pipe: 4, blocks: 5, stairs: 2, step: 3, coins: 1.5, plat: 1 + d * 0.2, cannon: d >= 4 ? 1.5 : 0, lift: d >= 3 ? 1 : 0 },
    cave: { flat: 4, gap: 3, pipe: 3, blocks: 5, stairs: 1.5, step: 3, coins: 2, plat: 2 + d * 0.2, lift: d >= 5 ? 1 : 0 },
    sky: { flat: 1.2, plat: 6, lift: 2 + d * 0.3, coins: 1, blocks: 1, gap: 1, cannon: d >= 6 ? 0.8 : 0 },
    castle: { flat: 3, gap: 3 + d * 0.3, firebar: 3 + d * 0.3, step: 2, plat: 1.5, blocks: 0.7 }
  }[kind];
  const names = Object.keys(WT), tot = names.reduce((a, k) => a + WT[k], 0);
  flat(10, 0);
  if (kind !== 'castle') { set(6, top() - 4, '?'); }
  while (x < LEN) {
    if (!L.midX && x >= LEN * 0.5) { L.midX = x; flat(3, 0); continue; }
    let r = rnd() * tot, k = names[0]; for (const n of names) { r -= WT[n]; if (r <= 0) { k = n; break; } }
    seg[k]();
  }
  if (kind !== 'castle') {
    flat(3, 0);
    const sh = Math.max(3, Math.min(8, top() - ceil - 4));
    for (let i = 0; i < sh; i++) { ground(x); for (let j = 0; j <= i; j++) set(x, top() - 1 - j, 'H'); x++; }
    ground(x); for (let j = 0; j < sh; j++) set(x, top() - 1 - j, 'H'); x++;
    flat(3, 0);
    const pc = x; flat(14, 0); set(pc, top() - 1, 'H');
    const ph = Math.min(10, top() - 2 - ceil);
    L.pole = { x: pc * 16 + 8, top: (top() - 1 - ph) * 16, bottom: (top() - 1) * 16 };
    L.flagY = L.pole.top + 4;
    L.castleX = pc + 5; L.castleY = top();
    L.w = x;
  } else {
    gh = Math.max(gh, 3); flat(4, 0);
    const bx = x, bl = 13, row = top();
    for (let i = 0; i < bl; i++) { fillCeil(x + i); set(x + i, row, 'D'); set(x + i, ROWS - 1, 'L'); set(x + i, ROWS - 2, 'L'); }
    x += bl; ground(x); set(x, top() - 1, 'A'); L.axe = { x, y: top() - 1 }; x++;
    flat(10, 0);
    L.bridge = { x0: bx, x1: bx + bl - 1, row };
    const b = ent('boss', bx + 8, row * 16); b.minX = bx * 16 + 24; b.maxX = (bx + bl) * 16 - 40; b.hp = 5; b.st = 'walk';
    L.castleX = x - 6; L.castleY = top(); L.princess = true;
    L.w = x;
  }
  if (!L.midX) L.midX = 10;
  return L;
}

// ---------------- game state ----------------
let L = null;
const G = { mode: 'menu', li: 0, me: null, others: new Map(), lives: 3, score: 0, coins: 0, pw: 0, fb: [], cx: 0, introT: 0, msg: '', msgT: 0,
  check: false, clearT: 0, mp: false, debugGod: false, bot: false, titleT: 0 };
function li2ws(li) { return [(li >> 2) + 1, (li & 3) + 1]; }
function newPlayer(ch, name) { return { ch, name, x: 0, y: 0, w: 12, h: 14, vx: 0, vy: 0, face: 1, onG: false, pw: 0, invT: 0, starT: 0, dead: false, deadT: 0,
  growT: 0, state: 'play', anim: 0, coyote: 0, jbuf: 0, pa: false, pb: false, jh: false, onLift: null, stompChain: 0, safeX: 32, safeY: 0 }; }
function tileAt(cx, cy) { if (cy < 0 || cy >= ROWS) return '.'; if (cx < 0 || cx >= L.w) return '#'; return L.t[cy][cx]; }
function solidAt(cx, cy) { return solid(tileAt(cx, cy)); }
function surfaceRow(col) { for (let y = ROWS - 1; y > 0; y--) if (solidAt(col, y) && !solidAt(col, y - 1)) return y; return ROWS - 2; }
function setPw(p, pw) { if (pw > 0 && p.pw === 0) { p.y -= 14; p.h = 28; } if (pw === 0 && p.pw > 0) { p.y += 14; p.h = 14; } p.pw = pw; }
function placeAt(p, col) { p.x = col * 16 + 2; p.y = surfaceRow(col) * 16 - p.h; p.vx = 0; p.vy = 0; }
const authority = () => !NET.inRoom || NET.id === NET.host;

function startLevel(li, o) {
  o = o || {};
  G.li = li; const [w, s] = li2ws(li);
  L = genLevel(w, s); buildTiles(PALS[L.theme]); TI_THEME = L.theme; NET.goneItems.clear();
  const me = G.me = newPlayer(SAVE.ch, myName());
  setPw(me, o.pw || 0);
  placeAt(me, o.check ? L.midX : 2);
  if (o.at) { me.x = o.at.x; me.y = o.at.y - me.h; }
  G.check = !!o.check; G.fb = []; G.cx = clamp(me.x - VW * 0.42, 0, L.w * 16 - VW);
  G.mode = 'intro'; G.introT = G.mp ? 90 : 140; G.clearT = 0; G.msgT = 0;
  if (o.tiles) for (const k in o.tiles) { const [tx, ty] = k.split(',').map(Number); L.t[ty][tx] = o.tiles[k]; }
  stopMusic(); showTouch(true);
}
function musicFor() { return { over: 'over', cave: 'cave', sky: 'sky', castle: 'castle' }[L.kind]; }

// ---------------- input ----------------
const keys = {};
addEventListener('keydown', e => {
  if (e.target && e.target.tagName === 'INPUT') return;
  keys[e.code] = true;
  if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code) && (G.mode === 'play' || G.mode === 'intro')) e.preventDefault();
  if ((e.code === 'KeyP' || e.code === 'Escape') && (G.mode === 'play' || G.mode === 'pause')) togglePause();
});
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
const TB = { L: $('tbL'), R: $('tbR'), A: $('tbA'), B: $('tbB') }, tPtr = new Map();
function tbAt(x, y) { for (const k in TB) { const r = TB[k].getBoundingClientRect(), pad = 14; if (x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad) return k; } return null; }
const tl = $('touch');
function tUpd(e) { if (e.type === 'pointerup' || e.type === 'pointercancel') tPtr.delete(e.pointerId); else tPtr.set(e.pointerId, tbAt(e.clientX, e.clientY));
  const on = new Set(tPtr.values()); for (const k in TB) TB[k].classList.toggle('down', on.has(k)); e.preventDefault(); ac(); }
['pointerdown', 'pointermove', 'pointerup', 'pointercancel'].forEach(t => tl.addEventListener(t, tUpd));
function showTouch(on) { const t = on && isTouch; tl.classList.toggle('hide', !t); tl.classList.toggle('on', t); $('pauseBtn').classList.toggle('hide', !on); }
function readInput() {
  const k = keys, tp = new Set(tPtr.values());
  const inp = { l: k.ArrowLeft || k.KeyA || tp.has('L'), r: k.ArrowRight || k.KeyD || tp.has('R'),
    a: k.ArrowUp || k.KeyW || k.Space || k.KeyZ || k.KeyK || tp.has('A'), b: k.KeyX || k.ShiftLeft || k.ShiftRight || k.KeyJ || tp.has('B') };
  const gps = navigator.getGamepads ? navigator.getGamepads() : [];
  for (const gp of gps) { if (!gp) continue; const ax = gp.axes[0] || 0;
    if (ax < -0.4 || (gp.buttons[14] && gp.buttons[14].pressed)) inp.l = true; if (ax > 0.4 || (gp.buttons[15] && gp.buttons[15].pressed)) inp.r = true;
    if (gp.buttons[0] && gp.buttons[0].pressed) inp.a = true; if ((gp.buttons[2] && gp.buttons[2].pressed) || (gp.buttons[1] && gp.buttons[1].pressed)) inp.b = true;
    if (gp.buttons[9] && gp.buttons[9].pressed && !G.gpStart && (G.mode === 'play' || G.mode === 'pause')) togglePause(); G.gpStart = gp.buttons[9] && gp.buttons[9].pressed; }
  if (G.bot) return botInput();
  if (G.paused) return { l: false, r: false, a: false, b: false };
  return inp;
}

// ---------------- physics helpers ----------------
function overlap(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }
function moveX(o) {
  o.x += o.vx; o.hitWall = 0;
  const t0 = Math.floor(o.y / 16), t1 = Math.floor((o.y + o.h - 1) / 16);
  if (o.vx > 0) { const tx = Math.floor((o.x + o.w - 1) / 16); for (let ty = t0; ty <= t1; ty++) if (solidAt(tx, ty)) { o.x = tx * 16 - o.w; o.hitWall = 1; break; } }
  else if (o.vx < 0) { const tx = Math.floor(o.x / 16); for (let ty = t0; ty <= t1; ty++) if (solidAt(tx, ty)) { o.x = (tx + 1) * 16; o.hitWall = -1; break; } }
}
function moveY(o) {
  o.y += o.vy; o.onG = false; o.headHit = null;
  const l = Math.floor(o.x / 16), r = Math.floor((o.x + o.w - 1) / 16);
  if (o.vy > 0) { const ty = Math.floor((o.y + o.h - 1) / 16); for (let tx = l; tx <= r; tx++) if (solidAt(tx, ty)) { o.y = ty * 16 - o.h; o.vy = 0; o.onG = true; break; } }
  else if (o.vy < 0) { const ty = Math.floor(o.y / 16); let best = null, bd = 99;
    for (let tx = l; tx <= r; tx++) if (solidAt(tx, ty)) { const dd = Math.abs(tx * 16 + 8 - (o.x + o.w / 2)); if (dd < bd) { bd = dd; best = tx; } }
    if (best !== null) { o.y = (ty + 1) * 16; o.vy = 0; o.headHit = [best, ty]; } }
}
function physEnt(e) { e.vy = Math.min(e.vy + 0.35, 5); moveX(e); moveY(e); }
function groundAhead(e) { const fx = e.dir > 0 ? e.x + e.w + 1 : e.x - 1; return solidAt(Math.floor(fx / 16), Math.floor((e.y + e.h + 2) / 16)); }

// ---------------- tiles changes / bumps ----------------
function setTile(tx, ty, c) {
  if (L.t[ty][tx] === c) return; L.t[ty][tx] = c;
  if (NET.inRoom && authority()) { L.changed[tx + ',' + ty] = c; netSend({ t: 'tile', x: tx, y: ty, c, li: G.li }); }
}
function addScore(n, x, y) { G.score += n; if (x !== undefined) L.pops.push({ x, y, txt: String(n), t: 45 }); }
function addCoin() { G.coins++; addScore(200); sfx('coin'); if (G.coins >= 100) { G.coins -= 100; G.lives++; sfx('oneup'); } }
function coinPop(tx, ty) { L.parts.push({ k: 'coin', x: tx * 16 + 4, y: ty * 16 - 12, vx: 0, vy: -5, t: 0 }); }
function bumpTile(tx, ty, big, local) {
  const c = tileAt(tx, ty), key = tx + ',' + ty; L.bumps[key] = 10;
  if (local && NET.inRoom && !authority()) netSend({ t: 'bump', x: tx, y: ty, big: big ? 1 : 0, li: G.li });
  if (c === '?' || c === 'N') {
    if (local) { coinPop(tx, ty); addCoin(); }
    if (c === 'N') { L.multi[key] = (L.multi[key] === undefined ? 8 : L.multi[key]) - 1; if (L.multi[key] <= 0) setTile(tx, ty, 'U'); }
    else setTile(tx, ty, 'U');
  } else if (c === 'P' || c === 'S' || c === '1') {
    setTile(tx, ty, 'U'); sfx('sprout');
    if (authority()) spawnItem(c === 'S' ? 'star' : c === '1' ? 'oneup' : big ? 'flower' : 'grow', tx, ty);
  } else if (c === 'B') {
    if (big) { setTile(tx, ty, '.'); delete L.bumps[key]; if (local) addScore(50); sfx('brk');
      for (let i = 0; i < 4; i++) L.parts.push({ k: 'brick', x: tx * 16 + (i % 2) * 8, y: ty * 16 + (i >> 1) * 8, vx: (i % 2 ? 1.2 : -1.2), vy: (i >> 1) ? -3 : -5, t: 0 }); }
    else sfx('bump');
  } else sfx('bump');
  // things standing on the block get knocked
  for (const e of L.ents) if (e.alive && !e.local && e.st !== 'dead' && Math.abs(e.y + e.h - ty * 16) < 4 && e.x + e.w > tx * 16 && e.x < tx * 16 + 16) {
    if (authority()) killEnt(e, local); else if (local) { netSend({ t: 'hit', id: e.id, li: G.li }); }
  }
  if (tileAt(tx, ty - 1) === 'C') { setTile(tx, ty - 1, '.'); if (local) { coinPop(tx, ty - 1); addCoin(); } if (local && NET.inRoom && !authority()) netSend({ t: 'coin', x: tx, y: ty - 1, li: G.li }); }
}
function spawnItem(type, tx, ty) { const it = { id: L.iid++, type, x: tx * 16 + 1, y: ty * 16, w: 14, h: 15, vx: 0, vy: 0, rise: 16, onG: false }; L.items.push(it); return it; }

// ---------------- enemies ----------------
function killEnt(e, credit) { // knocked upside down (fire, star, shell, bump)
  if (e.type === 'boss') { e.hp--; e.flash = 20; sfx('boss'); if (e.hp <= 0) { e.st = 'dead'; e.vy = -4; L.bossDown = true; if (credit) addScore(5000, e.x, e.y); } return; }
  if (e.type === 'plant') { e.alive = false; if (credit) addScore(200, e.x, e.y - 16); sfx('kick'); return; }
  e.st = 'dead'; e.vy = -4; e.vx = 0; e.t = 0; if (credit) addScore(200, e.x, e.y); sfx('kick');
}
function stompEnt(e, p) {
  const val = [100, 200, 400, 800, 1000, 2000, 4000, 8000][Math.min(7, p.stompChain)]; p.stompChain++;
  if (e.type === 'walker') { e.st = 'flat'; e.t = 0; e.vx = 0; }
  else if (e.type === 'bullet') { e.st = 'dead'; e.vy = 0; }
  else if (e.type === 'turtle') {
    if (e.wing) e.wing = false;
    else if (e.st === 'walk') { e.st = 'shell'; e.t = 0; e.y += 8; e.h = 14; e.vx = 0; }
    else if (e.st === 'spin') { e.st = 'shell'; e.t = 0; e.vx = 0; }
  }
  addScore(val, e.x, e.y); sfx('stomp');
  if (NET.inRoom && !authority()) { e.pend = 30; netSend({ t: 'stomp', id: e.id, li: G.li }); }
}
function kickShell(e, dir, local) {
  e.st = 'spin'; e.dir = dir; e.kick = 12; e.t = 0; if (local) { sfx('kick'); addScore(400, e.x, e.y); }
  if (local && NET.inRoom && !authority()) { e.pend = 30; netSend({ t: 'kick', id: e.id, dir, li: G.li }); }
}
function allPlayers() { const a = [G.me]; for (const o of G.others.values()) if (o.li === G.li && !o.dead) a.push(o); return a; }
function updEnts() {
  const ps = allPlayers(); let minX = 1e9, maxX = -1e9; for (const p of ps) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); }
  const actR = maxX + VW, actL = minX - VW;
  for (const e of L.ents) {
    if (!e.alive || e.local) continue;
    if (!e.active) { if (e.x < actR && e.x > actL) e.active = true; else continue; }
    e.t++; if (e.flash) e.flash--;
    if (e.st === 'dead' && e.type !== 'boss') { e.vy += 0.35; e.y += e.vy; if (e.y > ROWS * 16 + 32) e.alive = false; continue; }
    switch (e.type) {
      case 'walker': case 'spiny':
        if (e.st === 'flat') { if (e.t > 30) e.alive = false; break; }
        e.vx = e.dir * 0.5; physEnt(e); if (e.hitWall) e.dir = -e.hitWall; break;
      case 'turtle':
        if (e.st === 'walk') { e.vx = e.dir * 0.5; if (e.wing && e.onG) e.vy = -5.5; if (e.red && e.onG && !groundAhead(e)) e.dir = -e.dir; e.vx = e.dir * 0.5;
          physEnt(e); if (e.hitWall) e.dir = -e.hitWall; }
        else if (e.st === 'shell') { e.vx = 0; physEnt(e); if (e.t > 480) { e.st = 'walk'; e.y -= 8; e.h = 22; e.t = 0; } }
        else if (e.st === 'spin') { if (e.kick) e.kick--; e.vx = e.dir * 4; physEnt(e); if (e.hitWall) { e.dir = -e.hitWall; if (Math.abs(e.x - G.cx - VW / 2) < VW) sfx('bump'); }
          for (const o of L.ents) if (o !== e && o.alive && !o.local && o.active && o.st !== 'dead' && o.type !== 'boss' && o.type !== 'flame' && overlap(e, o)) killEnt(o, false); }
        break;
      case 'plant': {
        const cyc = e.t % 220; let near = false; for (const p of ps) if (Math.abs(p.x + p.w / 2 - (e.x + 8)) < 28) near = true;
        if (cyc === 0 && near) e.t--; // stay hidden while someone stands next to the pipe
        e.off = cyc < 60 ? 24 - cyc * 0.4 : cyc < 120 ? 0 : cyc < 180 ? (cyc - 120) * 0.4 : 24;
        e.y = e.base - 24 + e.off; break; }
      case 'bullet': e.x += e.vx; if (e.x < actL - 64 || e.x > actR + 64) e.alive = false; break;
      case 'flame': e.x += e.vx; e.y += (e.ty - e.y) * 0.05; if (e.x < actL - 64 || e.x > actR + 64) e.alive = false; break;
      case 'boss': updBoss(e, ps); break;
    }
    if (e.y > ROWS * 16 + 48) e.alive = false;
  }
  // walkers bounce off each other
  const act = L.ents.filter(e => e.alive && e.active && !e.local && e.st === 'walk' && (e.type === 'walker' || e.type === 'turtle' || e.type === 'spiny'));
  for (let i = 0; i < act.length; i++) for (let j = i + 1; j < act.length; j++) { const a = act[i], b = act[j];
    if (overlap(a, b)) { if (a.x < b.x) { a.dir = -1; b.dir = 1; } else { a.dir = 1; b.dir = -1; } } }
  // cannons
  for (const c of L.cannons) { c.t++; if (c.t < 200) continue; let tgt = null, bd = 1e9;
    for (const p of ps) { const dx = p.x - c.x * 16; if (Math.abs(dx) > 48 && Math.abs(dx) < 260 && Math.abs(dx) < bd) { bd = Math.abs(dx); tgt = p; } }
    if (tgt) { c.t = Math.floor(Math.random() * 60); const dir = tgt.x < c.x * 16 ? -1 : 1; const e = mkEnt('bullet', c.x * 16 + 1 + dir * 8, c.y * 16 + 2, { vx: dir * 1.6, dir, active: true }); e.id = L.iid++; L.ents.push(e); sfx('boss'); } }
  L.ents = L.ents.filter(e => e.alive);
}
function updBoss(e, ps) {
  if (e.st === 'dead' || e.st === 'fall') { e.vy = Math.min(e.vy + 0.3, 6); e.y += e.vy; if (e.y > ROWS * 16 + 40) { e.alive = false; L.bossDown = true; } return; }
  const tgt = ps.reduce((a, p) => (!a || Math.abs(p.x - e.x) < Math.abs(a.x - e.x)) ? p : a, null);
  if (e.t % 160 < 80) e.vx = -0.5; else e.vx = 0.5;
  if (e.x < e.minX) e.vx = 0.5; if (e.x > e.maxX) e.vx = -0.5;
  e.dir = tgt && tgt.x < e.x ? -1 : 1;
  if (e.onG && e.t % 150 === 75) e.vy = -5;
  physEnt(e);
  if (!e.onG && e.y > L.bridge.row * 16) e.st = 'fall';
  if (tgt && e.t % 170 === 0 && Math.abs(tgt.x - e.x) < 260) {
    const f = mkEnt('flame', e.x + (e.dir < 0 ? -12 : 24), e.y + 6, { vx: e.dir * 1.5, active: true, ty: Math.min(tgt.y + 4, L.bridge.row * 16 - 12) }); f.id = L.iid++; L.ents.push(f); sfx('boss'); }
}
function updLocalEnts() { // lifts, firebars and lava embers run on every screen
  for (const e of L.ents) { if (!e.local) continue;
    if (e.type === 'lift') { const nx = e.x0 + (1 - Math.cos((L.frame + e.ph) * 2 * Math.PI / e.per)) / 2 * (e.x1 - e.x0); e.dx = nx - e.x; e.dy = 0; e.x = nx; e.y = e.y0; }
    else if (e.type === 'firebar') e.ang += e.spd;
    else if (e.type === 'ember') { e.t++; if (e.st !== 'up' && e.t % 150 === 0) { e.st = 'up'; e.vy = -7.6; }
      if (e.st === 'up') { e.vy += 0.2; e.y += e.vy; if (e.y >= e.base) { e.y = e.base; e.st = 'wait'; } } }
  }
}
function updItems() {
  for (const it of L.items) {
    if (it.rise > 0) { it.y -= 0.5; it.rise -= 0.5; if (it.rise <= 0) { it.vx = it.type === 'flower' ? 0 : 1; if (it.type === 'star') it.vy = -4; } continue; }
    if (it.type === 'flower') continue;
    it.vy = Math.min(it.vy + (it.type === 'star' ? 0.25 : 0.35), 5); moveX(it); if (it.hitWall) it.vx = -it.hitWall * Math.max(1, Math.abs(it.vx)); moveY(it);
    if (it.type === 'star' && it.onG) it.vy = -4.5;
    if (it.y > ROWS * 16 + 32) it.gone = true;
  }
  L.items = L.items.filter(i => !i.gone);
}

// ---------------- player ----------------
function hurt(p) {
  if (p.invT > 0 || p.starT > 0 || p.dead || p.state !== 'play' || G.debugGod) return;
  if (p.pw > 0) { setPw(p, 0); p.invT = 120; sfx('hurt'); } else die(p);
}
function die(p) {
  if (p.dead) return; if (G.debugGod && p.y < ROWS * 16) return;
  p.dead = true; p.deadT = 0; p.vx = 0; p.vy = 0; p.starT = 0; stopMusic(); sfx('die');
  if (p.pw > 0) setPw(p, 0);
}
function updPlayer(p, inp) {
  if (p.state === 'flag') return flagSeq(p);
  if (p.state === 'walkout') { p.vx = 1.2; p.face = 1; p.vy = Math.min(p.vy + 0.6, 6); moveX(p); moveY(p); p.anim += 0.18;
    if (p.x + p.w / 2 >= L.castleX * 16 + 40) p.state = 'done'; return; }
  if (p.state === 'done' || p.state === 'frozen') return;
  if (p.dead) { p.deadT++; if (p.deadT === 30) p.vy = -6; if (p.deadT > 30) { p.vy = Math.min(p.vy + 0.3, 6); p.y += p.vy; } return; }
  if (p.growT > 0) { p.growT--; return; }
  if (p.invT > 0) p.invT--;
  if (p.starT > 0) { p.starT--; if (p.starT === 0) playMusic(musicFor()); }
  if (p.onLift) { p.x += p.onLift.dx; p.y += p.onLift.dy; }
  const run = inp.b || SAVE.run, max = run ? 2.6 : 1.6;
  const ax = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
  if (ax) { if (p.vx * ax < 0) p.vx += ax * (p.onG ? 0.22 : 0.12);
    else if (Math.abs(p.vx) < max) p.vx = Math.min(max, Math.abs(p.vx) + (run ? 0.08 : 0.06)) * ax;
    else if (p.onG) p.vx -= Math.sign(p.vx) * 0.05;
    p.face = ax; }
  else if (p.onG) { if (Math.abs(p.vx) <= 0.1) p.vx = 0; else p.vx -= Math.sign(p.vx) * 0.1; }
  if (inp.a && !p.pa) p.jbuf = 7; else if (p.jbuf > 0) p.jbuf--;
  if (p.jbuf > 0 && (p.onG || p.coyote > 0)) { p.vy = -(5.4 + Math.abs(p.vx) * 0.3); p.onG = false; p.coyote = 0; p.jbuf = 0; p.jh = true; p.onLift = null; sfx(p.pw ? 'jumpbig' : 'jump'); }
  p.pa = inp.a; if (!inp.a) p.jh = false;
  p.vy += (p.vy < 0 && p.jh) ? 0.22 : 0.6; if (p.vy > 6) p.vy = 6;
  if (inp.b && !p.pb && p.pw === 2 && G.fb.length < 2) { G.fb.push({ x: p.x + (p.face > 0 ? p.w : -6), y: p.y + 8, vx: p.face * 4.2, vy: 2, t: 0 }); sfx('fire'); }
  p.pb = inp.b;
  moveX(p); if (p.hitWall) p.vx = 0;
  const prevBottom = p.y + p.h, yPre = p.y, vyPre = p.vy;
  moveY(p);
  // corner slip: just clipping the edge of a block above moves you beside it and you keep rising
  if (p.headHit) { const [tx, ty] = p.headHit; const cx = p.x + p.w / 2;
    const lOver = (tx * 16 + 16) - p.x, rOver = (p.x + p.w) - tx * 16;
    let nx = null;
    if (cx < tx * 16 && rOver <= 5 && !solidAt(tx - 1, ty)) nx = tx * 16 - p.w;
    else if (cx > tx * 16 + 16 && lOver <= 5 && !solidAt(tx + 1, ty)) nx = tx * 16 + 16;
    if (nx !== null) { p.x = nx; p.y = yPre + vyPre; p.vy = vyPre; p.headHit = null; } }
  if (p.headHit) { bumpTile(p.headHit[0], p.headHit[1], p.pw > 0, true); p.jh = false; }
  // lifts
  p.onLift = null;
  if (p.vy >= 0) for (const l of L.ents) { if (l.type !== 'lift') continue;
    if (p.x + p.w > l.x && p.x < l.x + l.w && prevBottom <= l.y + 3 && p.y + p.h >= l.y) { p.y = l.y - p.h; p.vy = 0; p.onG = true; p.onLift = l; } }
  if (p.onG) { p.coyote = 6; p.stompChain = 0; if (!p.onLift && solidAt(Math.floor((p.x + p.w / 2) / 16), Math.floor((p.y + p.h + 2) / 16))) { p.safeX = p.x; p.safeY = p.y + p.h; } }
  else if (p.coyote > 0) p.coyote--;
  if (p.y > ROWS * 16 + 8) { if (G.debugGod) { p.x = p.safeX; p.y = p.safeY - p.h - 2; p.vy = 0; p.vx = 0; G.botPlan = null; } else die(p); }
  p.anim += Math.abs(p.vx) * 0.12;
  // tiles touched: coins, lava, axe
  const c0 = Math.floor(p.x / 16), c1 = Math.floor((p.x + p.w - 1) / 16), r0 = Math.floor(p.y / 16), r1 = Math.floor((p.y + p.h - 1) / 16);
  for (let ty = r0; ty <= r1; ty++) for (let tx = c0; tx <= c1; tx++) { const c = tileAt(tx, ty);
    if (c === 'C') { setTile(tx, ty, '.'); L.t[ty][tx] = '.'; addCoin(); if (NET.inRoom && !authority()) netSend({ t: 'coin', x: tx, y: ty, li: G.li }); }
    else if (c === 'L' && p.y + p.h > ty * 16 + 6) die(p);
    else if (c === 'A' && p.state === 'play') touchAxe(p); }
  if (L.pole && p.state === 'play' && !p.dead && p.x + p.w >= L.pole.x - 1) { if (p.y < L.pole.top) p.y = L.pole.top; grabFlag(p); }
  if (!G.check && L.midX && p.x > L.midX * 16) G.check = true;
}
function grabFlag(p) {
  p.state = 'flag'; p.vx = 0; p.vy = 0; p.x = L.pole.x - p.w + 3; p.face = 1;
  const hgt = L.pole.bottom - (p.y + p.h), pts = hgt > 130 ? 5000 : hgt > 100 ? 2000 : hgt > 70 ? 800 : hgt > 40 ? 400 : 100;
  addScore(pts, p.x + 16, p.y); stopMusic(); sfx('flag');
  if (NET.inRoom) netSend({ t: 'goal', li: G.li, name: p.name });
  levelDone(p.name);
}
function flagSeq(p) {
  if (L.flagY < L.pole.bottom - 16) L.flagY += 2;
  if (p.y + p.h < L.pole.bottom) { p.y = Math.min(p.y + 2, L.pole.bottom - p.h); }
  else if (L.flagY >= L.pole.bottom - 16) { p.state = 'walkout'; p.x = L.pole.x + 4; }
}
function touchAxe(p) {
  if (L.collapse || L.axeHit) return; L.axeHit = true;
  if (!NET.inRoom || authority()) startCollapse(); else netSend({ t: 'axe', li: G.li });
}
function startCollapse() { if (L.collapse) return; L.collapse = { x: L.bridge.x1, t: 0 }; setTile(L.axe.x, L.axe.y, '.'); stopMusic(); }
function updCollapse() {
  const c = L.collapse; if (!c || c.done) return; c.t++;
  if (c.x >= L.bridge.x0) { if (c.t % 4 === 0) { setTile(c.x, L.bridge.row, '.'); sfx('bump'); c.x--; } return; }
  const boss = L.ents.find(e => e.type === 'boss');
  if (boss && boss.st !== 'fall' && boss.st !== 'dead') { boss.st = 'fall'; boss.vy = 0; sfx('boss'); }
  if (!boss || !boss.alive || c.t > 260) { c.done = true; levelDone(null, true); }
}
function levelDone(who, castle) {
  if (G.clearT) return;
  G.clearT = 1; G.clearWho = who; G.clearCastle = !!castle;
  if (castle) { sfx('clear'); G.msg = G.li === 39 ? 'YOU SAVED ACORN KINGDOM!' : 'THE CASTLE IS SAFE!'; G.msgT = 400; if (G.li === 39) playMusic('win'); }
  else if (who && NET.inRoom) { G.msg = who + ' REACHED THE FLAG!'; G.msgT = 300; }
  if (!castle) setTimeout(() => sfx('clear'), 900);
  if (NET.inRoom && authority() && castle) netSend({ t: 'clear', li: G.li });
  SAVE.un = Math.max(SAVE.un, Math.min(39, G.li + 1)); SAVE.cur = Math.min(39, G.li + 1); save();
}
function touchEnts(p) {
  if (p.dead || p.state !== 'play' || p.growT > 0) return;
  for (const e of L.ents) {
    if (!e.alive || e.st === 'dead' || e.st === 'flat' || e.pend > 0) continue;
    if (e.type === 'lift') continue;
    if (e.type === 'firebar') { for (let i = 0; i < e.len; i++) { const bx = e.x + Math.cos(e.ang) * i * 8, by = e.y + Math.sin(e.ang) * i * 8;
        if (bx + 3 > p.x && bx - 3 < p.x + p.w && by + 3 > p.y && by - 3 < p.y + p.h) { hurt(p); break; } } continue; }
    if (!overlap(p, e)) continue;
    if (e.type === 'ember' || e.type === 'flame' || e.type === 'boss' || (e.type === 'plant' && e.off < 20)) {
      if (p.starT > 0 && e.type === 'plant') { if (authority()) killEnt(e, true); else { e.pend = 30; netSend({ t: 'hit', id: e.id, li: G.li }); addScore(200, e.x, e.y); } }
      else hurt(p); continue; }
    if (e.type === 'plant') continue;
    if (p.starT > 0) { if (authority()) killEnt(e, true); else { e.pend = 30; e.st = 'dead'; netSend({ t: 'hit', id: e.id, li: G.li }); addScore(200, e.x, e.y); sfx('kick'); } continue; }
    const fromAbove = p.vy > 0 && (p.y + p.h - p.vy) <= e.y + 7;
    if (e.type === 'turtle' && e.st === 'shell') {
      if (fromAbove) { p.vy = -4; }
      const dir = (p.x + p.w / 2) < (e.x + e.w / 2) ? 1 : -1; kickShell(e, dir, true);
      if (!fromAbove) p.x += -dir * 2; continue; }
    if (e.type === 'turtle' && e.st === 'spin' && e.kick > 0) continue;
    if (e.type === 'spiny') { hurt(p); continue; }
    if (fromAbove) { p.vy = p.pa ? -6.5 : -4.2; p.jh = p.pa; stompEnt(e, p); }
    else hurt(p);
  }
  for (const it of L.items) {
    if (it.rise > 8 || it.gone || !overlap(p, it)) continue;
    it.gone = true; giveItem(p, it.type, it.x, it.y);
    if (NET.inRoom && !authority()) { NET.goneItems.add(it.id); netSend({ t: 'grab', id: it.id, li: G.li }); }
  }
}
function giveItem(p, type, x, y) {
  if (type === 'grow') { addScore(1000, x, y); if (p.pw === 0) { setPw(p, 1); p.growT = 40; } sfx('up'); }
  else if (type === 'flower') { addScore(1000, x, y); if (p.pw === 0) { setPw(p, 1); p.growT = 40; } else setPw(p, 2); sfx('up'); }
  else if (type === 'star') { p.starT = 600; addScore(1000, x, y); playMusic('star'); }
  else if (type === 'oneup') { G.lives++; L.pops.push({ x, y, txt: '1UP', t: 50 }); sfx('oneup'); }
}
function updFireballs() {
  for (const f of G.fb) {
    f.t++; f.vy = Math.min(f.vy + 0.35, 5);
    f.x += f.vx; if (solidAt(Math.floor((f.x + 3) / 16), Math.floor((f.y + 3) / 16))) { f.dead = true; continue; }
    f.y += f.vy; if (solidAt(Math.floor((f.x + 3) / 16), Math.floor((f.y + 6) / 16))) { f.y = Math.floor((f.y + 6) / 16) * 16 - 7; f.vy = -3.5; }
    if (f.t > 150 || f.y > ROWS * 16 || Math.abs(f.x - G.me.x) > VW) f.dead = true;
    const box = { x: f.x, y: f.y, w: 7, h: 7 };
    for (const e of L.ents) { if (!e.alive || e.local || e.st === 'dead' || e.pend > 0 || e.type === 'flame' || e.type === 'bullet') continue;
      if (e.type === 'plant' && e.off > 20) continue;
      if (overlap(box, e)) { f.dead = true; if (authority()) killEnt(e, true); else { netSend({ t: 'hit', id: e.id, li: G.li }); e.pend = 20; if (e.type !== 'boss') { e.st = 'dead'; addScore(200, e.x, e.y); } sfx('kick'); } break; } }
  }
  G.fb = G.fb.filter(f => !f.dead);
}

// ---------------- main step ----------------
function step() {
  const inp = readInput();
  if (G.mode === 'menu') { G.titleT++; return; }
  if (G.mode === 'pause') return;
  if (G.msgT > 0) G.msgT--;
  if (G.mode === 'intro') { if (--G.introT <= 0) { G.mode = 'play'; playMusic(musicFor()); } return; }
  if (G.mode === 'over') { if (--G.overT <= 0) toMenu(); return; }
  if (G.mode !== 'play') return;
  L.frame++;
  updLocalEnts();
  if (authority()) { updEnts(); updItems(); updCollapse(); }
  else {
    for (const e of L.ents) { if (e.local) continue; if (e.pend > 0) { e.pend--; continue; } if (e.tx !== undefined) {
      if (Math.abs(e.tx - e.x) > 48 || Math.abs(e.ty - e.y) > 48) { e.x = e.tx; e.y = e.ty; } else { e.x += (e.tx - e.x) * 0.35; e.y += (e.ty - e.y) * 0.35; } } }
    for (const it of L.items) if (it.tx !== undefined) { it.x += (it.tx - it.x) * 0.35; it.y += (it.ty - it.y) * 0.35; }
  }
  const me = G.me;
  updPlayer(me, inp);
  touchEnts(me);
  updFireballs();
  for (const k in L.bumps) if (--L.bumps[k] <= 0) delete L.bumps[k];
  for (const q of L.parts) { q.t++; q.vy += q.k === 'coin' ? 0.35 : 0.3; q.x += q.vx; q.y += q.vy; }
  L.parts = L.parts.filter(q => q.k === 'coin' ? q.t < 30 : q.y < ROWS * 16 + 16);
  for (const q of L.pops) { q.t--; q.y -= 0.6; } L.pops = L.pops.filter(q => q.t > 0);
  for (const o of G.others.values()) { if (o.tx === undefined) continue; if (Math.abs(o.tx - o.x) > 64) { o.x = o.tx; o.y = o.ty; } else { o.x += (o.tx - o.x) * 0.4; o.y += (o.ty - o.y) * 0.4; } }
  if (!me.dead) { const tx = clamp(me.x + me.w / 2 - VW * 0.42, 0, L.w * 16 - VW); G.cx += (tx - G.cx) * 0.18; }
  // timer
  if (!G.mp && !SAVE.easy && me.state === 'play' && !me.dead && L.frame % 24 === 0) { L.time--; if (L.time <= 0) die(me); }
  // death handling
  if (me.dead && me.deadT > 150) {
    if (NET.inRoom) respawnMP(me);
    else { if (!SAVE.easy) G.lives--; if (G.lives < 0) { gameOver(); return; } startLevel(G.li, { check: G.check }); return; }
  }
  // level clear
  if (G.clearT) { G.clearT++;
    const flagDone = me.state === 'done';
    if (!G.clearCastle && flagDone && L.time > 0 && !G.mp && !SAVE.easy) { const n = Math.min(L.time, 5); L.time -= n; G.score += n * 50; if (L.frame % 3 === 0) tone(1600, 0, 0.03, 'square', 0.05); }
    if (G.li >= 39 && G.clearCastle) { if (G.clearT > 600) toMenu(); return; }
    const ready = G.clearCastle || NET.inRoom ? G.clearT > 300 : flagDone && (L.time <= 0 || SAVE.easy) && G.clearT > 150;
    if (ready && (!NET.inRoom || authority())) {
      const next = G.li + 1; G.pw = me.pw;
      if (NET.inRoom) { netSend({ t: 'start', li: next }); G.clearT = 0; startLevel(next, { pw: me.pw }); }
      else startLevel(next, { pw: me.pw });
      return; }
  }
  netTick();
}
function respawnMP(me) {
  let best = null; for (const o of G.others.values()) if (o.li === G.li && !o.dead && o.state === 'play' && (!best || o.x > best.x)) best = o;
  me.dead = false; me.deadT = 0; me.vx = 0; me.vy = 0; me.invT = 150; me.state = 'play';
  if (best) { me.x = best.x; me.y = best.y + (best.h || 14) - me.h - 20; } else { me.x = me.safeX; me.y = me.safeY - me.h - 2; }
  playMusic(musicFor());
}
function gameOver() { G.mode = 'over'; G.overT = 240; stopMusic(); SAVE.cur = G.li & ~3; save(); }
function toMenu() {
  G.mode = 'menu'; G.paused = false; stopMusic(); showTouch(false); $('pause').classList.add('hide');
  if (NET.inRoom) netLeave();
  $('menu').classList.remove('hide'); updMenu(); $('bPlay').focus();
}

// ---------------- rendering ----------------
function hash(n) { n = (n << 13) ^ n; return ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 0x7fffffff; }
function drawBG(cx) {
  const pal = PALS[L.theme]; ctx.fillStyle = pal.sky; ctx.fillRect(0, 0, VW, VH);
  if (pal.stars) { ctx.fillStyle = '#fff'; for (let i = 0; i < 60; i++) { const x = ((hash(i) * 2000 - cx * 0.1) % VW + VW) % VW, y = hash(i + 99) * 150; ctx.fillRect(x | 0, y | 0, 1, 1); } }
  if (pal.dark) {
    if (L.kind === 'castle') { ctx.fillStyle = '#1a1010'; for (let i = Math.floor(cx * 0.5 / 64) - 1; i < Math.floor(cx * 0.5 / 64) + VW / 64 + 2; i++) { const x = i * 64 - cx * 0.5; ctx.fillRect(x + 20, 60, 14, 22); } }
    else { ctx.fillStyle = shadeI(pal.dirt2, -0.5); for (let i = Math.floor(cx * 0.4 / 90) - 1; i < Math.floor(cx * 0.4 / 90) + VW / 90 + 2; i++) { const x = i * 90 - cx * 0.4, h = 20 + hash(i) * 40;
      ctx.beginPath(); ctx.moveTo(x, 32); ctx.lineTo(x + 20, 32 + h); ctx.lineTo(x + 40, 32); ctx.fill(); } }
    return;
  }
  // clouds
  ctx.fillStyle = pal.cloud;
  for (let i = Math.floor(cx * 0.15 / 140) - 1; i < Math.floor(cx * 0.15 / 140) + VW / 140 + 2; i++) {
    const x = i * 140 - cx * 0.15 + hash(i) * 60, y = 20 + hash(i + 7) * 60;
    ctx.beginPath(); ctx.arc(x, y, 10, 0, 7); ctx.arc(x + 14, y - 6, 13, 0, 7); ctx.arc(x + 30, y, 10, 0, 7); ctx.fill(); ctx.fillRect(x, y, 30, 10);
  }
  if (L.kind === 'sky') return;
  const base = VH - 32;
  for (let i = Math.floor(cx * 0.3 / 200) - 1; i < Math.floor(cx * 0.3 / 200) + VW / 200 + 2; i++) {
    const x = i * 200 - cx * 0.3 + hash(i + 3) * 80, r = 40 + hash(i + 5) * 40;
    ctx.fillStyle = pal.hill; ctx.beginPath(); ctx.arc(x, base, r, Math.PI, 0); ctx.fill();
    ctx.fillStyle = pal.hill2; ctx.fillRect(x - 3, base - r + 12, 2, 6); ctx.fillRect(x + 6, base - r + 18, 2, 6);
  }
  for (let i = Math.floor(cx * 0.6 / 160) - 1; i < Math.floor(cx * 0.6 / 160) + VW / 160 + 2; i++) {
    const x = i * 160 - cx * 0.6 + hash(i + 11) * 60; ctx.fillStyle = pal.bush;
    ctx.beginPath(); ctx.arc(x, base + 4, 10, Math.PI, 0); ctx.arc(x + 14, base + 4, 13, Math.PI, 0); ctx.arc(x + 30, base + 4, 10, Math.PI, 0); ctx.fill();
  }
}
function tileImg(c, x, y) {
  switch (c) {
    case 'G': return (y > 0 && L.t[y - 1][x] === 'G') || L.kind === 'castle' ? TI.G : TI.Gt;
    case 'B': case 'N': case 'S': case '1': return TI.B;
    case '?': case 'P': return TI['Q' + [0, 0, 0, 1, 2, 1][Math.floor(L.frame / 8) % 6]];
    case 'U': return TI.U; case 'H': return TI.H; case 'p': return TI.p; case 'q': return TI.q; case 'l': return TI.l; case 'r': return TI.r;
    case 'Q': return TI.Qc; case 'I': return TI.I; case 'D': return TI.D; case 'A': return TI.A;
    case 'L': return TI['L' + (Math.floor(L.frame / 16) % 2)];
    case 'C': return TI['C' + (Math.floor(L.frame / 8) % 4)];
    case 'T': { const lt = x > 0 && L.t[y][x - 1] === 'T', rt = L.t[y][x + 1] === 'T'; return TI['T' + (lt && rt ? 'm' : lt ? 'r' : rt ? 'l' : 'o')]; }
  }
  return null;
}
function drawPlayer(p, cx, remote) {
  if (p.state === 'done' || (p.li !== undefined && p.li !== G.li)) return;
  if (!p.dead && p.invT > 0 && Math.floor(p.invT / 3) % 2) return;
  let pw = p.pw, h = p.h;
  if (p.growT > 0) { pw = Math.floor(p.growT / 5) % 2 ? 1 : 0; }
  let fr = 'stand';
  if (p.dead) fr = 'jump';
  else if (p.state === 'flag') fr = 'jump';
  else if (!p.onG && !remote) fr = 'jump';
  else if (remote && p.air) fr = 'jump';
  else if (Math.abs(p.vx) > 0.15) fr = ['w1', 'w2', 'stand'][Math.floor(p.anim) % 3];
  const star = p.starT > 0 ? Math.floor(p.starT / 3) % 4 : -1;
  const img = pipSprite(p.ch || 0, pw > 0 ? (p.pw === 2 && pw ? 2 : 1) : 0, fr, star);
  blit(img, p.x - 2 - cx, p.y + h - img.height, p.face < 0);
  if (remote || G.mp) { ctx.font = '7px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#000'; ctx.fillText(p.name || '', p.x + p.w / 2 - cx + 1, p.y + h - img.height - 2);
    ctx.fillStyle = remote ? charCol(p.ch || 0) : '#fff'; ctx.fillText(p.name || '', p.x + p.w / 2 - cx, p.y + h - img.height - 3); ctx.textAlign = 'left'; }
}
function drawEnt(e, cx) {
  const x = Math.round(e.x - cx), y = Math.round(e.y), fr = Math.floor(L.frame / 10) % 2;
  const dead = e.st === 'dead';
  if (dead && e.type !== 'boss') { ctx.save(); ctx.translate(x + 8, y + e.h / 2); ctx.scale(1, -1); ctx.translate(-x - 8, -y - e.h / 2); }
  switch (e.type) {
    case 'walker': if (e.st === 'flat') blit(SPR.grumble[0], x - 1, y + 7, false, 16, 8); else blit(SPR.grumble[fr], x - 1, y - 2); break;
    case 'spiny': { blit(SPR.spinyShell, x - 1, y + 4); ctx.fillStyle = '#fff'; for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.moveTo(x + 1 + i * 4, y + 6); ctx.lineTo(x + 3 + i * 4, y + (fr && i % 2 ? 0 : 1)); ctx.lineTo(x + 5 + i * 4, y + 6); ctx.fill(); }
      ctx.fillStyle = '#f2d16b'; ctx.fillRect(x + (e.dir < 0 ? 0 : 11), y + 10, 3, 3); break; }
    case 'turtle':
      if (e.st === 'walk') { const im = (e.red ? SPR.turtleR : SPR.turtle)[fr]; blit(im, x - 1, y + e.h - im.height, e.dir > 0);
        if (e.wing) { ctx.fillStyle = '#fff'; const wy = y + 8 + (fr ? -3 : 0), wx = e.dir > 0 ? x - 2 : x + 12; ctx.fillRect(wx, wy, 5, 3); ctx.fillRect(wx + (e.dir > 0 ? -2 : 3), wy - 3, 4, 3); } }
      else blit(e.red ? SPR.shellR : SPR.shell, x - 1, y + e.h - 10); break;
    case 'plant': {
      const px = x, py = y, open = Math.abs(Math.sin(L.frame * 0.12)) * 5;
      ctx.fillStyle = '#2a9a3a'; ctx.fillRect(px + 6, py + 12, 4, 12); ctx.fillRect(px + 1, py + 15, 5, 3); ctx.fillRect(px + 10, py + 18, 5, 3);
      ctx.fillStyle = '#e03a3a'; ctx.beginPath(); ctx.arc(px + 8, py + 6, 7, 0, 7); ctx.fill();
      ctx.fillStyle = '#fff'; ctx.fillRect(px + 3, py + 2, 2, 2); ctx.fillRect(px + 11, py + 3, 2, 2); ctx.fillRect(px + 6, py, 2, 2);
      ctx.fillStyle = '#3a0a0a'; ctx.fillRect(px + 3, py + 7, 10, open); ctx.fillStyle = '#fff'; ctx.fillRect(px + 4, py + 7, 1, 2); ctx.fillRect(px + 8, py + 7, 1, 2); ctx.fillRect(px + 11, py + 7, 1, 2); break; }
    case 'bullet': ctx.fillStyle = '#111'; ctx.beginPath(); ctx.ellipse(x + 7, y + 6, 7, 6, 0, 0, 7); ctx.fill(); ctx.fillRect(e.vx < 0 ? x + 8 : x, y, 6, 12);
      ctx.fillStyle = '#fff'; ctx.fillRect(e.vx < 0 ? x + 3 : x + 9, y + 3, 3, 3); ctx.fillStyle = '#000'; ctx.fillRect(e.vx < 0 ? x + 3 : x + 10, y + 4, 1, 1); break;
    case 'flame': ctx.fillStyle = fr ? '#ff8020' : '#ffd040'; ctx.beginPath(); ctx.ellipse(x + 8, y + 4, 8, 4, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#fff6a0'; ctx.fillRect(x + (e.vx < 0 ? 2 : 9), y + 3, 5, 2); break;
    case 'ember': ctx.fillStyle = '#ff7020'; ctx.beginPath(); ctx.arc(x + 6, y + 6, 6, 0, 7); ctx.fill(); ctx.fillStyle = '#ffe060'; ctx.beginPath(); ctx.arc(x + 6, y + 5, 3, 0, 7); ctx.fill(); break;
    case 'firebar': for (let i = 0; i < e.len; i++) { const bx = e.x + Math.cos(e.ang) * i * 8 - cx, by = e.y + Math.sin(e.ang) * i * 8;
      ctx.fillStyle = '#ff6a10'; ctx.beginPath(); ctx.arc(bx, by, 4, 0, 7); ctx.fill(); ctx.fillStyle = '#ffe070'; ctx.fillRect(bx - 1, by - 1, 2, 2); } break;
    case 'lift': ctx.fillStyle = '#f0f0f0'; ctx.fillRect(x, y, e.w, 6); ctx.fillStyle = '#d07a2a'; for (let i = 0; i < e.w; i += 8) ctx.fillRect(x + i + 1, y + 1, 6, 4); break;
    case 'boss': {
      if (e.st === 'dead') { ctx.save(); ctx.translate(x + 14, y + 15); ctx.scale(1, -1); ctx.translate(-x - 14, -y - 15); }
      const im = e.flash && e.flash % 4 < 2 ? SPR.kingHurt : SPR.king[fr]; blit(im, x - 2, y - 2, false, 32, 32);
      ctx.fillStyle = '#ffd030'; ctx.beginPath(); ctx.moveTo(x + 4, y + 2); ctx.lineTo(x + 6, y - 8); ctx.lineTo(x + 10, y - 2); ctx.lineTo(x + 14, y - 10); ctx.lineTo(x + 18, y - 2); ctx.lineTo(x + 22, y - 8); ctx.lineTo(x + 24, y + 2); ctx.fill();
      ctx.fillStyle = '#e02040'; ctx.fillRect(x + 13, y - 5, 3, 3);
      if (e.st === 'dead') ctx.restore(); break; }
  }
  if (dead && e.type !== 'boss') ctx.restore();
}
function drawItem(it, cx) {
  const x = Math.round(it.x - cx), y = Math.round(it.y);
  if (it.type === 'grow' || it.type === 'oneup') {
    ctx.fillStyle = it.type === 'grow' ? '#e0406a' : '#3cc060'; ctx.beginPath(); ctx.arc(x + 7, y + 9, 6, 0, 7); ctx.fill();
    ctx.fillStyle = '#fff'; ctx.fillRect(x + 4, y + 6, 2, 2); ctx.fillStyle = '#2a8a2a'; ctx.fillRect(x + 6, y + 1, 2, 3); ctx.fillRect(x + 8, y + 1, 4, 2);
    ctx.fillStyle = '#000'; ctx.fillRect(x + 5, y + 9, 1, 2); ctx.fillRect(x + 9, y + 9, 1, 2);
  } else if (it.type === 'flower') {
    ctx.fillStyle = '#2a9a3a'; ctx.fillRect(x + 6, y + 8, 2, 7); ctx.fillRect(x + 2, y + 11, 4, 2); ctx.fillRect(x + 8, y + 12, 4, 2);
    const cols = ['#ff5020', '#ffd020']; ctx.fillStyle = cols[Math.floor(L.frame / 6) % 2]; ctx.beginPath(); ctx.arc(x + 7, y + 5, 6, 0, 7); ctx.fill();
    ctx.fillStyle = cols[(Math.floor(L.frame / 6) + 1) % 2]; ctx.beginPath(); ctx.arc(x + 7, y + 5, 3, 0, 7); ctx.fill();
  } else if (it.type === 'star') {
    ctx.fillStyle = STAR_COLS[Math.floor(L.frame / 4) % 4]; ctx.beginPath();
    for (let i = 0; i < 10; i++) { const r = i % 2 ? 3 : 7.5, a = -Math.PI / 2 + i * Math.PI / 5; ctx.lineTo(x + 7 + Math.cos(a) * r, y + 8 + Math.sin(a) * r); } ctx.fill();
    ctx.fillStyle = '#000'; ctx.fillRect(x + 5, y + 6, 1, 3); ctx.fillRect(x + 9, y + 6, 1, 3);
  }
}
function drawCastle(cx) {
  const pal = PALS[L.theme], x = L.castleX * 16 - cx, by = L.castleY * 16; if (x > VW + 10 || x < -100) return;
  const br = L.kind === 'castle' ? '#9a9a9a' : '#c0602a', dk = L.kind === 'castle' ? '#4a4a4a' : '#6a2a0a';
  ctx.fillStyle = br; ctx.fillRect(x, by - 48, 80, 48); ctx.fillRect(x + 16, by - 80, 48, 32);
  for (let i = 0; i < 5; i++) ctx.fillRect(x + i * 16 + 2, by - 56, 12, 8); for (let i = 0; i < 3; i++) ctx.fillRect(x + 16 + i * 16 + 2, by - 88, 12, 8);
  ctx.fillStyle = dk; for (let r = 0; r < 10; r++) ctx.fillRect(x + (r < 4 ? 16 : 0), by - 80 + r * 8, r < 4 ? 48 : 80, 1);
  ctx.fillStyle = '#000'; ctx.fillRect(x + 32, by - 24, 16, 24); ctx.beginPath(); ctx.arc(x + 40, by - 24, 8, Math.PI, 0); ctx.fill();
  ctx.fillRect(x + 26, by - 72, 6, 10); ctx.fillRect(x + 48, by - 72, 6, 10);
  if (L.princess) { ctx.fillStyle = '#ffd030'; ctx.fillRect(x + 36, by - 100, 8, 10); ctx.fillStyle = '#e8641c'; ctx.fillRect(x + 37, by - 99, 6, 4); }
  void pal;
}
function drawHUD() {
  ctx.font = 'bold 9px "Courier New",monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#fff';
  const sh = (t, x, y, a) => { ctx.textAlign = a || 'left'; ctx.fillStyle = '#000'; ctx.fillText(t, x + 1, y + 1); ctx.fillStyle = '#fff'; ctx.fillText(t, x, y); };
  sh((G.me ? CHARS[G.me.ch].name : 'PIP').toUpperCase(), 8, 4); sh(String(G.score).padStart(6, '0'), 8, 14);
  ctx.fillStyle = '#ffd030'; ctx.fillRect(VW * 0.27, 15, 5, 7); sh('x' + String(G.coins).padStart(2, '0'), VW * 0.27 + 7, 14);
  const [w, s] = li2ws(G.li); sh('WORLD', VW * 0.5, 4, 'center'); sh(w + '-' + s, VW * 0.5, 14, 'center');
  if (G.mp) sh('TOGETHER', VW * 0.7, 4, 'center'), sh(String(NET.players.length), VW * 0.7, 14, 'center');
  else sh('LIVES', VW * 0.7, 4, 'center'), sh(SAVE.easy ? '∞' : String(Math.max(0, G.lives)), VW * 0.7, 14, 'center');
  if (!G.mp && !SAVE.easy) { sh('TIME', VW - 30, 4, 'right'); sh(String(Math.max(0, L.time)), VW - 30, 14, 'right'); }
  ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
function render() {
  if (G.mode === 'menu' || !L) { renderTitle(); return; }
  if (G.mode === 'intro' || G.mode === 'over') {
    ctx.fillStyle = '#000'; ctx.fillRect(0, 0, VW, VH); drawHUD();
    ctx.font = 'bold 14px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#fff';
    const [w, s] = li2ws(G.li);
    if (G.mode === 'over') ctx.fillText('GAME OVER', VW / 2, 120);
    else { ctx.fillText('WORLD ' + w + '-' + s, VW / 2, 92); ctx.font = '10px "Courier New",monospace'; ctx.fillText(WORLD_NAMES[w - 1].toUpperCase(), VW / 2, 108);
      const im = pipSprite(SAVE.ch, 0, 'stand', -1); ctx.drawImage(im, VW / 2 - 30, 124); ctx.font = 'bold 12px "Courier New",monospace';
      ctx.fillText(G.mp ? 'TOGETHER: ' + NET.players.length : (SAVE.easy ? 'x ∞' : 'x ' + G.lives), VW / 2 + 16, 137); }
    ctx.textAlign = 'left'; return;
  }
  const cx = Math.round(G.cx);
  drawBG(cx);
  for (const e of L.ents) if (e.type === 'plant' && e.alive) drawEnt(e, cx);
  for (const it of L.items) if (it.rise > 0) drawItem(it, cx);
  if (L.castleX) drawCastle(cx);
  const c0 = Math.max(0, Math.floor(cx / 16)), c1 = Math.min(L.w - 1, c0 + Math.ceil(VW / 16) + 1);
  for (let y = 0; y < ROWS; y++) for (let x = c0; x <= c1; x++) { const c = L.t[y][x]; if (c === '.' || c === '#') continue;
    const im = tileImg(c, x, y); if (!im) continue; const b = L.bumps[x + ',' + y]; const off = b ? -Math.sin((10 - b) / 10 * Math.PI) * 5 : 0;
    ctx.drawImage(im, x * 16 - cx, y * 16 + off); }
  if (L.pole) { const px = L.pole.x - cx; ctx.fillStyle = '#7ad87a'; ctx.fillRect(px - 1, L.pole.top, 2, L.pole.bottom - L.pole.top);
    ctx.fillStyle = '#2a9a3a'; ctx.beginPath(); ctx.arc(px, L.pole.top - 3, 4, 0, 7); ctx.fill();
    ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.moveTo(px - 1, L.flagY); ctx.lineTo(px - 17, L.flagY + 7); ctx.lineTo(px - 1, L.flagY + 14); ctx.fill();
    ctx.fillStyle = '#e8641c'; ctx.beginPath(); ctx.arc(px - 7, L.flagY + 7, 3, 0, 7); ctx.fill(); }
  for (const it of L.items) if (it.rise <= 0) drawItem(it, cx);
  for (const e of L.ents) if (e.type !== 'plant' && e.alive) { if (e.x - cx > VW + 40 || e.x - cx < -100) continue; drawEnt(e, cx); }
  for (const o of G.others.values()) drawPlayer(o, cx, true);
  drawPlayer(G.me, cx, false);
  for (const f of G.fb) { ctx.fillStyle = f.t % 4 < 2 ? '#ff7020' : '#ffd040'; ctx.fillRect(Math.round(f.x - cx), Math.round(f.y), 7, 7); ctx.fillStyle = '#fff6a0'; ctx.fillRect(Math.round(f.x - cx) + 2, Math.round(f.y) + 2, 3, 3); }
  for (const q of L.parts) { if (q.k === 'coin') ctx.drawImage(TI['C' + (Math.floor(q.t / 3) % 4)], q.x - cx - 4, q.y);
    else { ctx.fillStyle = PALS[L.theme].brick; ctx.fillRect(q.x - cx, q.y, 7, 7); ctx.fillStyle = PALS[L.theme].brick2; ctx.fillRect(q.x - cx + 3, q.y, 1, 7); } }
  ctx.font = 'bold 8px "Courier New",monospace'; ctx.textAlign = 'center';
  for (const q of L.pops) { ctx.fillStyle = '#000'; ctx.fillText(q.txt, q.x - cx + 9, q.y + 1); ctx.fillStyle = '#fff'; ctx.fillText(q.txt, q.x - cx + 8, q.y); }
  ctx.textAlign = 'left';
  drawHUD();
  if (G.msgT > 0 && G.msg) { ctx.font = 'bold 12px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#000'; ctx.fillText(G.msg, VW / 2 + 1, 71); ctx.fillStyle = '#ffd84a'; ctx.fillText(G.msg, VW / 2, 70); ctx.textAlign = 'left'; }
  if (G.clearCastle && G.clearT > 60 && G.li < 39) { ctx.font = 'bold 10px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.fillText('ON TO WORLD ' + (li2ws(G.li)[0] + 1) + '!', VW / 2, 90); ctx.textAlign = 'left'; }
}
let titleL = null;
function renderTitle() {
  if (!titleL) { titleL = genLevel(1, 1); }
  const save = L; L = titleL; L.frame = G.titleT; if (!TI || TI_THEME !== 'grass') { buildTiles(PALS.grass); TI_THEME = 'grass'; }
  const cx = (G.titleT * 0.5) % (L.w * 16 - VW - 200);
  drawBG(cx);
  const c0 = Math.floor(cx / 16), c1 = c0 + Math.ceil(VW / 16) + 1;
  for (let y = 0; y < ROWS; y++) for (let x = c0; x <= c1; x++) { const c = L.t[y][x]; if (c === '.' || c === '#') continue; const im = tileImg(c, x, y); if (im) ctx.drawImage(im, x * 16 - cx, y * 16); }
  L = save;
}

// ---------------- networking (relay on CT 127, /mp/ws) ----------------
const NET = { ws: null, id: 0, host: 0, room: '', players: [], inRoom: false, goneItems: new Set(), sendT: 0, snapT: 0 };
function netConnect() {
  if (NET.ws && NET.ws.readyState <= 1) return;
  $('lbStatus').textContent = 'Connecting...';
  let ws; try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/mp/ws'); } catch (e) { $('lbStatus').textContent = 'Could not connect.'; return; }
  NET.ws = ws;
  ws.onopen = () => { ws.send(JSON.stringify(helloMsg())); ws.send(JSON.stringify({ t: 'list' })); $('lbStatus').textContent = 'Connected. Start a new game or join one.'; };
  ws.onmessage = ev => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } onNet(m); };
  ws.onclose = () => { const was = NET.inRoom; NET.ws = null; NET.inRoom = false; NET.players = [];
    if (was && G.mode !== 'menu') { G.mp = false; G.msg = 'LOST CONNECTION'; G.msgT = 200; G.others.clear(); }
    $('lbStatus').textContent = 'Not connected. Is the internet working?'; showLobby(); };
}
function netSend(m) { if (NET.ws && NET.ws.readyState === 1) NET.ws.send(JSON.stringify(m)); }
function netLeave() { netSend({ t: 'leave' }); NET.inRoom = false; NET.room = ''; NET.players = []; G.others.clear(); G.mp = false; }
function onNet(m) {
  switch (m.t) {
    case 'id': NET.id = m.id; return;
    case 'rooms': showRooms(m.rooms); return;
    case 'err': $('lbStatus').textContent = m.msg; return;
    case 'room': {
      const wasHost = NET.host === NET.id; NET.room = m.code; NET.host = m.host; NET.players = m.players; NET.inRoom = true;
      for (const id of [...G.others.keys()]) if (!m.players.find(p => p.id === id)) G.others.delete(id);
      if (G.mode === 'menu') showLobby();
      else if (authority() && G.mode !== 'menu') { // tell newcomers where we are
        for (const p of m.players) if (p.id !== NET.id && !G.others.has(p.id)) netSend({ t: 'start', li: G.li, to: p.id, tiles: L ? L.changed : {} });
        if (!wasHost && L) { G.msg = 'YOU ARE NOW THE HOST'; G.msgT = 120; for (const e of L.ents) if (!e.local) { e.active = true; if (e.tx !== undefined) { e.x = e.tx; e.y = e.ty; } } }
      }
      return; }
  }
  if (!NET.inRoom) return;
  const from = m.f;
  if (m.t === 'start') { if (from !== NET.host) return; if (G.mode === 'menu') { G.score = 0; G.coins = 0; } G.mp = true; hideMenus(); G.paused = false; G.clearT = 0; startLevel(m.li, { pw: G.me && G.mode !== 'menu' ? G.me.pw : 0, tiles: m.tiles }); return; }
  if (!L || m.li !== G.li) return;
  switch (m.t) {
    case 'ps': { let o = G.others.get(from); if (!o) { o = { x: m.x, y: m.y, w: 12, h: 14 }; G.others.set(from, o); }
      Object.assign(o, { tx: m.x, ty: m.y, vx: m.vx, face: m.f, pw: m.p, h: m.p ? 28 : 14, dead: !!m.d, state: m.s, ch: m.c, name: m.n, starT: m.st, invT: m.iv, air: !!m.a, li: m.li, anim: (o.anim || 0) + Math.abs(m.vx) * 0.5 });
      if (o.x === undefined) { o.x = m.x; o.y = m.y; } return; }
    case 'snap': if (from === NET.host && !authority()) applySnap(m); return;
    case 'tile': if (from === NET.host) L.t[m.y][m.x] = m.c; return;
    case 'goal': levelDone(m.name || 'A PLAYER'); return;
    case 'clear': if (from === NET.host) { if (L.collapse) L.collapse.done = true; levelDone(null, true); } return;
  }
  if (!authority()) return;
  // host handles requests from other players
  const e = m.id !== undefined ? L.ents.find(x => x.id === m.id) : null;
  switch (m.t) {
    case 'stomp': if (e && e.alive && e.st !== 'dead') { const fake = { stompChain: 0 }; const s = G.score; stompEnt(e, fake); G.score = s; L.pops.pop(); } break;
    case 'kick': if (e && e.alive) kickShell(e, m.dir, false); break;
    case 'hit': if (e && e.alive && e.st !== 'dead') killEnt(e, false); break;
    case 'bump': bumpTile(m.x, m.y, !!m.big, false); break;
    case 'coin': if (L.t[m.y][m.x] === 'C') setTile(m.x, m.y, '.'); break;
    case 'grab': { const it = L.items.find(i => i.id === m.id); if (it) it.gone = true; break; }
    case 'axe': touchAxe({ state: 'play' }); break;
  }
}
const TYPES = ['walker', 'turtle', 'spiny', 'plant', 'bullet', 'boss', 'flame'], STS = ['walk', 'flat', 'dead', 'shell', 'spin', 'fall'], ITS = ['grow', 'flower', 'star', 'oneup'];
function netTick() {
  if (!NET.inRoom) return;
  const me = G.me;
  if (++NET.sendT >= 4) { NET.sendT = 0;
    netSend({ t: 'ps', li: G.li, x: Math.round(me.x), y: Math.round(me.y), vx: +me.vx.toFixed(2), f: me.face, p: me.pw, d: me.dead ? 1 : 0, s: me.state, c: [CHARS[me.ch].c, CHARS[me.ch].o], n: me.name, st: me.starT, iv: me.invT, a: me.onG ? 0 : 1 }); }
  if (authority() && ++NET.snapT >= 5) { NET.snapT = 0;
    const e = [], it = [];
    for (const x of L.ents) if (x.alive && x.active && !x.local) e.push([x.id, TYPES.indexOf(x.type), Math.round(x.x), Math.round(x.y), STS.indexOf(x.st), x.dir, (x.wing ? 1 : 0) | (x.red ? 2 : 0) | (x.flash ? 4 : 0), x.h]);
    for (const x of L.items) it.push([x.id, ITS.indexOf(x.type), Math.round(x.x), Math.round(x.y), x.rise]);
    netSend({ t: 'snap', li: G.li, e, it });
  }
}
function applySnap(m) {
  const seen = new Set();
  for (const a of m.e) {
    const id = a[0]; seen.add(id); let e = L.ents.find(x => x.id === id);
    if (!e) { e = mkEnt(TYPES[a[1]], a[2], a[3]); e.id = id; if (e.type === 'plant') { e.base = a[3] + 24; } L.ents.push(e); }
    e.synced = true; e.active = true;
    if (e.pend > 0) continue;
    e.tx = a[2]; e.ty = a[3]; e.st = STS[a[4]] || 'walk'; e.dir = a[5]; e.wing = !!(a[6] & 1); e.red = !!(a[6] & 2); e.flash = (a[6] & 4) ? 3 : 0; e.h = a[7];
    if (e.type === 'plant') e.off = a[3] - (e.base - 24);
  }
  for (const e of L.ents) if (!e.local && e.synced && !seen.has(e.id) && e.pend <= 0) e.alive = false;
  L.ents = L.ents.filter(e => e.alive);
  const iseen = new Set();
  for (const a of m.it) { iseen.add(a[0]); if (NET.goneItems.has(a[0])) continue; let it = L.items.find(i => i.id === a[0]);
    if (!it) { it = { id: a[0], type: ITS[a[1]], x: a[2], y: a[3], w: 14, h: 15 }; L.items.push(it); }
    it.tx = a[2]; it.ty = a[3]; it.rise = a[4]; }
  L.items = L.items.filter(i => iseen.has(i.id) && !NET.goneItems.has(i.id));
}

// ---------------- menus ----------------
function hideMenus() { ['menu', 'levels', 'lobby', 'pause'].forEach(id => $(id).classList.add('hide')); }
function updMenu() {
  const c = $('chars'); c.innerHTML = '';
  CHARS.forEach((ch, i) => { const b = document.createElement('button'); b.className = 'ch'; b.textContent = ch.name; b.style.background = ch.c; b.setAttribute('aria-pressed', i === SAVE.ch);
    b.onclick = () => { SAVE.ch = i; if (!SAVE.name || CHARS.some(x => x.name === SAVE.name)) SAVE.name = ''; save(); updMenu(); }; c.appendChild(b); });
  const [w, s] = li2ws(SAVE.cur); $('bPlay').textContent = SAVE.cur ? 'Play (World ' + w + '-' + s + ')' : 'Play';
  $('oEasy').checked = SAVE.easy; $('oRun').checked = SAVE.run; $('oSnd').checked = SAVE.snd;
}
$('oEasy').onchange = e => { SAVE.easy = e.target.checked; save(); };
$('oRun').onchange = e => { SAVE.run = e.target.checked; save(); };
$('oSnd').onchange = e => { SAVE.snd = e.target.checked; save(); if (!SAVE.snd) stopMusic(); };
$('bPlay').onclick = () => { ac(); G.mp = false; G.lives = 3; G.score = 0; G.coins = 0; hideMenus(); startLevel(SAVE.cur); };
$('bLevels').onclick = () => { hideMenus(); const g = $('lvGrid'); g.innerHTML = '';
  for (let w = 1; w <= 10; w++) { const sp = document.createElement('span'); sp.textContent = w + ' ' + WORLD_NAMES[w - 1]; g.appendChild(sp);
    for (let s = 1; s <= 4; s++) { const li = (w - 1) * 4 + s - 1, b = document.createElement('button'); b.textContent = w + '-' + s + (s === 4 ? ' ♜' : ''); b.disabled = li > SAVE.un;
      b.setAttribute('aria-label', 'World ' + w + ' level ' + s + (s === 4 ? ' castle' : '') + (li > SAVE.un ? ' locked' : ''));
      b.onclick = () => { ac(); G.mp = false; G.lives = 3; G.score = 0; G.coins = 0; hideMenus(); SAVE.cur = li; save(); startLevel(li); }; g.appendChild(b); } }
  $('levels').classList.remove('hide'); };
$('bLvBack').onclick = () => { hideMenus(); $('menu').classList.remove('hide'); };
function helloMsg() { return { t: 'hello', game: 'pip', name: myName(), ch: SAVE.ch, col: CHARS[SAVE.ch].c }; }
$('bMP').onclick = () => { ac(); hideMenus(); $('lobby').classList.remove('hide'); $('lbName').value = myName();
  $('lbName').disabled = !!BC; $('lbSigned').classList.toggle('hide', !BC); netConnect(); showLobby(); };
$('lbName').onchange = () => { if (BC) return; SAVE.name = $('lbName').value.trim().slice(0, 12); save(); netSend(helloMsg()); };
$('bCreate').onclick = () => { $('lbName').onchange(); netSend({ t: 'create' }); };
$('bLbBack').onclick = () => { if (NET.inRoom) netLeave(); hideMenus(); $('menu').classList.remove('hide'); updMenu(); };
$('bStartMP').onclick = () => { const li = +$('lbLevel').value; G.mp = true; G.score = 0; G.coins = 0; netSend({ t: 'started', v: true }); netSend({ t: 'start', li }); hideMenus(); startLevel(li); };
let roomTimer = null;
function showLobby() {
  if ($('lobby').classList.contains('hide')) { clearInterval(roomTimer); roomTimer = null; return; }
  $('lbOut').classList.toggle('hide', NET.inRoom); $('lbIn').classList.toggle('hide', !NET.inRoom);
  if (!roomTimer) roomTimer = setInterval(() => { if (!$('lobby').classList.contains('hide') && !NET.inRoom) netSend({ t: 'list' }); }, 2000);
  if (NET.inRoom) {
    const host = NET.players.find(p => p.id === NET.host);
    $('lbRoom').innerHTML = '<h2>Game ' + NET.room + '</h2>' + NET.players.map(p => '<div style="color:' + (p.col || '#fff') + '">' + esc(p.name) + (p.id === NET.host ? ' (host)' : '') + (p.id === NET.id ? ' - you' : '') + '</div>').join('');
    const amHost = NET.host === NET.id; $('lbHost').classList.toggle('hide', !amHost);
    if (amHost) { const sel = $('lbLevel'); if (!sel.options.length || sel.dataset.un != SAVE.un) { sel.innerHTML = ''; sel.dataset.un = SAVE.un;
      for (let li = 0; li <= SAVE.un; li++) { const [w, s] = li2ws(li); const o = document.createElement('option'); o.value = li; o.textContent = 'World ' + w + '-' + s; sel.appendChild(o); } sel.value = SAVE.cur; } }
    $('lbStatus').textContent = amHost ? (NET.players.length < 2 ? 'Waiting for friends to join game ' + NET.room + '...' : 'Pick a level and press Start.') : 'Waiting for ' + (host ? host.name : 'the host') + ' to start.';
  }
}
function esc(s) { return String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function showRooms(rooms) {
  const box = $('rooms'); box.innerHTML = rooms.length ? '' : '<div>No games yet. Start one, then your friends can join it.</div>';
  for (const r of rooms) { const d = document.createElement('div'); d.innerHTML = '<span>' + esc(r.host) + "'s game " + r.code + ' (' + r.n + '/4)' + (r.started ? ' playing' : '') + '</span>';
    const b = document.createElement('button'); b.className = 'big'; b.style.minWidth = '0'; b.textContent = 'Join'; b.onclick = () => { $('lbName').onchange(); netSend({ t: 'join', code: r.code }); }; d.appendChild(b); box.appendChild(d); }
}
function togglePause() {
  if (NET.inRoom) { // playing together: the game keeps running for everyone, the menu just covers it
    G.paused = !G.paused; $('pause').classList.toggle('hide', !G.paused); $('bRestart').classList.add('hide'); if (G.paused) $('bResume').focus(); return; }
  if (G.mode === 'play') { G.mode = 'pause'; $('pause').classList.remove('hide'); $('bRestart').classList.toggle('hide', NET.inRoom); sfx('pause'); stopMusic(); $('bResume').focus(); }
  else if (G.mode === 'pause') { G.mode = 'play'; $('pause').classList.add('hide'); playMusic(G.me.starT > 0 ? 'star' : musicFor()); }
}
$('pauseBtn').onclick = () => { if (G.mode === 'play' || G.mode === 'pause') togglePause(); };
$('bResume').onclick = togglePause;
$('bRestart').onclick = () => { $('pause').classList.add('hide'); startLevel(G.li, { check: G.check }); };
$('bQuit').onclick = () => toMenu();
updMenu();

// ---------------- test helpers ----------------
function simJump(p, hold, brakeAt) { // dry-run a jump with the real collision code; returns landing x or -1
  const q = { x: p.x, y: p.y, w: p.w, h: p.h, vx: p.vx, vy: 0, onG: true };
  for (let t = 0; t < 120; t++) {
    const ax = brakeAt >= 0 && t >= brakeAt ? -1 : 1;
    if (q.vx * ax < 0) q.vx += ax * (q.onG ? 0.22 : 0.12); else if (Math.abs(q.vx) < 2.6) q.vx = Math.min(2.6, Math.abs(q.vx) + 0.08) * ax;
    if (t === 0 && hold > 0) q.vy = -(5.4 + Math.abs(q.vx) * 0.3);
    q.vy += (q.vy < 0 && t < hold) ? 0.22 : 0.6; if (q.vy > 6) q.vy = 6;
    moveX(q); if (q.hitWall) q.vx = 0; moveY(q);
    if (q.y > ROWS * 16) return -1;
    for (let ty = Math.floor(q.y / 16); ty <= Math.floor((q.y + q.h - 1) / 16); ty++) for (let tx = Math.floor(q.x / 16); tx <= Math.floor((q.x + q.w - 1) / 16); tx++) if (tileAt(tx, ty) === 'L') return -1;
    if (t > 4 && q.onG) return q.x;
  }
  return -1;
}
function botInput() { // bot used by the level checker: run right, plan jumps with simJump
  const p = G.me, inp = { l: false, r: true, a: false, b: true }; if (!p || !L) return inp;
  const lift = L.ents.find(e => e.type === 'lift' && e.x0 - 40 < p.x && e.x1 + 60 > p.x);
  if (lift && !G.botPlan) { // wait for the lift, ride it, step off at the far end
    if (p.onLift) { if (lift.x >= lift.x1 - 2) G.botExit = 60; if (G.botExit > 0) G.botExit--; inp.r = G.botExit > 0; inp.b = false; return inp; }
    if (p.onG && p.x + p.w < lift.x0 + 4) { if (lift.x <= lift.x0 + 24 && lift.dx <= 0) G.botBoard = 30; if (G.botBoard > 0) G.botBoard--;
      inp.r = G.botBoard > 0 || p.x + p.w < lift.x0 - 6; inp.b = false; return inp; }
  }
  if (G.botPlan) { const bp = G.botPlan; bp.t++; inp.a = bp.t < bp.hold; if (bp.brake >= 0 && bp.t >= bp.brake) { inp.r = false; inp.l = true; }
    if ((p.onG && bp.t > 4) || bp.t > 150) G.botPlan = null; G.botPrevA = inp.a; return inp; }
  if (p.onG && !p.onLift) {
    const fx2 = Math.floor((p.x + p.w + 6) / 16), fy2 = Math.floor((p.y + p.h - 1) / 16);
    let hazard = solidAt(fx2, fy2) || solidAt(fx2, Math.floor(p.y / 16)); let gap = true;
    for (let y = fy2 + 1; y < ROWS; y++) if (solidAt(fx2, y)) gap = false;
    for (const e of L.ents) if (e.alive && !e.local && e.st !== 'dead' && e.x > p.x && e.x - p.x < 40 && Math.abs(e.y - p.y) < 30) hazard = true;
    if (hazard || gap) { let best = null, bx = p.x + 2;
      for (const hold of [26, 16, 8]) for (const brake of [-1, 30, 22, 16, 10, 5]) { const lx = simJump(p, hold, brake); if (lx > bx) { bx = lx; best = { hold, brake, t: 0 }; } }
      if (best && !L.ents.some(e => e.type === 'lift' && Math.abs(e.x - p.x) < 140) ) { G.botPlan = best; inp.a = !G.botPrevA; G.botPrevA = inp.a; if (!inp.a) G.botPlan = null; return inp; } }
  }
  const fx = Math.floor((p.x + p.w + 6) / 16), fy = Math.floor((p.y + p.h - 1) / 16), hy = Math.floor(p.y / 16);
  const wall = solidAt(fx, fy) || solidAt(fx, hy);
  let gapAhead = true; for (let i = 0; i < 2; i++) for (let y = fy + 1; y < ROWS; y++) if (solidAt(fx + i, y)) gapAhead = false;
  let liftNear = false; for (const e of L.ents) if (e.type === 'lift' && Math.abs(e.x + 24 - (p.x + 6)) < 30) liftNear = true;
  let foe = false; for (const e of L.ents) if (e.alive && !e.local && e.st !== 'dead' && e.x > p.x && e.x - p.x < 40 && Math.abs(e.y - p.y) < 30) foe = true;
  if (gapAhead && !p.onLift && L.ents.some(e => e.type === 'lift' && e.x > p.x + 8 && e.x - p.x < 120)) { inp.r = liftNear; }
  if (!p.onG && p.vy > 0) { // falling: brake if we are over a platform and there is a hole just ahead
    const cc = Math.floor((p.x + p.w / 2) / 16); let below = false, aheadG = false;
    for (let y = fy + 1; y < ROWS; y++) { if (solidAt(cc, y)) below = true; if (solidAt(cc + 1, y) || solidAt(cc + 2, y)) aheadG = true; }
    if (below && !aheadG) { inp.r = false; inp.l = true; }
  }
  G.botHold = (G.botHold || 0) - 1;
  if ((wall || gapAhead || foe) && p.onG) G.botHold = 26;
  inp.a = G.botHold > 0 && !(p.onG && G.botPrevA);
  G.botPrevA = inp.a;
  return inp;
}
window.PIPdebug = { G, get L() { return L; }, step, render, genLevel, startLevel, NET, SAVE, keys };

// ---------------- main loop ----------------
let acc = 0, last = 0;
function frame(ts) {
  requestAnimationFrame(frame);
  const dt = Math.min(100, ts - (last || ts)); last = ts; acc += dt; let n = 0;
  while (acc >= 1000 / 60 && n < 5 && !G.hold) { step(); acc -= 1000 / 60; n++; }
  if (n === 5) acc = 0;
  render();
}
requestAnimationFrame(frame);
</script>
</body>
</html>
EOF

3. Copy the icons

make-icons.ps1 (from the games site post) draws this game's icons into a pip subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):

scp -o BatchMode=yes pip\icon-512.png pip\icon-192.png pip\apple-touch-icon.png pip\favicon-32.png proxmox:/root/nibbles/pip/

4. Push it into CT 125

Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a pip folder:

bash /root/nibbles/05-push-site.sh

5. Check it

Proxmox host shell. Should print 200:

pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/pip/

Then open https://games.503mail.com/pip/ in a browser.

6. Multiplayer: the games relay

Play Together (and Dash Rush's Race Together) needs a tiny WebSocket relay. It runs as its own service, games-relay on port 8090, inside CT 127 (blockcraft), which already has Node.js. It doesn't know any game rules: it keeps a list of rooms (4-letter codes, up to 4 players each) and passes each player's messages to the others in the room. The first player in a room is the "host" whose game runs the enemies; if the host leaves, the next player takes over. BlockCraft is not touched or restarted. CT 127 comes from the BlockCraft post.

Paste these four blocks into the Proxmox host shell. They write the relay, the script that installs it inside CT 127, the script that pushes it there, and the edge route script:

cat > /root/nibbles/pip/relay.js <<'EOF'
// Games relay: tiny WebSocket "rooms" server for the games site (Super Pip, Dash Rush).
// Runs on CT 127 next to BlockCraft as its own service (games-relay, port 8090).
// The edge Caddy on CT 108 sends games.503mail.com/mp/* here.
// It does not know any game rules: it keeps a list of rooms (4-letter codes, max 4 players)
// and forwards every other message to the rest of the room (or to one player with "to").
const http = require('http');
const WebSocket = require('ws');

const PORT = +(process.env.PORT || 8090);
const MAX = 4;
const rooms = new Map(); // code -> { code, game, host, clients:Set, started }
let nextId = 1;

const server = http.createServer((req, res) => {
  if (req.url === '/mp/health') {
    let players = 0; for (const r of rooms.values()) players += r.clients.size;
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('ok rooms=' + rooms.size + ' players=' + players + '\n');
    return;
  }
  res.writeHead(404); res.end();
});
const wss = new WebSocket.Server({ server, path: '/mp/ws', maxPayload: 64 * 1024 });

function clean(s) { return String(s || 'Player').replace(/[^\w \-']/g, '').trim().slice(0, 12) || 'Player'; }
function newCode() { const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; let c; do { c = ''; for (let i = 0; i < 4; i++) c += A[Math.random() * A.length | 0]; } while (rooms.has(c)); return c; }
function send(ws, m) { if (ws.readyState === 1) ws.send(typeof m === 'string' ? m : JSON.stringify(m)); }
function roomInfo(r) { return { t: 'room', code: r.code, host: r.host, started: r.started, players: [...r.clients].map(c => ({ id: c.id, name: c.name, ch: c.ch, col: c.col })) }; }
function bcast(r, m, except) { const s = JSON.stringify(m); for (const c of r.clients) if (c !== except) send(c, s); }
function leave(ws) {
  const r = ws.room; if (!r) return;
  r.clients.delete(ws); ws.room = null;
  if (!r.clients.size) { rooms.delete(r.code); return; }
  if (r.host === ws.id) r.host = [...r.clients][0].id;
  bcast(r, roomInfo(r));
}

wss.on('connection', ws => {
  ws.id = nextId++; ws.name = 'Player'; ws.game = ''; ws.ch = 0; ws.col = ''; ws.room = null; ws.alive = true;
  ws.on('pong', () => { ws.alive = true; });
  send(ws, { t: 'id', id: ws.id });
  ws.on('message', data => {
    let m; try { m = JSON.parse(data); } catch (e) { return; }
    if (!m || typeof m.t !== 'string') return;
    const r = ws.room;
    switch (m.t) {
      case 'hello':
        ws.game = String(m.game || '').slice(0, 16); ws.name = clean(m.name); ws.ch = (m.ch | 0) & 7;
        ws.col = /^#[0-9a-fA-F]{6}$/.test(m.col || '') ? m.col : '';
        if (r) bcast(r, roomInfo(r));
        return;
      case 'list':
        send(ws, { t: 'rooms', rooms: [...rooms.values()].filter(x => x.game === ws.game && x.clients.size < MAX)
          .map(x => { const h = [...x.clients].find(c => c.id === x.host); return { code: x.code, host: h ? h.name : '?', n: x.clients.size, started: x.started }; }) });
        return;
      case 'create': {
        leave(ws);
        const nr = { code: newCode(), game: ws.game, host: ws.id, clients: new Set([ws]), started: false };
        rooms.set(nr.code, nr); ws.room = nr; send(ws, roomInfo(nr));
        return; }
      case 'join': {
        const jr = rooms.get(String(m.code || '').toUpperCase());
        if (!jr || jr.game !== ws.game) { send(ws, { t: 'err', msg: 'That game is gone. Pick another one.' }); return; }
        if (jr.clients.size >= MAX) { send(ws, { t: 'err', msg: 'That game is full (4 players).' }); return; }
        if (jr === r) return;
        leave(ws); jr.clients.add(ws); ws.room = jr; bcast(jr, roomInfo(jr));
        return; }
      case 'leave': leave(ws); return;
      case 'started': if (r && r.host === ws.id) r.started = !!m.v; return;
      default:
        if (!r) return;
        m.f = ws.id;
        if (m.to) { for (const c of r.clients) if (c.id === m.to) send(c, m); }
        else bcast(r, m, ws);
    }
  });
  ws.on('close', () => leave(ws));
  ws.on('error', () => {});
});

setInterval(() => {
  for (const ws of wss.clients) { if (!ws.alive) { ws.terminate(); continue; } ws.alive = false; try { ws.ping(); } catch (e) {} }
}, 20000);

server.listen(PORT, () => console.log('games relay on port ' + PORT));
EOF
cat > /root/nibbles/pip/06-relay-inside.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs inside CT 127 (BlockCraft's container). Installs the games relay (Super Pip / Dash Rush
# multiplayer rooms) as its own systemd service on port 8090. BlockCraft is not touched.
# Expects /root/relay.js (pushed by 07-push-relay.sh). Node + node-ws are already there for BlockCraft.
set -euo pipefail
command -v node >/dev/null || { apt-get update -q; apt-get install -y -q nodejs node-ws; }
id gamesrelay >/dev/null 2>&1 || useradd --system --home /opt/games-relay --shell /usr/sbin/nologin gamesrelay
mkdir -p /opt/games-relay
cp /root/relay.js /opt/games-relay/relay.js
chown -R gamesrelay:gamesrelay /opt/games-relay

cat > /etc/systemd/system/games-relay.service <<'EOF'
[Unit]
Description=Games relay (Super Pip / Dash Rush multiplayer rooms)
After=network-online.target

[Service]
User=gamesrelay
WorkingDirectory=/opt/games-relay
Environment=PORT=8090
ExecStart=/usr/bin/node /opt/games-relay/relay.js
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable games-relay >/dev/null 2>&1
systemctl restart games-relay
sleep 2
systemctl is-active games-relay
node -e "require('http').get('http://127.0.0.1:8090/mp/health', r => r.pipe(process.stdout))"
EOF_OUTER1
cat > /root/nibbles/pip/07-push-relay.sh <<'EOF'
#!/bin/bash
# Runs on the Proxmox host after copying relay.js and 06-relay-inside.sh to /root/nibbles/.
# Installs or updates the games relay on CT 127 (restarts only games-relay, not BlockCraft).
set -euo pipefail
CTID=127
pct push $CTID /root/nibbles/relay.js /root/relay.js
pct push $CTID /root/nibbles/06-relay-inside.sh /root/06-relay-inside.sh
pct exec $CTID -- bash /root/06-relay-inside.sh
EOF
cat > /root/nibbles/pip/08-edge-mp-route.sh <<'EOF'
#!/bin/bash
# Runs inside CT 108 (edge Caddy). Usage: bash 08-edge-mp-route.sh <CT127_IP>
# Sends games.503mail.com/mp/* (the multiplayer WebSocket for Super Pip and Dash Rush) to the
# games relay on CT 127 port 8090. Adds a handle block in front of the BlockCraft one.
set -euo pipefail
IP="$1"; F=/etc/caddy/Caddyfile
if grep -q 'handle /mp/\*' "$F"; then echo "mp route already present"; exit 0; fi
BAK="$F.bak-mp-$(date +%Y%m%d%H%M%S)"
cp "$F" "$BAK"
perl -0pi -e "s{(games\.503mail\.com \{\n)(\t# BlockCraft)}{\$1\t# Games relay (CT 127 :8090, Super Pip / Dash Rush multiplayer) - added $(date +%F)\n\thandle /mp/* {\n\t\treverse_proxy $IP:8090\n\t}\n\$2}" "$F"
if ! grep -q 'handle /mp/\*' "$F"; then cp "$BAK" "$F"; echo "games block not in the expected shape - nothing changed"; exit 1; fi
if ! caddy validate --config "$F" --adapter caddyfile; then
  cp "$BAK" "$F"; echo "validate failed - restored $BAK"; exit 1
fi
systemctl reload caddy
echo "edge reloaded"
grep -n -A 14 'games.503mail.com {' "$F"
EOF

Proxmox host shell. Moves them out of the game folder to where the scripts expect them:

mv /root/nibbles/pip/relay.js /root/nibbles/pip/06-relay-inside.sh /root/nibbles/pip/07-push-relay.sh /root/nibbles/pip/08-edge-mp-route.sh /root/nibbles/

Proxmox host shell. Installs and starts the relay in CT 127. It should end with active and ok rooms=0 players=0:

bash /root/nibbles/07-push-relay.sh

Proxmox host shell. Copies the edge route script into CT 108 (Caddy reverse proxy):

pct push 108 /root/nibbles/08-edge-mp-route.sh /root/08-edge-mp-route.sh

Proxmox host shell. Adds a handle /mp/* block for the relay to the games.503mail.com block (looking up CT 127's address itself), validates and reloads Caddy. It should end with Valid configuration, edge reloaded and the new games block:

pct exec 108 -- bash /root/08-edge-mp-route.sh $(pct exec 127 -- hostname -I | awk '{print $1}')

Check it from anywhere. Should print ok rooms=0 players=0 (the numbers go up while people play):

curl -s https://games.503mail.com/mp/health

To update the relay later, paste the new relay.js block, run the mv and 07-push-relay.sh again. It only restarts games-relay; players in a game are dropped back to the menu and can rejoin.

Updating it later

Edit index.html, paste the new version over /root/nibbles/pip/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.

Troubleshooting

The game runs entirely in the browser, so the server's only job is handing out the page.

  • The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print 200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy).
  • The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for /favicon.ico is harmless (browsers ask for it on their own, and the games use PNG icons instead).
  • Settings or best scores look wrong: they're kept in the browser's localStorage under 'superpip'. Clearing the site's data in the browser resets them; nothing is stored on the server.
  • Play Together says "Not connected": check the relay with curl -s https://games.503mail.com/mp/health. A 502 means the relay is down or CT 127's address changed (then fix the IP in the handle /mp/* block in CT 108's /etc/caddy/Caddyfile and reload Caddy). The relay's log is in CT 127; in the Proxmox host shell run pct exec 127 -- journalctl -u games-relay -n 50 --no-pager. Healthy is a single games relay on port 8090 per start; restart it with pct exec 127 -- systemctl restart games-relay.
  • Players in the same game see different things for a moment: normal on a slow connection. The host's game is in charge of enemies and blocks and sends an update 12 times a second; everyone catches up within a second.

Notes

  • How the levels are made: each level comes from a seeded random generator built out of pieces (flat ground, gaps, pipes, block rows, staircases, steps, floating platforms, moving lifts, cannons, fire bars), with limits that keep every jump possible: gaps of at most 4 tiles (3 early on), pipes at most 3 tiles tall, climbs of at most 2 tiles. The seed is the world and level number, so all players get the same level.
  • Checked by a robot: a test bot that runs right and plans each jump by dry-running the real jump physics played all 40 levels with damage turned off. It finishes 37; the other 3 are moving-lift spots that the bot is too impatient for. Riding those lifts by hand works.
  • Bugs the bot found: in castles where the floor had stepped down, the lava overwrote King Grumble's bridge so there was nothing to walk on (fixed); 4-tile pipes could only be cleared with a running start (now at most 3); and jumping into the corner of a block while holding ◀ or ▶ left Pip hovering in mid-air, because the "slide past the corner" nudge kept resetting his jump (fixed).
  • Hidden-tab bug: a tablet that reports a zero-size window (a hidden tab) made the screen width "not a number" and the game drew only sky. The game now falls back to a normal size.
  • Name: Mario, the Mushroom Kingdom and their art and music belong to Nintendo, so the game has its own names, pixel art and tunes. Ideas like coin blocks and stomping enemies aren't owned by anyone.