Dino Island: a Jurassic Park-style egg hunt (rebuild guide) 04OCT2026

Collect the eggs, put dinosaurs to sleep, get back to the helicopter

Posted by on 4th Oct 2026

My own top-down adventure inspired by the 1993 Jurassic Park DOS game. That game is still commercial, so this is written from scratch, and it's kid-friendly: nothing gets killed. Play it at https://games.503mail.com/dinoisland/ 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

You land by helicopter on a jungle island that's generated fresh every level, with trails leading out from the pad. Find all the dino eggs, then run back to the helicopter. The dinosaurs are small compys that nip, raptors that chase you when they can see you, triceratops that charge in a straight line, and from level 3 a T-rex that roars. Your dart gun fires sleepy darts that put a dinosaur to sleep for a few seconds (bigger ones need more darts). Dart boxes and first aid kits lie around the island, and an arrow next to you points at the nearest egg, then at the helicopter. Auto aim (on by default on touch screens) points each dart at the nearest dinosaur. Easy gives more hearts, more darts and sleepier dinosaurs.

Controls

Keyboard: arrows walk, Space fires a sleepy dart, P pause, M sound. Touch: an arrow pad on the left and a DART button on the right, and holding a finger on the island walks toward it.

Rebuild, step by step

Dino Island 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 Dino Island tile.

1. Make the game's folder

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

mkdir -p /root/nibbles/dinoisland

2. Create the game file

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

cat > /root/nibbles/dinoisland/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Dino Island</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="Dino Island">
<meta property="og:image" content="https://games.503mail.com/dinoisland/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#000;color:#aaa;font-family:"Lucida Console","Courier New",monospace}
  body{display:flex;align-items:center;justify-content:center;overflow:hidden}
  :root{--ar:1.3333;--padh:30vh;--b:min(14vw,calc(var(--padh) / 2.3))}
  #wrap{position:relative;width:min(100vw,calc(100vh * var(--ar)));aspect-ratio:var(--ar)}
  canvas{display:block;width:100%;height:100%;touch-action:none}
  #setup{position:fixed;inset:0;z-index:5;background:radial-gradient(rgba(10,50,20,.9),rgba(0,0,0,.95));color:#fff;display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:2.2vmin;font-size:clamp(12px,2.6vmin,22px);text-align:center;padding:16px;box-sizing:border-box}
  #setup.hidden{display:none}
  #setup h1{margin:0;color:#FFFF55;letter-spacing:.3em;font-size:1.8em}
  #setup .sub{color:#55FF55;letter-spacing:.3em;margin-top:-1.2vmin}
  #setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
  #setup button{font:inherit;cursor:pointer;border:0}
  #setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
  #setup .help{color:#AAAAAA;font-size:.85em;line-height:1.6}
  #setup kbd{color:#FFFF55}
  #setup a{color:#55FFFF}
  /* on-screen buttons, touch screens only: arrows on the left, fire on the right */
  .pad{display:none;gap:calc(var(--b) * .12)}
  #pl{grid-template-columns:repeat(3,var(--b));grid-template-rows:repeat(2,var(--b))}
  #pr{grid-template-columns:repeat(2,var(--b));grid-template-rows:repeat(2,var(--b))}
  body.touch{display:grid;grid-template-areas:"w w" "l r";grid-template-columns:1fr 1fr;align-content:center;justify-items:center;row-gap:1.5vh}
  body.touch #wrap{grid-area:w;width:min(100vw,calc((100vh - var(--padh)) * var(--ar)))}
  body.touch .pad{display:grid}
  #pl{grid-area:l} #pr{grid-area:r}
  @media (orientation:landscape){
    :root{--b:min(9vw,19vh)}
    body.touch{grid-template-areas:"l w r";grid-template-columns:auto auto auto;column-gap:1.5vw;align-items:end;justify-content:center}
    body.touch #wrap{width:min(calc(100vw - 5 * var(--b) - 8vw),calc(96vh * var(--ar)));align-self:center}
    .pad{padding-bottom:4vh}
  }
  .pad button{background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:18%;
    font:bold calc(var(--b) * .4)/1 "Lucida Console","Courier New",monospace;padding:0;
    touch-action:none;user-select:none;-webkit-user-select:none}
  .pad button.hit,.pad button.held{background:#5555FF}
  .pad .f{grid-column:1/3;background:#AA0000;border-color:#FF5555;color:#fff;font-size:calc(var(--b) * .3);border-radius:calc(var(--b) * .2)}
  .pad .p{color:#fff;border-color:#AAAAAA;font-size:calc(var(--b) * .28)}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="c" width="960" height="720"></canvas>
</div>
<div id="setup">
  <h1>DINO ISLAND</h1>
  <div class="sub">EGG RESCUE</div>
  <div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
    <label><input type="checkbox" id="aim"> Auto aim</label>
    <label><input type="checkbox" id="easy"> Easy</label></div>
  <button id="go">START</button>
  <div id="hi"></div>
  <div class="help">
    <kbd>Arrows</kbd> walk &nbsp;·&nbsp; <kbd>Space</kbd> sleepy dart &nbsp;·&nbsp; <kbd>P</kbd> pause &nbsp;·&nbsp; <kbd>M</kbd> sound<br>
    Find all the <span style="color:#fff">dino eggs</span>, then run back to the <span style="color:#FFFF55">helicopter</span>.<br>
    Sleepy darts put dinosaurs to sleep for a little while.<br>
    Grab <span style="color:#d0a060">dart boxes</span> and <span style="color:#FF5555">first aid kits</span>. The arrow points to the next egg.
  </div>
  <a href="/">Other games</a>
</div>
<div class="pad" id="pl" aria-label="Walk">
  <span></span><button data-a="up" aria-label="Up">&#9650;</button><span></span>
  <button data-a="left" aria-label="Left">&#9664;</button><button data-a="down" aria-label="Down">&#9660;</button><button data-a="right" aria-label="Right">&#9654;</button>
</div>
<div class="pad" id="pr" aria-label="Actions">
  <span></span><button class="p" data-a="pause" aria-label="Pause or continue">II</button>
  <button class="f" data-a="fire" aria-label="Fire sleepy dart">DART</button>
</div>
<script>
(() => {
  const LW = 320, LH = 240;                          // logical canvas size (drawn at 3x)
  const cv = document.getElementById('c'), g = cv.getContext('2d');
  g.scale(3, 3);
  const setupEl = document.getElementById('setup');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v)), lerp = (a, b, p) => a + (b - a) * p;

  // ---- settings + high score (per-viewer convenience only) ----
  let cfg = { sound:true, aim:isTouch, easy:false, high:0 };
  try { Object.assign(cfg, JSON.parse(localStorage.getItem('dinoisland') || '{}')); } catch (e) {}
  const saveCfg = () => { try { localStorage.setItem('dinoisland', JSON.stringify(cfg)); } catch (e) {} };

  // ---- sound: synthesized chirps and roars ----
  let actx = null, noiseBuf = null;
  function audio() {
    if (!cfg.sound) return null;
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
    if (actx.state === 'suspended') actx.resume();
    return actx;
  }
  function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
    const a = audio(); if (!a) return;
    const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
    o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
    v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
    o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
  }
  function boom(dur = 0.4, vol = 0.25, cutoff = 900) {
    const a = audio(); if (!a) return;
    if (!noiseBuf) {
      noiseBuf = a.createBuffer(1, a.sampleRate, 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, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
    s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(60, t + dur);
    v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.001, t + dur);
    s.connect(f).connect(v).connect(a.destination); s.start(t); s.stop(t + dur);
  }
  const notes = (fs, gap, dur, vol = 0.04, type = 'square') => fs.forEach((f, i) => setTimeout(() => sweep(f, f, dur, type, vol), i * gap));
  const SFX = {
    dart:   () => sweep(900, 400, 0.08, 'triangle', 0.06),
    thud:   () => sweep(300, 150, 0.1, 'square', 0.04),
    sleep:  () => notes([660, 550, 440, 330], 90, 0.12, 0.04, 'triangle'),
    egg:    () => notes([784, 988, 1319], 70, 0.1, 0.04),
    item:   () => notes([523, 784], 80, 0.1, 0.04),
    hurt:   () => sweep(300, 80, 0.3, 'square', 0.06),
    empty:  () => sweep(120, 100, 0.05, 'square', 0.02),
    compy:  () => sweep(2400, 3200, 0.06, 'square', 0.012),
    raptor: () => sweep(1900, 800, 0.3, 'sawtooth', 0.03),
    trike:  () => boom(0.5, 0.2, 400),
    rex:    () => { boom(1.1, 0.35, 350); sweep(140, 55, 1.0, 'sawtooth', 0.07); },
    clear:  () => notes([523, 659, 784, 1047, 784, 1047], 150, 0.14, 0.05),
    over:   () => notes([392, 330, 262, 196], 220, 0.25, 0.05),
  };

  // ---- the island: random every level ----
  const MW = 56, MH = 40, TS = 16;
  const WATER = 0, SAND = 1, GRASS = 2, PATH = 3, TREE = 4, ROCK = 5, PAD = 6, PALM = 7;
  const WALK = [false, true, true, true, false, false, true, false];
  let map = new Uint8Array(MW * MH), tileVar = new Uint8Array(MW * MH), padX = 0, padY = 0;
  const tile = (x, y) => (x < 0 || y < 0 || x >= MW || y >= MH) ? WATER : map[y * MW + x];
  const solid = (x, y) => !WALK[tile(x, y)];
  const blocksDart = (x, y) => { const t = tile(x, y); return t === TREE || t === ROCK || t === PALM; };

  function rng(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
  function noise(R, cell) {
    const gw = Math.ceil(MW / cell) + 2, gh = Math.ceil(MH / cell) + 2, v = Array.from({ length: gw * gh }, R), sm = t => t * t * (3 - 2 * t);
    return (x, y) => {
      const fx = x / cell, fy = y / cell, ix = Math.floor(fx), iy = Math.floor(fy), tx = sm(fx - ix), ty = sm(fy - iy);
      return lerp(lerp(v[iy * gw + ix], v[iy * gw + ix + 1], tx), lerp(v[(iy + 1) * gw + ix], v[(iy + 1) * gw + ix + 1], tx), ty);
    };
  }

  function makeIsland(R) {
    const n1 = noise(R, 9), n2 = noise(R, 4), nt = noise(R, 3.5);
    for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
      const nx = (x - MW / 2) / (MW / 2), ny = (y - MH / 2) / (MH / 2);
      const h = 1 - Math.hypot(nx, ny) + (n1(x, y) - .5) * .5 + (n2(x, y) - .5) * .2;
      let t = h < .12 ? WATER : h < .2 ? SAND : GRASS;
      if (x < 2 || y < 2 || x >= MW - 2 || y >= MH - 2) t = WATER;
      if (t === GRASS && nt(x, y) > .58) t = TREE;
      else if (t === GRASS && R() < .02) t = ROCK;
      else if (t === SAND && R() < .06) t = PALM;
      map[y * MW + x] = t; tileVar[y * MW + x] = R() * 4 | 0;
    }
    const cx = MW / 2 | 0, cy = MH / 2 | 0; padX = cx * TS + TS / 2; padY = cy * TS + TS / 2;
    for (let dy = -3; dy <= 3; dy++) for (let dx = -4; dx <= 4; dx++) map[(cy + dy) * MW + cx + dx] = GRASS;
    for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) map[(cy + dy) * MW + cx + dx] = PAD;
    // dirt trails out from the helicopter pad, two tiles wide so there's always room to walk
    for (let k = 0; k < 6; k++) {
      let x = cx, y = cy, dir = k * Math.PI / 3 + R();
      for (let s = 0; s < 45; s++) {
        dir += (R() - .5) * .8; x += Math.cos(dir); y += Math.sin(dir);
        const tx = Math.round(x), ty = Math.round(y);
        if (tile(tx, ty) === WATER || tile(tx + 1, ty + 1) === WATER) break;
        for (const [ax, ay] of [[0, 0], [1, 0], [0, 1], [1, 1]]) if (tile(tx + ax, ty + ay) !== PAD) map[(ty + ay) * MW + tx + ax] = PATH;
      }
    }
    // which tiles can be walked to from the pad, and how far away they are
    const dist = new Int16Array(MW * MH).fill(-1), q = [cy * MW + cx]; dist[q[0]] = 0;
    for (let i = 0; i < q.length; i++) {
      const p = q[i], x = p % MW, y = p / MW | 0;
      for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
        const nx = x + dx, ny = y + dy, np = ny * MW + nx;
        if (!solid(nx, ny) && dist[np] < 0) { dist[np] = dist[p] + 1; q.push(np); }
      }
    }
    return q.map(p => ({ x: p % MW, y: p / MW | 0, d: dist[p] })).filter(s => map[s.y * MW + s.x] !== PAD).sort(() => R() - .5);
  }

  // ---- dinosaurs ----
  // r = size, see = how far they notice you, dmg in half hearts, c = light/mid/dark colours
  const KIND = {
    compy:  { r: 4,  hp: 1, wander: 35, chase: 70,  see: 70,  dmg: 1, pts: 50,  c: ['#e4f5b0', '#86b84a', '#33601e'] },
    raptor: { r: 7,  hp: 2, wander: 40, chase: 80,  see: 110, dmg: 2, pts: 150, c: ['#ffe0b0', '#d08a3a', '#5d3a12'] },
    trike:  { r: 10, hp: 3, wander: 22, chase: 140, see: 90,  dmg: 2, pts: 200, c: ['#e0ecf8', '#7d8fa8', '#2e3a4c'] },
    rex:    { r: 14, hp: 4, wander: 25, chase: 58,  see: 150, dmg: 3, pts: 400, c: ['#d8eeb0', '#557f38', '#1f3315'] },
  };

  // ---- game state ----
  let mode = 'setup', level = 1, score = 0, p, dinos = [], darts = [], items = [], fx = [], popups = [];
  let camX = 0, camY = 0, msg = '', msgT = 0, flashT = 0, eggsLeft = 0, eggsTotal = 0, time = 0, newHigh = false, target = null, clearBonus = 0;
  const held = { up:false, down:false, left:false, right:false, fire:false };
  const maxHp = () => cfg.easy ? 16 : 10;

  function newGame() { level = 1; score = 0; newHigh = false; startLevel(true); }
  function startLevel(fresh) {
    const R = rng((Date.now() ^ level * 7919) | 0);
    const spots = makeIsland(R), used = [];
    const pick = (minD, count, gap) => {
      const out = [];
      for (const s of spots) {
        if (out.length >= count) break;
        if (s.d < minD || used.some(u => Math.abs(u.x - s.x) + Math.abs(u.y - s.y) < gap)) continue;
        used.push(s); out.push({ x: s.x * TS + TS / 2, y: s.y * TS + TS / 2 });
      }
      return out;
    };
    const hp = fresh ? maxHp() : p.hp, ammo = fresh ? (cfg.easy ? 20 : 12) : Math.max(p.ammo, 6);
    p = { x: padX, y: padY + TS * 2, r: 5, a: -Math.PI / 2, hp, ammo, inv: 0, fireT: 0, step: 0 };
    items = [];
    pick(6, 6 + level * 2, 6).forEach(s => items.push({ k: 'egg', ...s }));
    pick(5, 4 + level, 4).forEach(s => items.push({ k: 'ammo', ...s }));
    pick(8, 2, 6).forEach(s => items.push({ k: 'med', ...s }));
    eggsLeft = eggsTotal = items.filter(i => i.k === 'egg').length;
    const easy = cfg.easy ? 1 : 0;
    const count = { compy: 3 + level, raptor: Math.max(0, Math.min(4, level) - easy), trike: level === 1 ? 1 : 2, rex: level >= 3 ? 1 + (level >= 5 ? 1 : 0) : 0 };
    dinos = [];
    for (const k in count) pick(12, count[k], 3).forEach(s => dinos.push(newDino(k, s.x, s.y)));
    darts = []; fx = []; popups = [];
    msg = `Find ${eggsTotal} dino eggs!`; msgT = 3; mode = 'play';
    camX = clamp(p.x - LW / 2, 0, MW * TS - LW); camY = clamp(p.y - LH / 2 - 8, -16, MH * TS - LH);
  }
  function newDino(k, x, y) {
    const K = KIND[k], hp = cfg.easy ? Math.ceil(K.hp / 2) : K.hp;
    return { k, K, x, y, r: K.r, a: Math.random() * 7, hp, max: hp, state: 'wander', t: Math.random() * 2, sleep: 0, seen: false, step: 0, flash: 0, bite: 0 };
  }

  // ---- movement with tile collisions (each thing is a square of half-size r) ----
  function hits(x, y, r) {
    for (let ty = Math.floor((y - r) / TS); ty <= Math.floor((y + r) / TS); ty++)
      for (let tx = Math.floor((x - r) / TS); tx <= Math.floor((x + r) / TS); tx++) if (solid(tx, ty)) return true;
    return false;
  }
  function move(e, vx, vy, dt) {
    let blocked = false;
    const nx = e.x + vx * dt; if (!hits(nx, e.y, e.r)) e.x = nx; else blocked = true;
    const ny = e.y + vy * dt; if (!hits(e.x, ny, e.r)) e.y = ny; else blocked = true;
    return blocked;
  }
  function canSee(a, b) {
    const d = Math.hypot(b.x - a.x, b.y - a.y), n = Math.ceil(d / 8);
    for (let i = 1; i < n; i++) { const x = lerp(a.x, b.x, i / n), y = lerp(a.y, b.y, i / n); if (blocksDart(Math.floor(x / TS), Math.floor(y / TS))) return false; }
    return true;
  }

  function hurt(n, from) {
    if (p.inv > 0 || mode !== 'play') return;
    if (cfg.easy) n = Math.max(1, Math.floor(n / 2));
    p.hp -= n; p.inv = 1.3; flashT = .2; SFX.hurt();
    const a = Math.atan2(p.y - from.y, p.x - from.x);
    for (let i = 0; i < 6; i++) move(p, Math.cos(a) * 400, Math.sin(a) * 400, 1 / 60);
    if (p.hp <= 0) { p.hp = 0; gameOver(); }
  }
  function gameOver() {
    mode = 'over'; SFX.over();
    newHigh = score > cfg.high;
    if (newHigh) { cfg.high = score; saveCfg(); }
  }

  function fire() {
    if (p.ammo <= 0) { SFX.empty(); p.fireT = .3; return; }
    let a = p.a;
    if (cfg.aim) {
      let best = 160;
      for (const d of dinos) {
        const dist = Math.hypot(d.x - p.x, d.y - p.y);
        if (!d.sleep && dist < best && canSee(p, d)) { best = dist; a = Math.atan2(d.y - p.y, d.x - p.x); }
      }
    }
    p.a = a; p.ammo--; p.fireT = .28; SFX.dart();
    darts.push({ x: p.x + Math.cos(a) * 6, y: p.y + Math.sin(a) * 6, vx: Math.cos(a) * 230, vy: Math.sin(a) * 230, life: .75 });
  }

  // ---- update ----
  function update(dt) {
    time += dt;
    fx.forEach(f => f.t += dt); fx = fx.filter(f => f.t < f.life);
    popups.forEach(q => { q.t += dt; q.y -= 18 * dt; }); popups = popups.filter(q => q.t < .9);
    msgT = Math.max(0, msgT - dt); flashT = Math.max(0, flashT - dt);
    if (mode !== 'play') return;

    // player
    let mx = (held.right ? 1 : 0) - (held.left ? 1 : 0), my = (held.down ? 1 : 0) - (held.up ? 1 : 0);
    if (target) { const dx = target.x + camX - p.x, dy = target.y + camY - p.y; if (Math.hypot(dx, dy) > 6) { mx = dx; my = dy; } }
    const ml = Math.hypot(mx, my);
    if (ml > 0) { mx /= ml; my /= ml; p.a = Math.atan2(my, mx); move(p, mx * 88, my * 88, dt); p.step += dt * 10; }
    p.inv = Math.max(0, p.inv - dt);
    p.fireT -= dt;
    if (held.fire && p.fireT <= 0) fire();

    // darts
    for (const d of darts) {
      d.x += d.vx * dt; d.y += d.vy * dt; d.life -= dt;
      if (blocksDart(Math.floor(d.x / TS), Math.floor(d.y / TS))) { d.life = 0; SFX.thud(); fx.push({ x: d.x, y: d.y, t: 0, life: .25, k: 'puff' }); continue; }
      for (const z of dinos) {
        if (z.sleep || Math.hypot(z.x - d.x, z.y - d.y) > z.r + 3) continue;
        d.life = 0; z.hp--; z.flash = .12;
        if (z.hp <= 0) {
          z.sleep = cfg.easy ? 11 : 8; z.state = 'sleep'; SFX.sleep(); score += z.K.pts;
          popups.push({ x: z.x, y: z.y - z.r, t: 0, s: '' + z.K.pts });
        } else { SFX.thud(); z.state = 'chase'; }
        break;
      }
    }
    darts = darts.filter(d => d.life > 0);

    // dinosaurs
    for (const z of dinos) {
      z.flash = Math.max(0, z.flash - dt); z.bite = Math.max(0, z.bite - dt);
      if (z.sleep > 0) { z.sleep -= dt; if (z.sleep <= 0) { z.sleep = 0; z.hp = z.max; z.state = 'wander'; z.t = 1; } continue; }
      const K = z.K, dx = p.x - z.x, dy = p.y - z.y, dist = Math.hypot(dx, dy);
      const sees = dist < K.see && canSee(z, p);
      z.t -= dt;
      if (z.k === 'trike') {
        // wanders, then lowers its horns and charges in a straight line
        if (z.state === 'charge') { z.step += dt * 16; if (move(z, Math.cos(z.a) * K.chase, Math.sin(z.a) * K.chase, dt) || z.t <= 0) { z.state = 'rest'; z.t = 2; } }
        else if (z.state === 'rest') { if (z.t <= 0) z.state = 'wander'; }
        else if (sees && z.t <= 0) { z.state = 'charge'; z.a = Math.atan2(dy, dx); z.t = 1.1; SFX.trike(); }
        else wander(z, dt);
      } else if (sees || (z.state === 'chase' && dist < K.see * 1.5)) {
        if (z.state !== 'chase') { z.state = 'chase'; if (!z.seen || Math.random() < .3) SFX[z.k](); z.seen = true; }
        z.a = Math.atan2(dy, dx); z.step += dt * K.chase / 6;
        if (move(z, dx / dist * K.chase, dy / dist * K.chase, dt)) {
          // blocked: try sliding sideways a little
          move(z, -dy / dist * K.chase * .6, dx / dist * K.chase * .6, dt);
        }
      } else { if (z.state === 'chase') z.state = 'wander'; wander(z, dt); }
      if (dist < z.r + p.r + 1 && z.bite <= 0) { hurt(K.dmg, z); z.bite = .8; }
    }

    // pick-ups
    for (const it of items) {
      if (Math.hypot(it.x - p.x, it.y - p.y) > 10) continue;
      it.gone = true;
      if (it.k === 'egg') {
        eggsLeft--; score += 100; SFX.egg(); popups.push({ x: it.x, y: it.y - 6, t: 0, s: '100' });
        if (!eggsLeft) { msg = 'All eggs! Run to the helicopter!'; msgT = 3; }
      } else if (it.k === 'ammo') { p.ammo += 6; SFX.item(); popups.push({ x: it.x, y: it.y - 6, t: 0, s: '+6 DARTS' }); }
      else { p.hp = Math.min(maxHp(), p.hp + 4); SFX.item(); popups.push({ x: it.x, y: it.y - 6, t: 0, s: '+2 HEARTS' }); }
    }
    items = items.filter(i => !i.gone);

    if (!eggsLeft && Math.abs(p.x - padX) < TS * 1.5 && Math.abs(p.y - padY) < TS * 1.5) {
      clearBonus = 500 * level + Math.ceil(p.hp / 2) * 50; score += clearBonus; mode = 'clear'; SFX.clear();
    }
    camX = clamp(p.x - LW / 2, 0, MW * TS - LW); camY = clamp(p.y - LH / 2 - 8, -16, MH * TS - LH);
  }
  function wander(z, dt) {
    if (z.t <= 0) { z.t = 1 + Math.random() * 2; z.a = Math.random() * 7; z.idle = Math.random() < .3; }
    if (z.idle) return;
    z.step += dt * z.K.wander / 6;
    if (move(z, Math.cos(z.a) * z.K.wander, Math.sin(z.a) * z.K.wander, dt)) z.t = 0;
  }

  // ---- drawing ----
  function text(s, x, y, color, size = 10, align = 'left') {
    g.font = `bold ${size}px "Lucida Console","Courier New",monospace`; g.textAlign = align; g.textBaseline = 'middle';
    g.fillStyle = '#000'; g.fillText(s, x + .8, y + .8);
    g.fillStyle = color; g.fillText(s, x, y);
  }
  function ell(x, y, rx, ry, color, rot = 0) { g.fillStyle = color; g.beginPath(); g.ellipse(x, y, rx, ry, rot, 0, 7); g.fill(); }
  function ball(x, y, r, light, mid, dark) {
    const gr = g.createRadialGradient(x - r * .35, y - r * .4, r * .1, x, y, r);
    gr.addColorStop(0, light); gr.addColorStop(.5, mid); gr.addColorStop(1, dark);
    g.fillStyle = gr; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();
  }

  // ground tiles, pre-rendered once (4 variants each, water has 2 wave frames)
  function tileImg(fn) { const c = document.createElement('canvas'); c.width = c.height = TS * 3; const x = c.getContext('2d'); x.scale(3, 3); fn(x); return c; }
  function speckle(x, base, dots, n, seed) { x.fillStyle = base; x.fillRect(0, 0, TS, TS); const R = rng(seed); for (let i = 0; i < n; i++) { x.fillStyle = dots[i % dots.length]; x.fillRect(R() * TS, R() * TS, 1 + R(), 1); } }
  const GROUND = {
    [GRASS]: [0, 1, 2, 3].map(v => tileImg(x => { speckle(x, '#3f9a3a', ['#58b84e', '#2f7d2c'], 10, v + 1); if (v === 0) { x.fillStyle = '#ff5'; x.fillRect(5, 9, 1.5, 1.5); } })),
    [SAND]: [0, 1, 2, 3].map(v => tileImg(x => speckle(x, '#e6d08c', ['#cdb46e', '#f4e4ae'], 8, v + 10))),
    [PATH]: [0, 1, 2, 3].map(v => tileImg(x => speckle(x, '#a27b4c', ['#8a6438', '#bb9663'], 9, v + 20))),
    [PAD]: [0, 1, 2, 3].map(() => tileImg(x => speckle(x, '#8a8a8a', ['#7a7a7a', '#999'], 6, 30))),
    [WATER]: [0, 1].map(f => tileImg(x => {
      x.fillStyle = '#1d63b8'; x.fillRect(0, 0, TS, TS); x.strokeStyle = '#4f8fe0'; x.lineWidth = .8;
      for (const [wx, wy] of [[2, 4], [9, 11]]) { x.beginPath(); x.moveTo(wx + f * 2, wy); x.quadraticCurveTo(wx + 2.5 + f * 2, wy - 2, wx + 5 + f * 2, wy); x.stroke(); }
    })),
  };
  GROUND[TREE] = GROUND[GRASS]; GROUND[ROCK] = GROUND[GRASS]; GROUND[PALM] = GROUND[SAND];

  // things that stand up (trees, rocks, palms) are drawn with the animals, sorted top to bottom
  function drawTree(x, y, v) {
    ell(x + 2, y + 6, 9, 4, 'rgba(0,0,0,.25)');
    g.fillStyle = '#5a3a1a'; g.fillRect(x - 1.5, y, 3, 7);
    ball(x - 4, y - 2, 6.5, '#8d6', '#2f8a2a', '#0c3a0c'); ball(x + 4, y - 1, 6.5, '#8d6', '#2f8a2a', '#0c3a0c');
    ball(x, y - 6 - (v & 1) * 2, 8, '#ae8', '#3a9a34', '#0c3a0c');
  }
  function drawPalm(x, y) {
    ell(x + 3, y + 6, 7, 3, 'rgba(0,0,0,.2)');
    g.strokeStyle = '#8a6232'; g.lineWidth = 2.2; g.beginPath(); g.moveTo(x, y + 6); g.quadraticCurveTo(x + 3, y - 2, x + 1, y - 9); g.stroke();
    g.fillStyle = '#2f9a3a';
    for (let i = 0; i < 6; i++) { const a = i / 6 * 7 + .3; ell(x + 1 + Math.cos(a) * 5, y - 9 + Math.sin(a) * 3, 6, 1.8, i % 2 ? '#2f9a3a' : '#48b850', a); }
  }
  function drawRock(x, y) { ell(x + 1, y + 5, 7, 3, 'rgba(0,0,0,.25)'); ball(x, y + 1, 6.5, '#ddd', '#888', '#333'); }

  function drawDino(z) {
    const K = z.K, r = z.r, c = K.c, x = z.x - camX, y = z.y - camY, asleep = z.sleep > 0;
    ell(x, y + r * .5, r * 1.3, r * .6, 'rgba(0,0,0,.28)');
    g.save(); g.translate(x, y); g.rotate(z.a);
    if (z.flash) g.filter = 'brightness(2.5)';
    const wig = asleep ? 0 : Math.sin(z.step * 2) * r * .25, legs = asleep ? 0 : Math.sin(z.step * 2);
    // tail
    g.fillStyle = c[1]; g.beginPath(); g.moveTo(-r * .5, -r * .35); g.quadraticCurveTo(-r * 1.5, wig * .5, -r * 2.1, wig); g.quadraticCurveTo(-r * 1.5, wig * .5 + 1, -r * .5, r * .35); g.fill();
    // legs
    for (const [lx, ly, ph] of [[r * .45, -r * .6, 1], [r * .45, r * .6, -1], [-r * .35, -r * .6, -1], [-r * .35, r * .6, 1]])
      ell(lx + legs * ph * r * .2, ly, r * .28, r * .2, c[2]);
    if (z.k === 'trike') {
      // frill and horns
      g.fillStyle = c[2]; g.beginPath(); g.arc(r * .75, 0, r * .75, Math.PI / 2, Math.PI * 1.5, true); g.fill();
      g.strokeStyle = '#f4ecd8'; g.lineWidth = r * .14;
      g.beginPath(); g.moveTo(r * 1.2, -r * .25); g.lineTo(r * 1.9, -r * .35); g.moveTo(r * 1.2, r * .25); g.lineTo(r * 1.9, r * .35); g.moveTo(r * 1.5, 0); g.lineTo(r * 1.8, 0); g.stroke();
    }
    // body
    const gr = g.createRadialGradient(-r * .2, -r * .3, r * .1, 0, 0, r * 1.1);
    gr.addColorStop(0, c[0]); gr.addColorStop(.5, c[1]); gr.addColorStop(1, c[2]);
    g.fillStyle = gr; g.beginPath(); g.ellipse(0, 0, r, r * .68, 0, 0, 7); g.fill();
    if (z.k === 'raptor') { g.strokeStyle = c[2]; g.lineWidth = 1; for (let i = -1; i <= 1; i++) { g.beginPath(); g.moveTo(i * 3, -r * .55); g.lineTo(i * 3 - 1, r * .55); g.stroke(); } }
    if (z.k === 'rex') { ell(r * .7, -r * .45, r * .18, r * .1, c[2]); ell(r * .7, r * .45, r * .18, r * .1, c[2]); }
    // head
    const hr = z.k === 'rex' ? r * .6 : z.k === 'trike' ? r * .5 : r * .45, hx = r * (z.k === 'rex' ? 1.15 : 1.05);
    const hg = g.createRadialGradient(hx - hr * .3, -hr * .3, hr * .1, hx, 0, hr);
    hg.addColorStop(0, c[0]); hg.addColorStop(.6, c[1]); hg.addColorStop(1, c[2]);
    g.fillStyle = hg; g.beginPath(); g.ellipse(hx, 0, hr * 1.25, hr, 0, 0, 7); g.fill();
    // eyes (closed when asleep)
    for (const s of [-1, 1]) {
      if (asleep) { g.strokeStyle = '#111'; g.lineWidth = .7; g.beginPath(); g.moveTo(hx - 1, s * hr * .55); g.lineTo(hx + 1, s * hr * .55); g.stroke(); }
      else { ell(hx, s * hr * .55, Math.max(1, hr * .22), Math.max(1, hr * .22), '#fff'); ell(hx + .4, s * hr * .55, Math.max(.6, hr * .12), Math.max(.6, hr * .12), z.state === 'chase' || z.state === 'charge' ? '#d00' : '#111'); }
    }
    g.filter = 'none'; g.restore();
    if (asleep) { const k = (time * .8) % 1; g.globalAlpha = 1 - k; text('z', x + r * .6 + k * 4, y - r - k * 10, '#fff', 7 + k * 4, 'center'); g.globalAlpha = 1; }
    else if (z.state === 'charge' || (z.state === 'chase' && z.t > -.6 && z.k !== 'compy')) {
      if (z.k !== 'trike' || z.state === 'charge') text('!', x, y - r - 6, '#FF5555', 10, 'center');
    }
  }

  function drawPlayer() {
    const x = p.x - camX, y = p.y - camY;
    if (p.inv > 0 && Math.floor(p.inv * 12) % 2) return;
    ell(x, y + 4, 6, 3, 'rgba(0,0,0,.3)');
    g.save(); g.translate(x, y); g.rotate(p.a);
    const sw = Math.sin(p.step) * 2;
    ell(-1 + sw, -3, 2, 1.6, '#4a3a20'); ell(-1 - sw, 3, 2, 1.6, '#4a3a20');        // boots
    g.fillStyle = '#c8b070'; g.beginPath(); g.ellipse(0, 0, 4, 5.2, 0, 0, 7); g.fill();  // shirt
    g.fillStyle = '#6a7a3a'; g.fillRect(-4.5, -2.5, 2.5, 5);                       // backpack
    g.strokeStyle = '#333'; g.lineWidth = 1.6; g.beginPath(); g.moveTo(2, 1.5); g.lineTo(8, 1.5); g.stroke();  // dart gun
    g.fillStyle = '#f80'; g.fillRect(7, .8, 1.5, 1.4);
    ball(0, 0, 3.6, '#f0dca0', '#b08a40', '#5a4010');                              // hat
    g.restore();
  }
  function drawItem(it) {
    const x = it.x - camX, y = it.y - camY + Math.sin(time * 4 + it.x) * .8;
    if (it.k === 'egg') {
      ell(x, y + 4, 4, 1.8, 'rgba(0,0,0,.25)');
      const gr = g.createRadialGradient(x - 1.5, y - 2, .5, x, y, 5); gr.addColorStop(0, '#fff'); gr.addColorStop(.6, '#efe6cc'); gr.addColorStop(1, '#a89870');
      g.fillStyle = gr; g.beginPath(); g.ellipse(x, y, 3.6, 4.8, 0, 0, 7); g.fill();
      g.fillStyle = '#8a7a50'; [[-1, -1], [1.2, .8], [-.6, 2]].forEach(([a, b]) => g.fillRect(x + a, y + b, 1, 1));
      if (Math.floor(time * 3 + it.y) % 4 === 0) { g.fillStyle = '#fff'; g.fillRect(x + 2.5, y - 5, 1, 1); g.fillRect(x + 3.5, y - 6, 1, 1); }
    } else if (it.k === 'ammo') {
      ell(x, y + 4, 5, 2, 'rgba(0,0,0,.25)');
      g.fillStyle = '#9a6a32'; g.fillRect(x - 5, y - 4, 10, 8); g.fillStyle = '#c8955a'; g.fillRect(x - 5, y - 4, 10, 2);
      g.strokeStyle = '#5a3a12'; g.lineWidth = .6; g.strokeRect(x - 4.7, y - 3.7, 9.4, 7.4);
      g.fillStyle = '#f80'; g.fillRect(x - 3, y, 6, 1); g.fillRect(x - 3, y + 2, 6, 1);
    } else {
      ell(x, y + 4, 5, 2, 'rgba(0,0,0,.25)');
      g.fillStyle = '#fff'; g.fillRect(x - 5, y - 4, 10, 8); g.fillStyle = '#e22'; g.fillRect(x - 1, y - 3, 2, 6); g.fillRect(x - 3, y - 1, 6, 2);
    }
  }
  function drawHeli() {
    const x = padX - camX, y = padY - camY, ready = eggsLeft === 0;
    g.strokeStyle = '#ff5'; g.lineWidth = 1.5; g.beginPath(); g.arc(x, y, 20, 0, 7); g.stroke();
    ell(x + 3, y + 7, 14, 5, 'rgba(0,0,0,.3)');
    g.fillStyle = '#c33'; g.fillRect(x + 6, y - 1.5, 18, 3); g.fillRect(x + 22, y - 5, 2.5, 10);
    const gr = g.createRadialGradient(x - 4, y - 5, 1, x, y, 11); gr.addColorStop(0, '#fff'); gr.addColorStop(.5, '#e33'); gr.addColorStop(1, '#600');
    g.fillStyle = gr; g.beginPath(); g.ellipse(x, y, 10, 7, 0, 0, 7); g.fill();
    ell(x - 5, y - 1, 4, 4.5, '#8ce');
    const ra = time * (ready ? 22 : 1.5);
    g.strokeStyle = 'rgba(40,40,40,.85)'; g.lineWidth = 1.6;
    for (let i = 0; i < 2; i++) { const a = ra + i * Math.PI / 2; g.beginPath(); g.moveTo(x - Math.cos(a) * 17, y - 2 - Math.sin(a) * 17); g.lineTo(x + Math.cos(a) * 17, y - 2 + Math.sin(a) * 17); g.stroke(); }
  }
  function arrow() {
    // points at the nearest egg, or at the helicopter once every egg is found
    let tx = padX, ty = padY, best = Infinity;
    if (eggsLeft) {
      for (const it of items) if (it.k === 'egg') { const d = Math.hypot(it.x - p.x, it.y - p.y); if (d < best) { best = d; tx = it.x; ty = it.y; } }
    } else best = Math.hypot(tx - p.x, ty - p.y);
    if (best < 40) return;
    const a = Math.atan2(ty - p.y, tx - p.x), x = p.x - camX + Math.cos(a) * 20, y = p.y - camY + Math.sin(a) * 20;
    g.save(); g.translate(x, y); g.rotate(a); g.globalAlpha = .55 + Math.sin(time * 6) * .25;
    g.fillStyle = eggsLeft ? '#fff' : '#FFFF55'; g.beginPath(); g.moveTo(5, 0); g.lineTo(-3, -4); g.lineTo(-1, 0); g.lineTo(-3, 4); g.closePath(); g.fill();
    g.restore(); g.globalAlpha = 1;
  }
  function heart(x, y, fill) {
    g.fillStyle = '#400'; g.beginPath(); g.arc(x - 2, y, 2.5, 0, 7); g.arc(x + 2, y, 2.5, 0, 7); g.moveTo(x - 4.4, y + 1); g.lineTo(x, y + 5.5); g.lineTo(x + 4.4, y + 1); g.fill();
    if (!fill) return;
    g.save(); if (fill === 1) { g.beginPath(); g.rect(x - 5, y - 4, 5, 10); g.clip(); }
    g.fillStyle = '#f33'; g.beginPath(); g.arc(x - 2, y, 2.5, 0, 7); g.arc(x + 2, y, 2.5, 0, 7); g.moveTo(x - 4.4, y + 1); g.lineTo(x, y + 5.5); g.lineTo(x + 4.4, y + 1); g.fill();
    g.restore();
  }
  function dialog(rows) {
    const w = 210, h = rows.length * 15 + 22, x = (LW - w) / 2, y = (LH - h) / 2;
    g.fillStyle = 'rgba(0,60,20,.92)'; g.fillRect(x, y, w, h);
    g.strokeStyle = '#55FF55'; g.lineWidth = 1; g.strokeRect(x + 4, y + 4, w - 8, h - 8);
    rows.forEach((s, i) => text(s, LW / 2, y + 18 + i * 15, i === 0 ? '#FFFF55' : '#fff', i === 0 ? 13 : 10, 'center'));
  }

  function draw() {
    g.fillStyle = '#1d63b8'; g.fillRect(0, 0, LW, LH);
    if (!p) { text('D I N O   I S L A N D', LW / 2, LH / 2, '#FFFF55', 14, 'center'); return; }
    const x0 = Math.floor(camX / TS), y0 = Math.floor(camY / TS), wf = Math.floor(time * 2) % 2;
    const tall = [];
    for (let ty = y0 - 1; ty <= y0 + LH / TS + 1; ty++) for (let tx = x0; tx <= x0 + LW / TS + 1; tx++) {
      const t = tile(tx, ty), v = ty >= 0 && tx >= 0 && tx < MW && ty < MH ? tileVar[ty * MW + tx] : 0;
      const img = t === WATER ? GROUND[WATER][(wf + tx + ty) & 1] : GROUND[t][v];
      g.drawImage(img, tx * TS - camX, ty * TS - camY, TS + .05, TS + .05);
      if (t === TREE || t === ROCK || t === PALM) tall.push({ y: ty * TS + TS - 2, t, x: tx * TS + TS / 2, v });
    }
    // shoreline foam
    g.fillStyle = 'rgba(255,255,255,.35)';
    for (let ty = y0; ty <= y0 + LH / TS + 1; ty++) for (let tx = x0; tx <= x0 + LW / TS + 1; tx++) {
      if (tile(tx, ty) !== WATER) continue;
      if (tile(tx, ty - 1) !== WATER) g.fillRect(tx * TS - camX, ty * TS - camY, TS, 1.2);
      if (tile(tx, ty + 1) !== WATER) g.fillRect(tx * TS - camX, ty * TS - camY + TS - 1.2, TS, 1.2);
    }
    g.font = 'bold 20px "Lucida Console","Courier New",monospace'; g.textAlign = 'center'; g.textBaseline = 'middle';
    g.fillStyle = 'rgba(255,255,85,.6)'; g.fillText('H', padX - camX, padY - camY);
    for (const it of items) drawItem(it);
    drawHeli();

    // trees, rocks, animals and the player, drawn top to bottom so things overlap properly
    for (const z of dinos) tall.push({ y: z.y + z.r * .5, z });
    tall.push({ y: p.y + 4, me: true });
    tall.sort((a, b) => a.y - b.y);
    for (const o of tall) {
      if (o.z) drawDino(o.z);
      else if (o.me) drawPlayer();
      else if (o.t === TREE) drawTree(o.x - camX, o.y - camY - 6, o.v);
      else if (o.t === PALM) drawPalm(o.x - camX, o.y - camY - 6);
      else drawRock(o.x - camX, o.y - camY - 6);
    }
    for (const d of darts) {
      const a = Math.atan2(d.vy, d.vx), x = d.x - camX, y = d.y - camY;
      g.strokeStyle = '#ddd'; g.lineWidth = 1; g.beginPath(); g.moveTo(x - Math.cos(a) * 4, y - Math.sin(a) * 4); g.lineTo(x, y); g.stroke();
      g.fillStyle = '#f80'; g.fillRect(x - Math.cos(a) * 4 - .8, y - Math.sin(a) * 4 - .8, 1.6, 1.6);
    }
    for (const f of fx) { g.globalAlpha = 1 - f.t / f.life; ell(f.x - camX, f.y - camY, 2 + f.t * 12, 2 + f.t * 12, '#eee'); g.globalAlpha = 1; }
    for (const q of popups) { g.globalAlpha = 1 - q.t / .9; text(q.s, q.x - camX, q.y - camY, '#fff', 8, 'center'); g.globalAlpha = 1; }
    if (mode === 'play') arrow();
    if (flashT > 0) { g.fillStyle = 'rgba(255,0,0,.25)'; g.fillRect(0, 0, LW, LH); }

    // HUD
    g.fillStyle = 'rgba(0,0,0,.55)'; g.fillRect(0, 0, LW, 15);
    const mh = maxHp();
    for (let i = 0; i < mh / 2; i++) heart(8 + i * 11, 6, clamp(p.hp - i * 2, 0, 2));
    const hx = 8 + mh / 2 * 11 + 4;
    text(`DARTS ${p.ammo}`, hx, 8, p.ammo ? '#FFAA55' : '#FF5555', 8);
    text(`EGGS ${eggsTotal - eggsLeft}/${eggsTotal}`, hx + 62, 8, eggsLeft ? '#fff' : '#FFFF55', 8);
    text(`LV ${level}  ${score.toLocaleString('en-US')}`, LW - 4, 8, '#55FFFF', 8, 'right');

    if (msgT > 0 && mode === 'play') { g.globalAlpha = Math.min(1, msgT); text(msg, LW / 2, LH - 22, '#FFFF55', 11, 'center'); g.globalAlpha = 1; }
    const go = isTouch ? 'Tap to continue' : 'Push Space';
    if (mode === 'paused') dialog(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
    if (mode === 'clear') dialog([`LEVEL ${level} DONE!`, '', `All ${eggsTotal} eggs saved`, `Bonus ${clearBonus.toLocaleString('en-US')}`, '', go]);
    if (mode === 'over') dialog(['GAME OVER', '', `Score ${score.toLocaleString('en-US')}`, `Level ${level}`].concat(newHigh ? ['NEW HIGH SCORE!'] : [], ['', go]));
  }

  let last = performance.now();
  function frame(now) {
    const dt = Math.min(0.05, (now - last) / 1000); last = now;
    if (mode !== 'paused') update(dt);
    if (mode !== 'setup') draw();
    requestAnimationFrame(frame);
  }

  // ---- control ----
  function pause() { if (mode === 'play') { mode = 'paused'; for (const k in held) held[k] = false; target = null; } }
  function proceed() {
    if (mode === 'paused') { mode = 'play'; last = performance.now(); }
    else if (mode === 'clear') { level++; startLevel(false); }
    else if (mode === 'over') openSetup();
  }
  function openSetup() {
    mode = 'setup';
    document.getElementById('snd').checked = cfg.sound;
    document.getElementById('aim').checked = cfg.aim;
    document.getElementById('easy').checked = cfg.easy;
    document.getElementById('hi').textContent = cfg.high ? `High score: ${cfg.high.toLocaleString('en-US')}` : '';
    setupEl.classList.remove('hidden');
  }
  function startFromSetup() { saveCfg(); setupEl.classList.add('hidden'); audio(); newGame(); }

  const KEYS = { ArrowLeft:'left', a:'left', ArrowRight:'right', d:'right', ArrowUp:'up', w:'up', ArrowDown:'down', s:'down', ' ':'fire' };
  addEventListener('keydown', e => {
    if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
    const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    if (KEYS[k]) { e.preventDefault(); if (mode === 'play') held[KEYS[k]] = true; }
    if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); return; }
    if (mode === 'play') { if (k === 'p' || k === 'Escape') pause(); return; }
    if (!e.repeat && (k === ' ' || k === 'Enter' || k === 'p')) proceed();
  });
  addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (KEYS[k]) held[KEYS[k]] = false; });
  addEventListener('blur', () => { for (const k in held) held[k] = false; target = null; });

  // Holding a finger on the island walks toward it.
  const toLogical = e => { const b = cv.getBoundingClientRect(); return { x: (e.clientX - b.left) / b.width * LW, y: (e.clientY - b.top) / b.height * LH }; };
  cv.addEventListener('pointerdown', e => {
    e.preventDefault();
    if (mode === 'play') { cv.setPointerCapture(e.pointerId); target = toLogical(e); }
    else proceed();
  });
  cv.addEventListener('pointermove', e => { if (target) target = toLogical(e); });
  ['pointerup', 'pointercancel'].forEach(t => cv.addEventListener(t, () => { target = null; }));

  // On-screen buttons act while held.
  if (isTouch) document.body.classList.add('touch');
  document.querySelectorAll('.pad button').forEach(b => {
    const a = b.dataset.a;
    const up = () => { if (a !== 'pause') held[a] = false; b.classList.remove('held'); };
    b.addEventListener('pointerdown', e => {
      e.preventDefault(); b.setPointerCapture(e.pointerId);
      if (mode !== 'play') { if (mode === 'paused' || mode === 'over' || mode === 'clear') proceed(); return; }
      if (a === 'pause') { pause(); b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 120); return; }
      held[a] = true; b.classList.add('held');
    });
    ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, up));
  });

  // Pause automatically if the tablet switches away from the game.
  document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });

  // ---- setup screen ----
  document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; if (cfg.sound) SFX.egg(); });
  document.getElementById('aim').addEventListener('change', e => { cfg.aim = e.target.checked; });
  document.getElementById('easy').addEventListener('change', e => { cfg.easy = e.target.checked; });
  document.getElementById('go').addEventListener('click', startFromSetup);

  // a sample island behind the title screen
  startLevel(true); mode = 'setup'; draw();
  openSetup();
  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 dinoisland subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):

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

4. Push it into CT 125

Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a dinoisland 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/dinoisland/

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

Updating it later

Edit index.html, paste the new version over /root/nibbles/dinoisland/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 'dinoisland'. Clearing the site's data in the browser resets them; nothing is stored on the server.

Notes

  • Every egg can be reached: after the island is made, a search from the helicopter pad marks every square you can walk to, and eggs, boxes and dinosaurs are only placed on those. The trails are two squares wide so there's always room to walk.
  • Phone fix: on a narrow phone the three-wide arrow pad ran off the left edge, so its buttons are a bit smaller.