Portland Tycoon: a property board game (rebuild guide) 04OCT2026

Buy Portland neighborhoods, build houses and collect rent, against people or the computer

Posted by on 4th Oct 2026

A property-trading board game that plays like the classic, with my own board of Portland places. Play it at https://games.503mail.com/tycoon/ 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

The streets are Portland neighborhoods from Lents and Gateway up to West Hills and the Waterfront. MAX lines, the Streetcar and the Aerial Tram take the place of railroads, and there's the Power Company and Water Bureau, Surprise and Neighbor cards, and Food Carts as the free space. From 2 to 4 players can play, each a person or the computer, and people can pass the tablet round. There's dice with doubles, START pays $200, rent doubles for a whole color, even building up to a hotel, mortgages, Jail (pay, use a card, or roll doubles), and going broke. Quick games end after 15 rounds with the richest player (cash + places + houses) winning. Full games go until one player is left. The game saves itself each turn, so a closed tab can carry on with Continue saved game.

Controls

Everything is big buttons under the board: ROLL DICE, BUY / NO THANKS, END TURN, and MY PLACES / BUILD for houses and mortgages. Tap any space on the board to see its card.

Rebuild, step by step

Portland Tycoon 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 Portland Tycoon tile.

1. Make the game's folder

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

mkdir -p /root/nibbles/tycoon

2. Create the game file

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

cat > /root/nibbles/tycoon/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="#0b3d24">
<title>Portland Tycoon</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="Portland Tycoon">
<meta property="og:image" content="https://games.503mail.com/tycoon/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#0b3d24;color:#fff;font-family:Tahoma,Verdana,Arial,sans-serif;overflow:hidden}
  body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1vmin}
  #board{display:block;touch-action:manipulation}
  #side{display:flex;flex-direction:column;gap:1vmin;width:min(96vw,640px)}
  #players{display:grid;grid-template-columns:1fr 1fr;gap:.6vmin}
  .pl{background:rgba(0,0,0,.35);border:2px solid transparent;border-radius:8px;padding:.5vmin 1vmin;display:flex;align-items:center;gap:.6em;font-size:clamp(12px,2.2vmin,18px)}
  .pl.cur{border-color:#ff5;background:rgba(80,80,0,.4)}
  .pl.out{opacity:.4;text-decoration:line-through}
  .pl .tok{font-size:1.5em;width:1.6em;height:1.6em;border-radius:50%;display:flex;align-items:center;justify-content:center}
  .pl .cash{margin-left:auto;font-weight:bold;color:#9f9}
  #actions{display:flex;flex-wrap:wrap;gap:1vmin;justify-content:center;min-height:6vmin}
  #actions button,.modal button{font:bold clamp(14px,2.6vmin,22px) Tahoma,Verdana,Arial,sans-serif;border:0;border-radius:8px;padding:.5em 1em;cursor:pointer;background:#2a6;color:#fff;touch-action:manipulation}
  #actions button.alt,.modal button.alt{background:#666}
  #actions button.warn,.modal button.warn{background:#c33}
  #actions button:disabled,.modal button:disabled{opacity:.4;cursor:default}
  @media (orientation:landscape){
    body{flex-direction:row;gap:2vmin}
    #side{width:min(40vw,440px)}
    #players{grid-template-columns:1fr}
  }
  #setup,.modal{position:fixed;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2vmin;padding:16px;box-sizing:border-box;text-align:center}
  #setup{background:radial-gradient(#1d6b40,#062214);font-size:clamp(13px,2.6vmin,22px)}
  #setup.hidden,.modal.hidden{display:none}
  #setup h1{margin:0;color:#ff5;letter-spacing:.15em;font-size:2em;text-shadow:3px 3px #000}
  #setup .sub{color:#9f9;margin-top:-1.5vmin}
  #setup .seats{display:flex;flex-direction:column;gap:1vmin}
  #setup .seat{display:flex;gap:.6em;align-items:center;justify-content:center;flex-wrap:wrap}
  #setup .seat .tok{font-size:1.6em}
  #setup input[type=text]{font:inherit;width:7em;padding:.2em .4em;border-radius:6px;border:0}
  #setup button,#setup select{font:inherit;border:0;border-radius:8px;padding:.3em .8em;cursor:pointer}
  #setup .on{background:#ff5;color:#000;font-weight:bold}
  #setup #go{background:#e33;color:#fff;font-size:1.3em;padding:.4em 2em}
  #setup #cont{background:#36f;color:#fff}
  #setup .help{color:#cfc;font-size:.85em;max-width:40em;line-height:1.5}
  #setup a{color:#5ff}
  .modal{background:rgba(0,0,0,.6)}
  .card{background:#fff;color:#000;border-radius:10px;padding:2vmin 3vmin;max-width:min(92vw,520px);max-height:84vh;overflow:auto;box-shadow:0 6px 30px #000;font-size:clamp(13px,2.4vmin,19px)}
  .card .band{margin:-2vmin -3vmin 1.5vmin;padding:1.5vmin;border-radius:10px 10px 0 0;font-weight:bold;font-size:1.3em;text-align:center}
  .card table{border-collapse:collapse;margin:0 auto}
  .card td{padding:.15em .6em;text-align:left}
  .card td+td{text-align:right}
  .card .btns{display:flex;gap:1vmin;justify-content:center;margin-top:1.5vmin;flex-wrap:wrap}
  .card .row{display:flex;align-items:center;gap:.5em;padding:.4em 0;border-bottom:1px solid #ddd;flex-wrap:wrap}
  .card .row .chip{width:1em;height:1em;border-radius:3px;border:1px solid #000}
  .card .row .nm{flex:1;text-align:left;min-width:8em}
  .card .row button{font-size:.8em;padding:.35em .6em}
</style>
</head>
<body>
<canvas id="board"></canvas>
<div id="side">
  <div id="players"></div>
  <div id="actions"></div>
</div>
<div id="modal" class="modal hidden"><div class="card" id="card"></div></div>
<div id="setup">
  <h1>PORTLAND TYCOON</h1>
  <div class="sub">Buy the neighborhoods ยท build houses ยท collect rent</div>
  <div class="seats" id="seats"></div>
  <div><label>Game length <select id="len"><option value="quick">Quick (15 rounds, richest wins)</option><option value="full">Full (until one player is left)</option></select></label></div>
  <div><label><input type="checkbox" id="snd" checked> Sound</label></div>
  <div><button id="go">PLAY</button> <button id="cont">Continue saved game</button></div>
  <div class="help">Roll the dice and go round the board. Land on a place nobody owns and you can buy it.
    Others pay you rent when they land on yours. Own a whole color and the rent doubles, then build houses for much more.
    Pass START to collect $200. Tap any space on the board to see its card.</div>
  <a href="/">Other games</a>
</div>
<script>
(() => {
  const cv = document.getElementById('board'), g = cv.getContext('2d');
  const $ = id => document.getElementById(id);
  const sleep = ms => new Promise(r => setTimeout(r, ms));

  // ---- the board: 40 spaces, START in the bottom right, going clockwise round to the left ----
  const GROUPS = {
    brown: { c: '#8B4513', house: 50 }, lblue: { c: '#87CEEB', house: 50 }, pink: { c: '#D93A96', house: 100 }, orange: { c: '#F7941D', house: 100 },
    red: { c: '#ED1B24', house: 150 }, yellow: { c: '#FEF200', house: 150 }, green: { c: '#1FB25A', house: 200 }, dblue: { c: '#0072BB', house: 200 },
  };
  const P = (name, grp, price) => ({ t: 'prop', name, grp, price, base: Math.max(2, Math.round(price * .1 - 4)) });
  const T = name => ({ t: 'rail', name, price: 200 }), U = name => ({ t: 'util', name, price: 150 });
  const BOARD = [
    { t: 'go', name: 'START' }, P('Lents', 'brown', 60), { t: 'card', deck: 'nb', name: 'Neighbor' }, P('Gateway', 'brown', 60), { t: 'tax', name: 'Property Tax', amt: 200 },
    T('MAX Red Line'), P('St. Johns', 'lblue', 100), { t: 'card', deck: 'sp', name: 'Surprise' }, P('Kenton', 'lblue', 100), P('Foster', 'lblue', 120),
    { t: 'jail', name: 'Jail' }, P('Sellwood', 'pink', 140), U('Power Company'), P('Woodstock', 'pink', 140), P('Montavilla', 'pink', 160),
    T('MAX Blue Line'), P('Hawthorne', 'orange', 180), { t: 'card', deck: 'nb', name: 'Neighbor' }, P('Division', 'orange', 180), P('Belmont', 'orange', 200),
    { t: 'free', name: 'Food Carts' }, P('Alberta', 'red', 220), { t: 'card', deck: 'sp', name: 'Surprise' }, P('Mississippi', 'red', 220), P('Williams', 'red', 240),
    T('Streetcar'), P('Hollywood', 'yellow', 260), P('Laurelhurst', 'yellow', 260), U('Water Bureau'), P('Irvington', 'yellow', 280),
    { t: 'gojail', name: 'Go To Jail' }, P('Nob Hill', 'green', 300), P('Pearl District', 'green', 300), { t: 'card', deck: 'nb', name: 'Neighbor' }, P('Lloyd', 'green', 320),
    T('Aerial Tram'), { t: 'card', deck: 'sp', name: 'Surprise' }, P('West Hills', 'dblue', 350), { t: 'tax', name: 'Parking Ticket', amt: 100 }, P('Waterfront', 'dblue', 400),
  ];
  const HOUSE_MULT = [1, 5, 15, 40, 55, 70];             // rent for 0-4 houses and a hotel, times the base rent
  const groupOf = grp => BOARD.map((s, i) => s.grp === grp ? i : -1).filter(i => i >= 0);
  const RAILS = BOARD.map((s, i) => s.t === 'rail' ? i : -1).filter(i => i >= 0);
  const UTILS = BOARD.map((s, i) => s.t === 'util' ? i : -1).filter(i => i >= 0);
  const buyable = i => ['prop', 'rail', 'util'].includes(BOARD[i].t);

  const DECKS = {
    sp: [
      ['Advance to START. Collect $200.', { t: 'move', to: 0 }], ['Take a ride to Hawthorne.', { t: 'move', to: 16 }],
      ['Stroll down to the Waterfront.', { t: 'move', to: 39 }], ['Hop on the nearest train or tram. If someone owns it, pay them double.', { t: 'nearest' }],
      ['The bank pays you $50.', { t: 'money', amt: 50 }], ['Get Out of Jail Free! Keep this card until you need it.', { t: 'free' }],
      ['Go back 3 spaces.', { t: 'back', n: 3 }], ['Go to Jail. Do not pass START.', { t: 'jail' }],
      ['Fix up your places: pay $25 per house and $100 per hotel.', { t: 'repair', h: 25, hotel: 100 }], ['Bike lane speeding ticket! Pay $15.', { t: 'money', amt: -15 }],
      ['Ride the Aerial Tram.', { t: 'move', to: 35 }], ['You won the bike race! Collect $150.', { t: 'money', amt: 150 }],
    ],
    nb: [
      ['Advance to START. Collect $200.', { t: 'move', to: 0 }], ['Bank mistake in your favor. Collect $200.', { t: 'money', amt: 200 }],
      ['Visit the dentist. Pay $50.', { t: 'money', amt: -50 }], ['Your lemonade stand made $50.', { t: 'money', amt: 50 }],
      ['Get Out of Jail Free! Keep this card until you need it.', { t: 'free' }], ['Go to Jail. Do not pass START.', { t: 'jail' }],
      ["It's your birthday! Collect $10 from every player.", { t: 'each', amt: -10 }], ['Tax refund. Collect $20.', { t: 'money', amt: 20 }],
      ['School trip. Pay $50.', { t: 'money', amt: -50 }], ['You found $100 in an old coat!', { t: 'money', amt: 100 }],
      ['You threw a block party. Pay every player $10.', { t: 'each', amt: 10 }], ['Street repairs: pay $40 per house and $115 per hotel.', { t: 'repair', h: 40, hotel: 115 }],
    ],
  };

  const TOKENS = ['๐Ÿšฒ', '๐Ÿถ', '๐Ÿš€', '๐Ÿฆ–'], PCOL = ['#e33', '#36f', '#2b2', '#f90'];
  let cfg = { sound: true, len: 'quick', seats: [{ name: 'Player 1', ai: false }, { name: 'Computer', ai: true }, { name: '', ai: true, off: true }, { name: '', ai: true, off: true }] };
  try { Object.assign(cfg, JSON.parse(localStorage.getItem('tycoon') || '{}')); } catch (e) {}
  const saveCfg = () => { try { localStorage.setItem('tycoon', JSON.stringify(cfg)); } catch (e) {} };

  // ---- sound ----
  let actx = 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 tone(f, dur, type = 'square', vol = .04) {
    const a = audio(); if (!a) return;
    const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
    o.type = type; o.frequency.setValueAtTime(f, t); v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(.0005, t + dur);
    o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
  }
  const notes = (fs, gap, dur, vol) => fs.forEach((f, i) => setTimeout(() => tone(f, dur, 'square', vol), i * gap));
  const SFX = {
    dice: () => { for (let i = 0; i < 6; i++) setTimeout(() => tone(200 + Math.random() * 300, .03, 'square', .03), i * 60); },
    step: () => tone(880, .04, 'triangle', .05),
    cash: () => notes([988, 1319], 80, .1, .04),
    pay: () => notes([440, 330], 90, .12, .04),
    buy: () => notes([523, 659, 784], 80, .1, .04),
    jail: () => notes([392, 330, 262, 196], 150, .2, .05),
    card: () => notes([660, 880], 70, .08, .04),
    win: () => notes([523, 659, 784, 1047, 784, 1047], 150, .14, .05),
  };

  // ---- game state (plain data, so it can be saved) ----
  let G = null;
  const cur = () => G.players[G.turn];
  function log(s) { G.log.push(s); if (G.log.length > 6) G.log.shift(); draw(); }
  function saveGame() { try { localStorage.setItem('tycoon-game', JSON.stringify(G)); } catch (e) {} }
  function clearSave() { try { localStorage.removeItem('tycoon-game'); } catch (e) {} }
  function loadGame() { try { return JSON.parse(localStorage.getItem('tycoon-game') || 'null'); } catch (e) { return null; } }

  function newGame() {
    const seats = cfg.seats.filter(s => !s.off);
    G = {
      players: seats.map((s, i) => ({ name: s.name || (s.ai ? 'Computer' : 'Player ' + (i + 1)), ai: s.ai, tok: TOKENS[cfg.seats.indexOf(s)], col: PCOL[cfg.seats.indexOf(s)], cash: 1500, pos: 0, jail: 0, cards: 0, out: false })),
      own: Array(40).fill(-1), houses: Array(40).fill(0), mort: Array(40).fill(false),
      turn: 0, round: 1, limit: cfg.len === 'quick' ? 15 : 0, dice: [1, 1], log: [], over: false,
      decks: { sp: shuffle(DECKS.sp.map((_, i) => i)), nb: shuffle(DECKS.nb.map((_, i) => i)) },
    };
    log('Everyone starts with $1,500. Good luck!');
  }
  function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }

  // ---- rules ----
  const ownsAll = (pi, grp) => groupOf(grp).every(i => G.own[i] === pi);
  function rentFor(i, roll) {
    const s = BOARD[i], o = G.own[i];
    if (o < 0 || G.mort[i]) return 0;
    if (s.t === 'prop') return G.houses[i] ? s.base * HOUSE_MULT[G.houses[i]] : s.base * (ownsAll(o, s.grp) ? 2 : 1);
    if (s.t === 'rail') return 25 * 2 ** (RAILS.filter(r => G.own[r] === o).length - 1);
    if (s.t === 'util') return roll * (UTILS.every(u => G.own[u] === o) ? 10 : 4);
    return 0;
  }
  const houseCost = i => GROUPS[BOARD[i].grp].house;
  function canBuild(pi, i) {
    const s = BOARD[i];
    if (s.t !== 'prop' || G.own[i] !== pi || !ownsAll(pi, s.grp)) return false;
    const grp = groupOf(s.grp);
    if (grp.some(j => G.mort[j]) || G.houses[i] >= 5) return false;
    return G.houses[i] <= Math.min(...grp.map(j => G.houses[j])) && G.players[pi].cash >= houseCost(i);
  }
  function canSell(pi, i) {
    const s = BOARD[i];
    if (s.t !== 'prop' || G.own[i] !== pi || !G.houses[i]) return false;
    return G.houses[i] >= Math.max(...groupOf(s.grp).map(j => G.houses[j]));
  }
  const canMortgage = (pi, i) => G.own[i] === pi && !G.mort[i] && (BOARD[i].t !== 'prop' || groupOf(BOARD[i].grp).every(j => !G.houses[j]));
  const unmortCost = i => Math.ceil(BOARD[i].price / 2 * 1.1);
  function netWorth(pi) {
    let w = G.players[pi].cash;
    for (let i = 0; i < 40; i++) if (G.own[i] === pi) w += (G.mort[i] ? BOARD[i].price / 2 : BOARD[i].price) + (G.houses[i] ? G.houses[i] * houseCost(i) : 0);
    return w;
  }

  // Raise money by selling houses (half price) and then mortgaging places (half price).
  function raise(p, need) {
    const pi = G.players.indexOf(p);
    let guard = 200;
    while (p.cash < need && guard--) {
      const sell = BOARD.map((_, i) => i).find(i => canSell(pi, i));
      if (sell !== undefined) { G.houses[sell]--; p.cash += houseCost(sell) / 2; log(`${p.name} sold a house on ${BOARD[sell].name}.`); continue; }
      const m = BOARD.map((_, i) => i).filter(i => canMortgage(pi, i)).sort((a, b) => BOARD[a].price - BOARD[b].price)[0];
      if (m !== undefined) { G.mort[m] = true; p.cash += BOARD[m].price / 2; log(`${p.name} mortgaged ${BOARD[m].name}.`); continue; }
      break;
    }
  }
  // pay money to another player (to = index) or to the bank (to = -1)
  async function pay(p, to, amt, why) {
    if (amt <= 0) return;
    if (p.cash < amt) raise(p, amt);
    const target = to >= 0 ? G.players[to] : null;
    if (p.cash < amt) { await bankrupt(p, to); return; }
    p.cash -= amt; if (target) target.cash += amt;
    SFX.pay(); log(`${p.name} paid $${amt}${target ? ' to ' + target.name : ''}${why ? ' (' + why + ')' : ''}.`);
    render();
  }
  async function bankrupt(p, to) {
    const pi = G.players.indexOf(p), target = to >= 0 ? G.players[to] : null;
    log(`${p.name} can't pay and is out of the game!`); SFX.jail();
    if (target) target.cash += p.cash;
    p.cash = 0; p.out = true;
    for (let i = 0; i < 40; i++) if (G.own[i] === pi) { G.houses[i] = 0; G.own[i] = target ? to : -1; if (!target) G.mort[i] = false; }
    render();
    await sleep(1200);
  }

  // ---- moving ----
  async function step(p, n) {
    for (let k = 0; k < n; k++) {
      p.pos = (p.pos + 1) % 40;
      if (p.pos === 0) { p.cash += 200; SFX.cash(); log(`${p.name} passed START: +$200.`); }
      else SFX.step();
      render(); await sleep(140);
    }
  }
  const moveTo = (p, to) => step(p, (to - p.pos + 40) % 40);
  async function sendJail(p) {
    p.pos = 10; p.jail = 1; SFX.jail(); log(`${p.name} went to Jail!`); render();
    await showInfo(p, `<div class="band" style="background:#333;color:#fff">GO TO JAIL</div><p>${esc(p.name)} goes straight to Jail.</p>`);
  }

  async function rollDice(p, label = 'ROLL DICE') {
    if (!p.ai) await choose([{ label, v: 1 }]);
    else await sleep(600);
    SFX.dice();
    for (let k = 0; k < 8; k++) { G.dice = [1 + Math.random() * 6 | 0, 1 + Math.random() * 6 | 0]; draw(); await sleep(60); }
    G.dice = [1 + Math.random() * 6 | 0, 1 + Math.random() * 6 | 0]; draw();
    log(`${p.name} rolled ${G.dice[0]} + ${G.dice[1]}${G.dice[0] === G.dice[1] ? ' (doubles!)' : ''}.`);
    await sleep(350);
    return G.dice;
  }

  async function land(p, roll) {
    const i = p.pos, s = BOARD[i], pi = G.players.indexOf(p);
    if (buyable(i)) {
      const o = G.own[i];
      if (o < 0) {
        if (p.cash < s.price) { log(`${p.name} can't afford ${s.name} ($${s.price}).`); return; }
        let buy;
        if (p.ai) {
          const completes = s.t === 'prop' && groupOf(s.grp).every(j => j === i || G.own[j] === pi);
          buy = p.cash - s.price >= 120 || completes;
          await sleep(500);
        } else buy = await showDeed(i, [{ label: `BUY $${s.price}`, v: true }, { label: 'NO THANKS', v: false, cls: 'alt' }]);
        if (buy) { p.cash -= s.price; G.own[i] = pi; SFX.buy(); log(`${p.name} bought ${s.name} for $${s.price}.`); render(); }
        else log(`${p.name} didn't buy ${s.name}.`);
      } else if (o !== pi) {
        if (G.mort[i]) { log(`${s.name} is mortgaged: no rent.`); return; }
        await pay(p, o, rentFor(i, roll), 'rent for ' + s.name);
      }
    } else if (s.t === 'tax') await pay(p, -1, s.amt, s.name);
    else if (s.t === 'gojail') await sendJail(p);
    else if (s.t === 'card') await drawCard(p, s.deck, roll);
  }

  async function drawCard(p, deck, roll) {
    const order = G.decks[deck], idx = order.shift(); order.push(idx);
    const [text, fx] = DECKS[deck][idx];
    SFX.card();
    await showInfo(p, `<div class="band" style="background:${deck === 'sp' ? '#f90' : '#5bf'}">${deck === 'sp' ? 'SURPRISE!' : 'NEIGHBOR'}</div><p style="font-size:1.2em">${esc(text)}</p>`);
    log(`${p.name}: ${text}`);
    const pi = G.players.indexOf(p);
    if (fx.t === 'move') { await moveTo(p, fx.to); await land(p, roll); }
    else if (fx.t === 'money') { if (fx.amt > 0) { p.cash += fx.amt; SFX.cash(); render(); } else await pay(p, -1, -fx.amt); }
    else if (fx.t === 'free') { p.cards++; render(); }
    else if (fx.t === 'back') { p.pos = (p.pos + 37) % 40; render(); await sleep(300); await land(p, roll); }
    else if (fx.t === 'jail') await sendJail(p);
    else if (fx.t === 'repair') {
      let cost = 0; for (let i = 0; i < 40; i++) if (G.own[i] === pi && G.houses[i]) cost += G.houses[i] === 5 ? fx.hotel : G.houses[i] * fx.h;
      await pay(p, -1, cost, 'repairs');
    } else if (fx.t === 'each') {
      for (const [oi, o] of G.players.entries()) {
        if (o === p || o.out) continue;
        if (fx.amt > 0) await pay(p, oi, fx.amt); else await pay(o, pi, -fx.amt);
        if (p.out) break;
      }
    } else if (fx.t === 'nearest') {
      const to = RAILS.find(r => r > p.pos) ?? RAILS[0];
      await moveTo(p, to);
      const o = G.own[to];
      if (o >= 0 && o !== pi && !G.mort[to]) await pay(p, o, rentFor(to, roll) * 2, 'double rent');
      else await land(p, roll);
    }
  }

  // jail turn: returns 'free' (roll normally), 'moved' (already moved out) or 'stay'
  async function jailTurn(p) {
    let how;
    if (p.ai) { await sleep(500); how = p.cards ? 'card' : (G.round < 8 && p.cash > 200) ? 'pay' : 'roll'; }
    else how = await choose([{ label: 'ROLL FOR DOUBLES', v: 'roll' }, { label: 'PAY $50', v: 'pay', dis: p.cash < 50, cls: 'alt' }, ...(p.cards ? [{ label: 'USE FREE CARD', v: 'card', cls: 'alt' }] : [])],
      `${p.name} is in Jail (try ${p.jail} of 3)`);
    if (how === 'card') { p.cards--; p.jail = 0; log(`${p.name} used a Get Out of Jail Free card.`); return 'free'; }
    if (how === 'pay') { await pay(p, -1, 50, 'jail fine'); p.jail = 0; return p.out ? 'stay' : 'free'; }
    const [a, b] = await rollDice(p, 'ROLL');
    if (a === b) { p.jail = 0; log(`${p.name} rolled doubles and is free!`); await step(p, a + b); await land(p, a + b); return 'moved'; }
    if (p.jail >= 3) { log(`${p.name} must pay $50 to leave Jail.`); await pay(p, -1, 50, 'jail fine'); if (p.out) return 'stay'; p.jail = 0; await step(p, a + b); await land(p, a + b); return 'moved'; }
    p.jail++; log(`${p.name} stays in Jail.`); return 'stay';
  }

  // ---- whole turns ----
  async function playTurn() {
    const p = cur();
    render(); saveGame();
    let doubles = 0, go = 'free';
    if (p.jail) go = await jailTurn(p);
    while (go === 'free' && !p.out) {
      const [a, b] = await rollDice(p);
      if (a === b && ++doubles === 3) { log(`${p.name} rolled doubles 3 times!`); await sendJail(p); break; }
      await step(p, a + b);
      await land(p, a + b);
      if (p.out || p.jail || a !== b) break;
      log(`${p.name} rolled doubles: roll again!`);
    }
    if (!p.out && !G.over) await endPhase(p);
    nextTurn();
  }
  async function endPhase(p) {
    const pi = G.players.indexOf(p);
    if (p.ai) {
      await aiTrade(p);
      // the computer builds when it has spare money, and pays off mortgages when rich
      let built = 0;
      for (let guard = 0; guard < 30; guard++) {
        const opts = BOARD.map((_, i) => i).filter(i => canBuild(pi, i) && p.cash - houseCost(i) >= 250);
        if (!opts.length) break;
        const i = opts[0]; p.cash -= houseCost(i); G.houses[i]++; built++;
      }
      if (built) { SFX.buy(); log(`${p.name} built ${built} house${built > 1 ? 's' : ''}.`); }
      for (let i = 0; i < 40; i++) if (G.own[i] === pi && G.mort[i] && p.cash - unmortCost(i) > 400) { p.cash -= unmortCost(i); G.mort[i] = false; log(`${p.name} paid off the mortgage on ${BOARD[i].name}.`); }
      render(); await sleep(400); return;
    }
    while (true) {
      const anyOwned = G.own.some(o => o === pi);
      const v = await choose([{ label: 'END TURN', v: 'end' }, { label: 'MY PLACES / BUILD', v: 'manage', dis: !anyOwned, cls: 'alt' }]);
      if (v === 'end') return;
      await manage(p);
    }
  }
  // Without trading, nobody would ever get a whole color. When the computer is one place short of a color,
  // it offers to buy that place for 1.6 times its price. Computers say yes; a person is asked
  // (and isn't asked about the same place again for 5 rounds).
  async function aiTrade(p) {
    const pi = G.players.indexOf(p);
    G.asked = G.asked || {};
    for (const grp of Object.keys(GROUPS)) {
      const idx = groupOf(grp), missing = idx.filter(i => G.own[i] !== pi);
      if (missing.length !== 1 || missing.length === idx.length) continue;
      const m = missing[0], o = G.own[m];
      if (o < 0 || G.mort[m] || idx.some(i => G.houses[i]) || G.players[o].out) continue;
      const offer = Math.round(BOARD[m].price * 1.6 / 10) * 10, key = m + '-' + pi;
      if (p.cash - offer < 100 || (G.asked[key] && G.round - G.asked[key] < 5)) continue;
      const owner = G.players[o];
      let ok = owner.ai;
      if (!ok) {
        G.asked[key] = G.round;
        ok = await showDeed(m, [{ label: `SELL FOR $${offer}`, v: true }, { label: 'NO THANKS', v: false, cls: 'alt' }],
          `<p style="font-size:1.15em"><b>${p.tok} ${esc(p.name)}</b> wants to buy <b>${esc(BOARD[m].name)}</b> from <b>${esc(owner.name)}</b> for <b>$${offer}</b>.</p>`);
      }
      if (ok) { p.cash -= offer; owner.cash += offer; G.own[m] = pi; SFX.cash(); log(`${p.name} bought ${BOARD[m].name} from ${owner.name} for $${offer}.`); render(); }
      else log(`${owner.name} kept ${BOARD[m].name}.`);
      return;
    }
  }
  function nextTurn() {
    if (checkOver()) return;
    let n = G.turn;
    do { n = (n + 1) % G.players.length; if (n === 0) G.round++; } while (G.players[n].out);
    G.turn = n;
    if (checkOver()) return;
    playTurn();
  }
  function checkOver() {
    const left = G.players.filter(p => !p.out);
    if (left.length > 1 && !(G.limit && G.round > G.limit)) return false;
    G.over = true; clearSave(); SFX.win();
    const ranks = G.players.map((p, i) => ({ p, w: p.out ? -1 : netWorth(i) })).sort((a, b) => b.w - a.w);
    const rows = ranks.map((r, k) => `<tr><td>${k + 1}. ${r.p.tok} ${esc(r.p.name)}</td><td>${r.w < 0 ? 'out' : '$' + r.w.toLocaleString('en-US')}</td></tr>`).join('');
    render();
    showModal(`<div class="band" style="background:#ff5">${esc(ranks[0].p.name)} WINS!</div>
      <p>${G.limit && left.length > 1 ? 'After ' + G.limit + ' rounds, the richest player wins.' : 'Everyone else ran out of money.'}<br>Worth = cash + places + houses.</p>
      <table>${rows}</table><div class="btns"><button id="again">NEW GAME</button></div>`);
    $('again').onclick = () => { hideModal(); openSetup(); };
    return true;
  }

  // ---- managing your places: build, sell, mortgage ----
  async function manage(p) {
    const pi = G.players.indexOf(p);
    return new Promise(done => {
      const show = () => {
        const mine = BOARD.map((_, i) => i).filter(i => G.own[i] === pi);
        const rows = mine.map(i => {
          const s = BOARD[i], h = G.houses[i], col = s.grp ? GROUPS[s.grp].c : s.t === 'rail' ? '#333' : '#ccc';
          const hs = h === 5 ? 'hotel' : h ? h + ' house' + (h > 1 ? 's' : '') : '';
          return `<div class="row"><span class="chip" style="background:${col}"></span><span class="nm">${esc(s.name)} ${G.mort[i] ? '<i>(mortgaged)</i>' : ''} <small>${hs}</small></span>
            ${s.t === 'prop' ? `<button data-a="b" data-i="${i}" ${canBuild(pi, i) ? '' : 'disabled'}>+ house $${houseCost(i)}</button><button class="alt" data-a="s" data-i="${i}" ${canSell(pi, i) ? '' : 'disabled'}>sell house</button>` : ''}
            ${G.mort[i] ? `<button class="alt" data-a="u" data-i="${i}" ${p.cash >= unmortCost(i) ? '' : 'disabled'}>pay off $${unmortCost(i)}</button>`
              : `<button class="warn" data-a="m" data-i="${i}" ${canMortgage(pi, i) ? '' : 'disabled'}>mortgage +$${s.price / 2}</button>`}</div>`;
        }).join('');
        showModal(`<div class="band" style="background:${p.col};color:#fff">${p.tok} ${esc(p.name)}: $${p.cash.toLocaleString('en-US')}</div>
          <p style="margin:.3em 0"><small>Own every place in a color to build houses. Build evenly: one on each before a second. 5 houses = a hotel.</small></p>
          ${rows}<div class="btns"><button id="mdone">DONE</button></div>`);
        $('card').querySelectorAll('button[data-a]').forEach(b => b.onclick = () => {
          const i = +b.dataset.i;
          if (b.dataset.a === 'b' && canBuild(pi, i)) { p.cash -= houseCost(i); G.houses[i]++; SFX.buy(); log(`${p.name} built on ${BOARD[i].name}.`); }
          if (b.dataset.a === 's' && canSell(pi, i)) { G.houses[i]--; p.cash += houseCost(i) / 2; log(`${p.name} sold a house on ${BOARD[i].name}.`); }
          if (b.dataset.a === 'm' && canMortgage(pi, i)) { G.mort[i] = true; p.cash += BOARD[i].price / 2; log(`${p.name} mortgaged ${BOARD[i].name}.`); }
          if (b.dataset.a === 'u' && p.cash >= unmortCost(i)) { p.cash -= unmortCost(i); G.mort[i] = false; log(`${p.name} paid off ${BOARD[i].name}.`); }
          render(); show();
        });
        $('mdone').onclick = () => { hideModal(); done(); };
      };
      show();
    });
  }

  // ---- dialogs ----
  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  function showModal(html) { $('card').innerHTML = html; $('modal').classList.remove('hidden'); }
  function hideModal() { $('modal').classList.add('hidden'); }
  function deedHtml(i) {
    const s = BOARD[i], o = G ? G.own[i] : -1, own = o >= 0 ? `<p>Owner: ${G.players[o].tok} ${esc(G.players[o].name)}${G.mort[i] ? ' (mortgaged)' : ''}</p>` : '';
    if (s.t === 'prop') {
      const r = HOUSE_MULT.map(m => s.base * m);
      return `<div class="band" style="background:${GROUPS[s.grp].c};color:${['yellow', 'lblue'].includes(s.grp) ? '#000' : '#fff'}">${esc(s.name)}</div>
        <table><tr><td>Price</td><td>$${s.price}</td></tr><tr><td>Rent</td><td>$${r[0]}</td></tr><tr><td>Whole color, no houses</td><td>$${r[0] * 2}</td></tr>
        ${[1, 2, 3, 4].map(h => `<tr><td>With ${h} house${h > 1 ? 's' : ''}</td><td>$${r[h]}</td></tr>`).join('')}<tr><td>With a hotel</td><td>$${r[5]}</td></tr>
        <tr><td>Each house costs</td><td>$${GROUPS[s.grp].house}</td></tr><tr><td>Mortgage value</td><td>$${s.price / 2}</td></tr></table>${own}`;
    }
    if (s.t === 'rail') return `<div class="band" style="background:#333;color:#fff">๐Ÿš‹ ${esc(s.name)}</div><table><tr><td>Price</td><td>$200</td></tr>
      <tr><td>Rent with 1 line</td><td>$25</td></tr><tr><td>2 lines</td><td>$50</td></tr><tr><td>3 lines</td><td>$100</td></tr><tr><td>All 4</td><td>$200</td></tr></table>${own}`;
    if (s.t === 'util') return `<div class="band" style="background:#ccc">${s.name.startsWith('Power') ? '๐Ÿ’ก' : '๐Ÿšฐ'} ${esc(s.name)}</div><table><tr><td>Price</td><td>$150</td></tr>
      <tr><td>Rent with 1</td><td>4 ร— the dice</td></tr><tr><td>Rent with both</td><td>10 ร— the dice</td></tr></table>${own}`;
    const what = { go: 'Collect $200 every time you pass.', jail: 'Just visiting, unless you were sent here.', free: 'Grab a snack and rest. Nothing happens here.',
      gojail: 'Go straight to Jail. Do not pass START.', tax: `Pay $${s.amt} to the bank.`, card: 'Take a card and do what it says.' }[s.t];
    return `<div class="band" style="background:#ddd">${esc(s.name)}</div><p>${what}</p>`;
  }
  function showDeed(i, buttons, before = '') {
    return new Promise(res => {
      showModal(before + deedHtml(i) +`<div class="btns">${buttons.map((b, k) => `<button data-k="${k}" class="${b.cls || ''}">${b.label}</button>`).join('')}</div>`);
      $('card').querySelectorAll('button[data-k]').forEach(btn => btn.onclick = () => { hideModal(); res(buttons[+btn.dataset.k].v); });
    });
  }
  async function showInfo(p, html) {
    if (p.ai) { showModal(html); await sleep(1500); hideModal(); return; }
    await new Promise(res => { showModal(html + '<div class="btns"><button id="ok">OK</button></div>'); $('ok').onclick = () => { hideModal(); res(); }; });
  }
  // the buttons under the board for the current human player
  function choose(buttons, title) {
    return new Promise(res => {
      const a = $('actions');
      a.innerHTML = (title ? `<div style="width:100%;text-align:center">${esc(title)}</div>` : '') +
        buttons.map((b, k) => `<button data-k="${k}" class="${b.cls || ''}" ${b.dis ? 'disabled' : ''}>${b.label}</button>`).join('');
      a.querySelectorAll('button').forEach(btn => btn.onclick = () => { audio(); a.innerHTML = ''; res(buttons[+btn.dataset.k].v); });
    });
  }

  // ---- drawing the board ----
  let S = 600, cell = 50;
  function layout() {
    const land = innerWidth > innerHeight;
    const side = land ? Math.min(innerWidth * .4, 440) + 30 : 0, below = land ? 0 : Math.max(170, innerHeight * .26);
    S = Math.floor(Math.min(innerWidth - side - 12, innerHeight - below - 12));
    const d = devicePixelRatio || 1;
    cv.style.width = cv.style.height = S + 'px'; cv.width = cv.height = S * d;
    g.setTransform(d, 0, 0, d, 0, 0); cell = S / 11;
    if (!land) $('side').style.width = Math.min(innerWidth * .96, 640) + 'px'; else $('side').style.width = '';
    draw();
  }
  addEventListener('resize', layout);
  function cellPos(i) {   // grid column/row of a space
    if (i <= 10) return [10 - i, 10];
    if (i <= 20) return [0, 10 - (i - 10)];
    if (i <= 30) return [i - 20, 0];
    return [10, i - 30];
  }
  function wrapText(s, x, y, maxW, size, color = '#000') {
    g.font = `bold ${size}px Tahoma,Verdana,Arial,sans-serif`; g.fillStyle = color; g.textAlign = 'center'; g.textBaseline = 'middle';
    const words = s.split(' '), lines = [];
    let line = '';
    for (const w of words) { const t = line ? line + ' ' + w : w; if (g.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t; }
    lines.push(line);
    lines.forEach((l, k) => g.fillText(l, x, y + (k - (lines.length - 1) / 2) * size * 1.1));
  }
  function draw() {
    g.fillStyle = '#cde6d0'; g.fillRect(0, 0, S, S);
    for (let i = 0; i < 40; i++) {
      const [cx, cy] = cellPos(i), x = cx * cell, y = cy * cell, s = BOARD[i];
      g.fillStyle = '#f4f1e4'; g.fillRect(x, y, cell, cell);
      g.strokeStyle = '#333'; g.lineWidth = 1; g.strokeRect(x + .5, y + .5, cell - 1, cell - 1);
      const band = cell * .24, fs = Math.max(6, cell * .15);
      // which side faces the middle of the board
      const side = i < 10 ? 'top' : i < 20 ? 'right' : i < 30 ? 'bottom' : 'left';
      let tx = x + cell / 2, ty = y + cell / 2;
      if (s.t === 'prop') {
        g.fillStyle = GROUPS[s.grp].c;
        if (side === 'top') { g.fillRect(x, y, cell, band); ty += band / 2; }
        if (side === 'bottom') { g.fillRect(x, y + cell - band, cell, band); ty -= band / 2; }
        if (side === 'left') { g.fillRect(x, y, band, cell); tx += band / 2; }
        if (side === 'right') { g.fillRect(x + cell - band, y, band, cell); tx -= band / 2; }
        g.strokeRect(x + .5, y + .5, cell - 1, cell - 1);
      }
      if (G && G.own[i] >= 0) {
        // owner's color dot in the corner, faded if mortgaged
        g.globalAlpha = G.mort[i] ? .35 : 1; g.fillStyle = G.players[G.own[i]].col;
        g.beginPath(); g.arc(x + cell * .84, y + cell * .84, cell * .1, 0, 7); g.fill(); g.globalAlpha = 1;
      }
      const icon = { go: 'โžœ', jail: '๐Ÿ”’', free: '๐ŸŒฎ', gojail: '๐Ÿ‘ฎ', tax: '๐Ÿ’ธ', rail: '๐Ÿš‹', util: s.name.startsWith('Power') ? '๐Ÿ’ก' : '๐Ÿšฐ', card: s.deck === 'sp' ? 'โ“' : '๐Ÿ ' }[s.t];
      const tw = cell * .86 - (s.t === 'prop' && (side === 'left' || side === 'right') ? band : 0);
      if (icon) { g.font = `${cell * .3}px sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#000'; g.fillText(icon, tx, ty - cell * .14); wrapText(s.name, tx, ty + cell * .22, tw, fs * .9); }
      else wrapText(s.name, tx, ty - cell * .08, tw, fs);
      if (s.price && (!G || G.own[i] < 0)) wrapText('$' + s.price, tx, ty + cell * .3, tw, fs * .85, '#333');
      if (G && G.houses[i]) {
        const h = G.houses[i];
        for (let k = 0; k < (h === 5 ? 1 : h); k++) {
          const hw = cell * (h === 5 ? .34 : .17), hx = x + cell / 2 + (h === 5 ? -hw / 2 : (k - h / 2) * cell * .2), hy = side === 'top' ? y + band * .2 : side === 'bottom' ? y + cell - band * .8 : y + cell * .3 + k * cell * .18;
          g.fillStyle = h === 5 ? '#d00' : '#0a0'; g.strokeStyle = '#000';
          if (side === 'left' || side === 'right') { const bx = side === 'left' ? x + band * .2 : x + cell - band * .8; g.fillRect(bx, h === 5 ? y + cell * .33 : y + cell * .12 + k * cell * .2, band * .6, h === 5 ? cell * .34 : cell * .16); }
          else g.fillRect(hx, hy, hw, band * .6);
        }
      }
    }
    // middle of the board: title, dice and what just happened
    g.fillStyle = '#0b3d24'; g.font = `bold ${cell * .62}px Tahoma,Verdana,Arial,sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle';
    g.save(); g.translate(S / 2, cell * 2.6); g.fillStyle = '#c00'; g.fillText('PORTLAND', 0, 0); g.fillStyle = '#0b3d24'; g.fillText('TYCOON', 0, cell * .7); g.restore();
    if (!G) return;
    const dz = cell * .9;
    G.dice.forEach((d, k) => {
      const x = S / 2 - dz * 1.15 + k * dz * 1.3, y = cell * 3.9;
      g.fillStyle = '#fff'; g.strokeStyle = '#000'; g.lineWidth = 2; g.beginPath(); g.roundRect ? g.roundRect(x, y, dz, dz, dz * .15) : g.rect(x, y, dz, dz); g.fill(); g.stroke();
      const pips = { 1: [[.5, .5]], 2: [[.25, .25], [.75, .75]], 3: [[.25, .25], [.5, .5], [.75, .75]], 4: [[.25, .25], [.75, .25], [.25, .75], [.75, .75]],
        5: [[.25, .25], [.75, .25], [.5, .5], [.25, .75], [.75, .75]], 6: [[.25, .22], [.75, .22], [.25, .5], [.75, .5], [.25, .78], [.75, .78]] }[d];
      g.fillStyle = '#000'; for (const [px, py] of pips) { g.beginPath(); g.arc(x + px * dz, y + py * dz, dz * .09, 0, 7); g.fill(); }
    });
    const p = cur();
    g.fillStyle = p.col; g.font = `bold ${cell * .32}px Tahoma,Verdana,Arial,sans-serif`;
    g.fillText(`${p.tok} ${p.name}'s turn` + (G.limit ? `  ยท  round ${Math.min(G.round, G.limit)}/${G.limit}` : ''), S / 2, cell * 5.3);
    G.log.slice(-5).forEach((l, k, arr) => {
      g.globalAlpha = .45 + .55 * (k + 1) / arr.length;
      wrapText(l, S / 2, cell * 6.1 + k * cell * .62, cell * 8.4, Math.max(9, cell * .24), '#000');
    });
    g.globalAlpha = 1;
    // tokens, spread out when several share a space
    const at = {};
    G.players.forEach((pl, k) => { if (!pl.out) (at[pl.pos] = at[pl.pos] || []).push(k); });
    for (const pos in at) {
      const [cx, cy] = cellPos(+pos), list = at[pos];
      list.forEach((k, n) => {
        const pl = G.players[k], ox = (n % 2 - .5) * cell * (list.length > 1 ? .42 : 0), oy = (Math.floor(n / 2) - (list.length > 2 ? .5 : 0)) * cell * .42;
        let x = cx * cell + cell / 2 + ox, y = cy * cell + cell / 2 + oy;
        if (+pos === 10 && pl.jail) { x += cell * .15; y -= cell * .15; }
        g.fillStyle = pl.col; g.strokeStyle = k === G.turn ? '#ff0' : '#fff'; g.lineWidth = k === G.turn ? 3 : 2;
        g.beginPath(); g.arc(x, y, cell * .22, 0, 7); g.fill(); g.stroke();
        g.font = `${cell * .26}px sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(pl.tok, x, y + 1);
      });
    }
  }
  function render() {
    draw();
    if (!G) return;
    $('players').innerHTML = G.players.map((p, k) => {
      const places = G.own.filter(o => o === k).length;
      return `<div class="pl ${k === G.turn ? 'cur' : ''} ${p.out ? 'out' : ''}"><span class="tok" style="background:${p.col}">${p.tok}</span>
        <span>${esc(p.name)}${p.jail ? ' ๐Ÿ”’' : ''}${p.cards ? ' ๐ŸŽŸ' : ''}<br><small>${places} place${places === 1 ? '' : 's'}</small></span><span class="cash">$${p.cash.toLocaleString('en-US')}</span></div>`;
    }).join('');
  }
  // tap a space to read its card
  cv.addEventListener('click', e => {
    if (!$('modal').classList.contains('hidden')) return;
    const r = cv.getBoundingClientRect(), cx = Math.floor((e.clientX - r.left) / cell), cy = Math.floor((e.clientY - r.top) / cell);
    const i = BOARD.findIndex((_, k) => { const [x, y] = cellPos(k); return x === cx && y === cy; });
    if (i < 0) return;
    showModal(deedHtml(i) + '<div class="btns"><button id="ok">OK</button></div>'); $('ok').onclick = hideModal;
  });

  // ---- setup screen ----
  function seatsUI() {
    $('seats').innerHTML = cfg.seats.map((s, k) => `<div class="seat"><span class="tok">${TOKENS[k]}</span>
      ${k < 2 ? '' : `<button data-k="${k}" data-a="off" class="${s.off ? '' : 'on'}">${s.off ? 'add' : 'in'}</button>`}
      ${s.off ? '<span style="opacity:.5">empty seat</span>' : `<button data-k="${k}" data-a="h" class="${s.ai ? '' : 'on'}">Person</button><button data-k="${k}" data-a="c" class="${s.ai ? 'on' : ''}">Computer</button>
      <input type="text" data-k="${k}" maxlength="12" value="${esc(s.name)}" placeholder="Name">`}</div>`).join('');
    $('seats').querySelectorAll('button').forEach(b => b.onclick = () => {
      const s = cfg.seats[+b.dataset.k];
      if (b.dataset.a === 'off') { s.off = !s.off; if (!s.off && !s.name) s.name = s.ai ? 'Computer' : 'Player ' + (+b.dataset.k + 1); }
      if (b.dataset.a === 'h') { s.ai = false; if (s.name === 'Computer') s.name = 'Player ' + (+b.dataset.k + 1); }
      if (b.dataset.a === 'c') { s.ai = true; if (/^Player \d$/.test(s.name)) s.name = 'Computer'; }
      seatsUI();
    });
    $('seats').querySelectorAll('input').forEach(i => i.oninput = () => { cfg.seats[+i.dataset.k].name = i.value; });
  }
  function openSetup() {
    G = null; $('players').innerHTML = ''; $('actions').innerHTML = '';
    seatsUI(); $('len').value = cfg.len; $('snd').checked = cfg.sound;
    $('cont').style.display = loadGame() ? '' : 'none';
    $('setup').classList.remove('hidden'); draw();
  }
  $('len').onchange = e => { cfg.len = e.target.value; };
  $('snd').onchange = e => { cfg.sound = e.target.checked; };
  $('go').onclick = () => { saveCfg(); audio(); $('setup').classList.add('hidden'); newGame(); render(); playTurn(); };
  $('cont').onclick = () => { const s = loadGame(); if (!s) return; audio(); G = s; $('setup').classList.add('hidden'); log(`Welcome back! It's ${cur().name}'s turn.`); render(); playTurn(); };

  layout();
  openSetup();
})();
</script>
</body>
</html>
EOF

3. Copy the icons

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

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

4. Push it into CT 125

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

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

Updating it later

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

Notes

  • Computer trading: there's no trading screen, and in a test full game nobody ever got a whole color. So a computer player that is one place short of a whole color offers to buy it for 1.6 times its price. Other computers accept, and a person is asked yes or no, at most once every 5 rounds for the same place.
  • Name: "Monopoly" and its board belong to Hasbro, so the game has its own name, streets and art. The rules of a game aren't owned by anyone, so it can play the same way.
  • Tested by the computer: 4 computer players played a whole quick game and a full game (including going broke) at high speed with no errors.