The games site at https://games.503mail.com: a 256 MB Debian container serving a menu of 14 homemade browser games. This post covers the site itself and the first game, a from-scratch HTML5 remake of the 1990 QBasic Nibbles snake game. Each of the other games has its own post on the Games page. Everything runs in the browser, so there is none of the input lag you get from streamed online DOS emulators. This post is the complete rebuild guide; every block has a Copy button.
How it works
- The browser asks for
https://games.503mail.com. The wildcard DNS record for*.503mail.comalready points at home, so no DNS change is needed. (The site was first atnibbles.503mail.com; it was renamed on 03OCT2026 and the old address now redirects here, so old bookmarks still work.) - CT 108 (Caddy reverse proxy) terminates TLS (automatic Let's Encrypt certificate) and proxies to CT 125 on port 80.
- CT 125 (nibbles) runs Caddy as a plain static file server.
/is the game menu,/nibbles/,/tetris/,/starkiller/,/speedway/,/dinoisland/,/minesweeper/,/skypilot/,/tycoon/,/highwayheat/,/agent/,/skytower/,/pip/and/dash/are the games, each a single 16–100 KBindex.html. (/blockcraft/and the multiplayer address/mp/are sent to CT 127 (blockcraft) instead; see their posts.) - From then on everything happens in the browser: the game loop, drawing (an 80×50 grid on a
<canvas>, the same "half-block" grid the original drew in text mode), and keyboard/swipe input. The server isn't involved again, which is why there's no lag.
What's in the game: the original's 10 level layouts, Sammy (arrow keys, swipe, or the on-screen arrow pad that appears under the game on touch screens; its middle button pauses) and an optional second player Jake (W A S D), numbers 1–9 that make the snake grow by 4× their value, 5 lives, P to pause, skill 1–100, an optional speed-up per level, and a Size choice: Normal (the original 80×50 board) or Big (2x), a 40×26 board drawn at double size with the same 10 layouts scaled down, which is the default on touch screens. Settings are remembered in the browser's localStorage. No QBasic or DOS binaries are used.
The other games
Each game has its own post with its full file, what's in it, and how it was made. They're all listed on the Games page:
- Tetris: falling blocks with Korobeiniki music.
- Star Killer: a remake of the 1995 SK-111 space shooter.
- Speedway: an IndyCar-style racer.
- Dino Island: a Jurassic Park-style egg hunt.
- Minesweeper: the Windows 95 classic.
- Sky Pilot: a Flight Simulator 98-style flying game.
- Portland Tycoon: a property board game.
- Highway Heat: Hot Pursuit-style police chases.
- Agent 503: a GoldenEye-style spy shooter.
- Sky Tower: a SimTower-style skyscraper sim.
- BlockCraft: a Minecraft-style block world the kids play together.
- Super Pip: a Mario-style platformer, 40 levels, co-op for up to 4.
- Dash Rush: a Sonic-style speed platformer, 24 acts, races for up to 4.
This post covers the games site itself (the container, the menu, the shared scripts and icons) plus Nibbles. Build this first; every other game then needs only its own file.
Containers involved
| Container | Role | LAN IP |
|---|---|---|
| CT 125 (nibbles) | Debian 12 + Caddy serving the static game on :80 | x.x.0.108 (DHCP) |
| CT 108 (Caddy reverse proxy) | Edge TLS + games.503mail.com route |
x.x.0.122 |
| CT 202 (creator-signup) | Only used to run the Uptime Kuma script (it already has the Kuma API library and login) | x.x.0.198 |
| CT 201 (uptime-kuma) | Monitoring at https://status.503mail.com | — |
CT 125 sizing: 1 core, 256 MB RAM + 256 MB swap, 2 GB disk, unprivileged, starts on boot. It idles at a few MB of RAM; that's plenty.
Where to run each command
- Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
- Inside CT 125 (nibbles): Proxmox web UI → 125 (nibbles) → Console, or
pct enter 125from the host shell.
Rebuild, step by step
1. Make a working folder on the host
Proxmox host shell:
mkdir -p /root/nibbles/nibbles /root/nibbles/tetris /root/nibbles/starkiller /root/nibbles/speedway /root/nibbles/dinoisland /root/nibbles/minesweeper /root/nibbles/skypilot /root/nibbles/tycoon /root/nibbles/highwayheat /root/nibbles/agent && cd /root/nibbles
2. Create every file
Paste each block into the Proxmox host shell. Each one writes one file under /root/nibbles/.
index.html: the game menu at /. Its title stays "Nibbles" so the Uptime Kuma keyword check and existing bookmarks keep working.
cat > /root/nibbles/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#000000">
<title>Nibbles</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="Nibbles">
<meta property="og:image" content="https://games.503mail.com/icon-512.png">
<style>
html,body{margin:0;min-height:100%;background:#000;color:#fff;font-family:"Lucida Console","Courier New",monospace}
body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:16px;box-sizing:border-box}
h1{color:#FFFF55;letter-spacing:.5em;margin:0 0 6vmin;font-size:clamp(22px,7vmin,48px);text-align:center}
.games{display:flex;flex-wrap:wrap;gap:6vmin;justify-content:center}
a.game{display:flex;flex-direction:column;align-items:center;gap:2.5vmin;text-decoration:none;color:#fff;
background:#0000AA;border:4px solid #55FFFF;border-radius:6%;padding:4vmin;width:min(38vmin,260px);
font-size:clamp(18px,5vmin,34px);letter-spacing:.15em}
a.game:active,a.game:focus{background:#5555FF;outline:none}
a.game img{width:100%;height:auto;image-rendering:pixelated;border-radius:4%}
</style>
</head>
<body>
<h1>GAMES</h1>
<div class="games">
<a class="game" href="/nibbles/"><img src="/icon-192.png" alt="">NIBBLES</a>
<a class="game" href="/tetris/"><img src="/tetris/icon-192.png" alt="">TETRIS</a>
<a class="game" href="/starkiller/"><img src="/starkiller/icon-192.png" alt="">STAR KILLER</a>
<a class="game" href="/speedway/"><img src="/speedway/icon-192.png" alt="">SPEEDWAY</a>
<a class="game" href="/dinoisland/"><img src="/dinoisland/icon-192.png" alt="">DINO ISLAND</a>
<a class="game" href="/minesweeper/"><img src="/minesweeper/icon-192.png" alt="">MINESWEEPER</a>
<a class="game" href="/skypilot/"><img src="/skypilot/icon-192.png" alt="">SKY PILOT</a>
<a class="game" href="/tycoon/"><img src="/tycoon/icon-192.png" alt="">TYCOON</a>
<a class="game" href="/highwayheat/"><img src="/highwayheat/icon-192.png" alt="">HIGHWAY HEAT</a>
<a class="game" href="/agent/"><img src="/agent/icon-192.png" alt="">AGENT 503</a>
<a class="game" href="/skytower/"><img src="/skytower/icon-192.png" alt="">SKY TOWER</a>
<a class="game" href="/blockcraft/"><img src="/blockcraft/icon-192.png" alt="">BLOCKCRAFT</a>
<a class="game" href="/pip/"><img src="/pip/icon-192.png" alt="">SUPER PIP</a>
<a class="game" href="/dash/"><img src="/dash/icon-192.png" alt="">DASH RUSH</a>
</div>
</body>
</html>
EOF
nibbles/index.html: the whole Nibbles game (HTML, CSS and JavaScript in one file).
cat > /root/nibbles/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>Nibbles</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="Nibbles">
<meta property="og:image" content="https://games.503mail.com/icon-512.png">
<style>
html,body{margin:0;height:100%;background:#000;color:#aaa;font-family:"Lucida Console","Courier New",monospace}
body{display:flex;align-items:center;justify-content:center;overflow:hidden}
:root{--ar:1.6}
#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.2vmin;font-size:clamp(12px,2.6vmin,22px);text-align:center;padding:16px;box-sizing:border-box}
#setup.hidden{display:none}
#setup h1{margin:0;color:#FFFF55;letter-spacing:.6em;font-size:1.8em}
#setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
#setup button{font:inherit;background:#AAAAAA;color:#000;border:0;padding:.35em 1.1em;cursor:pointer}
#setup button.on{background:#FFFF55}
#setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
#setup input[type=range]{width:min(50vw,320px)}
#setup .help{color:#AAAAAA;font-size:.85em;line-height:1.6}
#setup kbd{color:#FFFF55}
/* on-screen arrow pad, touch screens only */
:root{--padh:40vh}
@media (orientation:landscape){:root{--padh:36vh}}
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))}
body.touch #pad{display:grid}
#pad button{background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:18%;
font-size:calc(var(--b) * .45);line-height:1;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)}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="1280" height="800"></canvas>
<div id="setup" class="hidden">
<h1>NIBBLES</h1>
<div class="row">Players:
<button data-p="1" id="p1">1 (Sammy)</button>
<button data-p="2" id="p2">2 (Sammy + Jake)</button>
</div>
<div class="row">Size:
<button data-z="1" id="z1">Normal</button>
<button data-z="2" id="z2">Big (2x)</button>
</div>
<div class="row">Skill: <input type="range" id="skill" min="1" max="100" value="50"> <span id="skillv">50</span></div>
<div class="row"><label><input type="checkbox" id="inc" checked> Speed up each level</label>
<label><input type="checkbox" id="snd" checked> Sound</label></div>
<button id="go">START</button>
<div class="help">
Sammy: <kbd>arrow keys</kbd>, swipe or on-screen buttons · Jake: <kbd>W A S D</kbd> · <kbd>P</kbd> pause · <kbd>M</kbd> sound<br>
Eat the numbers 1 to 9 to clear a level. Walls, yourself and the other snake are deadly.
</div>
<a href="/" style="color:#55FFFF">Other games</a>
</div>
</div>
<div id="pad" aria-label="Arrow buttons">
<span></span><button data-d="U" aria-label="Up">▲</button><span></span>
<button data-d="L" aria-label="Left">◀</button><button class="mid" data-d="P" aria-label="Pause or continue">II</button><button data-d="R" aria-label="Right">▶</button>
<span></span><button data-d="D" aria-label="Down">▼</button><span></span>
</div>
<script>
(() => {
const PAL = { bg:'#0000AA', wall:'#FF5555', sammy:'#FFFF55', jake:'#FF55FF', white:'#FFFFFF',
red:'#AA0000', gray:'#AAAAAA', black:'#000000', cyan:'#55FFFF' };
const DIRS = { U:[-1,0], D:[1,0], L:[0,-1], R:[0,1] };
const OPP = { U:'D', D:'U', L:'R', R:'L' };
const cv = document.getElementById('c'), g = cv.getContext('2d');
const setupEl = document.getElementById('setup');
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
// ---- settings (per-viewer convenience only) ----
let cfg = { players:1, skill:50, inc:true, sound:true, big:isTouch };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('nibbles') || '{}')); } catch (e) {}
const saveCfg = () => { try { localStorage.setItem('nibbles', JSON.stringify(cfg)); } catch (e) {} };
// ---- sound: square-wave "PC speaker" beeps from QBasic-style PLAY strings ----
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) {
const o = a.createOscillator(), v = a.createGain();
o.type = 'square'; o.frequency.value = f;
v.gain.setValueAtTime(0.05, t); v.gain.setValueAtTime(0.05, t + dur * 0.9); v.gain.linearRampToValueAtTime(0, t + dur);
o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
}
const SEMI = { C:0, D:2, E:4, F:5, G:7, A:9, B:11 };
function play(mml) {
if (!cfg.sound) return;
const a = audio(); if (!a) return;
const s = mml.toUpperCase().replace(/\s/g, '');
let t = 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);
t += dur;
}
}
}
const SFX = {
start: 'T160 O4 L8 CDEDCD L4 ECC',
eat: 'T160 O4 L16 CCCE',
die: 'T160 O4 L32 EFGEFDC',
level: 'T180 O4 L16 CEG >C',
over: 'T120 O4 L8 GEC <L4 G',
};
// ---- grid size: normal = 80x50 cells of 8px; big = 40x26 cells drawn at 2x ----
let Z = 1, COLS = 80, ROWS = 50;
function setSize(big) {
Z = big ? 2 : 1; COLS = 80 / Z; ROWS = big ? 26 : 50;
cv.width = 1280; cv.height = ROWS * 8 * 2 * Z;
document.documentElement.style.setProperty('--ar', cv.width / cv.height);
g.setTransform(2 * Z, 0, 0, 2 * Z, 0, 0); // logical space = COLS*8 x ROWS*8
}
setSize(false);
// ---- playfield ----
let wall;
const idx = (r, c) => r * (COLS + 1) + c;
// Levels are written in the original 80x50 coordinates; big mode maps them onto its half-size grid.
const mr = r => Z === 1 ? r : Math.ceil((r - 2) / 2) + 2;
const mc = c => Z === 1 ? c : Math.ceil(c / 2);
const setW = (r, c) => { wall[idx(mr(r), mc(c))] = 1; };
// Returns start positions [sammy, jake] as [row, col, dir]. Layouts follow the original's 10 levels.
function buildLevel(lv) {
wall = new Uint8Array((ROWS + 1) * (COLS + 1));
for (let c = 1; c <= 80; c++) { setW(3, c); setW(50, c); }
for (let r = 3; r <= 50; r++) { setW(r, 1); setW(r, 80); }
let i;
const st = 2 * Z; // dashed walls keep their gaps in big mode
switch (Math.min(lv, 10)) {
case 1: return [[25,50,'R'],[25,30,'L']];
case 2: for (i = 20; i <= 60; i++) setW(25, i); return [[7,60,'L'],[43,20,'R']];
case 3: for (i = 10; i <= 40; i++) { setW(i,20); setW(i,60); } return [[25,50,'U'],[25,30,'D']];
case 4: for (i = 4; i <= 30; i++) { setW(i,20); setW(53-i,60); }
for (i = 2; i <= 40; i++) { setW(38,i); setW(15,81-i); } return [[7,60,'L'],[43,20,'R']];
case 5: for (i = 13; i <= 39; i++) { setW(i,21); setW(i,59); }
for (i = 23; i <= 57; i++) { setW(11,i); setW(41,i); } return [[25,50,'U'],[25,30,'D']];
case 6: for (i = 4; i <= 49; i++) if (i > 30 || i < 21) for (let c = 10; c <= 70; c += 10) setW(i, c);
return [[7,65,'D'],[43,15,'U']];
case 7: for (i = 4; i <= 49; i += st) setW(i, 40); return [[7,65,'D'],[43,15,'U']];
case 8: for (i = 4; i <= 40; i++) { setW(i,10); setW(53-i,20); setW(i,30); setW(53-i,40);
setW(i,50); setW(53-i,60); setW(i,70); }
return [[7,65,'D'],[43,15,'U']];
case 9: for (i = 6; i <= 47; i++) { setW(i,i); setW(i,i+28); } return [[40,75,'U'],[15,5,'D']];
default:
for (i = 4; i <= 49; i += st) { setW(i,10); setW(i+1,20); setW(i,30); setW(i+1,40);
setW(i,50); setW(i+1,60); setW(i,70); }
return [[7,65,'D'],[43,15,'U']];
}
}
// ---- game state ----
let mode = 'intro', level = 1, numVal = 1, num = null, players = [], msg = null, timer = null;
function newPlayers() {
players = [
{ name:'SAMMY', color:PAL.sammy, lives:5, score:0 },
{ name:'JAKE', color:PAL.jake, lives:5, score:0 },
].slice(0, cfg.players);
}
function startLevel() {
const starts = buildLevel(level);
players.forEach((p, i) => {
const [r, c, d] = starts[i];
p.body = [[mr(r), mc(c)]]; p.dir = d; p.q = []; p.grow = 1;
});
numVal = 1; placeNumber();
mode = 'play'; draw(); schedule();
}
function occupied(r, c) {
return players.some(p => p.body.some(([br, bc]) => br === r && bc === c));
}
function placeNumber() {
for (let t = 0; t < 10000; t++) {
const tr = 2 + Math.floor(Math.random() * (ROWS / 2 - 1)); // text rows; walls reject the edges
const c = 2 + Math.floor(Math.random() * (COLS - 2)); // cols 2..COLS-1
const r1 = tr * 2 - 1, r2 = tr * 2;
if (wall[idx(r1, c)] || wall[idx(r2, c)] || occupied(r1, c) || occupied(r2, c)) continue;
if (players.some(p => Math.abs(p.body[0][0] - r1) + Math.abs(p.body[0][1] - c) < 4)) continue;
num = { n:numVal, c, r1, r2 }; return;
}
}
function delay() {
let d = 40 + (100 - cfg.skill) * 2.2;
if (cfg.inc) d -= (level - 1) * 8;
return Math.max(25, d);
}
function schedule() {
clearTimeout(timer);
if (mode === 'play') timer = setTimeout(() => { tick(); draw(); schedule(); }, delay());
}
function tick() {
const heads = players.map(p => {
while (p.q.length) { const d = p.q.shift(); if (d !== p.dir && d !== OPP[p.dir]) { p.dir = d; break; } }
const [dr, dc] = DIRS[p.dir];
return [p.body[0][0] + dr, p.body[0][1] + dc];
});
// Tails vacate this tick unless that snake is growing.
const occ = new Set();
players.forEach(p => {
const n = p.grow > 0 ? p.body.length : p.body.length - 1;
for (let i = 0; i < n; i++) occ.add(idx(p.body[i][0], p.body[i][1]));
});
const dead = players.map((p, i) => {
const [r, c] = heads[i], k = idx(r, c);
if (r < 1 || r > ROWS || c < 1 || c > COLS || wall[k] || occ.has(k)) return true;
return heads.some((h, j) => j !== i && h[0] === r && h[1] === c);
});
if (dead.some(Boolean)) return die(dead);
players.forEach((p, i) => {
p.body.unshift(heads[i]);
if (p.grow > 0) p.grow--; else p.body.pop();
});
for (const p of players) {
const [r, c] = p.body[0];
if (num && c === num.c && (r === num.r1 || r === num.r2)) {
p.score += num.n; p.grow += num.n * 4 / Z;
numVal++;
if (numVal === 10) {
num = null; level++; play(SFX.level);
return showMsg([`Level ${level}, Push Space`], startLevel);
}
play(SFX.eat);
placeNumber();
break;
}
}
}
function die(dead) {
const names = [];
players.forEach((p, i) => { if (dead[i]) { p.lives--; p.score -= 10; names.push(p.name[0] + p.name.slice(1).toLowerCase()); } });
const line = names.length > 1 ? `${names.join(' & ')} Die! Push Space! --->` : `${names[0]} Dies! Push Space! --->`;
const over = players.some(p => p.lives <= 0);
play(SFX.die);
showMsg([line], over ? gameOver : startLevel);
}
function gameOver() {
mode = 'over'; msg = { lines:['G A M E O V E R', '', 'Play Again? (Y/N)'] }; draw(); play(SFX.over);
}
function showMsg(lines, then) {
mode = 'msg'; msg = { lines, then }; clearTimeout(timer); draw();
}
function newGame() { level = 1; setSize(cfg.big); newPlayers(); startLevel(); }
function openSetup() {
mode = 'setup'; clearTimeout(timer); setSize(false);
document.getElementById('z1').classList.toggle('on', !cfg.big);
document.getElementById('z2').classList.toggle('on', cfg.big);
document.getElementById('p1').classList.toggle('on', cfg.players === 1);
document.getElementById('p2').classList.toggle('on', cfg.players === 2);
document.getElementById('skill').value = cfg.skill;
document.getElementById('skillv').textContent = cfg.skill;
document.getElementById('inc').checked = cfg.inc;
document.getElementById('snd').checked = cfg.sound;
setupEl.classList.remove('hidden');
}
// ---- drawing ----
function txt(col, row, s, fg, bg) {
const x = (col - 1) * 8, y = (row - 1) * 16;
if (bg) { g.fillStyle = bg; g.fillRect(x, y, s.length * 8, 16); }
g.fillStyle = fg; g.font = 'bold 13px "Lucida Console","Courier New",monospace';
g.textBaseline = 'middle'; g.textAlign = 'center';
for (let i = 0; i < s.length; i++) g.fillText(s[i], x + i * 8 + 4, y + 9);
}
const cell = (r, c, color) => { g.fillStyle = color; g.fillRect((c - 1) * 8, (r - 1) * 8, 8, 8); };
const fmt = n => (n * 100).toLocaleString('en-US');
function dialog(lines) {
const w = Math.max(...lines.map(l => l.length)) + 6;
const col = Math.floor((COLS - w) / 2) + 1, row = Z === 1 ? 11 : 5;
const x = (col - 1) * 8, y = (row - 1) * 16, h = (lines.length + 2) * 16;
g.fillStyle = PAL.red; g.fillRect(x, y, w * 8, h);
g.strokeStyle = PAL.white; g.lineWidth = 1.5; g.strokeRect(x + 4, y + 6, w * 8 - 8, h - 12);
lines.forEach((l, i) => txt(col + Math.floor((w - l.length) / 2), row + 1 + i, l, PAL.white));
}
function draw() {
if (mode === 'intro' || mode === 'setup') return drawIntro();
g.fillStyle = PAL.bg; g.fillRect(0, 0, COLS * 8, ROWS * 8);
for (let r = 1; r <= ROWS; r++) for (let c = 1; c <= COLS; c++) if (wall[idx(r, c)]) cell(r, c, PAL.wall);
if (num) txt(num.c, num.r2 / 2, String(num.n), PAL.white);
players.forEach(p => p.body.forEach(([r, c]) => cell(r, c, p.color)));
const s = players[0], j = players[1];
if (Z === 1) {
const sl = `SAMMY--> Lives: ${s.lives} ${fmt(s.score).padStart(9)}`;
txt(81 - sl.length, 1, sl, PAL.white);
if (j) txt(1, 1, `${fmt(j.score).padStart(9)} Lives: ${j.lives} <--JAKE`, PAL.white);
txt(j ? 37 : 2, 1, `Level ${level}`, PAL.cyan);
} else { // 40 columns: short status line
const sl = `SAMMY x${s.lives} ${fmt(s.score)}`;
txt(COLS + 1 - sl.length, 1, sl, PAL.white);
if (j) txt(1, 1, `${fmt(j.score)} x${j.lives} JAKE`, PAL.white);
txt(j ? 19 : 2, 1, `L${level}`, PAL.cyan);
}
if (mode === 'paused') dialog(['Game Paused ... Push Space']);
if ((mode === 'msg' || mode === 'over') && msg) dialog(msg.lines);
}
let introT = 0, introRaf = null;
function drawIntro() {
g.fillStyle = PAL.black; g.fillRect(0, 0, 640, 400);
// marching border of stars
const star = (col, row, on) => txt(col, row, '*', on ? PAL.wall : PAL.white);
const ring = [];
for (let c = 1; c <= 80; c++) ring.push([c, 1]);
for (let r = 2; r <= 25; r++) ring.push([80, r]);
for (let c = 79; c >= 1; c--) ring.push([c, 25]);
for (let r = 24; r >= 2; r--) ring.push([1, r]);
ring.forEach(([c, r], i) => star(c, r, (i + introT) % 5 === 0));
const center = (row, s, color) => txt(Math.floor((80 - s.length) / 2) + 1, row, s, color);
center(4, 'N I B B L E S', PAL.sammy);
center(6, 'a tribute to the 1990 QBasic snake game', PAL.gray);
center(9, 'Guide Sammy the snake to eat the numbers 1 through 9.', PAL.white);
center(10, 'Every number makes Sammy longer. Clear all nine to reach the next level.', PAL.white);
center(12, "Don't run into walls, your own tail, or the other snake.", PAL.white);
center(13, 'Two players? Jake joins in, and the snakes can collide head-on.', PAL.white);
center(16, 'Sammy: arrows, swipe or buttons Jake: W A S D Pause: P Sound: M', PAL.cyan);
if (mode === 'intro') center(21, 'Press any key or tap to continue', (introT >> 2) % 2 ? PAL.sammy : PAL.white);
}
function introLoop() {
introT++; if (mode === 'intro' || mode === 'setup') drawIntro();
if (mode === 'intro' || mode === 'setup') introRaf = setTimeout(introLoop, 120);
}
// ---- input ----
const SAMMY_KEYS = { ArrowUp:'U', ArrowDown:'D', ArrowLeft:'L', ArrowRight:'R' };
const JAKE_KEYS = { w:'U', s:'D', a:'L', d:'R' };
function queue(p, d) {
if (!p || mode !== 'play') return;
const last = p.q.length ? p.q[p.q.length - 1] : p.dir;
if (d === last || d === OPP[last] || p.q.length >= 3) return;
p.q.push(d);
}
function proceed() {
if (mode === 'intro') { openSetup(); return; }
if (mode === 'msg') { const then = msg.then; msg = null; then(); return; }
if (mode === 'paused') { mode = 'play'; draw(); schedule(); return; }
if (mode === 'over') { msg = null; newGame(); }
}
addEventListener('keydown', e => {
if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (SAMMY_KEYS[k] || k === ' ') e.preventDefault();
if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); if (cfg.sound) play(SFX.eat); return; }
if (mode === 'play') {
if (SAMMY_KEYS[k]) return queue(players[0], SAMMY_KEYS[k]);
if (JAKE_KEYS[k]) return queue(players[1] || players[0], JAKE_KEYS[k]);
if (k === 'p') { mode = 'paused'; clearTimeout(timer); draw(); }
return;
}
if (mode === 'over') {
if (k === 'y') proceed();
else if (k === 'n') { msg = null; openSetup(); introLoop(); }
return;
}
if (mode === 'intro' || k === ' ' || k === 'Enter') proceed();
});
let touch = null;
cv.addEventListener('touchstart', e => { const t = e.changedTouches[0]; touch = [t.clientX, t.clientY]; e.preventDefault(); }, { passive:false });
cv.addEventListener('touchend', e => {
if (!touch) return;
const t = e.changedTouches[0], dx = t.clientX - touch[0], dy = t.clientY - touch[1];
touch = null; e.preventDefault();
if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) { if (mode !== 'play') proceed(); return; }
queue(players[0], Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'R' : 'L') : (dy > 0 ? 'D' : 'U'));
}, { passive:false });
cv.addEventListener('click', () => { if (mode !== 'play') proceed(); });
// On-screen pad: arrows steer Sammy; middle button pauses. Outside play, any button continues.
if (isTouch) document.body.classList.add('touch');
document.querySelectorAll('#pad button').forEach(b => b.addEventListener('pointerdown', e => {
e.preventDefault();
b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 120);
const d = b.dataset.d;
if (mode === 'setup') return;
if (mode !== 'play') return proceed();
if (d === 'P') { mode = 'paused'; clearTimeout(timer); draw(); }
else queue(players[0], d);
}));
// ---- setup screen ----
document.querySelectorAll('[data-p]').forEach(b => b.addEventListener('click', () => {
cfg.players = +b.dataset.p; openSetup();
}));
document.querySelectorAll('[data-z]').forEach(b => b.addEventListener('click', () => {
cfg.big = b.dataset.z === '2'; openSetup();
}));
document.getElementById('skill').addEventListener('input', e => {
cfg.skill = +e.target.value; document.getElementById('skillv').textContent = cfg.skill;
});
document.getElementById('inc').addEventListener('change', e => { cfg.inc = e.target.checked; });
document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; if (cfg.sound) play(SFX.eat); });
document.getElementById('go').addEventListener('click', startFromSetup);
function startFromSetup() {
saveCfg(); setupEl.classList.add('hidden'); clearTimeout(introRaf); play(SFX.start); newGame();
}
introLoop();
})();
</script>
</body>
</html>
EOF
01-create-ct.sh: creates CT 125, waits for its DHCP address, pushes the game in and runs the setup script inside it. It refuses to run if CT 125 already exists.
cat > /root/nibbles/01-create-ct.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host. Expects /root/nibbles/{index.html,02-setup-inside.sh} staged first.
set -euo pipefail
CTID=125
if pct status $CTID >/dev/null 2>&1; then echo "CT $CTID already exists"; exit 1; fi
pct create $CTID local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
--hostname nibbles --cores 1 --memory 256 --swap 256 \
--rootfs local-lvm:2 --net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 1 --features nesting=1 --onboot 1 --ostype debian
pct start $CTID
for i in $(seq 1 30); do
IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
[ -n "$IP" ] && break; sleep 2
done
echo "CT $CTID IP: $IP"
pct push $CTID /root/nibbles/index.html /root/index.html
pct push $CTID /root/nibbles/02-setup-inside.sh /root/02-setup-inside.sh
pct exec $CTID -- bash /root/02-setup-inside.sh
EOF
02-setup-inside.sh: runs inside CT 125. Installs Debian's caddy package, copies the game to /var/www/nibbles, writes a 6-line Caddyfile and checks it answers locally.
cat > /root/nibbles/02-setup-inside.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs inside CT 125: Caddy serving the static game on :80.
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q caddy curl
mkdir -p /var/www/nibbles
install -m 644 /root/index.html /var/www/nibbles/index.html
cat > /etc/caddy/Caddyfile <<'EOF'
:80 {
root * /var/www/nibbles
encode gzip
header Cache-Control "no-cache"
file_server
}
EOF
systemctl enable caddy
systemctl restart caddy
sleep 1
curl -s -o /dev/null -w 'local HTTP %{http_code}\n' http://127.0.0.1/
EOF_OUTER1
03-edge-block.sh: runs inside CT 108 (Caddy reverse proxy). Backs up the edge Caddyfile, appends the games.503mail.com block (plus a redirect from the old nibbles.503mail.com address), validates and reloads. If validation fails it restores the backup on its own.
cat > /root/nibbles/03-edge-block.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs inside CT 108 (edge Caddy). Usage: bash 03-edge-block.sh <CT125_IP>
set -euo pipefail
IP="$1"; F=/etc/caddy/Caddyfile
if grep -q '^games.503mail.com' "$F"; then echo "edge block already present"; exit 0; fi
BAK="$F.bak-games-$(date +%Y%m%d%H%M%S)"
cp "$F" "$BAK"
cat >> "$F" <<EOF
# Games site (CT 125, static: menu + Nibbles + Tetris) - added $(date +%F)
games.503mail.com {
reverse_proxy $IP:80
}
# Old address, kept so bookmarks keep working
nibbles.503mail.com {
redir https://games.503mail.com{uri} permanent
}
EOF
if ! caddy validate --config "$F" --adapter caddyfile; then
cp "$BAK" "$F"; echo "validate failed - restored $BAK"; exit 1
fi
systemctl reload caddy
echo "edge reloaded"
EOF_OUTER1
04-add-kuma-monitors.py: runs inside CT 202 (creator-signup) and adds two Uptime Kuma monitors tagged "Personal Site". Safe to re-run.
cat > /root/nibbles/04-add-kuma-monitors.py <<'EOF'
"""Add Uptime Kuma monitors for Nibbles (games.503mail.com, CT 125).
Run inside CT 202 (creator-signup), which already has uptime_kuma_api and the
Kuma credentials in signup_handler.py:
pct push 202 /root/nibbles/04-add-kuma-monitors.py /root/04-add-kuma-monitors.py
pct exec 202 -- /opt/creator-signup/venv/bin/python /root/04-add-kuma-monitors.py <CT 125 IP>
Idempotent: skips monitors that already exist by name.
"""
import sys
sys.path.insert(0, "/opt/creator-signup")
import signup_handler as sh
from uptime_kuma_api import UptimeKumaApi, MonitorType
BACKEND_IP = sys.argv[1] # CT 125 (nibbles)'s LAN IP
TAG_NAME = "Personal Site"
MONITORS = [
dict(type=MonitorType.KEYWORD, name="games.503mail.com — Games (Nibbles, Tetris)",
url="https://games.503mail.com/", keyword="<title>Nibbles</title>", interval=300),
dict(type=MonitorType.PORT, name=f"{BACKEND_IP}:80 — Nibbles LXC (backend for games.503mail.com)",
hostname=BACKEND_IP, port=80, interval=300),
]
api = UptimeKumaApi(sh.UPTIME_KUMA_URL)
api.login(sh.UPTIME_KUMA_USER, sh.UPTIME_KUMA_PASS)
existing = {m["name"] for m in api.get_monitors()}
tag = next(t for t in api.get_tags() if t["name"] == TAG_NAME)
for m in MONITORS:
if m["name"] in existing:
print("exists:", m["name"])
continue
monitor_id = api.add_monitor(**m)["monitorID"]
api.add_monitor_tag(tag_id=tag["id"], monitor_id=monitor_id, value="")
print("added:", monitor_id, m["name"])
api.disconnect()
EOF
05-push-site.sh: copies the menu, every game that's on the host and all their icons into CT 125. Used in step 5, whenever a game is added or updated, and by every game's post.
cat > /root/nibbles/05-push-site.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after copying the site files to /root/nibbles/
# (index.html + icons at the top, nibbles/index.html, and index.html + icons
# for each game folder listed in GAMES below).
set -e
CTID=125
SRC=/root/nibbles
DST=/var/www/nibbles
GAMES="tetris starkiller speedway dinoisland minesweeper skypilot tycoon highwayheat agent skytower pip dash"
pct exec $CTID -- mkdir -p $DST/nibbles $(for d in $GAMES; do echo $DST/$d; done)
for f in index.html favicon-32.png icon-192.png icon-512.png apple-touch-icon.png; do
pct push $CTID $SRC/$f $DST/$f --perms 644
for d in $GAMES; do
if [ -f $SRC/$d/$f ]; then pct push $CTID $SRC/$d/$f $DST/$d/$f --perms 644; fi
done
done
pct push $CTID $SRC/nibbles/index.html $DST/nibbles/index.html --perms 644
pct exec $CTID -- ls -lR $DST
EOF
make-icons.ps1: this one is for your Windows PC, not the host. It draws all the icons (Nibbles: a yellow block snake heading for a white 5; Tetris: stacked coloured blocks; Star Killer: a ship firing at an asteroid among stars; Speedway: a race car on the road with a chequered flag; Dino Island: an orange dinosaur and an egg on an island; Minesweeper: grey squares with numbers, a flag and a mine; Sky Pilot: a banking plane over hills with a yellow ring; Portland Tycoon: board-edge colors, a house, a hotel and dice; Highway Heat: a police car on a sunset road; Agent 503: gold sight rings around "503"; all with a red border) as PNGs with Windows' built-in System.Drawing, so no extra software is needed. Save it as make-icons.ps1 in any folder on the PC, e.g. C:\nibbles\, using Notepad.
cat > /root/nibbles/make-icons.ps1 <<'EOF'
# Draws the site icons as PNGs:
# Nibbles (yellow block snake + white number, red border) -> next to this script
# Tetris (stacked coloured blocks, red border) -> tetris\ next to this script
# Star Killer (ship, rock and laser shots, red border) -> starkiller\ next to this script
# Speedway (race car on the road + chequered flag) -> speedway\ next to this script
# Dino Island (orange dinosaur head + egg on an island)
# Minesweeper (grey squares, numbers, flag, mine) -> minesweeper\
# Sky Pilot (banking plane, hills, yellow ring) -> skypilot\
# Portland Tycoon (board edge colours, house, hotel, dice) -> tycoon\
# Highway Heat (sunset road, police car with lights) -> highwayheat\
# Agent 503 (gold sight rings, crosshair, "503") -> agent\
# Sky Tower (lit tower, elevator car, sunset) -> skytower\ -> dinoisland\ next to this script
# BlockCraft (grass block on blue sky) -> ..\BlockCraft\public\ (served by CT 127)
# Super Pip (Pip jumping under a ? block) -> pip\
# Dash Rush (rolling ball, speed lines, gold ring) -> dash\
# Run: powershell -ExecutionPolicy Bypass -File make-icons.ps1
Add-Type -AssemblyName System.Drawing
$out = Split-Path -Parent $MyInvocation.MyCommand.Path
New-Item -ItemType Directory -Force (Join-Path $out 'tetris') | Out-Null
function Color($hex) { [System.Drawing.ColorTranslator]::FromHtml($hex) }
function New-Canvas([int]$size) {
$bmp = New-Object System.Drawing.Bitmap $size, $size
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'None'
$g.TextRenderingHint = 'AntiAliasGridFit'
$g.Clear([System.Drawing.Color]::Black)
# red border, like the games' walls
$u = $size / 16.0
$red = New-Object System.Drawing.SolidBrush (Color '#C81E1E')
$g.FillRectangle($red, 0, 0, $size, $u * 0.6)
$g.FillRectangle($red, 0, $size - $u * 0.6, $size, $u * 0.6)
$g.FillRectangle($red, 0, 0, $u * 0.6, $size)
$g.FillRectangle($red, $size - $u * 0.6, 0, $u * 0.6, $size)
return @($bmp, $g)
}
function Draw-Nibbles([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$u = $size / 16.0 # 16x16 grid
$yellow = New-Object System.Drawing.SolidBrush (Color '#FFDC00')
$cells = @(
@(3,12),@(4,12),@(5,12),@(6,12),@(7,12),@(8,12),@(9,12),
@(9,11),@(9,10),@(9,9),@(8,9),@(7,9),@(6,9),@(5,9),@(4,9),
@(4,8),@(4,7),@(4,6),@(4,5),@(5,5),@(6,5),@(7,5),@(8,5)
)
foreach ($c in $cells) { $g.FillRectangle($yellow, [float]($c[0] * $u), [float]($c[1] * $u), [float]($u * 0.95), [float]($u * 0.95)) }
# white number the snake is heading for
$font = New-Object System.Drawing.Font ('Consolas', [float]($u * 3.2), [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$g.DrawString('5', $font, [System.Drawing.Brushes]::White, [float]($u * 10.3), [float]($u * 3.3))
$bmp.Save((Join-Path $out $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Tetris([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$u = $size / 8.0 # 8x8 grid, pieces inside the border
$hi = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(115, 255, 255, 255))
$lo = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(90, 0, 0, 0))
# row, col, colour
$cells = @(
@(1,3,'#FF55FF'),@(1,4,'#FF55FF'),@(1,5,'#FF55FF'),@(2,4,'#FF55FF'), # T, falling
@(4,1,'#55FF55'),@(5,1,'#55FF55'),@(5,2,'#55FF55'),@(6,2,'#55FF55'), # S
@(5,5,'#FFFF55'),@(5,6,'#FFFF55'),@(6,5,'#FFFF55'),@(6,6,'#FFFF55'), # O
@(4,3,'#FFAA00'),@(5,3,'#FFAA00'),@(5,4,'#FFAA00'),@(6,4,'#FFAA00'), # L-ish
@(6,1,'#55FFFF'),@(6,3,'#55FFFF') # filler
)
foreach ($c in $cells) {
$x = [float]($c[1] * $u); $y = [float]($c[0] * $u); $s = [float]($u * 0.94); $b = [float]($u * 0.12)
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color $c[2])), $x, $y, $s, $s)
$g.FillRectangle($hi, $x, $y, $s, $b); $g.FillRectangle($hi, $x, $y, $b, $s)
$g.FillRectangle($lo, $x, $y + $s - $b, $s, $b); $g.FillRectangle($lo, $x + $s - $b, $y, $b, $s)
}
$bmp.Save((Join-Path (Join-Path $out 'tetris') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-StarKiller([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
# stars
$rnd = New-Object System.Random 111
for ($i = 0; $i -lt 26; $i++) {
$s = [float]([Math]::Max(1, $u * (0.15 + $rnd.NextDouble() * 0.25)))
$g.FillRectangle([System.Drawing.Brushes]::White, [float]($u + $rnd.NextDouble() * $u * 14), [float]($u + $rnd.NextDouble() * $u * 14), $s, $s)
}
# rock top-right
$rock = New-Object System.Drawing.Drawing2D.LinearGradientBrush (New-Object System.Drawing.PointF ([float]($u * 9), [float]($u * 1.5))), (New-Object System.Drawing.PointF ([float]($u * 14), [float]($u * 6.5))), (Color '#DDCCAA'), (Color '#332211')
$g.FillEllipse($rock, [float]($u * 9.5), [float]($u * 1.8), [float]($u * 4.6), [float]($u * 4.2))
# laser shots
$yel = New-Object System.Drawing.SolidBrush (Color '#FFFF55')
$g.FillRectangle($yel, [float]($u * 6.6), [float]($u * 3.5), [float]($u * 0.35), [float]($u * 1.8))
$g.FillRectangle($yel, [float]($u * 9.1), [float]($u * 5.5), [float]($u * 0.35), [float]($u * 1.8))
# ship: red wings + silver body + blue cockpit
$cx = $u * 8
$wing = [System.Drawing.PointF[]]@(
(New-Object System.Drawing.PointF ([float]$cx, [float]($u * 7))),
(New-Object System.Drawing.PointF ([float]($cx + $u * 5), [float]($u * 13.6))),
(New-Object System.Drawing.PointF ([float]$cx, [float]($u * 12.4))),
(New-Object System.Drawing.PointF ([float]($cx - $u * 5), [float]($u * 13.6))))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#CC3333')), $wing)
$body = [System.Drawing.PointF[]]@(
(New-Object System.Drawing.PointF ([float]$cx, [float]($u * 4.5))),
(New-Object System.Drawing.PointF ([float]($cx + $u * 2), [float]($u * 12.6))),
(New-Object System.Drawing.PointF ([float]$cx, [float]($u * 14))),
(New-Object System.Drawing.PointF ([float]($cx - $u * 2), [float]($u * 12.6))))
$silver = New-Object System.Drawing.Drawing2D.LinearGradientBrush (New-Object System.Drawing.PointF ([float]($cx - $u * 2), 0)), (New-Object System.Drawing.PointF ([float]($cx + $u * 2.01), 0)), (Color '#555566'), (Color '#EEEEFF')
$g.FillPolygon($silver, $body)
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#55CCFF')), [float]($cx - $u * 0.9), [float]($u * 8), [float]($u * 1.8), [float]($u * 1.8))
$bmp.Save((Join-Path (Join-Path $out 'starkiller') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF ([float]$x, [float]$y) }
function Pen($color, [double]$w) { New-Object System.Drawing.Pen -ArgumentList $color, ([float]$w) }
function Draw-Speedway([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
# sky, then the road running away to the horizon
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#4A80E0')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 6))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#22AA22')), [float]($u * .6), [float]($u * 6.6), [float]($u * 14.8), [float]($u * 8.8))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#FFFFFF')), [System.Drawing.PointF[]]@((P ($u * 7.4) ($u * 6.6)), (P ($u * 8.6) ($u * 6.6)), (P ($u * 15.4) ($u * 15.4)), (P ($u * .6) ($u * 15.4))))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#707070')), [System.Drawing.PointF[]]@((P ($u * 7.6) ($u * 6.6)), (P ($u * 8.4) ($u * 6.6)), (P ($u * 14) ($u * 15.4)), (P ($u * 2) ($u * 15.4))))
# race car from behind: tyres, body, helmet, yellow wing
$blk = New-Object System.Drawing.SolidBrush (Color '#111111')
$g.FillRectangle($blk, [float]($u * 3.2), [float]($u * 10.2), [float]($u * 2.4), [float]($u * 3.6))
$g.FillRectangle($blk, [float]($u * 10.4), [float]($u * 10.2), [float]($u * 2.4), [float]($u * 3.6))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#EE2222')), [System.Drawing.PointF[]]@((P ($u * 6.2) ($u * 13.8)), (P ($u * 9.8) ($u * 13.8)), (P ($u * 9.1) ($u * 9.6)), (P ($u * 6.9) ($u * 9.6))))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#AA1111')), [float]($u * 5.2), [float]($u * 11.4), [float]($u * 5.6), [float]($u * 1.8))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#FFFF55')), [float]($u * 7.1), [float]($u * 8.9), [float]($u * 1.8), [float]($u * 1.8))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#FFFF55')), [float]($u * 3.6), [float]($u * 8.2), [float]($u * 8.8), [float]($u * 1.1))
$g.FillRectangle($blk, [float]($u * 3.4), [float]($u * 7.9), [float]($u * .5), [float]($u * 2))
$g.FillRectangle($blk, [float]($u * 12.1), [float]($u * 7.9), [float]($u * .5), [float]($u * 2))
# chequered flag, top left
for ($r = 0; $r -lt 3; $r++) { for ($c = 0; $c -lt 4; $c++) {
$b = if (($r + $c) % 2) { [System.Drawing.Brushes]::Black } else { [System.Drawing.Brushes]::White }
$g.FillRectangle($b, [float]($u * (1.6 + $c * .9)), [float]($u * (1.4 + $r * .9)), [float]($u * .9), [float]($u * .9))
} }
$g.FillRectangle([System.Drawing.Brushes]::Silver, [float]($u * 1.3), [float]($u * 1.4), [float]($u * .3), [float]($u * 4.6))
$bmp.Save((Join-Path (Join-Path $out 'speedway') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-DinoIsland([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#1D63B8')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#E6D08C')), [float]($u * 1.2), [float]($u * 2), [float]($u * 13.6), [float]($u * 12.6))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#3F9A3A')), [float]($u * 2), [float]($u * 2.8), [float]($u * 12), [float]($u * 11))
# orange dinosaur head with a big jaw, looking right
$green = New-Object System.Drawing.SolidBrush (Color '#D0863A')
$g.FillEllipse($green, [float]($u * 3), [float]($u * 3.6), [float]($u * 7.4), [float]($u * 5.4))
$g.FillRectangle($green, [float]($u * 3.6), [float]($u * 7), [float]($u * 3), [float]($u * 5.4))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#D8EEB0')), [float]($u * 5.6), [float]($u * 6.9), [float]($u * 4.8), [float]($u * 1.8))
$g.FillEllipse([System.Drawing.Brushes]::White, [float]($u * 6.6), [float]($u * 4.4), [float]($u * 1.5), [float]($u * 1.5))
$g.FillEllipse([System.Drawing.Brushes]::Black, [float]($u * 7.2), [float]($u * 4.8), [float]($u * .8), [float]($u * .8))
$teeth = New-Object System.Drawing.SolidBrush (Color '#FFFFFF')
foreach ($tx in 6.8, 7.8, 8.8) { $g.FillPolygon($teeth, [System.Drawing.PointF[]]@((P ($u * $tx) ($u * 7.5)), (P ($u * ($tx + .6)) ($u * 7.5)), (P ($u * ($tx + .3)) ($u * 8.3)))) }
# speckled egg, bottom right
$egg = New-Object System.Drawing.Drawing2D.LinearGradientBrush (P ($u * 9.4) ($u * 8)), (P ($u * 13.4) ($u * 14)), (Color '#FFFFFF'), (Color '#B8A880')
$g.FillEllipse($egg, [float]($u * 9.6), [float]($u * 8.4), [float]($u * 3.8), [float]($u * 5))
$dot = New-Object System.Drawing.SolidBrush (Color '#8A7A50')
foreach ($d in @(@(10.6, 9.8), @(12, 11), @(10.9, 12.2))) { $g.FillEllipse($dot, [float]($u * $d[0]), [float]($u * $d[1]), [float]($u * .5), [float]($u * .5)) }
$bmp.Save((Join-Path (Join-Path $out 'dinoisland') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Minesweeper([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
# 3x3 grey squares: some raised, some dug with numbers, a flag and a mine in the middle
$grey = New-Object System.Drawing.SolidBrush (Color '#C0C0C0')
$g.FillRectangle($grey, [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
$c = $u * 4.6
for ($r = 0; $r -lt 3; $r++) { for ($q = 0; $q -lt 3; $q++) {
$x = $u * 1.1 + $q * $c; $y = $u * 1.1 + $r * $c
$raised = ($r -eq 0 -and $q -ne 1) -or ($r -eq 2 -and $q -eq 2)
if ($raised) {
$g.FillRectangle([System.Drawing.Brushes]::White, [float]$x, [float]$y, [float]$c, [float]($c * .12))
$g.FillRectangle([System.Drawing.Brushes]::White, [float]$x, [float]$y, [float]($c * .12), [float]$c)
$g.FillRectangle([System.Drawing.Brushes]::Gray, [float]$x, [float]($y + $c * .88), [float]$c, [float]($c * .12))
$g.FillRectangle([System.Drawing.Brushes]::Gray, [float]($x + $c * .88), [float]$y, [float]($c * .12), [float]$c)
} else {
$g.DrawRectangle((Pen ([System.Drawing.Color]::Gray) ($u * .15)), [float]$x, [float]$y, [float]$c, [float]$c)
}
} }
# flag on the top-left square
$fx = $u * 1.1; $fy = $u * 1.1
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($fx + $c * .48), [float]($fy + $c * .2), [float]($c * .08), [float]($c * .6))
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($fx + $c * .3), [float]($fy + $c * .75), [float]($c * .42), [float]($c * .08))
$g.FillPolygon([System.Drawing.Brushes]::Red, [System.Drawing.PointF[]]@((P ($fx + $c * .56) ($fy + $c * .15)), (P ($fx + $c * .56) ($fy + $c * .5)), (P ($fx + $c * .22) ($fy + $c * .33))))
# numbers
$font = New-Object System.Drawing.Font ('Arial', [float]($c * .62), [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$fmt = New-Object System.Drawing.StringFormat; $fmt.Alignment = 'Center'; $fmt.LineAlignment = 'Center'
foreach ($n in @(@(1, 0, '1', '#0000FF'), @(0, 1, '2', '#008000'), @(2, 1, '1', '#0000FF'), @(0, 2, '3', '#FF0000'), @(1, 2, '2', '#008000'))) {
$rect = New-Object System.Drawing.RectangleF ([float]($u * 1.1 + $n[0] * $c), [float]($u * 1.1 + $n[1] * $c), [float]$c, [float]$c)
$g.DrawString($n[2], $font, (New-Object System.Drawing.SolidBrush (Color $n[3])), $rect, $fmt)
}
# mine in the middle
$mx = $u * 1.1 + $c * 1.5; $my = $u * 1.1 + $c * 1.5
$pen = Pen ([System.Drawing.Color]::Black) ($c * .1)
$g.DrawLine($pen, [float]($mx - $c * .38), [float]$my, [float]($mx + $c * .38), [float]$my)
$g.DrawLine($pen, [float]$mx, [float]($my - $c * .38), [float]$mx, [float]($my + $c * .38))
$g.FillEllipse([System.Drawing.Brushes]::Black, [float]($mx - $c * .26), [float]($my - $c * .26), [float]($c * .52), [float]($c * .52))
$g.FillEllipse([System.Drawing.Brushes]::White, [float]($mx - $c * .14), [float]($my - $c * .14), [float]($c * .1), [float]($c * .1))
$bmp.Save((Join-Path (Join-Path $out 'minesweeper') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-SkyPilot([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$sky = New-Object System.Drawing.Drawing2D.LinearGradientBrush (P 0 ($u * .6)), (P 0 ($u * 11)), (Color '#2A5FCF'), (Color '#A8D0FF')
$g.FillRectangle($sky, [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 10.4))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#2F7D36')), [System.Drawing.PointF[]]@((P ($u * .6) ($u * 11)), (P ($u * 4) ($u * 8.6)), (P ($u * 7) ($u * 10.2)), (P ($u * 11) ($u * 8)), (P ($u * 15.4) ($u * 10.4)), (P ($u * 15.4) ($u * 15.4)), (P ($u * .6) ($u * 15.4))))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#96AF3C')), [float]($u * .6), [float]($u * 12), [float]($u * 14.8), [float]($u * 3.4))
# cloud
$g.FillEllipse([System.Drawing.Brushes]::White, [float]($u * 1.5), [float]($u * 2), [float]($u * 4), [float]($u * 1.6))
$g.FillEllipse([System.Drawing.Brushes]::White, [float]($u * 2.6), [float]($u * 1.3), [float]($u * 2.6), [float]($u * 1.8))
# plane seen from behind, banking: white wing tilted, red tail
$st = $g.Save()
$g.TranslateTransform([float]($u * 9.5), [float]($u * 6.5)); $g.RotateTransform(-14)
$g.FillRectangle([System.Drawing.Brushes]::White, [float]($u * -6), [float]($u * -1.4), [float]($u * 12), [float]($u * .7))
$red = New-Object System.Drawing.SolidBrush (Color '#D22828')
$g.FillRectangle($red, [float]($u * -6), [float]($u * -1.4), [float]($u * 1), [float]($u * .7))
$g.FillRectangle($red, [float]($u * 5), [float]($u * -1.4), [float]($u * 1), [float]($u * .7))
$g.FillEllipse([System.Drawing.Brushes]::White, [float]($u * -1.2), [float]($u * -1.2), [float]($u * 2.4), [float]($u * 2.6))
$g.FillRectangle([System.Drawing.Brushes]::White, [float]($u * -2.6), [float]($u * .6), [float]($u * 5.2), [float]($u * .45))
$g.FillRectangle($red, [float]($u * -.18), [float]($u * -2.6), [float]($u * .36), [float]($u * 3.4))
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($u * -1.6), [float]($u * 1.4), [float]($u * .5), [float]($u * .8))
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($u * 1.1), [float]($u * 1.4), [float]($u * .5), [float]($u * .8))
$g.Restore($st)
# yellow ring
$g.DrawEllipse((Pen (Color '#FFFF55') ($u * .5)), [float]($u * 2), [float]($u * 9.2), [float]($u * 3), [float]($u * 3.6))
$bmp.Save((Join-Path (Join-Path $out 'skypilot') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Tycoon([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#CDE6D0')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# coloured property bands round the edge, like the board
$cols = '#8B4513', '#87CEEB', '#D93A96', '#F7941D', '#ED1B24', '#FEF200', '#1FB25A', '#0072BB'
for ($i = 0; $i -lt 4; $i++) {
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color $cols[$i])), [float]($u * (1.2 + $i * 3.5)), [float]($u * 1.2), [float]($u * 3), [float]($u * 1.2))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color $cols[$i + 4])), [float]($u * (1.2 + $i * 3.5)), [float]($u * 13.6), [float]($u * 3), [float]($u * 1.2))
}
# green house and red hotel
$grn = New-Object System.Drawing.SolidBrush (Color '#00A000')
$g.FillRectangle($grn, [float]($u * 2.4), [float]($u * 7.6), [float]($u * 3.6), [float]($u * 3))
$g.FillPolygon($grn, [System.Drawing.PointF[]]@((P ($u * 1.9) ($u * 7.7)), (P ($u * 4.2) ($u * 5.3)), (P ($u * 6.5) ($u * 7.7))))
$redb = New-Object System.Drawing.SolidBrush (Color '#D00000')
$g.FillRectangle($redb, [float]($u * 9.6), [float]($u * 6.4), [float]($u * 5), [float]($u * 4.2))
$g.FillPolygon($redb, [System.Drawing.PointF[]]@((P ($u * 9.1) ($u * 6.5)), (P ($u * 12.1) ($u * 4)), (P ($u * 15.1) ($u * 6.5))))
# dice in the middle
foreach ($d in @(@(5.7, 9.4, 3), @(7.8, 10.6, 5))) {
$g.FillRectangle([System.Drawing.Brushes]::White, [float]($u * $d[0]), [float]($u * $d[1]), [float]($u * 2.2), [float]($u * 2.2))
$g.DrawRectangle((Pen ([System.Drawing.Color]::Black) ($u * .12)), [float]($u * $d[0]), [float]($u * $d[1]), [float]($u * 2.2), [float]($u * 2.2))
$pips = if ($d[2] -eq 3) { @(@(.3, .3), @(.5, .5), @(.7, .7)) } else { @(@(.28, .28), @(.72, .28), @(.5, .5), @(.28, .72), @(.72, .72)) }
foreach ($pp in $pips) { $g.FillEllipse([System.Drawing.Brushes]::Black, [float]($u * ($d[0] + $pp[0] * 2.2 - .2)), [float]($u * ($d[1] + $pp[1] * 2.2 - .2)), [float]($u * .4), [float]($u * .4)) }
}
$bmp.Save((Join-Path (Join-Path $out 'tycoon') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-HighwayHeat([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$sky = New-Object System.Drawing.Drawing2D.LinearGradientBrush (P 0 ($u * .6)), (P 0 ($u * 7)), (Color '#2A1A5A'), (Color '#FFB050')
$g.FillRectangle($sky, [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 6.6))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#FFF3A0')), [float]($u * 9.5), [float]($u * 4), [float]($u * 3), [float]($u * 3))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#C8B070')), [float]($u * .6), [float]($u * 7.2), [float]($u * 14.8), [float]($u * 8.2))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#525258')), [System.Drawing.PointF[]]@((P ($u * 7.3) ($u * 7.2)), (P ($u * 8.7) ($u * 7.2)), (P ($u * 15.4) ($u * 15.4)), (P ($u * .6) ($u * 15.4))))
# white police car from behind with red and blue lights
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($u * 3.4), [float]($u * 12.4), [float]($u * 2), [float]($u * 2))
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($u * 10.6), [float]($u * 12.4), [float]($u * 2), [float]($u * 2))
$g.FillRectangle([System.Drawing.Brushes]::WhiteSmoke, [float]($u * 3), [float]($u * 10.2), [float]($u * 10), [float]($u * 3))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#1A2A3A')), [float]($u * 4.6), [float]($u * 8.8), [float]($u * 6.8), [float]($u * 1.5))
$g.FillRectangle([System.Drawing.Brushes]::Red, [float]($u * 3.4), [float]($u * 10.8), [float]($u * 1.8), [float]($u * .6))
$g.FillRectangle([System.Drawing.Brushes]::Red, [float]($u * 10.8), [float]($u * 10.8), [float]($u * 1.8), [float]($u * .6))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#FF2020')), [float]($u * 5.6), [float]($u * 7.6), [float]($u * 2.2), [float]($u * 1.4))
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#2080FF')), [float]($u * 8.2), [float]($u * 7.6), [float]($u * 2.2), [float]($u * 1.4))
$bmp.Save((Join-Path (Join-Path $out 'highwayheat') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Agent([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#14140E')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# gold sight rings with a crosshair
$gold = Color '#E8C040'
$g.DrawEllipse((Pen $gold ($u * .55)), [float]($u * 2.2), [float]($u * 2.2), [float]($u * 11.6), [float]($u * 11.6))
$g.DrawEllipse((Pen $gold ($u * .3)), [float]($u * 4.4), [float]($u * 4.4), [float]($u * 7.2), [float]($u * 7.2))
$p = Pen $gold ($u * .3)
$g.DrawLine($p, [float]($u * 8), [float]($u * 1.4), [float]($u * 8), [float]($u * 5.4)); $g.DrawLine($p, [float]($u * 8), [float]($u * 10.6), [float]($u * 8), [float]($u * 14.6))
$g.DrawLine($p, [float]($u * 1.4), [float]($u * 8), [float]($u * 5.4), [float]($u * 8)); $g.DrawLine($p, [float]($u * 10.6), [float]($u * 8), [float]($u * 14.6), [float]($u * 8))
$font = New-Object System.Drawing.Font ('Consolas', [float]($u * 2.6), [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$fmt = New-Object System.Drawing.StringFormat; $fmt.Alignment = 'Center'; $fmt.LineAlignment = 'Center'
$g.DrawString('503', $font, (New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::White)), (New-Object System.Drawing.RectangleF ([float]($u * 4), [float]($u * 6), [float]($u * 8), [float]($u * 4))), $fmt)
$bmp.Save((Join-Path (Join-Path $out 'agent') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-SkyTower([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$sky = New-Object System.Drawing.Drawing2D.LinearGradientBrush (P 0 ($u * .6)), (P 0 ($u * 15.4)), (Color '#1A2A5A'), (Color '#E08050')
$g.FillRectangle($sky, [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# the tower: grey floors with lit windows, a dark elevator shaft and a yellow car
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#4A4A55')), [float]($u * 4), [float]($u * 2), [float]($u * 8), [float]($u * 12.6))
$lit = New-Object System.Drawing.SolidBrush (Color '#FFE08A'); $dark = New-Object System.Drawing.SolidBrush (Color '#2A2A35')
$rnd = New-Object System.Random 503
for ($r = 0; $r -lt 11; $r++) { for ($c = 0; $c -lt 5; $c++) {
if ($c -eq 2) { continue }
$b = if ($rnd.NextDouble() -lt .7) { $lit } else { $dark }
$g.FillRectangle($b, [float]($u * (4.4 + $c * 1.5)), [float]($u * (2.4 + $r * 1.1)), [float]($u * 1.1), [float]($u * .7))
} }
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#1A1A22')), [float]($u * 7.4), [float]($u * 2), [float]($u * 1.2), [float]($u * 12.6))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#FFD040')), [float]($u * 7.45), [float]($u * 6.5), [float]($u * 1.1), [float]($u * 1))
# lobby and the antenna
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#EFE4C8')), [float]($u * 3.5), [float]($u * 13.6), [float]($u * 9), [float]($u * 1.8))
$g.FillRectangle([System.Drawing.Brushes]::Silver, [float]($u * 7.85), [float]($u * .9), [float]($u * .3), [float]($u * 1.2))
$g.FillEllipse([System.Drawing.Brushes]::Red, [float]($u * 7.7), [float]($u * .7), [float]($u * .6), [float]($u * .6))
$bmp.Save((Join-Path (Join-Path $out 'skytower') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-BlockCraft([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$u = $size / 16.0
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#8FBEF0')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# one grass block seen from above the corner: green top, dirt sides with a green fringe
$top = [System.Drawing.PointF[]]@((P ($u * 8) ($u * 2.2)), (P ($u * 13.6) ($u * 5)), (P ($u * 8) ($u * 7.8)), (P ($u * 2.4) ($u * 5)))
$left = [System.Drawing.PointF[]]@((P ($u * 2.4) ($u * 5)), (P ($u * 8) ($u * 7.8)), (P ($u * 8) ($u * 14.2)), (P ($u * 2.4) ($u * 11.4)))
$right = [System.Drawing.PointF[]]@((P ($u * 8) ($u * 7.8)), (P ($u * 13.6) ($u * 5)), (P ($u * 13.6) ($u * 11.4)), (P ($u * 8) ($u * 14.2)))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#5F9E3C')), $top)
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#866043')), $left)
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#5E432E')), $right)
$fl = [System.Drawing.PointF[]]@((P ($u * 2.4) ($u * 5)), (P ($u * 8) ($u * 7.8)), (P ($u * 8) ($u * 9.2)), (P ($u * 2.4) ($u * 6.4)))
$fr = [System.Drawing.PointF[]]@((P ($u * 8) ($u * 7.8)), (P ($u * 13.6) ($u * 5)), (P ($u * 13.6) ($u * 6.4)), (P ($u * 8) ($u * 9.2)))
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#4F8A30')), $fl)
$g.FillPolygon((New-Object System.Drawing.SolidBrush (Color '#3F7024')), $fr)
# a few darker pixels so it reads as blocky
$dk = New-Object System.Drawing.SolidBrush (Color '#4A3424')
foreach ($q in @(@(3.6, 8.4), @(5.6, 10.6), @(6.6, 8.6), @(9.6, 11.2), @(11.8, 8.8), @(10.4, 9.2))) { $g.FillRectangle($dk, [float]($u * $q[0]), [float]($u * $q[1]), [float]($u * .7), [float]($u * .7)) }
$bmp.Save((Join-Path $bcOut $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Pip([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$u = $size / 16.0
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#6AA8FF')), [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# ground with grass
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#B9692E')), [float]($u * .6), [float]($u * 12.5), [float]($u * 14.8), [float]($u * 2.9))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#4CC24A')), [float]($u * .6), [float]($u * 12.5), [float]($u * 14.8), [float]($u * .9))
# ? block
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#8A4A00')), [float]($u * 9.4), [float]($u * 1.6), [float]($u * 4.6), [float]($u * 4.6))
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#F0A020')), [float]($u * 9.7), [float]($u * 1.9), [float]($u * 4), [float]($u * 4))
$font = New-Object System.Drawing.Font ('Consolas', [float]($u * 3.4), [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
$g.DrawString('?', $font, [System.Drawing.Brushes]::White, [float]($u * 10.1), [float]($u * 1.7))
# Pip: orange cap, face, purple overalls, jumping
$cap = New-Object System.Drawing.SolidBrush (Color '#E8641C'); $skin = New-Object System.Drawing.SolidBrush (Color '#F5C28E')
$ov = New-Object System.Drawing.SolidBrush (Color '#3B2F8F'); $brown = New-Object System.Drawing.SolidBrush (Color '#5A3210')
$g.FillRectangle($cap, [float]($u * 3), [float]($u * 4), [float]($u * 4.2), [float]($u * 1.2))
$g.FillRectangle($cap, [float]($u * 3.4), [float]($u * 3.2), [float]($u * 3), [float]($u * 1))
$g.FillRectangle($skin, [float]($u * 3.4), [float]($u * 5.2), [float]($u * 3.2), [float]($u * 2.4))
$g.FillRectangle($brown, [float]($u * 3.2), [float]($u * 5.2), [float]($u * .8), [float]($u * 1.8))
$g.FillRectangle([System.Drawing.Brushes]::Black, [float]($u * 5.4), [float]($u * 5.6), [float]($u * .5), [float]($u * .8))
$g.FillRectangle($cap, [float]($u * 2.8), [float]($u * 7.6), [float]($u * 4.4), [float]($u * 1.6))
$g.FillRectangle($skin, [float]($u * 7.2), [float]($u * 6.4), [float]($u * .9), [float]($u * 1.6))
$g.FillRectangle($ov, [float]($u * 3.2), [float]($u * 8.6), [float]($u * 3.6), [float]($u * 2.4))
$g.FillRectangle($brown, [float]($u * 2.6), [float]($u * 11), [float]($u * 1.8), [float]($u * 1))
$g.FillRectangle($brown, [float]($u * 5.8), [float]($u * 11), [float]($u * 1.8), [float]($u * 1))
$bmp.Save((Join-Path (Join-Path $out 'pip') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
function Draw-Dash([int]$size, [string]$file) {
$bmp, $g = New-Canvas $size
$g.SmoothingMode = 'AntiAlias'
$u = $size / 16.0
$sky = New-Object System.Drawing.Drawing2D.LinearGradientBrush (P 0 ($u * .6)), (P 0 ($u * 15.4)), (Color '#3A8CFF'), (Color '#BFE6FF')
$g.FillRectangle($sky, [float]($u * .6), [float]($u * .6), [float]($u * 14.8), [float]($u * 14.8))
# checkered ground
for ($c = 0; $c -lt 8; $c++) { for ($r = 0; $r -lt 2; $r++) {
$b = if (($c + $r) % 2) { New-Object System.Drawing.SolidBrush (Color '#C87830') } else { New-Object System.Drawing.SolidBrush (Color '#A05A1C') }
$g.FillRectangle($b, [float]($u * (.6 + $c * 1.85)), [float]($u * (12.4 + $r * 1.5)), [float]($u * 1.85), [float]($u * 1.5)) } }
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#38C040')), [float]($u * .6), [float]($u * 11.8), [float]($u * 14.8), [float]($u * .8))
# speed lines, then the rolling ball with bands
$w = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(200, 255, 255, 255))
foreach ($ly in 6.2, 8, 9.8) { $g.FillRectangle($w, [float]($u * 1.2), [float]($u * $ly), [float]($u * 4), [float]($u * .45)) }
$g.FillEllipse((New-Object System.Drawing.SolidBrush (Color '#20B0A0')), [float]($u * 4.8), [float]($u * 4.6), [float]($u * 7), [float]($u * 7))
$band = Pen (Color '#0A6A60') ($u * .5)
$g.DrawArc($band, [float]($u * 5.6), [float]($u * 4.6), [float]($u * 3.4), [float]($u * 7), -70, 140)
$g.DrawArc($band, [float]($u * 7.6), [float]($u * 4.6), [float]($u * 3.4), [float]($u * 7), -70, 140)
$g.FillRectangle((New-Object System.Drawing.SolidBrush (Color '#FF3A3A')), [float]($u * 10), [float]($u * 7.4), [float]($u * 1.4), [float]($u * 1.2))
# gold ring
$g.DrawEllipse((Pen (Color '#FFD020') ($u * .7)), [float]($u * 11.6), [float]($u * 2), [float]($u * 2.4), [float]($u * 3))
$bmp.Save((Join-Path (Join-Path $out 'dash') $file), [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
$bcOut = Join-Path (Split-Path -Parent $out) 'BlockCraft\public'
foreach ($d in 'starkiller', 'speedway', 'dinoisland', 'minesweeper', 'skypilot', 'tycoon', 'highwayheat', 'agent', 'skytower', 'pip', 'dash') { New-Item -ItemType Directory -Force (Join-Path $out $d) | Out-Null }
foreach ($f in @(@(512,'icon-512.png'), @(192,'icon-192.png'), @(180,'apple-touch-icon.png'), @(32,'favicon-32.png'))) {
Draw-Nibbles $f[0] $f[1]
Draw-Tetris $f[0] $f[1]
Draw-StarKiller $f[0] $f[1]
Draw-Speedway $f[0] $f[1]
Draw-DinoIsland $f[0] $f[1]
Draw-Minesweeper $f[0] $f[1]
Draw-SkyPilot $f[0] $f[1]
Draw-Tycoon $f[0] $f[1]
Draw-HighwayHeat $f[0] $f[1]
Draw-Agent $f[0] $f[1]
Draw-SkyTower $f[0] $f[1]
Draw-BlockCraft $f[0] $f[1]
Draw-Pip $f[0] $f[1]
Draw-Dash $f[0] $f[1]
}
EOF
3. Make sure the Debian 12 template is downloaded
Proxmox host shell. If debian-12-standard_12.12-1_amd64.tar.zst is listed you can skip this step:
pveam list local
Otherwise download it (if a newer 12.x is offered, use that name and edit it into 01-create-ct.sh):
pveam update && pveam download local debian-12-standard_12.12-1_amd64.tar.zst
4. Create and set up CT 125
Proxmox host shell. Takes about a minute and ends with local HTTP 200:
bash /root/nibbles/01-create-ct.sh
The script prints CT 125 IP: ... near the top. You'll need that address in the next two steps; this saves it to a variable for the same shell session:
NIBBLES_IP=$(pct exec 125 -- hostname -I | awk '{print $1}'); echo $NIBBLES_IP
5. Add the icons
The icon is what shows on home screens, bookmarks and Amazon Kids' "My Stuff". On your Windows PC, in PowerShell, go to the folder where you saved make-icons.ps1:
cd C:\nibbles
Draw the PNGs (makes icon-512.png, icon-192.png, apple-touch-icon.png and favicon-32.png next to the script for Nibbles, and the same four names in tetris, starkiller, speedway, dinoisland, minesweeper, skypilot, tycoon, highwayheat and agent subfolders):
powershell -ExecutionPolicy Bypass -File .\make-icons.ps1
Copy the Nibbles icons to the host (this uses an SSH alias named proxmox; use root@<your host's IP> instead if you don't have one). Each game's own post has the matching command for its icons:
scp -o BatchMode=yes icon-512.png icon-192.png apple-touch-icon.png favicon-32.png proxmox:/root/nibbles/
Then in the Proxmox host shell, push the menu, Nibbles and the icons into CT 125. It ends with a listing of /var/www/nibbles:
bash /root/nibbles/05-push-site.sh
6. Add the public route on CT 108 (Caddy reverse proxy)
Proxmox host shell:
pct push 108 /root/nibbles/03-edge-block.sh /root/03-edge-block.sh
pct exec 108 -- bash /root/03-edge-block.sh "$NIBBLES_IP"
It should end with Valid configuration and edge reloaded. A warning saying "Caddyfile input is not formatted" is harmless.
7. Add the Uptime Kuma monitors
Proxmox host shell:
pct push 202 /root/nibbles/04-add-kuma-monitors.py /root/04-add-kuma-monitors.py
pct exec 202 -- /opt/creator-signup/venv/bin/python /root/04-add-kuma-monitors.py "$NIBBLES_IP"
8. Play
Open https://games.503mail.com and pick Nibbles. The other menu tiles work once their games are added from their own posts. The first visit can take a few seconds while CT 108 fetches the TLS certificate.
Adding another game
Follow that game's post (see the Games page): it writes the game's index.html into its folder under /root/nibbles/, copies its icons, and runs 05-push-site.sh. The menu index.html above already links all of them, and 05-push-site.sh skips any game whose files aren't there yet.
Updating the games later
Edit the file (index.html or any game's index.html, or redraw the icons), copy it to the same place under /root/nibbles/ on the host, then from the Proxmox host shell (no restart needed; the pages are served with no-cache):
bash /root/nibbles/05-push-site.sh
Adding it to an Amazon Kids profile (Fire tablet)
Kids' profiles only open websites a parent has approved, and the approval can't be done from the parents.amazon.com website (it only offers Amazon's own picks). On the tablet:
- Switch to the adult profile and open Settings → Profiles & Family Library.
- Tap the child, then Device Settings → Manage Web Content → Websites, then the + at the top right.
- Type
https://games.503mail.comin the URL bar, let it load, tap ALLOW, then DONE. - On the child's profile it appears under My Stuff, and under Recent once it has been played. It doesn't appear under "Websites", which only lists Amazon's curated sites.
- Do this for each child.
Approvals follow the child, not the tablet. They're saved to the child's Amazon account, so a site approved on one Fire tablet also shows up for that child on every other tablet. You only need to add it once per child.
Launch it from the tile. If the child's general Kids web browser is turned off, opening the link any other way (a QR code, a link in another app) shows "Amazon Kids Browser is disabled". The My Stuff/Recent tile still works even with the browser off. Amazon saves the site's picture when it's first added, so a site added before its icon existed keeps a blank tile until it's removed and added again.
Redirects to a new address get blocked (found 03OCT2026). Amazon Kids checks the address the page actually lands on, not the one that was approved. When the site moved from nibbles.503mail.com to games.503mail.com, the old tiles started showing the yellow-hand "Try Something Else" screen, even though the redirect itself worked fine in a normal browser. The fix is to also approve https://games.503mail.com. The old entry can stay, and its tile then works again by following the redirect. Approving the whole of 503mail.com would also work in theory, but it would open every other tool on the domain to the kids, so approve only the games address.
Doing it over ADB from a PC (optional)
Useful for checking what a child's profile actually sees without taking the tablet away. You need Android's platform-tools (adb) on the PC, and on the tablet Settings → Device Options → Developer Options → Wireless debugging turned on. All commands below are run in PowerShell on your Windows PC, from the platform-tools folder.
On the tablet, tap Pair device with pairing code. Leave that box open. It shows a 6-digit code. This lists the tablet's pairing port and connect port, so you only need to read out the code:
.\adb.exe mdns services
Pair, using the _adb-tls-pairing address from that list and the 6-digit code:
.\adb.exe pair <tablet-ip>:<pairing-port> <6-digit-code>
Connect, using the _adb-tls-connect address:
.\adb.exe connect <tablet-ip>:<connect-port>
List the profiles and their user numbers. They differ between tablets, so check each one:
.\adb.exe shell pm list users
Switch the tablet to a child's profile (replace 11 with that child's number):
.\adb.exe shell am switch-user 11
Read the text on the screen. Screenshots of PIN and some Amazon screens come out black, but this still works:
.\adb.exe shell uiautomator dump /sdcard/ui.xml
.\adb.exe shell cat /sdcard/ui.xml
Tap something, using the centre of its bounds="[x1,y1][x2,y2]" from the dump:
.\adb.exe shell input tap 300 600
Switch back to the adult profile when done. The tablet then shows its lock screen. ADB can't type the lock screen or parental PIN for you; unlock it on the tablet if the next step needs the adult profile.
.\adb.exe shell am switch-user 0
If more than one tablet is connected, put -s <tablet-ip>:<connect-port> straight after .\adb.exe in every command.
Monitoring
At https://status.503mail.com, tagged Personal Site, both checked every 5 minutes:
- games.503mail.com — Games (Nibbles, Tetris): a keyword check that the public page loads and contains
<title>Nibbles</title>. If this is down but the next one is up, the problem is CT 108 (Caddy reverse proxy), DNS or the internet connection. - x.x.0.108:80 — Nibbles LXC: a TCP check straight to CT 125. If this is down, CT 125 is stopped or Caddy inside it has died.
Troubleshooting: where the logs are
Caddy inside CT 125 (nibbles)
- Where: systemd journal for
caddyin CT 125 - Open: Proxmox host shell
pct exec 125 -- journalctl -u caddy -n 50 --no-pager
- Look for:
serving initial configurationmeans it's healthy.address already in usemeans something else holds port 80. A warning that the server is "listening only on the HTTP port, so no automatic HTTPS" is expected, since CT 108 handles HTTPS.
Edge Caddy in CT 108 (Caddy reverse proxy)
- Where: systemd journal for
caddyin CT 108 - Open: Proxmox host shell
pct exec 108 -- journalctl -u caddy -n 100 --no-pager | grep -i nibbles
- Look for:
certificate obtained successfullyforgames.503mail.comis good.dial tcp ...:80: connect: connection refusedorno route to hostmeans CT 125 is down or its DHCP address changed. Check the address with the command below and fix the IP in thegames.503mail.comblock at the end of/etc/caddy/Caddyfilein CT 108, thenpct exec 108 -- systemctl reload caddy.
Quick health check
Proxmox host shell. Should print 200:
pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/
Current address of CT 125:
pct exec 125 -- hostname -I
Notes
- Why not a real DOS emulator? Streamed emulators send every keypress over the network, and that felt laggy. js-dos/DOSBox-in-the-browser would also need
QBASIC.EXE, which is Microsoft's and isn't redistributable. A small JavaScript remake gives instant input and needs nothing but a static file server. - Faithful bits: level layouts and start positions follow the original; score is shown with two trailing zeros like the original (eating a 5 adds 500); dying costs 1,000 points and restarts the level from number 1.
- Not faithful on purpose: the speed setting is in milliseconds per step rather than a CPU-speed busy-loop, so it plays the same on every machine; a 3-key input buffer makes quick U-turns (up then left) reliable; reversing straight into yourself is ignored instead of killing you.
- Touch pad (added 03OCT2026): it only appears when the browser reports a touch screen (
pointer:coarseor touch events), and the game picture shrinks so the pad fits below it upright or sideways. Buttons react onpointerdownrather than click, so there's no tap delay. When the game is waiting for "Push Space", any pad button continues. - Big (2x) mode (added 03OCT2026): a 7-inch tablet held upright gives the 80×50 board only ~7 px per square, too small for kids. Big mode halves the board instead of zooming, so the whole level stays visible. Levels are still written in the original coordinates and mapped onto the small grid; the dashed walls on levels 7 and 10 step twice as far so their gaps survive. Eating a number grows the snake half as much, since the board is a quarter of the size.
- Room for more games (04OCT2026): the games run on the player's device, so CT 125 only hands out a few hundred KB of files once per visit. Several kids playing at once, on the same or different games, is no load at all, and new games don't need more CPU or RAM. That would only change for a game that saves scores to the server or plays online. (10OCT2026: Super Pip and Dash Rush do play online, but their small relay runs in CT 127 (blockcraft), so CT 125 still only serves files.)
- Build time: creating CT 125 and installing Caddy took about a minute; the template extraction is the slow part.
- The CT uses DHCP like the other personal-site containers. If the router ever hands it a different address, only the edge block on CT 108 and the port monitor need updating.