My own open-wheel racer in the style of IndyCar Racing II (1995). The original is still a commercial game, so this is written from scratch. Play it at https://games.503mail.com/speedway/ 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
There are two tracks, SPEEDWAY (a big oval with grandstands) and HILL COUNTRY (hills, S-bends and trees), each 3 laps against 7 computer cars. The cars steer around each other and around you. Driving right behind a car gives a DRAFT speed boost, like real IndyCar. Grass slows you down, and hitting a tree or sign nearly stops you. There's a start-light countdown, lap and race timers, your place, and best times saved per track. The engine sound is synthesized and changes gear with speed. Auto gas (on by default on touch screens) means kids only have to steer. Easy makes the other cars slower and the grass less sticky.
Controls
Keyboard: ◀ ▶ steer, ▲ gas, ▼ brake, P pause, M sound. Touch: ◀ ▶ buttons on the left, BRAKE and GAS on the right, and holding a finger on the road steers toward it.
Rebuild, step by step
Speedway 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 Speedway tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/speedway
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/speedway/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/speedway/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>Speedway</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="Speedway">
<meta property="og:image" content="https://games.503mail.com/speedway/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;--padh:30vh;--b:min(20vw,calc(var(--padh) / 2.3))}
#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:fixed;inset:0;z-index:5;background:radial-gradient(rgba(16,16,64,.88),rgba(0,0,0,.94));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:.3em;font-size:1.8em}
#setup .sub{color:#55FFFF;letter-spacing:.3em;margin-top:-1.2vmin}
#setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
#setup button{font:inherit;cursor:pointer;border:0}
#setup #trk button{background:#0000AA;color:#fff;border:3px solid #555;padding:.4em 1em}
#setup #trk button.on{border-color:#FFFF55;color:#FFFF55}
#setup #trk small{color:#AAAAAA}
#setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
#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: steering on the left, pedals on the right */
.pad{display:none;gap:calc(var(--b) * .12);grid-template-columns:repeat(2,var(--b));grid-template-rows:repeat(2,var(--b))}
body.touch{display:grid;grid-template-areas:"w w" "l r";grid-template-columns:1fr 1fr;align-content:center;justify-items:center;row-gap:1.5vh}
body.touch #wrap{grid-area:w;width:min(100vw,calc((100vh - var(--padh)) * var(--ar)))}
body.touch .pad{display:grid}
#pl{grid-area:l} #pr{grid-area:r}
@media (orientation:landscape){
:root{--b:min(11vw,20vh)}
body.touch{grid-template-areas:"l w r";grid-template-columns:auto auto auto;column-gap:1.5vw;align-items:end;justify-content:center}
body.touch #wrap{width:min(calc(100vw - 4 * var(--b) - 8vw),calc(96vh * var(--ar)));align-self:center}
.pad{padding-bottom:4vh}
}
.pad button{background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:18%;
font:bold calc(var(--b) * .4)/1 "Lucida Console","Courier New",monospace;padding:0;
touch-action:none;user-select:none;-webkit-user-select:none}
.pad button.hit,.pad button.held{background:#5555FF}
.pad .gas{background:#007700;border-color:#55FF55;color:#fff;font-size:calc(var(--b) * .22)}
.pad .brk{background:#AA0000;border-color:#FF5555;color:#fff;font-size:calc(var(--b) * .2)}
.pad .p{color:#fff;border-color:#AAAAAA;font-size:calc(var(--b) * .28)}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="960" height="600"></canvas>
</div>
<div id="setup">
<h1>SPEEDWAY</h1>
<div class="sub">OPEN-WHEEL RACING</div>
<div class="row" id="trk">
<button data-t="oval">SPEEDWAY<br><small>big oval</small></button>
<button data-t="road">HILL COUNTRY<br><small>hills and turns</small></button>
</div>
<div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
<label><input type="checkbox" id="auto"> Auto gas</label>
<label><input type="checkbox" id="easy"> Easy</label></div>
<button id="go">RACE!</button>
<div id="hi"></div>
<div class="help">
<kbd>← →</kbd> steer · <kbd>↑</kbd> gas · <kbd>↓</kbd> brake · <kbd>P</kbd> pause · <kbd>M</kbd> sound<br>
3 laps against 7 cars. Stay on the road, grass slows you down.<br>
Drive right behind a car to <span style="color:#55FFFF">draft</span> and go faster!
</div>
<a href="/">Other games</a>
</div>
<div class="pad" id="pl" aria-label="Steering">
<span></span><span></span>
<button data-a="left" aria-label="Steer left">◀</button>
<button data-a="right" aria-label="Steer right">▶</button>
</div>
<div class="pad" id="pr" aria-label="Pedals">
<span></span><button class="p" data-a="pause" aria-label="Pause or continue">II</button>
<button class="brk" data-a="brake" aria-label="Brake">BRAKE</button>
<button class="gas" data-a="gas" aria-label="Gas">GAS</button>
</div>
<script>
(() => {
const LW = 320, LH = 200; // logical canvas size (drawn at 3x)
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;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v)), lerp = (a, b, p) => a + (b - a) * p;
const mod = (v, m) => ((v % m) + m) % m;
// ---- settings + best times (per-viewer convenience only) ----
let cfg = { sound:true, auto:isTouch, easy:false, track:'oval', best:{} };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('speedway') || '{}')); } catch (e) {}
if (!cfg.best || typeof cfg.best !== 'object') cfg.best = {};
const saveCfg = () => { try { localStorage.setItem('speedway', JSON.stringify(cfg)); } catch (e) {} };
// ---- sound: synthesized engine, beeps and bangs ----
let actx = null, noiseBuf = null, eng = null;
function audio() {
if (!cfg.sound) return null;
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
if (actx.state === 'suspended') actx.resume();
return actx;
}
function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
const a = audio(); if (!a) return;
const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
}
function boom(dur = 0.4, vol = 0.25, cutoff = 900) {
const a = audio(); if (!a) return;
if (!noiseBuf) {
noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate);
const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const t = a.currentTime, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(60, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.001, t + dur);
s.connect(f).connect(v).connect(a.destination); s.start(t); s.stop(t + dur);
}
const notes = (fs, gap, dur, vol = 0.04) => fs.forEach((f, i) => setTimeout(() => sweep(f, f, dur, 'square', vol), i * gap));
const SFX = {
beep: () => sweep(440, 440, 0.25, 'square', 0.05),
go: () => sweep(880, 880, 0.6, 'square', 0.05),
bump: () => boom(0.15, 0.15, 1800),
crash: () => boom(0.5, 0.3, 700),
lap: () => notes([523, 659, 784], 110, 0.1),
finish: () => notes([523, 659, 784, 1047, 784, 1047], 150, 0.14, 0.05),
};
// Engine: two oscillators through a low-pass filter; pitch follows speed and "changes gear".
function engineSet(on, pct) {
if (!eng) {
if (!on) return;
const a = audio(); if (!a) return;
const o1 = a.createOscillator(), o2 = a.createOscillator(), f = a.createBiquadFilter(), v = a.createGain();
o1.type = 'sawtooth'; o2.type = 'square'; f.type = 'lowpass'; f.frequency.value = 700; v.gain.value = 0;
o1.connect(f); o2.connect(f); f.connect(v).connect(a.destination); o1.start(); o2.start();
eng = { o1, o2, v, a };
}
const t = eng.a.currentTime, gear = Math.min(5, Math.floor(pct * 6)), fr = 55 + gear * 12 + (pct * 6 - gear) * 110;
eng.v.gain.setTargetAtTime(on && cfg.sound ? 0.035 : 0, t, 0.08);
eng.o1.frequency.setTargetAtTime(fr, t, 0.05); eng.o2.frequency.setTargetAtTime(fr / 2, t, 0.05);
}
// ---- pre-rendered sprites (shaded with gradients once, then reused) ----
function sprite(w, h, fn) { const c = document.createElement('canvas'); c.width = w * 3; c.height = h * 3; const x = c.getContext('2d'); x.scale(3, 3); fn(x); return c; }
function ball(x, cx, cy, r, light, mid, dark) {
const gr = x.createRadialGradient(cx - r * .35, cy - r * .4, r * .1, cx, cy, r);
gr.addColorStop(0, light); gr.addColorStop(.45, mid); gr.addColorStop(1, dark);
x.fillStyle = gr; x.beginPath(); x.arc(cx, cy, r, 0, 7); x.fill();
}
function rr(x, px, py, w, h, r) {
x.beginPath(); x.moveTo(px + r, py); x.arcTo(px + w, py, px + w, py + h, r); x.arcTo(px + w, py + h, px, py + h, r);
x.arcTo(px, py + h, px, py, r); x.arcTo(px, py, px + w, py, r); x.closePath();
}
// Open-wheel race car seen from behind.
function carSprite([light, body, dark, wing]) {
return sprite(64, 30, x => {
x.fillStyle = 'rgba(0,0,0,.35)'; x.beginPath(); x.ellipse(32, 28.5, 31, 2.5, 0, 0, 7); x.fill();
for (const tx of [1, 49]) {
const gr = x.createLinearGradient(tx, 0, tx + 14, 0); gr.addColorStop(0, '#111'); gr.addColorStop(.5, '#555'); gr.addColorStop(1, '#111');
x.fillStyle = gr; rr(x, tx, 11, 14, 18, 3); x.fill();
}
x.strokeStyle = '#222'; x.lineWidth = 1.2;
x.beginPath(); x.moveTo(15, 17); x.lineTo(24, 20); x.moveTo(15, 23); x.lineTo(24, 23); x.moveTo(49, 17); x.lineTo(40, 20); x.moveTo(49, 23); x.lineTo(40, 23); x.stroke();
x.fillStyle = dark; rr(x, 18, 17, 28, 9, 2); x.fill();
const gb = x.createLinearGradient(24, 0, 40, 0); gb.addColorStop(0, dark); gb.addColorStop(.45, light); gb.addColorStop(1, body);
x.fillStyle = gb; x.beginPath(); x.moveTo(23, 28); x.lineTo(41, 28); x.lineTo(37, 9); x.lineTo(27, 9); x.closePath(); x.fill();
x.fillStyle = '#111'; x.fillRect(22, 26, 20, 3);
x.fillStyle = '#f22'; x.fillRect(30.5, 22.5, 3, 2.5);
ball(x, 32, 10, 4.5, '#fff', wing, '#000');
x.fillStyle = '#222'; x.fillRect(31, 6, 2, 6);
const gw = x.createLinearGradient(0, 1, 0, 7); gw.addColorStop(0, '#fff'); gw.addColorStop(.4, wing); gw.addColorStop(1, dark);
x.fillStyle = gw; rr(x, 7, 1, 50, 6, 1); x.fill();
x.fillStyle = '#222'; x.fillRect(5.5, 0, 3, 11); x.fillRect(55.5, 0, 3, 11);
});
}
const PAL = [
['#ffaaaa', '#e22', '#700', '#FFFF55'], // player: red with a yellow wing
['#aaccff', '#25f', '#013', '#fff'], ['#bfb', '#2a2', '#030', '#ff5'], ['#ffeeaa', '#fc0', '#640', '#222'],
['#ecf', '#a3d', '#303', '#5ff'], ['#ffd0a0', '#f80', '#630', '#fff'], ['#cff', '#0bb', '#033', '#f55'], ['#fff', '#ccc', '#444', '#25f'],
];
const CARS = PAL.map(carSprite);
// Roadside things. w = width in road units (the road is 4000 wide).
const SP = {
tree: { w: 1400, img: sprite(40, 56, x => {
x.fillStyle = '#5a3a1a'; x.fillRect(17, 36, 6, 20);
ball(x, 12, 32, 10, '#9e6', '#2a2', '#031'); ball(x, 28, 32, 10, '#9e6', '#2a2', '#031'); ball(x, 20, 21, 16, '#bf8', '#3b3', '#031');
}) },
pine: { w: 1000, img: sprite(30, 60, x => {
x.fillStyle = '#5a3a1a'; x.fillRect(13, 48, 4, 12);
[[0, 50, 24], [5, 38, 18], [10, 26, 12]].forEach(([top, bot, hw], i) => {
const gr = x.createLinearGradient(15 - hw, 0, 15 + hw, 0); gr.addColorStop(0, '#7c7'); gr.addColorStop(.5, '#284'); gr.addColorStop(1, '#031');
x.fillStyle = gr; x.beginPath(); x.moveTo(15, top + i * 4); x.lineTo(15 + hw / 2 + 3, bot); x.lineTo(15 - hw / 2 - 3, bot); x.closePath(); x.fill();
});
}) },
bush: { w: 1000, img: sprite(30, 18, x => { ball(x, 9, 11, 7, '#ae7', '#3a3', '#031'); ball(x, 21, 11, 8, '#ae7', '#3a3', '#031'); ball(x, 15, 9, 8, '#cf9', '#4b4', '#031'); }) },
stand: { w: 4000, img: sprite(120, 50, x => {
x.fillStyle = '#666'; x.fillRect(4, 8, 2, 42); x.fillRect(114, 8, 2, 42); x.fillRect(59, 8, 2, 14);
x.fillStyle = '#a22'; x.fillRect(0, 4, 120, 6);
for (let r = 0; r < 7; r++) {
x.fillStyle = r % 2 ? '#8a8a8a' : '#9a9a9a'; x.fillRect(2, 12 + r * 5.4, 116, 5.4);
for (let c = 0; c < 38; c++) { x.fillStyle = ['#fc8', '#a64', '#f55', '#55f', '#ff5', '#fff', '#5c5'][(r * 7 + c * 3 + (c * r) % 5) % 7]; x.fillRect(4 + c * 3 + (r % 2) * 1.5, 12.5 + r * 5.4, 1.8, 2.4); }
}
x.fillStyle = '#ddd'; x.fillRect(0, 48, 120, 2);
}) },
chevL: null, chevR: null, banner: null, boards: [],
};
function chevron(dir) {
return { w: 800, img: sprite(30, 26, x => {
x.fillStyle = '#888'; x.fillRect(6, 14, 2, 12); x.fillRect(22, 14, 2, 12);
x.fillStyle = '#fd0'; rr(x, 0, 0, 30, 15, 2); x.fill();
x.fillStyle = '#111';
for (const cx of [9, 19]) {
x.beginPath(); x.moveTo(cx - 4 * dir, 2.5); x.lineTo(cx + 4 * dir, 7.5); x.lineTo(cx - 4 * dir, 12.5); x.lineTo(cx - 1 * dir, 12.5); x.lineTo(cx + 7 * dir, 7.5); x.lineTo(cx - 1 * dir, 2.5);
x.closePath(); x.fill();
}
}) };
}
SP.chevL = chevron(-1); SP.chevR = chevron(1);
SP.boards = [['503MAIL', '#0000AA', '#FFFF55'], ['NIBBLES', '#000', '#FFDC00'], ['TETRIS', '#a3d', '#fff'], ['STAR KILLER', '#111', '#55FFFF']].map(([t, bg, fg]) => ({ w: 2400, img: sprite(80, 46, x => {
x.fillStyle = '#666'; x.fillRect(14, 30, 4, 16); x.fillRect(62, 30, 4, 16);
x.fillStyle = '#fff'; x.fillRect(0, 0, 80, 31); x.fillStyle = bg; x.fillRect(2, 2, 76, 27);
x.fillStyle = fg; x.font = 'bold 12px "Lucida Console","Courier New",monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(t, 40, 16);
}) }));
SP.banner = { w: 5400, img: sprite(200, 70, x => {
x.fillStyle = '#777'; x.fillRect(4, 12, 6, 58); x.fillRect(190, 12, 6, 58);
x.fillStyle = '#000'; x.fillRect(0, 0, 200, 20);
for (let i = 0; i < 50; i++) { x.fillStyle = i % 2 ? '#000' : '#fff'; x.fillRect(i * 4, 0, 4, 3); x.fillStyle = i % 2 ? '#fff' : '#000'; x.fillRect(i * 4, 17, 4, 3); }
x.fillStyle = '#fff'; x.font = 'bold 11px "Lucida Console","Courier New",monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('START · FINISH', 100, 10.5);
}) };
// Far mountains and near hills, drawn so the left and right edges join up for scrolling.
const BGW = LW * 2, TAU = Math.PI * 2;
function ridge(h, color, top, fn) {
return sprite(BGW, h, x => {
x.fillStyle = color; x.beginPath(); x.moveTo(0, h);
for (let px = 0; px <= BGW; px += 2) x.lineTo(px, top + fn(px / BGW));
x.lineTo(BGW, h); x.closePath(); x.fill();
});
}
const FAR = ridge(60, '#6b7fb2', 26, u => 12 * Math.sin(TAU * 3 * u) + 7 * Math.sin(TAU * 7 * u + 1) + 4 * Math.sin(TAU * 13 * u + 2));
const NEAR = ridge(30, '#2f7d36', 16, u => 5 * Math.sin(TAU * 5 * u) + 3 * Math.sin(TAU * 11 * u + .5));
const SKY = g.createLinearGradient(0, 0, 0, LH / 2); SKY.addColorStop(0, '#2a5fcf'); SKY.addColorStop(1, '#a8d0ff');
// ---- the road: a list of short segments, each with a bend and a height (classic "pseudo 3D") ----
const SEG = 200, RUMBLE = 3, ROADW = 2000, LANES = 3, CAM_H = 1000, DEPTH = 1 / Math.tan(50 * Math.PI / 180), DRAW = 220;
const PLAYER_Z = CAM_H * DEPTH, MAXV = SEG * 60, LAPS = 3, CAR_W = 560;
const ACCEL = MAXV / 5, BRAKE = -MAXV, DECEL = -MAXV / 5, OFF_DECEL = -MAXV / 2, OFF_LIMIT = MAXV / 4;
let segs = [], N = 0, L = 1;
function rng(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const lastY = () => segs.length ? segs[segs.length - 1].p2.world.y : 0;
function addSeg(curve, y) {
const n = segs.length;
segs.push({ i: n, curve, sprites: [], dark: Math.floor(n / RUMBLE) % 2 === 1,
p1: { world: { y: lastY(), z: n * SEG }, camera: {}, screen: {} },
p2: { world: { y, z: (n + 1) * SEG }, camera: {}, screen: {} } });
}
const easeIn = (a, b, p) => a + (b - a) * p * p;
const easeInOut = (a, b, p) => a + (b - a) * (-Math.cos(p * Math.PI) / 2 + .5);
function road(enter, hold, leave, curve = 0, hill = 0) {
const y0 = lastY(), y1 = y0 + hill * SEG, t = enter + hold + leave;
for (let n = 0; n < enter; n++) addSeg(easeIn(0, curve, n / enter), easeInOut(y0, y1, n / t));
for (let n = 0; n < hold; n++) addSeg(curve, easeInOut(y0, y1, (enter + n) / t));
for (let n = 0; n < leave; n++) addSeg(easeInOut(curve, 0, n / leave), easeInOut(y0, y1, (enter + hold + n) / t));
}
const add = (i, s, offset, obstacle = true) => segs[i].sprites.push({ s, offset, obstacle });
const TRACKS = {
oval: { grass: ['#22aa22', '#1d991d'], build(R) {
road(0, 160, 0); road(40, 220, 40, -2); road(0, 300, 0); road(40, 220, 40, -2); road(0, 140, 0);
for (let i = 0; i < segs.length; i++) {
const c = segs[i].curve;
if ((i < 150 || i > 1070) && i % 7 === 3) add(i, SP.stand, 2.7); // grandstands along the front stretch
else if (c && i % 14 === 0) add(i, SP.chevL, 1.55); // turn arrows on the outside
else if (i > 470 && i < 750 && i % 45 === 0) add(i, SP.boards[(i / 45 | 0) % 4], 2.1);
if (i % 23 === 11) add(i, R() < .5 ? SP.tree : SP.bush, -(2 + R() * 3)); // infield
}
} },
road: { grass: ['#3a9d23', '#33901f'], build(R) {
road(0, 60, 0); road(30, 60, 30, 0, 20); road(30, 60, 30, 3, 0); road(25, 40, 25, 0, -25);
road(25, 30, 25, -4, 0); road(20, 25, 20, 4, 10); road(20, 25, 20, -4, 0); road(30, 100, 30, 0, 40);
road(30, 50, 30, 5, 0); road(30, 80, 30, 0, -40); road(25, 60, 25, -3, 10); road(25, 30, 25, 2, -10);
road(0, 120, 0); road(30, 70, 30, 4, 15); road(30, 60, 30, -2, 0); road(50, 50, 50, 0, -lastY() / SEG); road(0, 60, 0);
const n = segs.length;
for (let i = 0; i < n; i++) {
const c = segs[i].curve;
if ((i < 50 || i > n - 40) && i % 7 === 3) add(i, SP.stand, 2.7);
else if (Math.abs(c) >= 2.5 && i % 10 === 0) add(i, c > 0 ? SP.chevR : SP.chevL, c > 0 ? -1.55 : 1.55);
else if (i % 170 === 85) add(i, SP.boards[(i / 170 | 0) % 4], R() < .5 ? -2.1 : 2.1);
else if (i % 5 === 0 && R() < .8) add(i, R() < .5 ? SP.tree : SP.pine, (R() < .5 ? -1 : 1) * (1.7 + R() * 2.5));
if (i % 9 === 4 && R() < .5) add(i, SP.bush, (R() < .5 ? -1 : 1) * (1.5 + R() * .8));
}
} },
};
function buildTrack(key) {
segs = []; TRACKS[key].build(rng(key === 'oval' ? 7 : 11));
add(0, SP.banner, 0, false);
N = segs.length; L = N * SEG; segs[N - 1].p2.world.y = 0;
}
// ---- game state ----
let mode = 'setup', resumeTo = 'race', T, position = 0, playerX = 0, playerY = 0, speed = 0, pDist = 0, cars = [];
let countT = 0, raceT = 0, lapT = 0, lapsDone = 0, lapTimes = [], place = 0, finishOrder = 0, finishT = 0;
let bgFar = 0, bgNear = 0, steer = 0, msg = '', msgT = 0, crashT = 0, draft = false, newRec = false, ptr = null;
const held = { left:false, right:false, gas:false, brake:false };
const segAt = z => segs[Math.floor(mod(z, L) / SEG)];
const playing = () => mode === 'count' || mode === 'race';
function prepare(key) {
T = TRACKS[key]; buildTrack(key);
const tops = [.93, .91, .89, .87, .85, .83, .80].sort(() => Math.random() - .5);
cars = tops.map((top, i) => {
const row = Math.floor(i / 2), off = i % 2 ? .45 : -.45;
return { spr: CARS[i + 1], dist: -SEG * (3 + row * 5), offset: off, lane: (Math.random() - .5) * 1.2, top, speed: 0, done: 0 };
});
pDist = -SEG * 18; playerX = .45; speed = 0; steer = 0; // the player starts last, on the right of row 4
raceT = 0; lapT = 0; lapsDone = 0; lapTimes = []; place = 0; finishOrder = 0; msgT = 0; crashT = 0; draft = false; newRec = false;
for (const c of cars) c.z = mod(c.dist, L);
position = mod(pDist - PLAYER_Z, L);
}
function newRace() { prepare(cfg.track); countT = 3.99; mode = 'count'; }
function finish() {
place = ++finishOrder; mode = 'finish'; finishT = 2.5; SFX.finish();
const best = cfg.best[cfg.track] || {}, lap = Math.min(...lapTimes);
newRec = !best.race || raceT < best.race;
cfg.best[cfg.track] = { race: Math.min(raceT, best.race || Infinity), lap: Math.min(lap, best.lap || Infinity) };
saveCfg();
}
// ---- update ----
function update(dt) {
if (mode === 'count') {
const before = Math.ceil(countT); countT -= dt;
if (Math.ceil(countT) !== before && countT > 0) SFX.beep();
if (countT <= 0) { mode = 'race'; SFX.go(); msg = 'GO!'; msgT = 1; }
}
const racing = mode === 'race' || mode === 'finish' || mode === 'over';
const pSeg = segAt(pDist), pct = speed / MAXV;
// player controls (after the finish the car cruises by itself)
let gas = false, brake = false, st = 0;
if (mode === 'race') {
brake = held.brake; gas = held.gas || ptr != null || (cfg.auto && !brake);
st = (held.right ? 1 : 0) - (held.left ? 1 : 0);
if (ptr != null) st = clamp((ptr - LW / 2) / (LW * .2), -1, 1);
} else if (racing) { gas = speed < MAXV * .5; st = clamp(-playerX * 3, -1, 1); }
const dx = dt * 2 * pct;
playerX += dx * st;
if (mode === 'race') playerX -= dx * pct * pSeg.curve * (cfg.easy ? .2 : .3); // bends push the car outwards
steer = st;
const top = MAXV * (draft ? 1.08 : 1);
speed += (gas ? ACCEL : brake ? BRAKE : DECEL) * dt;
const off = Math.abs(playerX) > 1.05;
if (off && speed > OFF_LIMIT) speed += (cfg.easy ? OFF_DECEL / 2 : OFF_DECEL) * dt;
if (speed > top) speed = Math.max(top, speed + DECEL * dt);
speed = Math.max(0, speed);
playerX = clamp(playerX, -2.6, 2.6);
// hitting trees, signs and stands
if (off) for (const sp of pSeg.sprites) {
if (!sp.obstacle || Math.abs(playerX - sp.offset) > sp.s.w / ROADW / 2 + CAR_W / ROADW / 2) continue;
if (speed > MAXV * .15) { SFX.crash(); speed = MAXV / (cfg.easy ? 4 : 6); pDist -= SEG * .5; crashT = .4; }
playerX -= Math.sign(playerX) * dt;
break;
}
if (racing) pDist += speed * dt;
// the other cars
const tgtScale = (cfg.easy ? .82 : 1) * MAXV;
for (const c of cars) {
let tgt = racing ? c.top * tgtScale : 0;
if (c.done) tgt *= .6;
else if (mode === 'race') { const gap = c.dist - pDist; if (gap > L * .25) tgt *= .93; else if (gap < -L * .25) tgt *= 1.04; }
c.speed += Math.sign(tgt - c.speed) * Math.min(Math.abs(tgt - c.speed), ACCEL * .9 * dt);
// steer around whatever is just ahead (the player or another car)
let avoid = 0;
const ahead = (d, x, v) => {
if (d <= 0 || d > SEG * 6 || Math.abs(x - c.offset) > .34 || v >= c.speed) return;
avoid = c.offset >= x ? 1 : -1;
if (Math.abs(c.offset + avoid * .3) > .85) avoid = -avoid;
if (d < SEG * 1.1) c.speed = Math.min(c.speed, v);
};
if (racing) { ahead(mod(pDist - c.dist, L), playerX, speed); for (const o of cars) if (o !== c) ahead(mod(o.dist - c.dist, L), o.offset, o.speed); }
if (avoid) c.offset += avoid * dt * .9;
else c.offset += Math.sign(c.lane - c.offset) * Math.min(Math.abs(c.lane - c.offset), dt * .2);
c.offset = clamp(c.offset, -.85, .85);
c.dist += c.speed * dt; c.z = mod(c.dist, L);
if (!c.done && c.dist >= LAPS * L) c.done = ++finishOrder;
}
// bumping into the back of a car, and drafting behind one
draft = false;
if (mode === 'race') for (const c of cars) {
const d = mod(c.dist - pDist, L), side = Math.abs(playerX - c.offset);
if (d < SEG * .9 && side < CAR_W / ROADW * .9 && speed > c.speed) { speed = c.speed * .8; pDist -= SEG * .9 - d; SFX.bump(); crashT = .2; }
else if (d > SEG && d < SEG * 7 && side < .3 && speed > MAXV * .6) draft = true;
}
// laps
if (mode === 'race') {
raceT += dt; lapT += dt;
const done = pDist < 0 ? 0 : Math.floor(pDist / L);
if (done > lapsDone) {
lapsDone = done; lapTimes.push(lapT); lapT = 0;
if (lapsDone >= LAPS) finish();
else { SFX.lap(); msg = lapsDone === LAPS - 1 ? 'FINAL LAP' : 'LAP ' + (lapsDone + 1); msgT = 2; }
}
}
if (mode === 'finish' && (finishT -= dt) <= 0) mode = 'over';
position = mod(pDist - PLAYER_Z, L);
bgFar += pSeg.curve * pct * dt * 4; bgNear += pSeg.curve * pct * dt * 9;
msgT = Math.max(0, msgT - dt); crashT = Math.max(0, crashT - dt);
}
// ---- drawing ----
function text(s, x, y, color, size = 10, align = 'left') {
g.font = `bold ${size}px "Lucida Console","Courier New",monospace`; g.textAlign = align; g.textBaseline = 'middle';
g.fillStyle = '#000'; g.fillText(s, x + .8, y + .8);
g.fillStyle = color; g.fillText(s, x, y);
}
function quad(x1, y1, x2, y2, x3, y3, x4, y4, color) {
g.fillStyle = color; g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.lineTo(x3, y3); g.lineTo(x4, y4); g.closePath(); g.fill();
}
function project(p, cx, cy, cz) {
p.camera.y = p.world.y - cy; p.camera.z = p.world.z - cz;
const s = p.screen; s.scale = DEPTH / p.camera.z;
s.x = Math.round((LW / 2 - s.scale * cx * LW / 2) * 3) / 3;
s.y = Math.round((LH / 2 - s.scale * p.camera.y * LH / 2) * 3) / 3;
s.w = s.scale * ROADW * LW / 2;
}
function drawSeg(s) {
const a = s.p1.screen, b = s.p2.screen, top = b.y - .34;
g.fillStyle = T.grass[s.dark ? 1 : 0]; g.fillRect(0, top, LW, a.y - top);
const r1 = a.w / 7, r2 = b.w / 7, rc = s.dark ? '#d00' : '#fff';
quad(a.x - a.w - r1, a.y, a.x - a.w, a.y, b.x - b.w, top, b.x - b.w - r2, top, rc);
quad(a.x + a.w + r1, a.y, a.x + a.w, a.y, b.x + b.w, top, b.x + b.w + r2, top, rc);
if (s.i < 2) { // chequered start/finish line
for (let k = 0; k < 10; k++) {
const f0 = k / 10 * 2 - 1, f1 = (k + 1) / 10 * 2 - 1;
quad(a.x + a.w * f0, a.y, a.x + a.w * f1, a.y, b.x + b.w * f1, top, b.x + b.w * f0, top, (k + s.i) % 2 ? '#111' : '#fff');
}
return;
}
quad(a.x - a.w, a.y, a.x + a.w, a.y, b.x + b.w, top, b.x - b.w, top, s.dark ? '#6a6a6a' : '#727272');
if (!s.dark) {
const l1 = a.w / 40, l2 = b.w / 40;
for (let ln = 1; ln < LANES; ln++) {
const x1 = a.x - a.w + a.w * 2 / LANES * ln, x2 = b.x - b.w + b.w * 2 / LANES * ln;
quad(x1 - l1, a.y, x1 + l1, a.y, x2 + l2, top, x2 - l2, top, '#ddd');
}
}
}
function drawSpr(img, worldW, scale, x, y, clip) {
const w = worldW * scale * LW / 2, h = w * img.height / img.width, dx = x - w / 2, dy = y - h;
if (w > LW * 3 || dy > LH || dx > LW || dx + w < 0) return;
const cut = Math.max(0, dy + h - clip);
if (cut >= h) return;
g.drawImage(img, 0, 0, img.width, img.height * (1 - cut / h), dx, dy, w, h - cut);
}
function tile(img, off, y) {
const w = BGW, h = img.height / 3, x = -mod(off, w);
g.drawImage(img, x, y, w, h); g.drawImage(img, x + w, y, w, h);
}
function drawCar(c, s, clip) {
const p = (c.z % SEG) / SEG, sc = lerp(s.p1.screen.scale, s.p2.screen.scale, p);
drawSpr(c.spr, CAR_W, sc, lerp(s.p1.screen.x, s.p2.screen.x, p) + sc * c.offset * ROADW * LW / 2, lerp(s.p1.screen.y, s.p2.screen.y, p), clip);
}
function drawPlayer(pSeg, pPct) {
const sc = DEPTH / PLAYER_Z, jig = crashT > 0 || Math.abs(playerX) > 1.05 ? (Math.random() - .5) * 2.4 : speed > 0 ? Math.random() * .5 : 0;
const y = LH / 2 - sc * lerp(pSeg.p1.camera.y, pSeg.p2.camera.y, pPct) * LH / 2 + jig;
const img = CARS[0], w = CAR_W * sc * LW / 2, h = w * img.height / img.width;
g.save(); g.translate(LW / 2, y); g.rotate(speed > 0 ? steer * .05 : 0); g.drawImage(img, -w / 2, -h, w, h); g.restore();
}
function render() {
const base = segAt(position), basePct = mod(position, SEG) / SEG;
const pz = mod(position + PLAYER_Z, L), pSeg = segAt(pz), pPct = (pz % SEG) / SEG;
playerY = lerp(pSeg.p1.world.y, pSeg.p2.world.y, pPct);
g.fillStyle = SKY; g.fillRect(0, 0, LW, LH / 2);
tile(FAR, bgFar, LH / 2 + 4 - 60); tile(NEAR, bgNear, LH / 2 + 4 - 30);
g.fillStyle = T.grass[1]; g.fillRect(0, LH / 2 + 3.5, LW, LH);
let maxy = LH, x = 0, dx = -(base.curve * basePct);
for (let n = 0; n < DRAW; n++) {
const s = segs[(base.i + n) % N], cz = position - (s.i < base.i ? L : 0);
s.clip = maxy;
project(s.p1, playerX * ROADW - x, playerY + CAM_H, cz);
project(s.p2, playerX * ROADW - x - dx, playerY + CAM_H, cz);
x += dx; dx += s.curve;
if (s.p1.camera.z <= DEPTH || s.p2.screen.y >= s.p1.screen.y || s.p2.screen.y >= maxy) continue;
drawSeg(s);
maxy = s.p1.screen.y;
}
// roadside things and cars, far to near
const bucket = new Map();
for (const c of cars) {
const n = (Math.floor(c.z / SEG) - base.i + N) % N;
if (n > 0 && n < DRAW) { if (!bucket.has(n)) bucket.set(n, []); bucket.get(n).push(c); }
}
for (let n = DRAW - 1; n > 0; n--) {
const s = segs[(base.i + n) % N], sc = s.p1.screen.scale;
for (const sp of s.sprites) drawSpr(sp.s.img, sp.s.w, sc, s.p1.screen.x + sc * sp.offset * ROADW * LW / 2, s.p1.screen.y, s.clip);
const here = (bucket.get(n) || []).sort((a, b) => b.z - a.z);
if (s === pSeg) {
for (const c of here) if (c.z > pz) drawCar(c, s, s.clip);
drawPlayer(pSeg, pPct);
for (const c of here) if (c.z <= pz) drawCar(c, s, LH);
} else for (const c of here) drawCar(c, s, s.clip);
}
}
const fmt = t => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, '0')}`;
const ORD = ['', '1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th'];
function dialog(rows) {
const w = 190, h = rows.length * 14 + 20, x = (LW - w) / 2, y = (LH - h) / 2;
g.fillStyle = 'rgba(0,0,120,.9)'; g.fillRect(x, y, w, h);
g.strokeStyle = '#55FFFF'; g.lineWidth = 1; g.strokeRect(x + 4, y + 4, w - 8, h - 8);
rows.forEach((s, i) => text(s, LW / 2, y + 17 + i * 14, i === 0 ? '#FFFF55' : '#fff', i === 0 ? 13 : 10, 'center'));
}
function draw() {
render();
if (mode === 'setup') return;
const pos = place || 1 + cars.filter(c => c.dist > pDist).length;
text(`LAP ${Math.min(lapsDone + 1, LAPS)}/${LAPS}`, 6, 9, '#fff', 10);
text(`${ORD[pos]} PLACE`, 6, 21, '#FFFF55', 10);
text(fmt(raceT), LW - 6, 9, '#fff', 10, 'right');
text('LAP ' + fmt(lapT), LW - 6, 21, '#AAAAAA', 8, 'right');
text(Math.round(speed / MAXV * 220) + ' MPH', 6, LH - 9, '#fff', 11);
if (draft) text('DRAFT!', LW / 2, 10, '#55FFFF', 10, 'center');
if (mode === 'count' || (mode === 'race' && raceT < 1)) {
const lit = mode === 'count' ? 4 - Math.ceil(countT) : 3;
g.fillStyle = '#111'; g.fillRect(LW / 2 - 30, 30, 60, 20);
for (let i = 0; i < 3; i++) {
g.fillStyle = mode === 'race' ? '#2f2' : i < lit ? '#f22' : '#400';
g.beginPath(); g.arc(LW / 2 + (i - 1) * 18, 40, 7, 0, 7); g.fill();
}
}
if (msgT > 0) text(msg, LW / 2, 70, '#FFFF55', 20, 'center');
if (mode === 'finish') text(place === 1 ? 'YOU WIN!' : 'FINISH!', LW / 2, 70, '#FFFF55', 22, 'center');
if (mode === 'paused') dialog(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
if (mode === 'over') {
const rows = [place === 1 ? 'YOU WIN!' : `${ORD[place]} PLACE`, '', `Time ${fmt(raceT)}`, `Best lap ${fmt(Math.min(...lapTimes))}`];
if (newRec) rows.push('NEW RECORD!');
dialog(rows.concat(['', isTouch ? 'Tap to continue' : 'Push Space']));
}
}
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (mode !== 'paused' && mode !== 'setup') update(dt);
engineSet(mode !== 'paused' && mode !== 'setup', speed / MAXV);
draw();
requestAnimationFrame(frame);
}
// ---- control ----
function pause() { if (playing() || mode === 'finish') { resumeTo = mode; mode = 'paused'; for (const k in held) held[k] = false; } }
function proceed() {
if (mode === 'paused') { mode = resumeTo; last = performance.now(); }
else if (mode === 'over') openSetup();
}
function showBest() {
const b = cfg.best[cfg.track];
document.getElementById('hi').textContent = b && b.race ? `Best: ${fmt(b.race)} · best lap ${fmt(b.lap)}` : '';
document.querySelectorAll('#trk button').forEach(btn => btn.classList.toggle('on', btn.dataset.t === cfg.track));
}
function openSetup() {
mode = 'setup'; prepare(cfg.track);
document.getElementById('snd').checked = cfg.sound;
document.getElementById('auto').checked = cfg.auto;
document.getElementById('easy').checked = cfg.easy;
showBest();
setupEl.classList.remove('hidden');
}
function startFromSetup() { saveCfg(); setupEl.classList.add('hidden'); audio(); newRace(); }
const KEYS = { ArrowLeft:'left', a:'left', ArrowRight:'right', d:'right', ArrowUp:'gas', w:'gas', ArrowDown:'brake', s:'brake' };
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]) { e.preventDefault(); if (playing()) held[KEYS[k]] = true; }
if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); return; }
if (playing() || mode === 'finish') { if (k === 'p' || k === 'Escape') pause(); return; }
if (!e.repeat && (k === ' ' || k === 'Enter' || k === 'p')) proceed();
});
addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (KEYS[k]) held[KEYS[k]] = false; });
addEventListener('blur', () => { for (const k in held) held[k] = false; });
// Holding a finger on the road steers toward it (and presses the gas).
const toLogicalX = e => (e.clientX - cv.getBoundingClientRect().left) / cv.getBoundingClientRect().width * LW;
cv.addEventListener('pointerdown', e => {
e.preventDefault();
if (playing()) { cv.setPointerCapture(e.pointerId); ptr = toLogicalX(e); }
else proceed();
});
cv.addEventListener('pointermove', e => { if (ptr != null) ptr = toLogicalX(e); });
['pointerup', 'pointercancel'].forEach(t => cv.addEventListener(t, () => { ptr = null; }));
// On-screen buttons act while held.
if (isTouch) document.body.classList.add('touch');
document.querySelectorAll('.pad button').forEach(b => {
const a = b.dataset.a;
const up = () => { if (a !== 'pause') held[a] = false; b.classList.remove('held'); };
b.addEventListener('pointerdown', e => {
e.preventDefault(); b.setPointerCapture(e.pointerId);
if (!playing()) { if (mode === 'paused' || mode === 'over') proceed(); else if (a === 'pause') pause(); return; }
if (a === 'pause') { pause(); b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 120); return; }
held[a] = true; b.classList.add('held');
});
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, up));
});
// Pause automatically if the tablet switches away from the game.
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });
// ---- setup screen ----
document.querySelectorAll('#trk button').forEach(b => b.addEventListener('click', () => { cfg.track = b.dataset.t; prepare(cfg.track); showBest(); }));
document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; if (cfg.sound) SFX.beep(); });
document.getElementById('auto').addEventListener('change', e => { cfg.auto = e.target.checked; });
document.getElementById('easy').addEventListener('change', e => { cfg.easy = e.target.checked; });
document.getElementById('go').addEventListener('click', startFromSetup);
openSetup();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
EOF
3. Copy the icons
make-icons.ps1 (from the games site post) draws this game's icons into a speedway subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes speedway\icon-512.png speedway\icon-192.png speedway\apple-touch-icon.png speedway\favicon-32.png proxmox:/root/nibbles/speedway/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a speedway 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/speedway/
Then open https://games.503mail.com/speedway/ in a browser.
Updating it later
Edit index.html, paste the new version over /root/nibbles/speedway/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 'speedway'. Clearing the site's data in the browser resets them; nothing is stored on the server.
Notes
- How the 3D works: the classic "pseudo 3D" trick from 80s arcade racers. The track is a list of short segments, each with a bend and a height, projected onto the screen from back to front. That's cheap enough for a Fire tablet.
- Tuned so kids can win: in a test race a script driving flat out down the middle finished 8th, stuck behind traffic, so the computer cars were slowed a little.