My own police-chase racer in the style of Need for Speed: Hot Pursuit 2 (2002), built on the Speedway road engine. Play it at https://games.503mail.com/highwayheat/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.
What's in it
It's set on a four-lane coast highway at sunset, with palm trees, beach houses, slow traffic and trucks. Getaway mode: pick one of five cars and reach the end of the road before the clock runs out. The cars are Viper (all-rounder), Bolt (fast), Arrow (quickest and grippiest), TORO (a lime-green wedge in the style of a Lamborghini) and F-ONE (papaya orange, in the style of a McLaren F1 with its roof scoop and round tail lights, and the highest top speed). Checkpoints add time. Police cars chase from behind with flashing lights and a siren that gets louder as they close in. More join every 16 seconds, and two roadblocks leave a single gap to squeeze through. A BUSTED bar fills while a police car is touching you, or while you crawl with police nearby. Outrun a cop far enough and you "lose" him. Cop mode: you drive the police car and have to bump into the back of a speeder 3 times (2 on Easy) before he gets away. Both modes have 3 nitro boosts, a countdown start and saved best times.
Controls
Keyboard: ◀ ▶ steer, ▲ gas, ▼ brake, Space nitro, P pause, M sound. Touch: NOS and ◀ ▶ on the left, BRAKE, GAS and pause on the right, and holding a finger on the road steers toward it. Auto gas is on by default on touch screens.
Rebuild, step by step
Highway Heat 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 Highway Heat tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/highwayheat
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/highwayheat/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/highwayheat/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>Highway Heat</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="Highway Heat">
<meta property="og:image" content="https://games.503mail.com/highwayheat/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(70,20,40,.9),rgba(0,0,0,.95));color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2vmin;font-size:clamp(12px,2.5vmin,21px);text-align:center;padding:16px;box-sizing:border-box}
#setup.hidden{display:none}
#setup h1{margin:0;color:#FFAA33;letter-spacing:.3em;font-size:1.8em;text-shadow:3px 3px #600}
#setup .sub{color:#FF5555;letter-spacing:.3em;margin-top:-1.2vmin}
#setup .row{display:flex;gap:.8em;align-items:center;flex-wrap:wrap;justify-content:center}
#setup button{font:inherit;cursor:pointer;border:0}
#setup .pick button{background:#222;color:#fff;border:3px solid #555;padding:.35em .9em}
#setup .pick button.on{border-color:#FFFF55;color:#FFFF55}
#setup .pick 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}
.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 .nos{background:#aa5500;border-color:#FFAA33;color:#fff;font-size:calc(var(--b) * .22)}
.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>HIGHWAY HEAT</h1>
<div class="sub">POLICE CHASE</div>
<div class="row pick" id="modes">
<button data-m="run">GETAWAY<br><small>outrun the police</small></button>
<button data-m="cop">COP<br><small>catch the speeder</small></button>
</div>
<div class="row pick" id="cars"></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">DRIVE!</button>
<div id="hi"></div>
<div class="help">
<kbd>← →</kbd> steer · <kbd>↑</kbd> gas · <kbd>↓</kbd> brake · <kbd>Space</kbd> nitro · <kbd>P</kbd> pause · <kbd>M</kbd> sound<br>
<b>Getaway:</b> reach the end of the coast road before time runs out. Checkpoints add time.
Don't let police cars box you in: the BUSTED bar fills while they touch you.<br>
<b>Cop:</b> chase the speeder down and bump into the back of the car 3 times.
</div>
<a href="/">Other games</a>
</div>
<div class="pad" id="pl" aria-label="Steering">
<span></span><button class="nos" data-a="nitro" aria-label="Nitro">NOS</button>
<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 + records (per-viewer convenience only) ----
let cfg = { sound:true, auto:isTouch, easy:false, mode:'run', car:0, best:{} };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('highwayheat') || '{}')); } catch (e) {}
if (!(cfg.car >= 0 && cfg.car < 5)) cfg.car = 0;
if (!cfg.best || typeof cfg.best !== 'object') cfg.best = {};
const saveCfg = () => { try { localStorage.setItem('highwayheat', JSON.stringify(cfg)); } catch (e) {} };
// ---- sound: engine, siren, bangs ----
let actx = null, noiseBuf = null, eng = null, siren = 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.2, 0.18, 1600), crash: () => boom(0.5, 0.3, 700),
check: () => notes([659, 784, 1047], 100, 0.12), nitro: () => { boom(0.8, 0.12, 3000); sweep(200, 900, .6, 'sawtooth', .03); },
win: () => notes([523, 659, 784, 1047, 784, 1047], 150, 0.14, 0.05), lose: () => notes([392, 330, 262, 196], 220, 0.25, 0.05),
lost: () => notes([784, 988], 90, 0.1),
};
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 = 800; 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 = 60 + gear * 14 + (Math.min(pct, 1.2) * 6 - gear) * 120;
eng.v.gain.setTargetAtTime(on && cfg.sound ? 0.035 : 0, t, 0.08);
eng.o1.frequency.setTargetAtTime(Math.max(40, fr), t, 0.05); eng.o2.frequency.setTargetAtTime(Math.max(20, fr / 2), t, 0.05);
}
// the siren: a tone swept up and down by a slow oscillator; loudness follows how close the police are
function sirenSet(vol) {
if (!siren) {
if (vol <= 0) return;
const a = audio(); if (!a) return;
const o = a.createOscillator(), lfo = a.createOscillator(), lg = a.createGain(), v = a.createGain();
o.type = 'triangle'; o.frequency.value = 900; lfo.type = 'sine'; lfo.frequency.value = 1.6; lg.gain.value = 300; v.gain.value = 0;
lfo.connect(lg).connect(o.frequency); o.connect(v).connect(a.destination); o.start(); lfo.start();
siren = { v, a };
}
siren.v.gain.setTargetAtTime(cfg.sound ? vol : 0, siren.a.currentTime, 0.15);
}
// ---- sprites ----
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();
}
// low sports car from behind; lights = police light bar colours (null for none)
function sportsCar(body, dark, lights, spoiler = true) {
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();
x.fillStyle = '#111'; rr(x, 3, 19, 11, 10, 2); x.fill(); rr(x, 50, 19, 11, 10, 2); x.fill();
const gb = x.createLinearGradient(0, 8, 0, 27); gb.addColorStop(0, body); gb.addColorStop(.6, body); gb.addColorStop(1, dark);
x.fillStyle = gb; x.beginPath(); x.moveTo(2, 26); x.lineTo(2, 17); x.quadraticCurveTo(6, 11, 16, 10); x.lineTo(48, 10); x.quadraticCurveTo(58, 11, 62, 17); x.lineTo(62, 26); x.closePath(); x.fill();
x.fillStyle = '#1a2a3a'; x.beginPath(); x.moveTo(17, 11); x.lineTo(21, 4); x.lineTo(43, 4); x.lineTo(47, 11); x.closePath(); x.fill();
x.fillStyle = 'rgba(255,255,255,.25)'; x.beginPath(); x.moveTo(22, 5); x.lineTo(28, 5); x.lineTo(24, 10); x.lineTo(19, 10); x.closePath(); x.fill();
x.fillStyle = '#f22'; x.fillRect(5, 16, 11, 3); x.fillRect(48, 16, 11, 3);
x.fillStyle = '#ff9'; x.fillRect(6, 16.5, 3, 2); x.fillRect(55, 16.5, 3, 2);
x.fillStyle = '#222'; x.fillRect(18, 20, 28, 5); x.fillStyle = '#ddd'; x.fillRect(27, 21, 10, 3);
x.fillStyle = '#555'; x.fillRect(22, 25, 4, 2); x.fillRect(38, 25, 4, 2);
if (spoiler) { x.fillStyle = dark; x.fillRect(6, 7, 52, 2.5); x.fillRect(12, 9, 2, 3); x.fillRect(50, 9, 2, 3); }
if (lights) { x.fillStyle = '#333'; x.fillRect(22, 1.5, 20, 3); x.fillStyle = lights[0]; x.fillRect(23, 1, 8, 3); x.fillStyle = lights[1]; x.fillRect(33, 1, 8, 3); }
});
}
// two supercar shapes: 'wedge' (sharp, angular, like a Lamborghini) and 'round' (smooth, roof scoop, like a McLaren F1)
function supercar(shape, body, dark) {
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();
x.fillStyle = '#111'; rr(x, 3, 20, 11, 9, 2); x.fill(); rr(x, 50, 20, 11, 9, 2); x.fill();
const gb = x.createLinearGradient(0, 8, 0, 27); gb.addColorStop(0, body); gb.addColorStop(.65, body); gb.addColorStop(1, dark);
x.fillStyle = gb; x.beginPath();
if (shape === 'wedge') { x.moveTo(1, 25); x.lineTo(2, 15); x.lineTo(14, 11); x.lineTo(50, 11); x.lineTo(62, 15); x.lineTo(63, 25); }
else { x.moveTo(2, 25); x.lineTo(2, 18); x.quadraticCurveTo(4, 11, 18, 10); x.lineTo(46, 10); x.quadraticCurveTo(60, 11, 62, 18); x.lineTo(62, 25); }
x.closePath(); x.fill();
x.fillStyle = '#1a2a3a'; x.beginPath();
if (shape === 'wedge') { x.moveTo(20, 11); x.lineTo(25, 5); x.lineTo(39, 5); x.lineTo(44, 11); }
else { x.moveTo(19, 11); x.quadraticCurveTo(24, 3, 32, 3); x.quadraticCurveTo(40, 3, 45, 11); }
x.closePath(); x.fill();
if (shape === 'wedge') {
// angular tail lights, black side intakes and a wide vented grille
x.fillStyle = '#f22'; x.beginPath(); x.moveTo(4, 15); x.lineTo(16, 14); x.lineTo(14, 18); x.lineTo(5, 18); x.closePath(); x.fill();
x.beginPath(); x.moveTo(60, 15); x.lineTo(48, 14); x.lineTo(50, 18); x.lineTo(59, 18); x.closePath(); x.fill();
x.fillStyle = '#111'; x.fillRect(16, 18, 32, 7); x.fillStyle = '#333'; for (let i = 0; i < 6; i++) x.fillRect(18 + i * 5, 19, 3, 5);
x.fillStyle = dark; x.beginPath(); x.moveTo(2, 15); x.lineTo(8, 12); x.lineTo(10, 15); x.closePath(); x.fill(); x.beginPath(); x.moveTo(62, 15); x.lineTo(56, 12); x.lineTo(54, 15); x.closePath(); x.fill();
x.fillStyle = '#ccc'; x.fillRect(29, 25, 6, 2);
} else {
// the roof air scoop, four round tail lights and a centre exhaust
x.fillStyle = dark; rr(x, 28, 0, 8, 4, 1.5); x.fill(); x.fillStyle = '#111'; x.fillRect(29.5, 1, 5, 2);
x.fillStyle = '#f22'; for (const cx of [8, 13, 51, 56]) { x.beginPath(); x.arc(cx, 16, 2.2, 0, 7); x.fill(); }
x.fillStyle = '#222'; x.fillRect(18, 19, 28, 6); x.fillStyle = '#999'; x.beginPath(); x.arc(32, 22, 2.4, 0, 7); x.fill(); x.fillStyle = '#333'; x.beginPath(); x.arc(32, 22, 1.4, 0, 7); x.fill();
}
});
}
function sedan(body, dark) {
return sprite(56, 34, x => {
x.fillStyle = 'rgba(0,0,0,.35)'; x.beginPath(); x.ellipse(28, 32.5, 27, 2.5, 0, 0, 7); x.fill();
x.fillStyle = '#111'; x.fillRect(4, 25, 9, 8); x.fillRect(43, 25, 9, 8);
x.fillStyle = body; rr(x, 2, 14, 52, 16, 3); x.fill();
x.fillStyle = dark; rr(x, 8, 3, 40, 13, 3); x.fill();
x.fillStyle = '#2a3a4a'; rr(x, 11, 5, 34, 9, 2); x.fill();
x.fillStyle = '#e22'; x.fillRect(4, 18, 9, 4); x.fillRect(43, 18, 9, 4);
x.fillStyle = '#ddd'; x.fillRect(20, 20, 16, 4); x.fillStyle = '#333'; x.fillRect(2, 27, 52, 3);
});
}
const truck = sprite(60, 56, x => {
x.fillStyle = 'rgba(0,0,0,.35)'; x.beginPath(); x.ellipse(30, 54.5, 29, 2.5, 0, 0, 7); x.fill();
x.fillStyle = '#111'; x.fillRect(4, 46, 12, 9); x.fillRect(44, 46, 12, 9);
const gb = x.createLinearGradient(0, 0, 60, 0); gb.addColorStop(0, '#bbb'); gb.addColorStop(.5, '#eee'); gb.addColorStop(1, '#999');
x.fillStyle = gb; x.fillRect(2, 2, 56, 44);
x.strokeStyle = '#888'; x.lineWidth = .8; x.beginPath(); x.moveTo(30, 4); x.lineTo(30, 44); x.stroke();
x.fillStyle = '#333'; x.fillRect(2, 44, 56, 4); x.fillStyle = '#e22'; x.fillRect(4, 40, 5, 3); x.fillRect(51, 40, 5, 3);
x.fillStyle = '#c80'; x.font = 'bold 8px "Lucida Console",monospace'; x.textAlign = 'center'; x.fillText('503 FREIGHT', 30, 24);
});
const CARS = [
{ name: 'VIPER', note: 'all-rounder', top: 1.0, acc: 1.0, grip: 1.0, spr: sportsCar('#e11', '#600') },
{ name: 'BOLT', note: 'fastest', top: 1.08, acc: .9, grip: .85, spr: sportsCar('#fd0', '#860') },
{ name: 'ARROW', note: 'quick + grippy', top: .95, acc: 1.15, grip: 1.2, spr: sportsCar('#18f', '#035') },
{ name: 'TORO', note: 'wild wedge', top: 1.1, acc: 1.05, grip: .9, spr: supercar('wedge', '#8e2', '#350') },
{ name: 'F-ONE', note: 'top speed king', top: 1.14, acc: .98, grip: .95, spr: supercar('round', '#f80', '#730') },
];
const COP = [sportsCar('#f4f4f4', '#222', ['#f00', '#025']), sportsCar('#f4f4f4', '#222', ['#500', '#28f'])];
const COP_PLAYER = COP;
const SUSPECT = sportsCar('#2c2', '#050');
const TRAFFIC = [sedan('#c8c8d0', '#9a9aa8'), sedan('#4466aa', '#2a4070'), sedan('#aa6633', '#704020'), sedan('#ddd8b0', '#a8a080'), truck];
// roadside things; w = width in road units (the road is 4800 wide)
const SP = {
palm: { w: 1300, img: sprite(40, 70, x => {
x.strokeStyle = '#8a6232'; x.lineWidth = 3.4; x.beginPath(); x.moveTo(20, 70); x.quadraticCurveTo(25, 40, 19, 14); x.stroke();
for (let i = 0; i < 7; i++) { const a = i / 7 * Math.PI * 2; x.fillStyle = i % 2 ? '#2a8a3a' : '#40aa48'; x.beginPath(); x.ellipse(19 + Math.cos(a) * 9, 14 + Math.sin(a) * 4, 11, 2.6, a, 0, 7); x.fill(); }
ball(x, 19, 15, 3, '#a96', '#740', '#320');
}) },
rock: { w: 1400, img: sprite(40, 24, x => { ball(x, 14, 16, 11, '#ccc', '#887', '#332'); ball(x, 27, 17, 9, '#bbb', '#776', '#332'); }) },
lamp: { w: 500, img: sprite(16, 60, x => { x.fillStyle = '#555'; x.fillRect(7, 6, 2, 54); x.fillRect(7, 5, 8, 2); x.fillStyle = '#ffc'; x.fillRect(11, 7, 5, 2); }) },
house: { w: 3400, img: sprite(70, 40, x => {
x.fillStyle = '#f4ecd8'; x.fillRect(4, 14, 62, 26); x.fillStyle = '#c55'; x.beginPath(); x.moveTo(0, 15); x.lineTo(35, 1); x.lineTo(70, 15); x.closePath(); x.fill();
x.fillStyle = '#5ae'; x.fillRect(10, 20, 12, 9); x.fillRect(48, 20, 12, 9); x.fillStyle = '#753'; x.fillRect(30, 24, 10, 16);
}) },
sign: { w: 1400, img: sprite(40, 34, x => {
x.fillStyle = '#777'; x.fillRect(8, 18, 2, 16); x.fillRect(30, 18, 2, 16);
x.fillStyle = '#085'; rr(x, 0, 0, 40, 19, 2); x.fill(); x.strokeStyle = '#fff'; x.lineWidth = .8; rr(x, 1.5, 1.5, 37, 16, 1.5); x.stroke();
x.fillStyle = '#fff'; x.font = 'bold 7px "Lucida Console",monospace'; x.textAlign = 'center'; x.fillText('COAST', 20, 8); x.fillText('HWY 503', 20, 15);
}) },
limit: { w: 700, img: sprite(20, 36, x => {
x.fillStyle = '#777'; x.fillRect(9, 20, 2, 16); x.fillStyle = '#fff'; x.fillRect(1, 0, 18, 22); x.strokeStyle = '#000'; x.lineWidth = .8; x.strokeRect(2, 1, 16, 20);
x.fillStyle = '#000'; x.font = 'bold 4px "Lucida Console",monospace'; x.textAlign = 'center'; x.fillText('SPEED', 10, 6); x.fillText('LIMIT', 10, 10.5); x.font = 'bold 8px "Lucida Console",monospace'; x.fillText('65', 10, 18.5);
}) },
check: { w: 6400, img: sprite(200, 60, x => {
x.fillStyle = '#777'; x.fillRect(4, 12, 6, 48); x.fillRect(190, 12, 6, 48);
x.fillStyle = '#063'; x.fillRect(0, 0, 200, 16);
x.fillStyle = '#ff5'; x.font = 'bold 10px "Lucida Console",monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('CHECKPOINT', 100, 8.5);
}) },
finish: { w: 6400, img: sprite(200, 60, x => {
x.fillStyle = '#777'; x.fillRect(4, 12, 6, 48); x.fillRect(190, 12, 6, 48);
x.fillStyle = '#000'; x.fillRect(0, 0, 200, 18);
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, 15, 4, 3); }
x.fillStyle = '#fff'; x.font = 'bold 10px "Lucida Console",monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('FINISH', 100, 9.5);
}) },
};
// sunset sky, ocean horizon on the left, coastal hills on the right
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(50, '#7a4a6a', 24, u => 10 * Math.sin(TAU * 3 * u) + 6 * Math.sin(TAU * 7 * u + 1) + 3 * Math.sin(TAU * 13 * u + 2));
const NEAR = ridge(30, '#4a5a3a', 18, u => 4 * Math.sin(TAU * 5 * u) + 3 * Math.sin(TAU * 11 * u + .5));
const SKY = g.createLinearGradient(0, 0, 0, LH / 2); SKY.addColorStop(0, '#2a1a5a'); SKY.addColorStop(.6, '#c8506a'); SKY.addColorStop(1, '#ffb050');
// ---- road ----
const SEG = 200, RUMBLE = 3, ROADW = 2400, LANES = 4, CAM_H = 1000, DEPTH = 1 / Math.tan(50 * Math.PI / 180), DRAW = 220;
const PLAYER_Z = CAM_H * DEPTH, MAXV = SEG * 60, CAR_W = 600;
const ACCEL = MAXV / 5, BRAKE = -MAXV, DECEL = -MAXV / 5, OFF_DECEL = -MAXV / 2, OFF_LIMIT = MAXV / 4;
const LANE_X = [-.75, -.25, .25, .75];
let segs = [], N = 0, L = 1, FIN = 0, CHECKS = [];
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, 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 });
function buildRoad() {
segs = []; const R = rng(2002);
// one long coast road, point to point: random bends and hills, then a straight run-out past the finish
road(0, 80, 0);
while (segs.length < 2600) {
const k = R();
if (k < .25) road(20, 60 + R() * 80 | 0, 20);
else if (k < .8) road(30, 40 + R() * 60 | 0, 30, (R() < .5 ? -1 : 1) * (2 + R() * 3), (R() - .5) * 40);
else road(25, 30, 25, (R() < .5 ? -1 : 1) * 5, 0);
}
road(40, 40, 40, 0, -lastY() / SEG); road(0, 400, 0);
N = segs.length; L = N * SEG; FIN = (N - 360) * SEG;
CHECKS = [1, 2, 3].map(k => Math.round(FIN / SEG * k / 4) * SEG);
for (let i = 0; i < N; i++) {
const c = segs[i].curve;
if (i % 8 === 0) add(i, SP.lamp, 1.25);
if (i % 6 === 3 && R() < .8) add(i, R() < .7 ? SP.palm : SP.rock, -(1.6 + R() * 2));
if (i % 9 === 5 && R() < .7) add(i, R() < .5 ? SP.palm : SP.rock, 1.7 + R() * 2.2);
if (i % 140 === 70) add(i, SP.house, 3.2);
if (i % 220 === 30) add(i, SP.sign, -1.7);
if (i % 260 === 150) add(i, SP.limit, 1.5);
}
for (const z of CHECKS) add(z / SEG, SP.check, 0, false);
add(FIN / SEG, SP.finish, 0, false);
}
// ---- game state ----
let mode = 'setup', resumeTo = 'race', car, position = 0, playerX = 0, playerY = 0, speed = 0, pDist = 0;
let traffic = [], cops = [], suspect = null, countT = 0, raceT = 0, timeLeft = 0, checkIdx = 0, bust = 0, nitro = 0, nitroT = 0, hits = 0;
let bgFar = 0, bgNear = 0, steer = 0, msg = '', msgT = 0, crashT = 0, ptr = null, result = '', escaped = 0, copSpawnT = 0, newRec = false;
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 newRace() {
buildRoad();
car = cfg.mode === 'cop' ? { top: 1.06, acc: 1.05, grip: 1.1, spr: null } : CARS[cfg.car];
pDist = SEG * 5; playerX = cfg.mode === 'cop' ? .25 : -.25; speed = 0; steer = 0; raceT = 0; checkIdx = 0; bust = 0; hits = 0; escaped = 0;
nitro = 3; nitroT = 0; crashT = 0; msgT = 0; result = ''; newRec = false; copSpawnT = 18;
timeLeft = cfg.mode === 'cop' ? (cfg.easy ? 130 : 100) : (cfg.easy ? 60 : 42);
traffic = [];
for (let i = 0; i < 18; i++) traffic.push(newTraffic(pDist + SEG * (25 + i * 14 + Math.random() * 8)));
cops = []; suspect = null;
if (cfg.mode === 'run') { cops.push(newCop(pDist - SEG * 14, -.25)); cops.push(newCop(pDist - SEG * 20, .25)); }
else suspect = { dist: pDist + SEG * 30, offset: -.25, lane: -.25, speed: 0, laneT: 2, slowT: 0, spr: SUSPECT };
// roadblocks before the 2nd and 3rd checkpoints: two parked police cars with one gap
if (cfg.mode === 'run') for (const k of [1, 2]) {
const gap = Math.floor(Math.random() * 4);
[0, 1, 2, 3].filter(l => l !== gap).slice(0, cfg.easy ? 2 : 3).forEach(l => cops.push(newCop(CHECKS[k] - SEG * 60, LANE_X[l], true)));
}
position = mod(pDist - PLAYER_Z, L);
countT = 3.99; mode = 'count';
}
function newTraffic(dist) {
const lane = Math.floor(Math.random() * 4);
return { dist, offset: LANE_X[lane], lane: LANE_X[lane], speed: MAXV * (.32 + Math.random() * .22), spr: TRAFFIC[Math.floor(Math.random() * TRAFFIC.length)], w: 1 };
}
function newCop(dist, offset, parked = false) { return { dist, offset, lane: offset, speed: 0, parked, spr: COP, cop: true }; }
function endRace(text, good) {
result = text; mode = 'end'; msgT = 0;
if (good) {
SFX.win();
const key = cfg.mode + (cfg.mode === 'run' ? cfg.car : '');
const b = cfg.best[key];
newRec = !b || raceT < b;
if (newRec) { cfg.best[key] = raceT; saveCfg(); }
} else SFX.lose();
setTimeout(() => { if (mode === 'end') mode = 'over'; }, 1800);
}
// ---- 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 = cfg.mode === 'cop' ? 'CATCH THAT CAR!' : 'GO GO GO!'; msgT = 1.5; }
}
const racing = mode === 'race', moving = mode === 'race' || mode === 'end' || mode === 'over';
const pSeg = segAt(pDist), top = MAXV * car.top * (nitroT > 0 ? 1.3 : 1), pct = speed / MAXV;
let gas = false, brake = false, st = 0;
if (racing) {
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 (moving) { brake = true; st = clamp(-playerX * 2, -1, 1); }
const dx = dt * 2.2 * Math.min(pct, 1) * car.grip;
playerX += dx * st;
if (racing) playerX -= dt * 2 * pct * pct * pSeg.curve * .3 / car.grip;
steer = st;
speed += (gas ? ACCEL * car.acc * (nitroT > 0 ? 1.8 : 1) : 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.4, 2.4);
nitroT = Math.max(0, nitroT - dt);
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); crashT = .4; }
playerX -= Math.sign(playerX) * dt; break;
}
if (countT <= 0) pDist += speed * dt;
// traffic: slow cars that change lanes now and then; recycled ahead once passed
for (const t of traffic) {
if (Math.random() < dt * .08) t.lane = LANE_X[Math.floor(Math.random() * 4)];
// in Cop mode, cars just ahead in your lane pull aside for the siren
const dd = t.dist - pDist;
const yielding = cfg.mode === 'cop' && dd > 0 && dd < SEG * 25 && Math.abs(t.offset - playerX) < .5;
if (yielding) t.lane = playerX > 0 ? LANE_X[0] : LANE_X[3];
t.offset += Math.sign(t.lane - t.offset) * Math.min(Math.abs(t.lane - t.offset), dt * (yielding ? 1.6 : .5));
t.dist += t.speed * dt;
if (t.dist < pDist - SEG * 12) Object.assign(t, newTraffic(pDist + SEG * (DRAW - 20 + Math.random() * 30)));
}
const ahead = (me, others, look = 6) => {
// nearest thing in my lane ahead of me, or null
let best = null;
for (const o of others) { if (o === me) continue; const d = o.dist - me.dist; if (d > 0 && d < SEG * look && Math.abs(o.offset - me.offset) < .4 && (!best || d < best.d)) best = { o, d }; }
return best;
};
const steerAround = (me, blk, dt2) => {
// switch to whichever neighbouring lane is clearer
const opts = LANE_X.filter(x => Math.abs(x - me.offset) < .6 && Math.abs(x - blk.o.offset) > .3);
if (opts.length) me.lane = opts[Math.floor(Math.random() * opts.length)];
};
// police
if (cfg.mode === 'run' && countT <= 0) {
let nearest = Infinity;
for (const c of cops) {
const gap = pDist - c.dist;
nearest = Math.min(nearest, Math.abs(gap));
if (c.parked) continue;
let tgt = gap > 0 ? Math.min(speed * 1.08 + MAXV * .18, MAXV * (cfg.easy ? 1.0 : 1.12)) : speed * .92;
const blk = ahead(c, traffic.concat(cops));
if (blk) { if (blk.d < SEG * 1.2) tgt = Math.min(tgt, blk.o.speed); if (Math.random() < dt * 3) steerAround(c, blk); }
else if (Math.abs(gap) < SEG * 25) c.lane = clamp(playerX, -.8, .8);
c.speed += Math.sign(tgt - c.speed) * Math.min(Math.abs(tgt - c.speed), ACCEL * 1.1 * dt);
c.offset += Math.sign(c.lane - c.offset) * Math.min(Math.abs(c.lane - c.offset), dt * .9);
c.dist += c.speed * dt;
}
// left far behind: that cop gives up
for (const c of cops) if (!c.parked && pDist - c.dist > SEG * 70) { c.gone = true; escaped++; msg = 'LOST ONE!'; msgT = 1.2; SFX.lost(); }
cops = cops.filter(c => !c.gone && !(c.parked && pDist - c.dist > SEG * 15));
copSpawnT -= dt;
if (racing && copSpawnT <= 0 && cops.filter(c => !c.parked).length < (cfg.easy ? 2 : 4)) {
cops.push(newCop(pDist - SEG * 30, LANE_X[Math.floor(Math.random() * 4)])); copSpawnT = cfg.easy ? 26 : 16; msg = 'MORE POLICE!'; msgT = 1.2;
}
sirenSet(nearest < SEG * 40 ? .05 * (1 - nearest / (SEG * 40)) + .01 : 0);
// the busted bar fills while a cop touches you, or while you're crawling with police close by
if (racing) {
let press = 0;
for (const c of cops) {
const d = c.dist - pDist, side = Math.abs(c.offset - playerX);
if (Math.abs(d) < SEG * 1.3 && side < .5) press += .55;
if (d > 0 && d < SEG * 1.0 && side < .4 && speed > c.speed) { speed = c.speed * .7; SFX.bump(); crashT = .2; } // ran into the back of a cop
}
if (speed < MAXV * .25 && cops.some(c => Math.abs(c.dist - pDist) < SEG * 5)) press += .6;
bust = clamp(bust + (press ? press * (cfg.easy ? .6 : 1) : -.22) * dt, 0, 1);
if (bust >= 1) endRace('BUSTED!', false);
}
}
// the speeder you're chasing (cop mode)
if (suspect && countT <= 0) {
const s = suspect;
s.slowT = Math.max(0, s.slowT - dt);
let tgt = MAXV * (cfg.easy ? .9 : .98) * (s.slowT > 0 ? .45 : 1);
const gap = s.dist - pDist;
if (gap > SEG * 80) tgt *= .65; else if (gap > SEG * 40) tgt *= .82; // don't run completely away
const blk = ahead(s, traffic);
if (blk) { if (blk.d < SEG * 1.2) tgt = Math.min(tgt, blk.o.speed); if (Math.random() < dt * 4) steerAround(s, blk); }
else if ((s.laneT -= dt) <= 0) { s.lane = LANE_X[Math.floor(Math.random() * 4)]; s.laneT = 1.5 + Math.random() * 2; }
s.speed += Math.sign(tgt - s.speed) * Math.min(Math.abs(tgt - s.speed), ACCEL * 1.2 * dt);
s.offset += Math.sign(s.lane - s.offset) * Math.min(Math.abs(s.lane - s.offset), dt * 1.1);
s.dist += s.speed * dt;
const d = s.dist - pDist;
if (racing && d > 0 && d < SEG * 1.1 && Math.abs(s.offset - playerX) < .45 && speed > s.speed && s.slowT <= 0) {
hits++; s.slowT = 1.6; speed = s.speed * .8; SFX.crash(); crashT = .3;
if (hits >= (cfg.easy ? 2 : 3)) endRace('GOT HIM!', true); else { msg = `HIT ${hits}!`; msgT = 1.2; }
}
if (racing && s.dist >= FIN) endRace('HE GOT AWAY!', false);
sirenSet(racing ? .03 : 0);
}
// running into traffic
if (racing) for (const t of traffic) {
const d = t.dist - pDist;
if (d > 0 && d < SEG * .9 && Math.abs(playerX - t.offset) < (t.spr === truck ? .45 : .4) && speed > t.speed) {
speed = t.speed * .6; pDist -= SEG * .9 - d; SFX.bump(); crashT = .3;
}
}
// clock, checkpoints and the finish
if (racing) {
raceT += dt; timeLeft -= dt;
if (checkIdx < CHECKS.length && pDist >= CHECKS[checkIdx]) {
checkIdx++; const add = cfg.mode === 'cop' ? 0 : (cfg.easy ? 30 : 22);
timeLeft += add; SFX.check(); msg = add ? `CHECKPOINT +${add}s` : 'CHECKPOINT'; msgT = 1.6;
}
if (cfg.mode === 'run' && pDist >= FIN) endRace('ESCAPED!', true);
else if (timeLeft <= 0) { timeLeft = 0; endRace(cfg.mode === 'cop' ? 'HE GOT AWAY!' : 'OUT OF TIME!', false); }
}
if (!racing && cfg.mode === 'run' && mode !== 'count') sirenSet(mode === 'end' && result === 'BUSTED!' ? .05 : 0);
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 = s.dark ? '#c8b070' : '#d8c080'; g.fillRect(0, top, LW, a.y - top); // sandy verge
const r1 = a.w / 9, r2 = b.w / 9;
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, s.dark ? '#888' : '#ddd');
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, s.dark ? '#888' : '#ddd');
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 ? '#4a4a50' : '#525258');
const l1 = a.w / 50, l2 = b.w / 50;
// solid yellow edge lines, dashed white lane lines
for (const f of [-.97, .97]) quad(a.x + a.w * f - l1, a.y, a.x + a.w * f + l1, a.y, b.x + b.w * f + l2, top, b.x + b.w * f - l2, top, '#fc3');
if (!s.dark) for (const f of [-.5, 0, .5]) quad(a.x + a.w * f - l1, a.y, a.x + a.w * f + l1, a.y, b.x + b.w * f + l2, top, b.x + b.w * f - l2, top, '#eee');
}
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); }
const flash = () => Math.floor(performance.now() / 180) % 2;
function drawCar(c, s, clip) {
const z = mod(c.dist, L), p = (z % SEG) / SEG, sc = lerp(s.p1.screen.scale, s.p2.screen.scale, p);
const img = c.cop ? c.spr[flash()] : c.spr, w = c.spr === truck ? CAR_W * 1.1 : c.cop || c.spr === SUSPECT ? CAR_W : CAR_W * .9;
drawSpr(img, 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 = car.spr || COP_PLAYER[flash()], 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);
if (nitroT > 0) for (const ex of [-.17, .17]) { g.fillStyle = Math.random() < .5 ? '#fa3' : '#5cf'; g.beginPath(); g.moveTo(w * ex - 3, -h * .12); g.lineTo(w * ex, -h * .12 + 6 + Math.random() * 6); g.lineTo(w * ex + 3, -h * .12); g.fill(); }
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);
g.fillStyle = '#fff3a0'; g.beginPath(); g.arc(LW * .7 - mod(bgFar * .3, LW * 2) * .1, LH / 2 - 18, 12, 0, 7); g.fill();
tile(FAR, bgFar, LH / 2 + 4 - 50); tile(NEAR, bgNear, LH / 2 + 4 - 30);
g.fillStyle = '#c8b070'; 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;
}
const all = traffic.concat(cops, suspect ? [suspect] : []), bucket = new Map();
for (const c of all) {
const n = (Math.floor(mod(c.dist, L) / SEG) - base.i + N) % N;
if (n > 0 && n < DRAW && c.dist > pDist - SEG * 8) { 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.dist - a.dist);
if (s === pSeg) {
for (const c of here) if (c.dist > pDist) drawCar(c, s, s.clip);
drawPlayer(pSeg, pPct);
for (const c of here) if (c.dist <= pDist) 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')}`;
function dialog(rows) {
const w = 200, h = rows.length * 14 + 20, x = (LW - w) / 2, y = (LH - h) / 2;
g.fillStyle = 'rgba(60,0,20,.9)'; g.fillRect(x, y, w, h);
g.strokeStyle = '#FFAA33'; 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() {
if (!segs.length) { g.fillStyle = SKY; g.fillRect(0, 0, LW, LH); return; }
render();
if (mode === 'setup') return;
text(`TIME ${Math.max(0, timeLeft).toFixed(1)}`, 6, 9, timeLeft < 10 && Math.floor(performance.now() / 250) % 2 ? '#f55' : '#fff', 10);
text(`${Math.round(speed / MAXV * 140)} MPH`, 6, LH - 9, nitroT > 0 ? '#5cf' : '#fff', 11);
text('NOS ' + '■'.repeat(nitro) + '□'.repeat(3 - nitro), 6, 21, '#FFAA33', 9);
const left = Math.max(0, Math.round((FIN - pDist) / SEG * 2) / 100);
text(`FINISH ${left.toFixed(1)} km`, LW - 6, 9, '#aaa', 8, 'right');
if (cfg.mode === 'run') {
text('BUSTED', LW - 6, 21, '#fff', 8, 'right');
g.fillStyle = '#222'; g.fillRect(LW - 88, 27, 82, 6);
g.fillStyle = bust > .7 && flash() ? '#fff' : '#f33'; g.fillRect(LW - 88, 27, 82 * bust, 6);
g.strokeStyle = '#888'; g.lineWidth = 1; g.strokeRect(LW - 87.5, 27.5, 81, 5);
const behind = cops.filter(c => !c.parked && c.dist < pDist).map(c => pDist - c.dist);
if (behind.length) text(`COP ${Math.round(Math.min(...behind) / SEG * 20)} m BEHIND`, LW - 6, 40, flash() ? '#f55' : '#55f', 8, 'right');
} else if (suspect) {
const d = Math.round((suspect.dist - pDist) / SEG * 20);
text(`SPEEDER ${Math.max(0, d)} m`, LW - 6, 21, '#5f5', 9, 'right');
text(`HITS ${hits}/${cfg.easy ? 2 : 3}`, LW - 6, 33, '#FFFF55', 9, 'right');
}
if (mode === 'count') {
const lit = 4 - Math.ceil(countT);
g.fillStyle = '#111'; g.fillRect(LW / 2 - 30, 30, 60, 20);
for (let i = 0; i < 3; i++) { g.fillStyle = 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', 16, 'center');
if (mode === 'end') text(result, LW / 2, 70, result === 'ESCAPED!' || result === 'GOT HIM!' ? '#5f5' : '#f55', 24, 'center');
if (mode === 'paused') dialog(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
if (mode === 'over') {
const good = result === 'ESCAPED!' || result === 'GOT HIM!';
const rows = [result, '', good ? `Time ${fmt(raceT)}` : `Drove ${(pDist / SEG / 50).toFixed(1)} km`];
if (cfg.mode === 'run') rows.push(`Police lost: ${escaped}`);
if (good && 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);
if (mode === 'paused' || mode === 'setup') sirenSet(0);
draw();
requestAnimationFrame(frame);
}
// ---- control ----
function useNitro() { if (mode === 'race' && nitro > 0 && nitroT <= 0) { nitro--; nitroT = 2.5; SFX.nitro(); } }
function pause() { if (playing()) { 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 showSetup() {
document.querySelectorAll('#modes button').forEach(b => b.classList.toggle('on', b.dataset.m === cfg.mode));
document.getElementById('cars').style.display = cfg.mode === 'run' ? '' : 'none';
document.querySelectorAll('#cars button').forEach(b => b.classList.toggle('on', +b.dataset.c === cfg.car));
const b = cfg.best[cfg.mode + (cfg.mode === 'run' ? cfg.car : '')];
document.getElementById('hi').textContent = b ? `Best time: ${fmt(b)}` : '';
}
function openSetup() {
mode = 'setup';
document.getElementById('snd').checked = cfg.sound; document.getElementById('auto').checked = cfg.auto; document.getElementById('easy').checked = cfg.easy;
showSetup(); 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()) { if (k === ' ' || k === 'n') { e.preventDefault(); useNitro(); } 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; });
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; }));
if (isTouch) document.body.classList.add('touch');
document.querySelectorAll('.pad button').forEach(b => {
const a = b.dataset.a;
const up = () => { if (a !== 'pause' && a !== 'nitro') 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(); return; }
if (a === 'pause') { pause(); return; }
if (a === 'nitro') { useNitro(); b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 150); return; }
held[a] = true; b.classList.add('held');
});
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, up));
});
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });
// ---- setup screen ----
document.getElementById('cars').innerHTML = CARS.map((c, i) => `<button data-c="${i}">${c.name}<br><small>${c.note}</small></button>`).join('');
document.querySelectorAll('#cars button').forEach(b => b.addEventListener('click', () => { cfg.car = +b.dataset.c; showSetup(); }));
document.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { cfg.mode = b.dataset.m; showSetup(); }));
document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; });
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);
// the coast road behind the title screen
buildRoad(); car = CARS[0]; pDist = SEG * 5; position = mod(pDist - PLAYER_Z, L);
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 highwayheat subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes highwayheat\icon-512.png highwayheat\icon-192.png highwayheat\apple-touch-icon.png highwayheat\favicon-32.png proxmox:/root/nibbles/highwayheat/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a highwayheat 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/highwayheat/
Then open https://games.503mail.com/highwayheat/ in a browser.
Updating it later
Edit index.html, paste the new version over /root/nibbles/highwayheat/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 'highwayheat'. Clearing the site's data in the browser resets them; nothing is stored on the server.
Notes
- Cop mode needed help: a test script driving the cop kept rear-ending traffic and fell further and further behind. So traffic now pulls aside for the siren, and the speeder eases off when far ahead, keeping the chase close.
- The cars: the real Hot Pursuit 2 had licensed cars (McLaren F1 LM, Lamborghini Murciélago and Diablo, Ferraris, Porsches). TORO and F-ONE are lookalikes with their own names.
- The clock was tightened after testing: at first there was over a minute to spare at the finish.