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>← →</kbd> move · <kbd>↑</kbd> turn · <kbd>↓</kbd> down · <kbd>Space</kbd> drop<br>
<kbd>P</kbd> pause · <kbd>M</kbd> sound · 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">▲</button><span></span>
<button data-a="left" aria-label="Left">◀</button><button class="mid" data-a="pause" aria-label="Pause or continue">II</button><button data-a="right" aria-label="Right">▶</button>
<span></span><button data-a="down" aria-label="Down">▼</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.icois 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.