Sky Tower: a SimTower-style skyscraper sim (rebuild guide) 04OCT2026

Build a skyscraper floor by floor, run the elevators and keep the tenants happy

Posted by on 4th Oct 2026

My own tower-building sim in the spirit of SimTower (Maxis, 1994). Play it at https://games.503mail.com/skytower/ 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 start with an empty lot and $2,000,000. Build a lobby on the ground floor, floors above it, and fill them with offices (rented by companies of 6, paying $10,000 every quarter), condos (a family of 3 buys each for $150,000), fast food and stairs, all linked by elevators that you stretch floor by floor (up to 30 floors each). More people earn stars, and stars unlock hotel rooms (guests check in at night and pay in the morning), shops, security and restaurants. The people are simulated one by one. Office workers arrive in the morning rush, go out for lunch and leave in the evening. Condo families leave for the day and come home, and visitors come in to shop and eat. Every elevator car really moves floor to floor with up to 21 riders, using the classic "keep going up while anyone wants up" rule, and lines form at each floor. People who wait too long turn pink, then red. Long waits lower a tenant's happiness, and unhappy tenants move out. A day lasts about 2ยฝ minutes at normal speed, with lit windows at night, weekdays and weekends, a quarterly rent report, and 1 to 5 stars (5 = TOWER).

Controls

Pick a tool from the toolbar, then tap (or click) where it should go. A green or red outline shows whether it fits and what it costs. Look (๐Ÿ”) taps a room to see its tenants and happiness; Ratings colors every room blue, yellow or red by happiness; Bulldoze removes things. Drag to look around, pinch or use the mouse wheel to zoom (arrow keys and + / - on a keyboard). The โ–ถ buttons set the speed, II pauses, and โ˜ฐ saves and goes to the title screen. Elevators: tap floor 1 above the lobby to build one, then tap a higher floor in line with it to stretch it up.

Rebuild, step by step

Sky Tower 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 Sky Tower tile.

1. Make the game's folder

Proxmox host shell (Proxmox web UI โ†’ pve โ†’ Shell):

mkdir -p /root/nibbles/skytower

2. Create the game file

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

cat > /root/nibbles/skytower/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="#1a2a4a">
<title>Sky Tower</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="Sky Tower">
<meta property="og:image" content="https://games.503mail.com/skytower/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#1a2a4a;color:#fff;font-family:Tahoma,Verdana,Arial,sans-serif;overflow:hidden}
  body{display:flex;flex-direction:column}
  #top{background:#c0c0c0;color:#000;border-bottom:2px solid #555;flex:none;font-size:clamp(11px,1.9vmin,15px)}
  #stats{display:flex;flex-wrap:wrap;gap:.3em 1.2em;align-items:center;padding:.3em .6em;background:#d8d8d8;border-bottom:1px solid #888}
  #stats b{font-weight:bold}
  #stars{color:#c80;letter-spacing:.1em;font-size:1.2em}
  #speed{display:flex;gap:.3em;margin-left:auto}
  #speed button,#menu{font:inherit;border:2px outset #fff;background:#c0c0c0;padding:.15em .6em;cursor:pointer}
  #speed button.on{border-style:inset;background:#a8a8a8;font-weight:bold}
  #tools{display:flex;gap:.3em;padding:.3em;overflow-x:auto;-webkit-overflow-scrolling:touch}
  #tools button{flex:none;font:inherit;border:2px outset #fff;background:#c0c0c0;padding:.2em .45em;cursor:pointer;display:flex;flex-direction:column;align-items:center;min-width:4.2em;line-height:1.15}
  #tools button .ic{font-size:1.5em}
  #tools button small{color:#333;font-size:.78em}
  #tools button.on{border-style:inset;background:#ffffa0}
  #tools button:disabled{opacity:.45;cursor:default}
  #view{flex:1;position:relative;min-height:0}
  canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;display:block}
  #toast{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);background:rgba(0,0,0,.75);color:#fff;padding:.4em .9em;border-radius:6px;font-size:clamp(12px,2.2vmin,17px);pointer-events:none;opacity:0;transition:opacity .3s;text-align:center;max-width:90%}
  .modal{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:12px;box-sizing:border-box}
  .modal.hidden{display:none}
  .box{background:#c0c0c0;color:#000;border:3px outset #fff;max-width:min(94vw,520px);max-height:88vh;overflow:auto;font-size:clamp(13px,2.3vmin,18px)}
  .box .ttl{background:linear-gradient(90deg,#000080,#1084d0);color:#fff;font-weight:bold;padding:.3em .6em}
  .box .in{padding:.8em 1em;line-height:1.5}
  .box button{font:inherit;border:2px outset #fff;background:#c0c0c0;padding:.3em 1.2em;cursor:pointer;margin:.3em .3em 0 0}
  .bar{height:.9em;background:#555;border:1px solid #000;position:relative}
  .bar i{position:absolute;left:0;top:0;bottom:0}
  #title{position:fixed;inset:0;z-index:6;background:linear-gradient(#0a1030,#2a4080 60%,#e08050);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2.2vmin;text-align:center;padding:16px;box-sizing:border-box;font-size:clamp(13px,2.5vmin,21px)}
  #title.hidden{display:none}
  #title h1{margin:0;font-size:2.4em;letter-spacing:.25em;color:#ffd860;text-shadow:3px 3px #000}
  #title .sub{color:#cde;margin-top:-1.5vmin}
  #title button{font:inherit;font-size:1.15em;border:3px outset #fff;background:#c0c0c0;padding:.35em 1.6em;cursor:pointer;color:#000}
  #title .help{color:#dde;font-size:.85em;max-width:38em;line-height:1.55}
  #title a{color:#9ef}
</style>
</head>
<body>
<div id="top">
  <div id="stats">
    <span id="stars">โ˜…</span>
    <span>$<b id="money">0</b></span>
    <span id="date">Day 1</span>
    <span>Pop <b id="pop">0</b></span>
    <span id="next"></span>
    <span id="speed"><button data-s="0">II</button><button data-s="1">โ–ถ</button><button data-s="2">โ–ถโ–ถ</button><button data-s="3">โ–ถโ–ถโ–ถ</button><button id="menu">โ˜ฐ</button></span>
  </div>
  <div id="tools"></div>
</div>
<div id="view"><canvas id="c"></canvas><div id="toast"></div></div>
<div id="modal" class="modal hidden"><div class="box"><div class="ttl" id="mttl"></div><div class="in" id="mbody"></div></div></div>
<div id="title">
  <h1>SKY TOWER</h1>
  <div class="sub">Build a skyscraper. Keep the elevators moving.</div>
  <div><button id="cont">CONTINUE</button> <button id="new">NEW TOWER</button></div>
  <div class="help">
    Start with a <b>LOBBY</b> on the ground floor. Build <b>OFFICES</b> and <b>CONDOS</b> above it, and an <b>ELEVATOR</b> from the lobby
    (tap floor 1, then tap a high floor to stretch it). Tenants move in, go to work, eat lunch and go home.
    People who wait too long turn <span style="color:#f8a">pink</span>, then <span style="color:#f55">red</span>, and unhappy tenants move out.
    More people earn more stars, and stars unlock hotels, shops, security and restaurants. Reach 5 stars for a TOWER!<br>
    Drag to look around, pinch or use the mouse wheel to zoom. The game saves itself every day.
  </div>
  <a href="/">Other games</a>
</div>
<script>
(() => {
  const $ = id => document.getElementById(id);
  const cv = $('c'), g = cv.getContext('2d');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v)), rnd = Math.random;
  const fmt$ = n => Math.round(n).toLocaleString('en-US');

  // ---- sound ----
  let actx = null, soundOn = true;
  function tone(f, dur, type = 'square', vol = .04, at = 0) {
    if (!soundOn) return;
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return; } }
    if (actx.state === 'suspended') actx.resume();
    const t = actx.currentTime + at, o = actx.createOscillator(), v = actx.createGain();
    o.type = type; o.frequency.setValueAtTime(f, t); v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(.0005, t + dur);
    o.connect(v).connect(actx.destination); o.start(t); o.stop(t + dur);
  }
  const SFX = {
    build: () => { tone(160, .12, 'square', .06); tone(120, .15, 'square', .05, .08); },
    bad: () => tone(110, .25, 'sawtooth', .04),
    cash: () => { tone(1320, .08, 'square', .03); tone(1760, .12, 'square', .03, .07); },
    ding: () => tone(1568, .25, 'sine', .04),
    star: () => [523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, .2, 'square', .045, i * .13)),
    remove: () => tone(80, .3, 'sawtooth', .05),
  };

  // ---- the building blocks ----
  // w = width in tiles; cost includes the room but not new floor under it ($500 a tile)
  const TYPES = {
    look:       { name: 'Look',       ic: '๐Ÿ”', tool: true },
    floor:      { name: 'Floor',      ic: 'โฌœ', w: 8, cost: 0, star: 1 },
    lobby:      { name: 'Lobby',      ic: '๐Ÿ›Ž๏ธ', w: 8, cost: 8000, star: 1 },
    office:     { name: 'Office',     ic: '๐Ÿ’ผ', w: 9, cost: 40000, star: 1, people: 6, rent: 10000, col: '#c8d4e0' },
    condo:      { name: 'Condo',      ic: '๐Ÿ ', w: 16, cost: 80000, star: 1, people: 3, sale: 150000, col: '#ecd6b4' },
    food:       { name: 'Fast Food',  ic: '๐Ÿ”', w: 16, cost: 100000, star: 1, spend: 15, col: '#f6c84a' },
    stairs:     { name: 'Stairs',     ic: '๐Ÿชœ', w: 8, cost: 5000, star: 1 },
    elevator:   { name: 'Elevator',   ic: '๐Ÿ›—', w: 4, cost: 50000, perFloor: 5000, star: 1 },
    hotel:      { name: 'Hotel Room', ic: '๐Ÿ›๏ธ', w: 4, cost: 20000, star: 2, night: 3000, col: '#d6c4ea' },
    shop:       { name: 'Shop',       ic: '๐Ÿ›๏ธ', w: 12, cost: 100000, star: 2, spend: 25, col: '#a6d8f4' },
    security:   { name: 'Security',   ic: '๐Ÿ‘ฎ', w: 16, cost: 100000, star: 2, col: '#5a6a8a' },
    restaurant: { name: 'Restaurant', ic: '๐Ÿฝ๏ธ', w: 24, cost: 200000, star: 3, spend: 40, col: '#c86a5a' },
    bulldoze:   { name: 'Bulldoze',   ic: '๐Ÿ’ฅ', tool: true },
  };
  const TILE_COST = 500, LOBBY_TILE = 1000, MAX_ELEV = 30;
  const STAR_RULES = [
    null, null,
    { pop: 150, text: '150 people' },
    { pop: 500, need: 'security', text: '500 people and a Security office' },
    { pop: 1500, need: 'hotel10', text: '1,500 people and 10 hotel rooms' },
    { pop: 3000, need: 'tall', text: '3,000 people and a tower 40 floors tall' },
  ];

  // ---- world ----
  const TW = 8, FH = 24, MAXW = 160, MAXF = 100;
  const CAR_SPEED = 1, DOOR = 1, CAP = 21, WALK = 1.5, STAIR_MIN = 3;
  let floors, occ, items, agents, money, day, time, star, nextId, routeCache, speed = 1, tool = 'look', running = false;
  let evalView = false;

  function fresh() {
    floors = Array.from({ length: MAXF }, () => new Uint8Array(MAXW));
    occ = Array.from({ length: MAXF }, () => new Int32Array(MAXW));
    items = new Map(); agents = []; money = 2000000; day = 1; time = 6 * 60; star = 1; nextId = 1; routeCache = new Map();
  }
  const weekend = () => day % 3 === 0;
  const span = (x, w) => Array.from({ length: w }, (_, i) => x + i);
  const units = () => [...items.values()].filter(it => it.t !== 'elevator' && it.t !== 'stairs');

  // ---- building rules ----
  function missingFloor(f, xs) { return xs.filter(x => !floors[f][x]).length; }
  function supported(f, xs) { return f === 0 || xs.every(x => floors[f - 1][x]); }
  function check(type, f, x) {
    const T = TYPES[type];
    if (f < 0 || f >= MAXF) return { why: 'Too high!' };
    if (type === 'elevator') return checkElevator(f, x);
    const w = T.w; if (x < 0 || x + w > MAXW) return { why: 'Off the edge of the lot.' };
    const xs = span(x, w);
    if (type === 'lobby') {
      if (f !== 0) return { why: 'Lobbies go on the ground floor.' };
      const todo = xs.filter(x2 => floors[0][x2] !== 2);
      if (!todo.length) return { why: 'Already lobby.' };
      if (todo.some(x2 => occ[0][x2])) return { why: 'Something is in the way.' };
      return { ok: true, cost: todo.length * LOBBY_TILE };
    }
    if (type === 'floor') {
      const todo = xs.filter(x2 => !floors[f][x2]);
      if (!todo.length) return { why: 'Already built.' };
      if (f > 0 && !todo.every(x2 => floors[f - 1][x2])) return { why: 'A floor needs floor under it.' };
      return { ok: true, cost: todo.length * TILE_COST };
    }
    if (type === 'stairs') {
      if (f + 1 >= MAXF) return { why: 'Too high!' };
      if (xs.some(x2 => occ[f][x2] > 0 || occ[f + 1][x2] > 0 || (f > 0 && occ[f][x2] < 0))) return { why: 'Something is in the way.' };
      if (!supported(f, xs)) return { why: 'Needs floor under it.' };
      return { ok: true, cost: T.cost + (missingFloor(f, xs) + missingFloor(f + 1, xs)) * TILE_COST };
    }
    if (f === 0 && !['food', 'shop', 'restaurant'].includes(type)) return { why: 'Only shops and food on the ground floor. Build upstairs!' };
    if (xs.some(x2 => occ[f][x2])) return { why: 'Something is in the way.' };
    if (!supported(f, xs)) return { why: 'Needs floor under it.' };
    return { ok: true, cost: T.cost + missingFloor(f, xs) * TILE_COST };
  }
  function elevatorAt(x) {
    for (const it of items.values()) if (it.t === 'elevator' && x >= it.x && x < it.x + 4) return it;
    return null;
  }
  function checkElevator(f, x) {
    const ex = elevatorAt(x);
    if (ex) {
      if (f >= ex.lo && f <= ex.hi) return { why: 'Tap a floor above or below the elevator to stretch it.' };
      const lo = Math.min(ex.lo, f), hi = Math.max(ex.hi, f);
      if (hi - lo + 1 > MAX_ELEV) return { why: `An elevator can reach at most ${MAX_ELEV} floors.` };
      const add = []; for (let k = lo; k <= hi; k++) if (k < ex.lo || k > ex.hi) add.push(k);
      for (const k of add) { const r = shaftOK(k, ex.x); if (r) return { why: r }; }
      return { ok: true, cost: add.length * TYPES.elevator.perFloor, extend: ex, lo, hi };
    }
    x = clamp(x - 2, 0, MAXW - 4);
    const lo = f > 0 ? f - 1 : 0, hi = lo + 1;
    for (let k = lo; k <= hi; k++) { const r = shaftOK(k, x); if (r) return { why: r }; }
    return { ok: true, cost: TYPES.elevator.cost + 2 * TYPES.elevator.perFloor, x, lo, hi };
  }
  function shaftOK(f, x) {
    const xs = span(x, 4);
    if (xs.some(x2 => !floors[f][x2])) return `Build floor ${floorName(f)} there first.`;
    if (xs.some(x2 => occ[f][x2] > 0)) return 'Something is in the way.';
    return null;
  }
  const floorName = f => f === 0 ? 'G' : String(f);

  function build(type, f, x) {
    const T = TYPES[type];
    const x0 = type === 'elevator' ? x : x - Math.floor(T.w / 2);
    const r = check(type, f, x0);
    if (!r.ok) { toast(r.why); SFX.bad(); return; }
    if (money < r.cost) { toast(`Not enough money: that costs $${fmt$(r.cost)}.`); SFX.bad(); return; }
    money -= r.cost; SFX.build(); routeCache.clear();
    if (type === 'elevator') {
      if (r.extend) {
        const e = r.extend;
        for (let k = r.lo; k <= r.hi; k++) span(e.x, 4).forEach(x2 => occ[k][x2] = e.id);
        e.lo = r.lo; e.hi = r.hi;
        toast(`Elevator now serves floors ${floorName(e.lo)} to ${floorName(e.hi)}.`);
      } else {
        const e = { id: nextId++, t: 'elevator', x: r.x, lo: r.lo, hi: r.hi };
        initElevator(e); items.set(e.id, e);
        for (let k = e.lo; k <= e.hi; k++) span(e.x, 4).forEach(x2 => occ[k][x2] = e.id);
        toast('Elevator built. Tap another floor in line with it to stretch it.');
      }
      return;
    }
    const xs = span(x0, T.w);
    if (type === 'floor') { xs.forEach(x2 => { if (!floors[f][x2]) floors[f][x2] = 1; }); return; }
    if (type === 'lobby') { xs.forEach(x2 => { floors[0][x2] = 2; occ[0][x2] = -1; }); return; }
    xs.forEach(x2 => { if (!floors[f][x2]) floors[f][x2] = 1; });
    if (type === 'stairs') {
      const s = { id: nextId++, t: 'stairs', f, x: x0, w: 8 };
      xs.forEach(x2 => { if (!floors[f + 1][x2]) floors[f + 1][x2] = 1; occ[f][x2] = s.id; occ[f + 1][x2] = s.id; });
      items.set(s.id, s); return;
    }
    const u = { id: nextId++, t: type, f, x: x0, w: T.w, occupied: false, eval: 70, inside: 0, cust: 0, guest: null };
    xs.forEach(x2 => occ[f][x2] = u.id);
    items.set(u.id, u);
    if (['food', 'shop', 'restaurant', 'security'].includes(type)) u.occupied = true;
  }
  function initElevator(e) { e.car = { y: e.lo, dir: 0, door: 0, riders: [], just: -999 }; e.q = {}; }

  function bulldoze(f, x) {
    if (f < 0 || f >= MAXF || x < 0 || x >= MAXW) return;
    const id = occ[f][x];
    if (id > 0) {
      const it = items.get(id);
      // its tenants leave for good; anyone inside it, heading to it or travelling through it goes back outside
      for (const a of agents) {
        if (a.home === it) { dropAgent(a); continue; }
        if (a.place === it || (a.trip && (a.trip.place === it || a.trip.hops.some(h => h.e === it || h.s === it)))) dropAgent(a, true);
      }
      for (let k = 0; k < MAXF; k++) for (let x2 = 0; x2 < MAXW; x2++) if (occ[k][x2] === id) occ[k][x2] = (k === 0 && floors[0][x2] === 2) ? -1 : 0;
      items.delete(id); routeCache.clear(); agents = agents.filter(a => !a.dead);
      SFX.remove(); toast(`${TYPES[it.t].name} removed.`); return;
    }
    // empty floor or lobby: clear the 8 tiles around the tap if nothing stands on them or above them
    const xs = span(clamp(x - 4, 0, MAXW - 8), 8).filter(x2 => floors[f][x2] && (occ[f][x2] <= 0) && !(f + 1 < MAXF && floors[f + 1][x2]));
    if (!xs.length) { toast('Nothing to remove there (clear the floors above first).'); SFX.bad(); return; }
    xs.forEach(x2 => { floors[f][x2] = 0; occ[f][x2] = 0; });
    SFX.remove(); routeCache.clear();
  }

  // ---- getting around: which elevators and stairs link which floors ----
  function route(fa, fb) {
    if (fa === fb) return [];
    const key = fa + '>' + fb;
    if (routeCache.has(key)) return routeCache.get(key);
    const prev = new Map([[fa, null]]), q = [fa], trans = [...items.values()].filter(it => it.t === 'elevator' || it.t === 'stairs');
    while (q.length) {
      const f = q.shift(); if (f === fb) break;
      for (const t of trans) {
        if (t.t === 'elevator') {
          if (f < t.lo || f > t.hi) continue;
          for (let k = t.lo; k <= t.hi; k++) if (!prev.has(k)) { prev.set(k, { f, hop: { k: 'e', e: t, from: f, to: k } }); q.push(k); }
        } else {
          const other = t.f === f ? f + 1 : t.f + 1 === f ? f - 1 : null;
          if (other !== null && !prev.has(other)) { prev.set(other, { f, hop: { k: 's', s: t, from: f, to: other } }); q.push(other); }
        }
      }
    }
    let res = null;
    if (prev.has(fb)) { res = []; for (let f = fb; f !== fa; f = prev.get(f).f) res.unshift(prev.get(f).hop); }
    routeCache.set(key, res);
    return res;
  }
  const hopX = h => h.k === 'e' ? h.e.x + 2 : (h.to > h.from ? h.s.x + .5 : h.s.x + 7.5);
  function lobbyEdge() {
    let lo = -1, hi = -1;
    for (let x = 0; x < MAXW; x++) if (floors[0][x] === 2) { if (lo < 0) lo = x; hi = x; }
    return lo < 0 ? null : (rnd() < .5 ? lo + .2 : hi + .8);
  }

  // ---- people ----
  let agentId = 1;
  function newAgent(role, home) { const a = { id: agentId++, role, home, state: 'out', plan: [], fy: 0, x: 0, wait: 0, mode: null }; agents.push(a); return a; }
  function dropAgent(a, keepHome) {
    if (a.mode === 'queue' && a.queueE) { const q = a.queueE.q[a.queueF]; if (q) { const i = q.indexOf(a); if (i >= 0) q.splice(i, 1); } }
    if (a.mode === 'ride' && a.car) { const i = a.car.car.riders.indexOf(a); if (i >= 0) a.car.car.riders.splice(i, 1); }
    if (a.state === 'in' && a.place) a.place.inside = Math.max(0, a.place.inside - 1);
    a.mode = null; a.trip = null;
    if (keepHome && a.home && items.has(a.home.id)) { a.state = 'out'; a.place = null; return; }
    a.dead = true;
  }
  function startTrip(a, p) {
    let dest, destF, destX, place = null;
    const pickPlace = types => {
      const c = [...items.values()].filter(u => types.includes(u.t) && route(a.state === 'in' && a.place ? a.place.f : 0, u.f));
      return c.length ? c[Math.floor(rnd() * c.length)] : null;
    };
    if (p.go === 'home') { if (!a.home || !items.has(a.home.id)) { dropAgent(a); return; } place = a.home; }
    else if (p.go === 'food') place = pickPlace(time >= 1020 ? ['restaurant', 'food'] : ['food']);
    else if (p.go === 'shop') place = pickPlace(['shop']);
    else if (p.go === 'unit') place = items.has(p.unit.id) ? p.unit : null;
    if (p.go !== 'exit' && !place) return;
    if (a.state === 'in' && a.place === place) return;
    let fromF = 0, fromX;
    if (a.state === 'in' && a.place) { fromF = a.place.f; fromX = a.place.x + a.place.w / 2; }
    else { fromX = lobbyEdge(); if (fromX === null) { if (a.home) a.home.eval = Math.max(0, a.home.eval - 2); return; } }
    if (p.go === 'exit') { if (a.state !== 'in') return; destF = 0; destX = lobbyEdge(); if (destX === null) destX = fromX; }
    else { destF = place.f; destX = place.x + 1 + rnd() * (place.w - 2); }
    const hops = route(fromF, destF);
    if (!hops) { if (a.home && a.state !== 'in') a.home.eval = Math.max(0, a.home.eval - 3); if (a.role === 'visitor') a.dead = true; return; }
    if (a.state === 'in' && a.place) a.place.inside = Math.max(0, a.place.inside - 1);
    a.state = 'trip'; a.place = null; a.fy = fromF; a.x = fromX; a.wait = 0;
    a.trip = { hops, i: 0, destX, destF, place, go: p.go, checkout: p.checkout };
    advance(a);
  }
  function advance(a) {
    const T = a.trip;
    if (T.i < T.hops.length) {
      const h = T.hops[T.i], hx = hopX(h);
      if (Math.abs(a.x - hx) > .05) { a.mode = 'walk'; a.tx = hx; return; }
      if (h.k === 'e') { a.mode = 'queue'; a.queueE = h.e; a.queueF = h.from; a.hopTo = h.to; (h.e.q[h.from] = h.e.q[h.from] || []).push(a); }
      else { a.mode = 'climb'; a.cf0 = h.from; a.cf1 = h.to; a.cx0 = a.x; a.cx1 = h.to > h.from ? h.s.x + 7.5 : h.s.x + .5; a.ct = STAIR_MIN; }
      return;
    }
    if (Math.abs(a.x - T.destX) > .05) { a.mode = 'walk'; a.tx = T.destX; return; }
    arrive(a);
  }
  function hopDone(a) { a.trip.i++; a.mode = null; advance(a); }
  function arrive(a) {
    const T = a.trip; a.trip = null; a.mode = null;
    // how the trip felt: long waits lower the home's rating
    const felt = clamp(100 - a.wait * .8, 0, 100);
    if (a.home && items.has(a.home.id) && a.role !== 'visitor') a.home.eval = a.home.eval * .8 + felt * .2;
    if (T.go === 'exit') {
      a.state = 'out';
      if (T.checkout && a.home) { money += TYPES.hotel.night; a.home.guest = null; a.dead = true; cashPop(a); }
      if (a.role === 'visitor') a.dead = true;
      return;
    }
    a.state = 'in'; a.place = T.place; T.place.inside++;
    const sp = TYPES[T.place.t].spend;
    if (sp) { money += sp; T.place.cust++; T.place.eval = T.place.eval * .9 + felt * .1; }
    if (a.role === 'visitor') a.plan = [{ t: time + 30 + rnd() * 40, go: 'exit' }];
  }
  let cashT = 0; function cashPop() { if (cashT <= 0) { SFX.cash(); cashT = 1; } }

  function stepAgent(a, dt) {
    if (a.state === 'trip') {
      if (a.mode === 'walk') {
        const d = a.tx - a.x, s = WALK * dt;
        if (Math.abs(d) <= s) { a.x = a.tx; a.mode = null; advance(a); } else a.x += Math.sign(d) * s;
      } else if (a.mode === 'queue') a.wait += dt;
      else if (a.mode === 'climb') {
        a.ct -= dt; const k = 1 - Math.max(0, a.ct) / STAIR_MIN;
        a.fy = a.cf0 + (a.cf1 - a.cf0) * k; a.x = a.cx0 + (a.cx1 - a.cx0) * k;
        if (a.ct <= 0) { a.fy = a.cf1; hopDone(a); }
      }
      return;
    }
    if (a.plan.length && a.plan[0].t <= time) startTrip(a, a.plan.shift());
  }

  // elevator cars: go up while anyone wants to go up, then down (the classic "elevator algorithm")
  function stepElevator(e, dt) {
    const c = e.car;
    if (c.door > 0) { c.door -= dt; return; }
    const space = c.riders.length < CAP, want = new Set(c.riders.map(a => a.hopTo));
    if (space) for (const f in e.q) if (e.q[f].length) want.add(+f);
    if (!want.size) { c.dir = 0; return; }
    const cur = Math.round(c.y), at = Math.abs(c.y - cur) < 1e-3;
    let list = [...want];
    if (at && c.just === cur) list = list.filter(f => f !== cur);
    if (!list.length) {
      // only people here going the other way: turn round and let them on
      if (at && e.q[cur] && e.q[cur].length && space) { c.dir = -c.dir || 1; stopAt(e, cur, true); }
      return;
    }
    if (c.dir === 0) c.dir = list.reduce((b, f) => Math.abs(f - c.y) < Math.abs(b - c.y) ? f : b) >= c.y ? 1 : -1;
    let ahead = list.filter(f => c.dir > 0 ? f >= c.y - 1e-3 : f <= c.y + 1e-3);
    if (!ahead.length) { c.dir = -c.dir; ahead = list.filter(f => c.dir > 0 ? f >= c.y - 1e-3 : f <= c.y + 1e-3); }
    const next = c.dir > 0 ? Math.min(...ahead) : Math.max(...ahead), d = next - c.y, s = CAR_SPEED * dt;
    if (Math.abs(d) <= s) { c.y = next; stopAt(e, next); } else { c.y += Math.sign(d) * s; c.just = -999; }
  }
  function stopAt(e, f, anyDir) {
    const c = e.car; c.just = f; c.door = DOOR;
    for (const a of c.riders.filter(a => a.hopTo === f)) { c.riders.splice(c.riders.indexOf(a), 1); a.fy = f; a.x = e.x + 2; a.car = null; hopDone(a); }
    const q = e.q[f] || [];
    if (!c.riders.length && q.length && (anyDir || !c.dir)) c.dir = Math.sign(q[0].hopTo - f);
    if (!c.riders.length && q.length && !q.some(a => Math.sign(a.hopTo - f) === c.dir)) c.dir = Math.sign(q[0].hopTo - f);
    for (const a of q.slice()) {
      if (c.riders.length >= CAP) break;
      if (Math.sign(a.hopTo - f) !== c.dir) continue;
      q.splice(q.indexOf(a), 1); a.mode = 'ride'; a.car = e; c.riders.push(a);
    }
  }

  // ---- tenants and the daily routine ----
  function plansFor(a, from) {
    const wd = !weekend(), out = [];
    const anyFood = [...items.values()].some(u => u.t === 'food');
    if (a.role === 'worker' && wd) {
      out.push({ t: 420 + rnd() * 120, go: 'home' });
      if (anyFood && rnd() < .6) { const l = 700 + rnd() * 40; out.push({ t: l, go: 'food' }, { t: l + 35 + rnd() * 20, go: 'home' }); }
      out.push({ t: 1020 + rnd() * 90, go: 'exit' });
    } else if (a.role === 'resident') {
      if (wd) out.push({ t: 420 + rnd() * 120, go: 'exit' }, { t: 1020 + rnd() * 150, go: 'home' });
      else { const t0 = 600 + rnd() * 180; out.push({ t: t0, go: rnd() < .5 ? 'shop' : 'food' }, { t: t0 + 60 + rnd() * 60, go: 'home' }); }
      if (anyFood && rnd() < .3) { const t1 = 1110 + rnd() * 90; out.push({ t: t1, go: 'food' }, { t: t1 + 50, go: 'home' }); }
    }
    return out.filter(p => p.t >= from).sort((x, y) => x.t - y.t);
  }
  function moveIn(u) {
    u.occupied = true; u.eval = 70;
    const T = TYPES[u.t];
    if (u.t === 'condo') { money += T.sale; cashPop(); toast(`A family bought the condo on floor ${u.f} for $${fmt$(T.sale)}!`); }
    for (let i = 0; i < T.people; i++) {
      const a = newAgent(u.t === 'office' ? 'worker' : 'resident', u);
      a.plan = u.t === 'condo' ? [{ t: time + rnd() * 60, go: 'home' }].concat(plansFor(a, time + 61)) : plansFor(a, time);
      // a company moving in during the working day starts work straight away
      if (u.t === 'office' && !weekend() && time < 960 && !a.plan.some(p => p.go === 'home' && p.t < 700)) a.plan.unshift({ t: time + rnd() * 60, go: 'home' });
    }
  }
  function moveOut(u) {
    for (const a of agents) if (a.home === u) dropAgent(a);
    agents = agents.filter(a => !a.dead);
    u.occupied = false; u.inside = 0;
    toast(`Unhappy tenants left the ${TYPES[u.t].name.toLowerCase()} on floor ${u.f}. Fix the elevators!`);
  }
  function reachable(u) { return lobbyEdge() !== null && route(0, u.f) !== null; }

  function hourly(h) {
    const us = units();
    if (h === 6) for (const u of us) if ((u.t === 'office' || u.t === 'condo') && !u.occupied && reachable(u) && rnd() < .35 + star * .07) moveIn(u);
    if (h === 16) for (const u of us) if (u.t === 'hotel' && !u.guest && reachable(u) && rnd() < clamp(u.eval / 100, .2, .9)) {
      const a = newAgent('guest', u); u.guest = a; a.plan = [{ t: 1020 + rnd() * 240, go: 'home' }];
    }
    if (h === 23) for (const u of us) if ((u.t === 'office' || u.t === 'condo') && u.occupied && u.eval < 30 && rnd() < .5) moveOut(u);
    // shoppers and diners from outside the tower, more with more stars
    if (h >= 10 && h <= 21) for (const u of us) {
      const k = TYPES[u.t].spend ? (u.t === 'food' ? (h >= 11 && h <= 13 ? 4 : 1) : u.t === 'restaurant' ? (h >= 17 ? 5 : 0) : 2) : 0;
      for (let i = 0; i < Math.round(k * (.5 + star * .4) * rnd() * 2); i++) { const a = newAgent('visitor', null); a.plan = [{ t: time + rnd() * 55, go: 'unit', unit: u }]; }
    }
  }
  function newDay() {
    day++;
    if (day % 3 === 1) quarter();
    for (const a of agents) {
      if (a.role === 'guest') {
        if (a.state === 'in') a.plan = [{ t: 480 + rnd() * 120, go: 'exit', checkout: true }];
        else if (a.state === 'out') { if (a.home) a.home.guest = null; a.dead = true; }   // never made it to the room
        continue;
      }
      if (a.role === 'visitor') continue;
      a.plan = plansFor(a, 0);
      if (a.role === 'worker' && a.state === 'in') a.plan.unshift({ t: 0, go: 'exit' });
    }
    agents = agents.filter(a => !a.dead);
    for (const u of units()) u.cust = 0;
    checkStars(); save();
  }
  function quarter() {
    let rent = 0, upkeep = 0;
    for (const it of items.values()) {
      if (it.t === 'office' && it.occupied) rent += TYPES.office.rent;
      if (it.t === 'elevator') upkeep += 10000;
      if (it.t === 'security') upkeep += 15000;
    }
    money += rent - upkeep;
    if (rent || upkeep) { toast(`Quarter report: rent +$${fmt$(rent)}, upkeep -$${fmt$(upkeep)}`); if (rent) cashPop(); }
  }
  function population() {
    let p = 0;
    for (const u of items.values()) { if (u.occupied && TYPES[u.t].people) p += TYPES[u.t].people; if (u.t === 'hotel' && u.guest) p++; }
    return p;
  }
  function topFloor() { for (let f = MAXF - 1; f >= 0; f--) if (floors[f].some(Boolean)) return f; return 0; }
  function checkStars() {
    const pop = population(), r = STAR_RULES[star + 1];
    if (!r) return;
    const has = { security: [...items.values()].some(u => u.t === 'security'), hotel10: units().filter(u => u.t === 'hotel').length >= 10, tall: topFloor() >= 40 };
    if (pop >= r.pop && (!r.need || has[r.need])) {
      star++; SFX.star();
      const unlocked = Object.entries(TYPES).filter(([, T]) => T.star === star).map(([, T]) => `${T.ic} ${T.name}`);
      showModal(star === 5 ? 'TOWER!' : `${star} STARS!`, star === 5
        ? '<p>Your skyscraper is officially a <b>TOWER</b>. Congratulations!</p><button onclick="this.closest(\'.modal\').classList.add(\'hidden\')">Hooray</button>'
        : `<p>Your tower is now rated <b>${'โ˜…'.repeat(star)}</b>.</p>${unlocked.length ? `<p>New to build: ${unlocked.join(', ')}</p>` : ''}<button onclick="this.closest('.modal').classList.add('hidden')">OK</button>`);
      buildToolbar();
    }
  }

  // ---- the clock ----
  function tick(dt) {
    const before = time;
    time += dt;
    if (Math.floor(time / 60) !== Math.floor(before / 60) && time < 1440) hourly(Math.floor(time / 60));
    if (time >= 1440) { time -= 1440; newDay(); hourly(0); }
    for (const it of items.values()) if (it.t === 'elevator') stepElevator(it, dt);
    for (const a of agents) stepAgent(a, dt);
    if (agents.some(a => a.dead)) agents = agents.filter(a => !a.dead);
    cashT -= dt / 10;
  }

  // ---- save / load (layout and money; people are rebuilt from the rooms) ----
  function save() {
    try {
      const rle = row => { const out = []; let v = row[0], n = 0; for (const c of row) { if (c === v) n++; else { out.push(v, n); v = c; n = 1; } } out.push(v, n); return out; };
      const it = [...items.values()].map(u => u.t === 'elevator' ? { id: u.id, t: u.t, x: u.x, lo: u.lo, hi: u.hi } : u.t === 'stairs' ? { id: u.id, t: u.t, f: u.f, x: u.x } : { id: u.id, t: u.t, f: u.f, x: u.x, occupied: u.occupied, eval: Math.round(u.eval) });
      localStorage.setItem('skytower', JSON.stringify({ v: 1, money, day, time, star, nextId, floors: floors.map(rle), items: it, cam }));
    } catch (e) {}
  }
  function load() {
    let s; try { s = JSON.parse(localStorage.getItem('skytower') || 'null'); } catch (e) { s = null; }
    if (!s || s.v !== 1) return false;
    fresh();
    money = s.money; day = s.day; time = s.time; star = s.star; nextId = s.nextId;
    s.floors.forEach((r, f) => { let x = 0; for (let i = 0; i < r.length; i += 2) { floors[f].fill(r[i], x, x + r[i + 1]); x += r[i + 1]; } });
    for (let x = 0; x < MAXW; x++) if (floors[0][x] === 2) occ[0][x] = -1;
    for (const u of s.items) {
      const it = Object.assign({}, u);
      if (it.t === 'elevator') { initElevator(it); for (let k = it.lo; k <= it.hi; k++) span(it.x, 4).forEach(x2 => occ[k][x2] = it.id); }
      else if (it.t === 'stairs') { it.w = 8; span(it.x, 8).forEach(x2 => { occ[it.f][x2] = it.id; occ[it.f + 1][x2] = it.id; }); }
      else { it.w = TYPES[it.t].w; it.inside = 0; it.cust = 0; it.guest = null; span(it.x, it.w).forEach(x2 => occ[it.f][x2] = it.id); }
      items.set(it.id, it);
    }
    for (const u of units()) if (u.occupied && TYPES[u.t].people) {
      for (let i = 0; i < TYPES[u.t].people; i++) {
        const a = newAgent(u.t === 'office' ? 'worker' : 'resident', u);
        if (u.t === 'condo' && (time < 420 || time > 1200)) { a.state = 'in'; a.place = u; u.inside++; }
        a.plan = plansFor(a, time);
        if (u.t === 'condo' && a.state === 'out') a.plan.unshift({ t: time + rnd() * 120, go: 'home' });
      }
    }
    if (s.cam) cam = s.cam;
    return true;
  }

  // ---- drawing ----
  let W = 0, H = 0, DPR = 1, cam = { x: 80 * TW, y: -4 * FH, z: 2 };
  function resize() {
    const r = cv.getBoundingClientRect(); DPR = devicePixelRatio || 1; W = r.width; H = r.height;
    cv.width = W * DPR; cv.height = H * DPR;
  }
  const sx = wx => (wx - cam.x) * cam.z + W / 2, sy = wy => (wy - cam.y) * cam.z + H / 2;
  const toWorld = (px, py) => ({ x: (px - W / 2) / cam.z + cam.x, y: (py - H / 2) / cam.z + cam.y });
  function skyColor() {
    const h = time / 60;
    const night = h < 5 || h > 20.5 ? 1 : h < 7 ? (7 - h) / 2 : h > 18.5 ? (h - 18.5) / 2 : 0;
    const dusk = h > 17.5 && h < 20.5 ? 1 - Math.abs(h - 19) / 1.5 : h > 5 && h < 7.5 ? 1 - Math.abs(h - 6.2) / 1.3 : 0;
    return { night: clamp(night, 0, 1), dusk: clamp(dusk, 0, 1) };
  }
  const mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));
  const rgbs = c => `rgb(${c[0]},${c[1]},${c[2]})`;
  const SKYLINE = Array.from({ length: 60 }, (_, i) => ({ x: i * 34 - 400 + rnd() * 20, w: 20 + rnd() * 30, h: 40 + rnd() * 160 }));
  const isNight = () => skyColor().night > .5;

  function draw() {
    g.setTransform(DPR, 0, 0, DPR, 0, 0);
    const sk = skyColor();
    let top = mix([70, 140, 220], [10, 15, 40], sk.night), bot = mix([170, 210, 245], [30, 35, 70], sk.night);
    top = mix(top, [90, 70, 140], sk.dusk * .6); bot = mix(bot, [250, 150, 90], sk.dusk * .8);
    const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, rgbs(top)); gr.addColorStop(1, rgbs(bot));
    g.fillStyle = gr; g.fillRect(0, 0, W, H);
    if (sk.night > .5) { g.fillStyle = '#fff'; for (let i = 0; i < 40; i++) g.fillRect((i * 97 % W), (i * 53 % (H * .6)), 1.2, 1.2); }
    // distant city, then the ground
    const gy = sy(0);
    g.fillStyle = sk.night > .5 ? '#1a1e30' : '#7a8aa0';
    for (const b of SKYLINE) { const bx = (b.x - cam.x * .3) * Math.min(cam.z, 2) * .6 + W / 2; g.fillRect(bx, gy - b.h * Math.min(cam.z, 2) * .5, b.w, b.h * Math.min(cam.z, 2) * .5); }
    g.fillStyle = '#4a7a3a'; g.fillRect(0, gy, W, 6 * cam.z); g.fillStyle = '#6a5038'; g.fillRect(0, gy + 6 * cam.z, W, H);
    g.fillStyle = '#888'; g.fillRect(sx(0), gy, MAXW * TW * cam.z, 2 * cam.z);

    const z = cam.z, fTop = clamp(Math.floor(-toWorld(0, 0).y / FH) + 1, 0, MAXF - 1), fBot = clamp(Math.floor(-toWorld(0, H).y / FH), 0, MAXF - 1);
    // floors
    for (let f = fBot; f <= fTop; f++) {
      const y0 = sy(-(f + 1) * FH), row = floors[f];
      for (let x = 0; x < MAXW;) {
        if (!row[x]) { x++; continue; }
        let x2 = x; while (x2 < MAXW && row[x2] === row[x]) x2++;
        const px = sx(x * TW), pw = (x2 - x) * TW * z;
        if (row[x] === 2) { g.fillStyle = '#efe4c8'; g.fillRect(px, y0, pw, FH * z); g.fillStyle = '#c8b890'; for (let c = x; c < x2; c += 4) g.fillRect(sx(c * TW), y0, 1.5 * z, FH * z); }
        else { g.fillStyle = isNight() ? '#2a2a30' : '#a8a8a8'; g.fillRect(px, y0, pw, FH * z); g.strokeStyle = '#777'; g.lineWidth = Math.max(1, z * .5); for (let c = x; c < x2; c += 2) { g.beginPath(); g.moveTo(sx(c * TW), y0 + FH * z); g.lineTo(sx((c + 2) * TW), y0); g.stroke(); } }
        g.fillStyle = '#555'; g.fillRect(px, y0 + FH * z - 2 * z, pw, 2 * z);
        x = x2;
      }
    }
    // rooms
    for (const u of items.values()) if (u.t !== 'elevator' && u.t !== 'stairs' && u.f >= fBot && u.f <= fTop) drawUnit(u);
    // stairs
    for (const s of items.values()) if (s.t === 'stairs' && s.f + 1 >= fBot && s.f <= fTop) {
      const x0 = sx(s.x * TW), y0 = sy(-s.f * FH), x1 = sx((s.x + 8) * TW), y1 = sy(-(s.f + 1) * FH);
      g.strokeStyle = '#8a5a2a'; g.lineWidth = 3 * z; g.beginPath(); g.moveTo(x0, y0 - 2 * z); g.lineTo(x1, y1 - 2 * z); g.stroke();
      g.lineWidth = Math.max(1, z); for (let k = 0; k < 8; k++) { const kx = x0 + (x1 - x0) * k / 8, ky = y0 + (y1 - y0) * k / 8 - 2 * z; g.beginPath(); g.moveTo(kx, ky); g.lineTo(kx + (x1 - x0) / 8, ky); g.stroke(); }
    }
    // elevators
    for (const e of items.values()) if (e.t === 'elevator') {
      const x0 = sx(e.x * TW), w = 4 * TW * z, yTop = sy(-(e.hi + 1) * FH), yBot = sy(-e.lo * FH);
      g.fillStyle = '#3a3a44'; g.fillRect(x0, yTop, w, yBot - yTop);
      g.strokeStyle = '#555'; g.lineWidth = 1; for (let f = e.lo; f <= e.hi; f++) { const y = sy(-f * FH); g.beginPath(); g.moveTo(x0, y); g.lineTo(x0 + w, y); g.stroke(); }
      const c = e.car, cy = sy(-(c.y + 1) * FH);
      g.fillStyle = c.door > 0 ? '#ffe9a0' : '#d8d8d8'; g.fillRect(x0 + z, cy + z, w - 2 * z, FH * z - 2 * z);
      g.strokeStyle = '#000'; g.strokeRect(x0 + z, cy + z, w - 2 * z, FH * z - 2 * z);
      if (c.riders.length && z > .9) { g.fillStyle = '#000'; g.font = `bold ${Math.max(8, 7 * z)}px Tahoma,Arial`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(c.riders.length, x0 + w / 2, cy + FH * z / 2); }
      // queues beside the shaft
      for (const f in e.q) {
        const q = e.q[f]; if (!q.length || f < fBot || f > fTop) continue;
        const show = Math.min(q.length, 14);
        for (let k = 0; k < show; k++) { const a = q[k]; drawPerson(x0 - (3 + k * 3.2) * z, sy(-f * FH), a.wait); }
        if (q.length > show && z > .9) { g.fillStyle = '#fff'; g.font = `bold ${Math.max(8, 6 * z)}px Tahoma,Arial`; g.textAlign = 'right'; g.fillText('+' + (q.length - show), x0 - (5 + show * 3.2) * z, sy(-f * FH) - 8 * z); }
      }
    }
    // people walking and on the stairs
    if (z >= .6) for (const a of agents) if (a.state === 'trip' && (a.mode === 'walk' || a.mode === 'climb') && a.fy >= fBot - 1 && a.fy <= fTop + 1) drawPerson(sx(a.x * TW), sy(-a.fy * FH), a.wait);
    // ghost of what you're about to build
    if (ghost && TYPES[tool] && !TYPES[tool].tool) {
      const T = TYPES[tool];
      let x0 = ghost.x - Math.floor(T.w / 2), f = ghost.f, w = T.w, h = 1;
      const r = tool === 'elevator' ? check('elevator', f, ghost.x) : check(tool, f, x0);
      if (tool === 'elevator') { if (r.extend) { x0 = r.extend.x; } else if (r.x !== undefined) { x0 = r.x; f = r.lo; h = 2; } else x0 = ghost.x - 2; }
      if (tool === 'stairs') h = 2;
      g.fillStyle = r.ok ? 'rgba(80,255,80,.35)' : 'rgba(255,60,60,.35)'; g.strokeStyle = r.ok ? '#0f0' : '#f33'; g.lineWidth = 2;
      g.fillRect(sx(x0 * TW), sy(-(f + h) * FH), w * TW * z, h * FH * z); g.strokeRect(sx(x0 * TW), sy(-(f + h) * FH), w * TW * z, h * FH * z);
      if (r.ok && z > .8) { g.fillStyle = '#fff'; g.font = `bold ${Math.max(10, 6 * z)}px Tahoma,Arial`; g.textAlign = 'center'; g.fillText('$' + fmt$(r.cost), sx((x0 + w / 2) * TW), sy(-(f + h) * FH) - 6); }
    }
    // floor numbers down the left edge of the built area
    if (z > .7) {
      g.font = `bold ${Math.max(9, 5 * z)}px Tahoma,Arial`; g.textAlign = 'right'; g.textBaseline = 'middle';
      for (let f = fBot; f <= fTop; f++) { const fx = floors[f].findIndex(Boolean); if (fx < 0) continue; g.fillStyle = isNight() ? '#ccd' : '#223'; g.fillText(floorName(f), sx(fx * TW) - 4, sy(-(f + .5) * FH)); }
    }
  }
  function drawPerson(px, py, wait) {
    const z = cam.z, c = wait > 60 ? '#ff2a2a' : wait > 30 ? '#ff8ab4' : '#111';
    g.fillStyle = c; g.fillRect(px - .9 * z, py - 7 * z, 1.8 * z, 5 * z); g.beginPath(); g.arc(px, py - 8.4 * z, 1.3 * z, 0, 7); g.fill();
    g.fillRect(px - .9 * z, py - 2.2 * z, .7 * z, 2.2 * z); g.fillRect(px + .2 * z, py - 2.2 * z, .7 * z, 2.2 * z);
  }
  function drawUnit(u) {
    const T = TYPES[u.t], z = cam.z, x0 = sx(u.x * TW), y0 = sy(-(u.f + 1) * FH), w = u.w * TW * z, h = FH * z;
    if (x0 > W || x0 + w < 0) return;
    const vacant = (u.t === 'office' || u.t === 'condo') && !u.occupied, hotelEmpty = u.t === 'hotel' && !u.guest;
    g.fillStyle = vacant ? '#6a6a6a' : T.col; g.fillRect(x0, y0, w, h);
    g.strokeStyle = '#333'; g.lineWidth = 1; g.strokeRect(x0 + .5, y0 + .5, w - 1, h - 1);
    // furniture
    const fy = y0 + h - 2 * z;
    if (!vacant) {
      if (u.t === 'office') { g.fillStyle = '#7a6040'; for (let k = 1; k < u.w; k += 3) g.fillRect(x0 + k * TW * z, fy - 5 * z, 2 * TW * z * .8, 2 * z); g.fillStyle = '#334'; for (let k = 1; k < u.w; k += 3) g.fillRect(x0 + (k + .5) * TW * z, fy - 9 * z, 5 * z, 4 * z); }
      if (u.t === 'condo') { g.fillStyle = '#a05050'; g.fillRect(x0 + 2 * TW * z, fy - 6 * z, 3 * TW * z, 6 * z); g.fillStyle = '#5a7a4a'; g.fillRect(x0 + 9 * TW * z, fy - 5 * z, 4 * TW * z, 5 * z); g.fillStyle = '#fff'; g.fillRect(x0 + 6.5 * TW * z, y0 + 5 * z, 2 * TW * z, 7 * z); }
      if (u.t === 'hotel') { g.fillStyle = '#fff'; g.fillRect(x0 + 4 * z, fy - 5 * z, w - 8 * z, 5 * z); g.fillStyle = '#6a4aa0'; g.fillRect(x0 + 4 * z, fy - 5 * z, 6 * z, 5 * z); }
      if (u.t === 'food' || u.t === 'restaurant') { g.fillStyle = '#7a4a2a'; for (let k = 2; k < u.w - 1; k += 4) { g.fillRect(x0 + k * TW * z, fy - 6 * z, 2 * TW * z, 1.5 * z); g.fillRect(x0 + (k + .9) * TW * z, fy - 5 * z, 2 * z, 5 * z); } }
      if (u.t === 'shop') { g.fillStyle = '#4a6a8a'; for (let k = 1; k < u.w - 1; k += 3) g.fillRect(x0 + k * TW * z, y0 + 6 * z, 2 * TW * z, h - 8 * z); }
      if (u.t === 'security') { g.fillStyle = '#ffd040'; g.beginPath(); g.arc(x0 + w / 2, y0 + h / 2, 4 * z, 0, 7); g.fill(); }
    }
    // people inside
    if (u.inside && z >= 1) for (let k = 0; k < Math.min(u.inside, Math.floor(u.w / 1.5)); k++) drawPerson(x0 + (1 + k * 1.4) * TW * z, y0 + h - 2 * z, 0);
    // night: lights on where somebody's in
    const sk = skyColor();
    if (sk.night > .3) { g.fillStyle = u.inside || (u.t === 'hotel' && u.guest && u.guest.state === 'in') ? 'rgba(255,220,120,.25)' : `rgba(0,0,30,${.5 * sk.night})`; g.fillRect(x0, y0, w, h); }
    if (evalView && (u.occupied || u.t === 'hotel')) { g.fillStyle = u.eval > 66 ? 'rgba(40,120,255,.45)' : u.eval > 33 ? 'rgba(255,220,0,.45)' : 'rgba(255,30,30,.5)'; g.fillRect(x0, y0, w, h); }
    if (z >= 1.2) {
      g.font = `bold ${Math.max(8, 4.6 * z)}px Tahoma,Arial`; g.textAlign = 'center'; g.textBaseline = 'top';
      const label = vacant ? (u.t === 'office' ? 'FOR RENT' : 'FOR SALE') : u.t === 'food' ? 'FAST FOOD' : u.t === 'shop' ? 'SHOP' : u.t === 'restaurant' ? 'RESTAURANT' : u.t === 'security' ? 'SECURITY' : '';
      if (label) { g.fillStyle = vacant ? '#fff' : '#222'; g.fillText(label, x0 + w / 2, y0 + 2 * z); }
      if (hotelEmpty && z >= 1.6) { g.fillStyle = '#555'; g.fillText('VACANT', x0 + w / 2, y0 + 2 * z); }
    }
  }

  // ---- input: drag to pan, pinch / wheel to zoom, tap to build or look ----
  let ghost = null;
  const pts = new Map(); let panStart = null, moved = false, pinch0 = null;
  function zoomAt(px, py, k) {
    const before = toWorld(px, py); cam.z = clamp(cam.z * k, .35, 5); const after = toWorld(px, py);
    cam.x += before.x - after.x; cam.y += before.y - after.y; clampCam();
  }
  function clampCam() { cam.x = clamp(cam.x, -20 * TW, (MAXW + 20) * TW); cam.y = clamp(cam.y, -(MAXF + 3) * FH, 6 * FH); }
  cv.addEventListener('pointerdown', e => {
    cv.setPointerCapture(e.pointerId); pts.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
    if (pts.size === 1) { panStart = { x: e.offsetX, y: e.offsetY, cx: cam.x, cy: cam.y }; moved = e.button === 1 || e.button === 2; }
    if (pts.size === 2) { const [a, b] = [...pts.values()]; pinch0 = { d: Math.hypot(a.x - b.x, a.y - b.y), z: cam.z }; moved = true; }
    if (TYPES[tool] && !TYPES[tool].tool) setGhost(e.offsetX, e.offsetY);
  });
  cv.addEventListener('pointermove', e => {
    if (!pts.has(e.pointerId)) { if (e.pointerType === 'mouse') setGhost(e.offsetX, e.offsetY); return; }
    pts.set(e.pointerId, { x: e.offsetX, y: e.offsetY });
    if (pts.size === 2 && pinch0) {
      const [a, b] = [...pts.values()], d = Math.hypot(a.x - b.x, a.y - b.y);
      const before = toWorld((a.x + b.x) / 2, (a.y + b.y) / 2); cam.z = clamp(pinch0.z * d / pinch0.d, .35, 5);
      const after = toWorld((a.x + b.x) / 2, (a.y + b.y) / 2); cam.x += before.x - after.x; cam.y += before.y - after.y; clampCam(); return;
    }
    if (panStart) {
      const dx = e.offsetX - panStart.x, dy = e.offsetY - panStart.y;
      if (Math.hypot(dx, dy) > 10) moved = true;
      if (moved) { cam.x = panStart.cx - dx / cam.z; cam.y = panStart.cy - dy / cam.z; clampCam(); ghost = null; }
      else if (TYPES[tool] && !TYPES[tool].tool) setGhost(e.offsetX, e.offsetY);
    }
  });
  const endPtr = e => {
    const tap = pts.size === 1 && !moved && pts.has(e.pointerId);
    pts.delete(e.pointerId); if (pts.size < 2) pinch0 = null;
    if (!pts.size) panStart = null;
    if (tap && e.button !== 2) { const w = toWorld(e.offsetX, e.offsetY); handleTap(Math.floor(-w.y / FH), Math.floor(w.x / TW)); }
    if (e.pointerType !== 'mouse') ghost = null;
  };
  cv.addEventListener('pointerup', endPtr);
  cv.addEventListener('pointercancel', e => { pts.delete(e.pointerId); panStart = null; pinch0 = null; });
  cv.addEventListener('contextmenu', e => e.preventDefault());
  cv.addEventListener('wheel', e => { e.preventDefault(); zoomAt(e.offsetX, e.offsetY, e.deltaY < 0 ? 1.15 : 1 / 1.15); }, { passive: false });
  cv.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') ghost = null; });
  function setGhost(px, py) { const w = toWorld(px, py); ghost = { f: Math.floor(-w.y / FH), x: Math.floor(w.x / TW) }; }
  addEventListener('keydown', e => {
    if (!running) return;
    const k = e.key, step = 60 / cam.z;
    if (k === 'ArrowLeft') cam.x -= step; else if (k === 'ArrowRight') cam.x += step; else if (k === 'ArrowUp') cam.y -= step; else if (k === 'ArrowDown') cam.y += step;
    else if (k === '+' || k === '=') zoomAt(W / 2, H / 2, 1.2); else if (k === '-') zoomAt(W / 2, H / 2, 1 / 1.2);
    else if (k === ' ') { setSpeed(speed ? 0 : 1); e.preventDefault(); } else if (k >= '1' && k <= '3') setSpeed(+k);
    else if (k === 'Escape') setTool('look'); else return;
    clampCam();
  });

  function handleTap(f, x) {
    if (tool === 'look') { inspect(f, x); return; }
    if (tool === 'bulldoze') { bulldoze(f, x); return; }
    build(tool, f, x);
  }
  function inspect(f, x) {
    if (f < 0 || f >= MAXF || x < 0 || x >= MAXW) return;
    const id = occ[f][x];
    if (id <= 0) { if (floors[f][x]) toast(`Floor ${floorName(f)}${floors[f][x] === 2 ? ': the lobby. People come in here.' : ': empty floor. Build something on it!'}`); return; }
    const u = items.get(id), T = TYPES[u.t], close = '<br><button onclick="this.closest(\'.modal\').classList.add(\'hidden\')">Close</button>';
    if (u.t === 'elevator') {
      const waiting = Object.values(u.q).reduce((n, q) => n + q.length, 0);
      showModal('Elevator', `Serves floors <b>${floorName(u.lo)}</b> to <b>${floorName(u.hi)}</b> (${u.hi - u.lo + 1} floors, up to ${MAX_ELEV}).<br>Riding: ${u.car.riders.length} of ${CAP} ยท Waiting: ${waiting}<br><small>Tap a floor in line with it, with the Elevator tool, to stretch it. Long lines? Build a second elevator.</small>${close}`);
      return;
    }
    if (u.t === 'stairs') { showModal('Stairs', `Links floor ${floorName(u.f)} and floor ${floorName(u.f + 1)}. People walk one or two floors instead of waiting for an elevator.${close}`); return; }
    const evalBar = `<div class="bar"><i style="width:${u.eval}%;background:${u.eval > 66 ? '#28f' : u.eval > 33 ? '#fd0' : '#f33'}"></i></div>`;
    let body = `Floor ${floorName(u.f)}<br>`;
    if (u.t === 'office') body += u.occupied ? `Rented: ${T.people} workers, $${fmt$(T.rent)} rent every quarter (3 days).<br>Happiness ${evalBar}` : (reachable(u) ? 'Empty. A company may rent it some morning.' : '<b>No one can get here!</b> Connect this floor to the lobby with an elevator or stairs.');
    else if (u.t === 'condo') body += u.occupied ? `A family of ${T.people} lives here.<br>Happiness ${evalBar}` : (reachable(u) ? `For sale. A family will pay $${fmt$(T.sale)} when they move in.` : '<b>No one can get here!</b> Connect this floor to the lobby.');
    else if (u.t === 'hotel') body += (u.guest ? `A guest has booked tonight ($${fmt$(T.night)} at checkout).` : 'No guest tonight.') + `<br>Rating ${evalBar}`;
    else if (T.spend) body += `Customers today: ${u.cust}. Each one spends $${T.spend}.<br>Rating ${evalBar}`;
    else if (u.t === 'security') body += 'Keeps the tower safe. Needed for 3 stars.';
    if (u.inside) body += `<br>People inside now: ${u.inside}`;
    showModal(T.ic + ' ' + T.name, body + close);
  }

  // ---- UI ----
  let toastT = null;
  function toast(s) { const t = $('toast'); t.textContent = s; t.style.opacity = 1; clearTimeout(toastT); toastT = setTimeout(() => t.style.opacity = 0, 2600); }
  function showModal(title, html) { $('mttl').textContent = title; $('mbody').innerHTML = html; $('modal').classList.remove('hidden'); }
  function buildToolbar() {
    $('tools').innerHTML = Object.entries(TYPES).map(([k, T]) => {
      const locked = T.star && T.star > star;
      const price = k === 'floor' ? '$500/tile' : k === 'elevator' ? '$60,000' : T.cost ? '$' + (T.cost >= 1000 ? fmt$(T.cost / 1000) + 'k' : fmt$(T.cost)) : '';
      return `<button data-t="${k}" class="${k === tool ? 'on' : ''}" ${locked ? 'disabled' : ''}><span class="ic">${T.ic}</span>${T.name}<small>${locked ? 'โ˜…'.repeat(T.star) : price}</small></button>`;
    }).join('') + `<button data-t="eval" class="${evalView ? 'on' : ''}"><span class="ic">๐Ÿ˜Š</span>Ratings<small>${evalView ? 'on' : 'off'}</small></button>`;
    $('tools').querySelectorAll('button').forEach(b => b.onclick = () => { if (b.dataset.t === 'eval') { evalView = !evalView; buildToolbar(); toast(evalView ? 'Blue = happy, yellow = so-so, red = about to leave.' : 'Ratings off.'); return; } setTool(b.dataset.t); });
  }
  function setTool(t) {
    tool = t; buildToolbar(); ghost = null;
    const T = TYPES[t];
    if (t === 'elevator') toast('Tap floor 1 above the lobby, then tap a higher floor in line with it to stretch the elevator.');
    else if (t === 'stairs') toast('Stairs link the floor you tap with the floor above.');
    else if (t === 'bulldoze') toast('Tap something to remove it. No refunds!');
    else if (T && !T.tool) toast(`${T.name}: tap where it should go.`);
  }
  function setSpeed(s) { speed = s; document.querySelectorAll('#speed [data-s]').forEach(b => b.classList.toggle('on', +b.dataset.s === s)); }
  document.querySelectorAll('#speed [data-s]').forEach(b => b.onclick = () => setSpeed(+b.dataset.s));
  $('menu').onclick = () => {
    setSpeed(0); save();
    showModal('Sky Tower', `<button id="mResume">Resume</button><button id="mSound">Sound: ${soundOn ? 'on' : 'off'}</button><button id="mTitle">Title screen</button><br><small>The tower is saved.</small>`);
    $('mResume').onclick = () => { $('modal').classList.add('hidden'); setSpeed(1); };
    $('mSound').onclick = () => { soundOn = !soundOn; $('mSound').textContent = 'Sound: ' + (soundOn ? 'on' : 'off'); };
    $('mTitle').onclick = () => { $('modal').classList.add('hidden'); running = false; openTitle(); };
  };
  function updateStats() {
    $('stars').textContent = star === 5 ? 'TOWER' : 'โ˜…'.repeat(star) + 'โ˜†'.repeat(5 - star);
    $('money').textContent = fmt$(money); $('money').style.color = money < 0 ? '#c00' : '';
    const hh = Math.floor(time / 60), mm = Math.floor(time % 60);
    $('date').textContent = `Day ${day} ${weekend() ? 'WE' : 'WD'} ${hh % 12 || 12}:${String(mm).padStart(2, '0')} ${hh < 12 ? 'AM' : 'PM'}`;
    $('pop').textContent = fmt$(population());
    const r = STAR_RULES[star + 1]; $('next').textContent = r ? `Next โ˜…: ${r.text}` : '';
  }

  // ---- main loop ----
  let last = performance.now(), statT = 0;
  function frame(now) {
    const real = Math.min(.1, (now - last) / 1000); last = now;
    if (running && speed) {
      let dt = real * 10 * [0, 1, 3, 8][speed];                       // 1 game minute = 0.1 s at normal speed
      while (dt > 0) { const s = Math.min(dt, .5); tick(s); dt -= s; }
    }
    if (W) draw();
    if ((statT -= real) <= 0) { statT = .25; if (running) updateStats(); }
    requestAnimationFrame(frame);
  }

  function openTitle() { $('title').classList.remove('hidden'); $('cont').style.display = localStorage.getItem('skytower') ? '' : 'none'; }
  function begin() { $('title').classList.add('hidden'); running = true; setSpeed(1); buildToolbar(); resize(); updateStats(); }
  $('new').onclick = () => {
    if (localStorage.getItem('skytower') && !confirm('Start a new tower? The saved one will be replaced.')) return;
    fresh(); cam = { x: 80 * TW, y: -4 * FH, z: clamp((cv.getBoundingClientRect().height || 500) / (12 * FH), .8, 3) }; tool = 'lobby';
    begin(); toast('Start with a LOBBY on the ground floor. Tap to build!'); save();
  };
  $('cont').onclick = () => { if (load()) { tool = 'look'; begin(); toast('Welcome back!'); } };
  addEventListener('resize', resize);
  document.addEventListener('visibilitychange', () => { if (document.hidden && running) save(); });
  addEventListener('pagehide', () => { if (running) save(); });

  fresh(); resize(); openTitle();
  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 skytower subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):

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

4. Push it into CT 125

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

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

Updating it later

Edit index.html, paste the new version over /root/nibbles/skytower/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 'skytower' (the whole tower is saved there, about 2 KB). Clearing the site's data in the browser resets them; nothing is stored on the server.

Notes

  • Star targets: 2โ˜… at 150 people; 3โ˜… at 500 people plus a Security office; 4โ˜… at 1,500 people plus 10 hotel rooms; TOWER at 3,000 people and 40 floors. These are lower than SimTower's so kids get there.
  • Tuned by simulation: a test script built 18 offices with one elevator and ran 3 game days. The first version's rush-hour lines reached 32 people and over 3 hours of game-time waiting, and half the offices moved out. The elevators are now twice as quick and tenants a bit more patient, while long lines still turn pink as a hint to add another elevator.
  • Saving: only the layout, money, day and ratings are saved; the people are rebuilt from the rooms when the game loads, which keeps the save tiny.
  • Name: "SimTower" belongs to Maxis/EA, so this one is called Sky Tower.