Tetris: falling blocks for the games site (rebuild guide) 03OCT2026

A 10x20 falling-blocks game with Korobeiniki music and a touch pad

Posted by on 3rd Oct 2026

A from-scratch falling-blocks game for the kids' games site, added the day after Nibbles. Play it at https://games.503mail.com/tetris/ 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

It has a 10×20 board and the seven pieces, drawn from a shuffled "bag" of seven so long droughts can't happen. There's a ghost piece showing where the piece will land, a next-piece box, soft and hard drop, simple wall kicks when turning, and levels every 10 lines that speed up the fall. Lines score the usual 100/300/500/800 × level. The high score is saved, and there's square-wave sound with optional Korobeiniki background music (the Russian folk song, which is public domain).

Controls

Keyboard: ◀ ▶ move, ▲ turn, ▼ soft drop, Space hard drop, P pause. Touch: a button pad appears under the board: ▲ turns, ◀ ▶ ▼ repeat while held, DROP drops, and the middle button pauses.

Rebuild, step by step

Tetris 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 Tetris tile.

1. Make the game's folder

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

mkdir -p /root/nibbles/tetris

2. Create the game file

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

cat > /root/nibbles/tetris/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Tetris</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="Tetris">
<meta property="og:image" content="https://games.503mail.com/tetris/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#000;color:#aaa;font-family:"Lucida Console","Courier New",monospace}
  body{display:flex;align-items:center;justify-content:center;overflow:hidden}
  :root{--ar:.75;--padh:38vh}
  #wrap{position:relative;width:min(100vw,calc(100vh * var(--ar)));aspect-ratio:var(--ar)}
  canvas{display:block;width:100%;height:100%;touch-action:none}
  #setup{position:absolute;inset:0;background:#0000AA;color:#fff;display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:2.4vmin;font-size:clamp(12px,2.6vmin,22px);text-align:center;padding:16px;box-sizing:border-box}
  #setup.hidden{display:none}
  #setup h1{margin:0;color:#FFFF55;letter-spacing:.6em;font-size:1.8em}
  #setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
  #setup button{font:inherit;cursor:pointer;border:0}
  #setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
  #setup input[type=range]{width:min(40vw,240px)}
  #setup .help{color:#AAAAAA;font-size:.85em;line-height:1.6}
  #setup kbd{color:#FFFF55}
  #setup a{color:#55FFFF}
  /* on-screen buttons, touch screens only */
  body.touch{flex-direction:column}
  body.touch #wrap{width:min(100vw,calc((100vh - var(--padh)) * var(--ar)))}
  #pad{display:none;--b:min(22vw,calc(var(--padh) / 3.4));gap:calc(var(--b) * .08);margin-top:1.5vh;
    grid-template-columns:repeat(3,var(--b));grid-template-rows:repeat(3,var(--b))}
  @media (orientation:landscape){
    body.touch{flex-direction:row;gap:4vw}
    body.touch #wrap{width:min(55vw,calc(96vh * var(--ar)))}
    #pad{--b:min(13vw,27vh);margin-top:0}
  }
  body.touch #pad{display:grid}
  #pad button{background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:18%;
    font:bold calc(var(--b) * .45)/1 "Lucida Console","Courier New",monospace;padding:0;
    touch-action:none;user-select:none;-webkit-user-select:none}
  #pad button:active,#pad button.hit{background:#5555FF}
  #pad .mid{color:#FFFFFF;border-color:#AAAAAA;font-size:calc(var(--b) * .3)}
  #pad .word{color:#FFFFFF;border-color:#FF5555;font-size:calc(var(--b) * .22)}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="c" width="900" height="1200"></canvas>
  <div id="setup">
    <h1>TETRIS</h1>
    <div class="row">Start level: <input type="range" id="lvl" min="1" max="10" value="1"> <span id="lvlv">1</span></div>
    <div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
      <label><input type="checkbox" id="mus" checked> Music</label></div>
    <button id="go">START</button>
    <div id="hi"></div>
    <div class="help">
      <kbd>&larr; &rarr;</kbd> move &nbsp;·&nbsp; <kbd>&uarr;</kbd> turn &nbsp;·&nbsp; <kbd>&darr;</kbd> down &nbsp;·&nbsp; <kbd>Space</kbd> drop<br>
      <kbd>P</kbd> pause &nbsp;·&nbsp; <kbd>M</kbd> sound &nbsp;·&nbsp; or use the buttons on a touch screen<br>
      Fill a whole row to clear it. Four rows at once scores the most.
    </div>
    <a href="/">Other games</a>
  </div>
</div>
<div id="pad" aria-label="Game buttons">
  <span></span><button data-a="rot" aria-label="Turn">&#9650;</button><span></span>
  <button data-a="left" aria-label="Left">&#9664;</button><button class="mid" data-a="pause" aria-label="Pause or continue">II</button><button data-a="right" aria-label="Right">&#9654;</button>
  <span></span><button data-a="down" aria-label="Down">&#9660;</button><button class="word" data-a="drop" aria-label="Drop">DROP</button>
</div>
<script>
(() => {
  const C = { bg:'#0000AA', frame:'#AAAAAA', white:'#FFFFFF', yellow:'#FFFF55', gray:'#AAAAAA', red:'#AA0000', black:'#000000' };
  const PIECES = {
    I:{ c:'#55FFFF', m:[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]] },
    O:{ c:'#FFFF55', m:[[1,1],[1,1]] },
    T:{ c:'#FF55FF', m:[[0,1,0],[1,1,1],[0,0,0]] },
    S:{ c:'#55FF55', m:[[0,1,1],[1,1,0],[0,0,0]] },
    Z:{ c:'#FF5555', m:[[1,1,0],[0,1,1],[0,0,0]] },
    J:{ c:'#5555FF', m:[[1,0,0],[1,1,1],[0,0,0]] },
    L:{ c:'#FFAA00', m:[[0,0,1],[1,1,1],[0,0,0]] },
  };
  const W = 10, H = 22, HIDDEN = 2;                 // 10x20 visible, 2 rows above for spawning
  const CS = 18, BX = 10, BY = 30;                  // cell size and board origin in the 300x400 logical canvas

  const cv = document.getElementById('c'), g = cv.getContext('2d');
  g.scale(3, 3);
  const setupEl = document.getElementById('setup');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;

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

  // ---- sound: square-wave beeps from QBasic-style PLAY strings, plus a looping music channel ----
  let actx = null;
  function audio() {
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
    if (actx.state === 'suspended') actx.resume();
    return actx;
  }
  function tone(a, f, t, dur, dest, vol) {
    const o = a.createOscillator(), v = a.createGain();
    o.type = 'square'; o.frequency.value = f;
    v.gain.setValueAtTime(vol, t); v.gain.setValueAtTime(vol, t + dur * 0.9); v.gain.linearRampToValueAtTime(0, t + dur);
    o.connect(v).connect(dest); o.start(t); o.stop(t + dur);
  }
  const SEMI = { C:0, D:2, E:4, F:5, G:7, A:9, B:11 };
  // Plays an MML string starting at t0 (default: now) and returns the time it ends.
  function play(mml, dest, vol = 0.05, t0) {
    if (!cfg.sound) return 0;
    const a = audio(); if (!a) return 0;
    const s = mml.toUpperCase().replace(/\s/g, '');
    let t = t0 || a.currentTime + 0.02, oct = 4, len = 4, tempo = 120, i = 0;
    const num = () => { let n = ''; while (i < s.length && /\d/.test(s[i])) n += s[i++]; return n ? +n : null; };
    while (i < s.length) {
      const ch = s[i++];
      if (ch === 'O') oct = num() ?? oct;
      else if (ch === 'L') len = num() ?? len;
      else if (ch === 'T') tempo = num() ?? tempo;
      else if (ch === '>') oct++;
      else if (ch === '<') oct--;
      else if (ch in SEMI || ch === 'P') {
        let semi = SEMI[ch] || 0;
        if (s[i] === '#' || s[i] === '+') { semi++; i++; } else if (s[i] === '-') { semi--; i++; }
        let dur = 240 / tempo / (num() ?? len);
        while (s[i] === '.') { dur *= 1.5; i++; }
        if (ch !== 'P') tone(a, 440 * Math.pow(2, ((oct + 1) * 12 + semi - 69) / 12), t, dur * 0.9, dest || a.destination, vol);
        t += dur;
      }
    }
    return t;
  }
  const SFX = {
    start:  'T160 O4 L16 CEG',
    rotate: 'T240 O5 L64 E',
    lock:   'T240 O3 L32 C',
    line:   'T200 O5 L32 CEG',
    four:   'T200 O5 L32 CEG >C <G >C',
    level:  'T180 O4 L16 CEG >C',
    over:   'T120 O4 L8 GEC <L4 G',
  };
  // Korobeiniki, the Russian folk song the original game made famous (public domain).
  const THEME = 'T150 ' +
    'O5E4 O4B8 O5C8 O5D4 O5C8 O4B8 O4A4 O4A8 O5C8 O5E4 O5D8 O5C8 O4B4. O5C8 O5D4 O5E4 O5C4 O4A4 O4A2 ' +
    'O5D4. O5F8 O5A4 O5G8 O5F8 O5E4. O5C8 O5E4 O5D8 O5C8 O4B4 O4B8 O5C8 O5D4 O5E4 O5C4 O4A4 O4A4 P4';
  let musicGain = null, musicTimer = null;
  function startMusic() {
    stopMusic();
    if (!cfg.sound || !cfg.music) return;
    const a = audio(); if (!a) return;
    musicGain = a.createGain(); musicGain.connect(a.destination);
    const gain = musicGain;
    const loop = t0 => {
      if (gain !== musicGain) return;
      const end = play(THEME, gain, 0.025, t0);
      if (end) musicTimer = setTimeout(() => loop(end), Math.max(0, (end - a.currentTime - 0.5) * 1000));
    };
    loop();
  }
  function stopMusic() {
    clearTimeout(musicTimer);
    if (musicGain) { musicGain.disconnect(); musicGain = null; }
  }

  // ---- game state ----
  let mode = 'setup', board, cur = null, next = null, bag = [], score = 0, lines = 0, level = 1;
  let timer = null, flash = null, newHigh = false;

  const newBoard = () => Array.from({ length:H }, () => Array(W).fill(null));
  function takeBag() {
    if (!bag.length) {                              // 7-bag: every piece once per 7, in random order
      bag = Object.keys(PIECES);
      for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; }
    }
    return bag.pop();
  }

  function collides(m, x, y) {
    for (let r = 0; r < m.length; r++) for (let c = 0; c < m.length; c++) {
      if (!m[r][c]) continue;
      const bx = x + c, by = y + r;
      if (bx < 0 || bx >= W || by >= H) return true;
      if (by >= 0 && board[by][bx]) return true;
    }
    return false;
  }
  const rotated = (m, dir) => m.map((row, r) => row.map((_, c) => dir > 0 ? m[m.length - 1 - c][r] : m[c][m.length - 1 - r]));

  function spawn() {
    const k = next || takeBag();
    next = takeBag();
    const m = PIECES[k].m.map(r => r.slice());
    cur = { k, m, x: Math.floor((W - m.length) / 2), y: HIDDEN - 1 };
    if (collides(cur.m, cur.x, cur.y)) gameOver();
  }

  const delay = () => Math.max(50, Math.round(800 * Math.pow(0.8, level - 1)));
  function schedule() {
    clearTimeout(timer);
    if (mode === 'play') timer = setTimeout(gravity, delay());
  }
  function gravity() {
    if (mode !== 'play' || !cur) return;
    if (!collides(cur.m, cur.x, cur.y + 1)) { cur.y++; draw(); schedule(); }
    else lock();
  }

  function move(dx) {
    if (!collides(cur.m, cur.x + dx, cur.y)) { cur.x += dx; draw(); }
  }
  function turn(dir) {
    if (cur.k === 'O') return;
    const m = rotated(cur.m, dir);
    for (const [dx, dy] of [[0,0], [-1,0], [1,0], [-2,0], [2,0], [0,-1]]) {   // simple wall kicks
      if (!collides(m, cur.x + dx, cur.y + dy)) {
        cur.m = m; cur.x += dx; cur.y += dy; play(SFX.rotate); draw(); return;
      }
    }
  }
  function softDrop() {
    if (!collides(cur.m, cur.x, cur.y + 1)) { cur.y++; score += 1; draw(); schedule(); }
    else lock();
  }
  function hardDrop() {
    let d = 0;
    while (!collides(cur.m, cur.x, cur.y + 1)) { cur.y++; d++; }
    score += 2 * d;
    lock();
  }

  function lock() {
    clearTimeout(timer);
    let above = false;
    cur.m.forEach((row, r) => row.forEach((v, c) => {
      if (!v) return;
      board[cur.y + r][cur.x + c] = cur.k;
      if (cur.y + r < HIDDEN) above = true;
    }));
    cur = null;
    if (above) return gameOver();
    play(SFX.lock);
    const full = [];
    for (let r = 0; r < H; r++) if (board[r].every(Boolean)) full.push(r);
    if (!full.length) { spawn(); draw(); schedule(); return; }

    mode = 'clear'; flash = full; draw();
    setTimeout(() => {
      board = board.filter((_, r) => !full.includes(r));
      while (board.length < H) board.unshift(Array(W).fill(null));
      const n = full.length;
      lines += n;
      score += [0, 100, 300, 500, 800][n] * level;
      play(n === 4 ? SFX.four : SFX.line);
      const nl = cfg.start + Math.floor(lines / 10);
      if (nl > level) { level = nl; setTimeout(() => play(SFX.level), 250); }
      flash = null; mode = 'play';
      spawn(); draw(); schedule();
    }, 180);
  }

  function gameOver() {
    mode = 'over'; cur = null; clearTimeout(timer); stopMusic();
    newHigh = score > cfg.high;
    if (newHigh) { cfg.high = score; saveCfg(); }
    play(SFX.over); draw();
  }

  function newGame() {
    board = newBoard(); bag = []; next = null;
    score = 0; lines = 0; level = cfg.start; newHigh = false;
    mode = 'play'; spawn(); draw(); schedule(); startMusic();
  }

  function pause() { if (mode !== 'play') return; mode = 'paused'; clearTimeout(timer); stopMusic(); draw(); }
  function resume() { mode = 'play'; draw(); schedule(); startMusic(); }

  function openSetup() {
    mode = 'setup'; clearTimeout(timer); stopMusic();
    document.getElementById('lvl').value = cfg.start;
    document.getElementById('lvlv').textContent = cfg.start;
    document.getElementById('snd').checked = cfg.sound;
    document.getElementById('mus').checked = cfg.music;
    document.getElementById('hi').textContent = cfg.high ? `High score: ${cfg.high.toLocaleString('en-US')}` : '';
    setupEl.classList.remove('hidden');
  }

  // ---- drawing ----
  function text(s, x, y, color, size = 10, align = 'left') {
    g.fillStyle = color; g.font = `bold ${size}px "Lucida Console","Courier New",monospace`;
    g.textAlign = align; g.textBaseline = 'middle'; g.fillText(s, x, y);
  }
  function block(x, y, color, s = CS) {
    g.fillStyle = color; g.fillRect(x + 1, y + 1, s - 2, s - 2);
    g.fillStyle = 'rgba(255,255,255,.45)'; g.fillRect(x + 1, y + 1, s - 2, 2); g.fillRect(x + 1, y + 1, 2, s - 2);
    g.fillStyle = 'rgba(0,0,0,.35)'; g.fillRect(x + 1, y + s - 3, s - 2, 2); g.fillRect(x + s - 3, y + 1, 2, s - 2);
  }
  const cellXY = (r, c) => [BX + c * CS, BY + (r - HIDDEN) * CS];

  function dialog(rows) {
    const w = 170, h = rows.length * 14 + 22, x = BX + (W * CS - w) / 2, y = BY + ((H - HIDDEN) * CS - h) / 2;
    g.fillStyle = C.red; g.fillRect(x, y, w, h);
    g.strokeStyle = C.white; g.lineWidth = 1; g.strokeRect(x + 4, y + 4, w - 8, h - 8);
    rows.forEach((s, i) => text(s, x + w / 2, y + 18 + i * 14, C.white, 10, 'center'));
  }

  function draw() {
    g.fillStyle = C.black; g.fillRect(0, 0, 300, 400);
    text('T E T R I S', BX + W * CS / 2, 15, C.yellow, 14, 'center');
    g.fillStyle = C.frame; g.fillRect(BX - 3, BY - 3, W * CS + 6, (H - HIDDEN) * CS + 6);
    g.fillStyle = C.bg; g.fillRect(BX, BY, W * CS, (H - HIDDEN) * CS);
    if (!board) return;

    for (let r = HIDDEN; r < H; r++) for (let c = 0; c < W; c++) {
      if (flash && flash.includes(r)) block(...cellXY(r, c), C.white);
      else if (board[r][c]) block(...cellXY(r, c), PIECES[board[r][c]].c);
    }

    if (cur) {
      let gy = cur.y;
      while (!collides(cur.m, cur.x, gy + 1)) gy++;
      const col = PIECES[cur.k].c;
      cur.m.forEach((row, r) => row.forEach((v, c) => {
        if (!v) return;
        if (gy + r >= HIDDEN) { g.globalAlpha = 0.3; block(...cellXY(gy + r, cur.x + c), col); g.globalAlpha = 1; }
      }));
      cur.m.forEach((row, r) => row.forEach((v, c) => { if (v && cur.y + r >= HIDDEN) block(...cellXY(cur.y + r, cur.x + c), col); }));
    }

    // side panel
    const x0 = BX + W * CS + 12;
    text('NEXT', x0, 40, C.gray);
    g.strokeStyle = C.frame; g.lineWidth = 1; g.strokeRect(x0, 48, 88, 60);
    if (next) {
      const m = PIECES[next].m, cells = [];
      m.forEach((row, r) => row.forEach((v, c) => { if (v) cells.push([r, c]); }));
      const rs = cells.map(p => p[0]), cs = cells.map(p => p[1]);
      const r0 = Math.min(...rs), c0 = Math.min(...cs), s = 14;
      const w = (Math.max(...cs) - c0 + 1) * s, h = (Math.max(...rs) - r0 + 1) * s;
      cells.forEach(([r, c]) => block(x0 + 44 - w / 2 + (c - c0) * s, 78 - h / 2 + (r - r0) * s, PIECES[next].c, s));
    }
    [['SCORE', score], ['LINES', lines], ['LEVEL', level], ['HIGH', Math.max(cfg.high, score)]].forEach(([label, v], i) => {
      text(label, x0, 130 + i * 45, C.gray);
      text(v.toLocaleString('en-US'), x0, 146 + i * 45, C.white, 13);
    });
    if (!isTouch) {
      ['← →  move', '↑  turn', '↓  down', 'SPACE drop', 'P  pause', 'M  sound']
        .forEach((s, i) => text(s, x0, 318 + i * 11, C.gray, 8));
    }

    if (mode === 'paused') dialog(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
    if (mode === 'over') {
      dialog(['GAME OVER', '', `Score ${score.toLocaleString('en-US')}`].concat(newHigh ? ['NEW HIGH SCORE!'] : [],
        ['', isTouch ? 'Tap to continue' : 'Push Space']));
    }
  }

  // ---- input ----
  function act(a) {
    if (mode !== 'play' || !cur) return;
    if (a === 'left') move(-1);
    else if (a === 'right') move(1);
    else if (a === 'down') softDrop();
    else if (a === 'rot') turn(1);
    else if (a === 'ccw') turn(-1);
    else if (a === 'drop') hardDrop();
    else if (a === 'pause') pause();
  }
  function proceed() {
    if (mode === 'paused') resume();
    else if (mode === 'over') openSetup();
  }

  const KEYS = { ArrowLeft:'left', ArrowRight:'right', ArrowDown:'down', ArrowUp:'rot', x:'rot', z:'ccw', ' ':'drop', p:'pause' };
  addEventListener('keydown', e => {
    if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
    const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    if (KEYS[k] && k !== 'p') e.preventDefault();
    if (k === 'm') {
      cfg.sound = !cfg.sound; saveCfg();
      if (!cfg.sound) stopMusic(); else if (mode === 'play') startMusic();
      return;
    }
    if (mode === 'play') return act(KEYS[k]);
    if (k === ' ' || k === 'Enter' || k === 'p') proceed();
  });

  cv.addEventListener('pointerdown', e => {
    e.preventDefault();
    if (mode === 'play') turn(1); else proceed();
  });

  // On-screen buttons: left, right and down repeat while held.
  if (isTouch) document.body.classList.add('touch');
  document.querySelectorAll('#pad button').forEach(b => {
    const a = b.dataset.a, repeats = a === 'left' || a === 'right' || a === 'down';
    let rep = null;
    const stop = () => { clearTimeout(rep); rep = null; };
    b.addEventListener('pointerdown', e => {
      e.preventDefault();
      b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 120);
      if (mode !== 'play') { if (mode === 'paused' || mode === 'over') proceed(); return; }
      act(a);
      if (repeats) { stop(); rep = setTimeout(function again() { act(a); rep = setTimeout(again, 55); }, 180); }
    });
    ['pointerup', 'pointerleave', 'pointercancel'].forEach(t => b.addEventListener(t, stop));
  });

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

  // ---- setup screen ----
  document.getElementById('lvl').addEventListener('input', e => {
    cfg.start = +e.target.value; document.getElementById('lvlv').textContent = cfg.start;
  });
  document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; if (cfg.sound) play(SFX.rotate); });
  document.getElementById('mus').addEventListener('change', e => { cfg.music = e.target.checked; });
  document.getElementById('go').addEventListener('click', startFromSetup);
  function startFromSetup() {
    saveCfg(); setupEl.classList.add('hidden'); play(SFX.start); newGame();
  }

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

3. Copy the icons

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

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

4. Push it into CT 125

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

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

Updating it later

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

Notes

  • Pauses itself if the tablet switches to another app, so a game isn't lost to a notification.
  • The bag randomizer is what modern Tetris uses: every 7 pieces contain one of each, so you never wait forever for a long bar.