BE AGGRESSIVE — Beat the Stopwatch
* { box-sizing: border-box; }
body {
margin: 0;
background: #101221;
color: #fff4ce;
font-family: “Courier New”, monospace;
display: flex;
flex-direction: column;
align-items: center;
padding: 18px 12px;
}
h1 {
margin: 0 0 5px;
color: #ffce45;
letter-spacing: 4px;
text-shadow: 3px 3px #ba3658;
font-size: clamp(24px, 5vw, 40px);
}
.subtitle {
color: #aebbd5;
font-size: 12px;
text-align: center;
margin-bottom: 14px;
}
.game {
width: min(100%, 1024px);
position: relative;
border: 4px solid #425273;
background: #151b2c;
box-shadow: 8px 8px 0 #080c16;
}
canvas {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
image-rendering: crisp-edges;
touch-action: none;
}
.overlay {
position: absolute;
inset: 0;
background: #0d1324e8;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
}
.overlay[hidden] { display: none; }
.panel { max-width: 590px; }
.panel h2 {
color: #ffce45;
font-size: clamp(19px, 4vw, 32px);
margin: 0 0 12px;
}
.panel p {
line-height: 1.5;
font-size: clamp(11px, 2vw, 16px);
}
button {
font: bold 14px “Courier New”, monospace;
background: #ffce45;
border: 0;
border-bottom: 4px solid #a86c27;
color: #171a28;
padding: 12px 18px;
cursor: pointer;
touch-action: none;
}
button:hover { background: #ffe294; }
button:active { transform: translateY(2px); }
.instructions {
max-width: 950px;
line-height: 1.6;
font-size: 13px;
text-align: center;
margin: 16px 0 9px;
}
.instructions b { color: #ffce45; }
.controls {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
justify-content: center;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(3, 40px);
gap: 4px;
}
.dpad button { padding: 4px; }
.up { grid-column: 2; grid-row: 1; }
.left { grid-column: 1; grid-row: 2; }
.right { grid-column: 3; grid-row: 2; }
.down { grid-column: 2; grid-row: 3; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.actions button { font-size: 12px; padding: 13px 11px; }
.note { color: #8b9bb9; font-size: 11px; margin-top: 12px; }
BE AGGRESSIVE
SANTA FELITA HIGH • AN ISOMETRIC ARCADE ROAD MOVIE
WASD / Arrow keys: drive along the city’s isometric axes.
W = ↗, D = ↘, S = ↙, A = ↖.
Release to brake. Shift: match bus speed during the chase.
E: begin / time the vault.
P: pause. R: restart.
A fictional arcade adaptation of Jeff Carpenter’s screenplay.
Original procedural graphics; no external assets.
“use strict”;
const canvas = document.getElementById(“game”);
const g = canvas.getContext(“2d”);
g.imageSmoothingEnabled = false;
const overlay = document.getElementById(“overlay”);
const heading = document.getElementById(“heading”);
const description = document.getElementById(“description”);
const startButton = document.getElementById(“start”);
const W = 640, H = 400;
const GRID = 180;
const LAST = 1080;
const ROAD = 72;
const keys = Object.create(null);
const P = {
grass: “#496553”,
grassDark: “#3a554b”,
road: “#33394d”,
sidewalk: “#87909b”,
yellow: “#ffce45”,
pink: “#ff658a”,
cyan: “#77d7dc”,
white: “#fff5cf”,
ink: “#172035”,
red: “#f45c69”,
green: “#83dc86”
};
let active = false, paused = false, state = “bus”;
let clock = 48, elapsed = 0, score = 0;
let collisions = 0, reds = 0, pickups = 0;
let poise = 3, checkpoint = 0, invulnerable = 0;
let bus, bike, player;
let traffic = [], buildings = [], tapes = [];
let camera = {x: 0, y: 0};
let message = “”, messageTimer = 0;
let dock = 0, timing = null, jumping = null;
let noelleAboard = false;
let particles = [], signalVisits = {};
let flash = 0, shake = 0;
let randomSeed = 42;
let audio = null, sound = false;
const route = [
{x:180, y:180},
{x:360, y:180},
{x:360, y:360},
{x:540, y:360},
{x:720, y:360},
{x:720, y:540}
];
const chaseRoute = [
{x:720, y:540},
{x:900, y:540},
{x:1080,y:540},
{x:1080,y:720},
{x:1080,y:900},
{x:900, y:900},
{x:720, y:900},
{x:540, y:900},
{x:360, y:900},
{x:180, y:900},
{x:180, y:720},
{x:180, y:540},
{x:360, y:540},
{x:540, y:540}
];
let chaseTarget = 1;
function rand() {
randomSeed = (randomSeed * 1664525 + 1013904223) >>> 0;
return randomSeed / 4294967296;
}
function clamp(v,a,b) { return Math.max(a,Math.min(b,v)); }
function dist(a,b) { return Math.hypot(a.x-b.x,a.y-b.y); }
function mix(a,b,t) { return a+(b-a)*t; }
function wrapAngle(a) {
while(a > Math.PI) a -= Math.PI*2;
while(a < -Math.PI) a += Math.PI*2;
return a;
}
function project(x,y,z=0) {
return {
x: Math.round(320+(x-y-camera.x+camera.y)*0.78),
y: Math.round(186+(x+y-camera.x-camera.y)*0.39-z)
};
}
function poly(points,color) {
g.fillStyle=color;
g.beginPath();
g.moveTo(points[0].x,points[0].y);
for(let i=1;imaxWidth && line) {
text(line,x,y,color,11);
line=word+” “; y+=lineHeight;
} else line=test;
}
text(line,x,y,color,11);
}
function say(s,duration=4) {
message=s;
messageTimer=duration;
}
function beep(freq=440,duration=.08,type=”square”) {
if(!sound) return;
try {
if(!audio) audio=new (window.AudioContext||window.webkitAudioContext)();
audio.resume();
let o=audio.createOscillator(), gain=audio.createGain();
o.type=type; o.frequency.value=freq;
gain.gain.setValueAtTime(.035,audio.currentTime);
gain.gain.exponentialRampToValueAtTime(.001,audio.currentTime+duration);
o.connect(gain); gain.connect(audio.destination);
o.start(); o.stop(audio.currentTime+duration);
} catch(e) {}
}
function burst(x,y,color,n=12) {
for(let i=0;i<n;i++) particles.push({
x,y,z:8, vx:(rand()-.5)*90,vy:(rand()-.5)*90,
vz:25+rand()*45,life:.5+rand()*.5,color
});
}
function makeVehicle(x,y,angle,type,color) {
return {x,y,angle,type,color,vx:0,vy:0,speed:0};
}
function reset() {
randomSeed=42;
keysClear();
state="bus"; clock=48; elapsed=0; score=0;
collisions=0; reds=0; pickups=0; poise=3;
checkpoint=0; invulnerable=0; dock=0;
timing=null; jumping=null; noelleAboard=false;
particles=[]; signalVisits={}; chaseTarget=1;
flash=0; shake=0; paused=false;
bus=makeVehicle(0,180,0,"bus",P.yellow);
bike=makeVehicle(0,180,0,"bike","#272335");
player=bus;
camera={x:bus.x,y:bus.y};
traffic=[]; buildings=[]; tapes=[];
const colors=["#7894a1","#a48285","#768394","#9c8d78","#719180"];
for(let x=0;x<6;x++) for(let y=0;y<6;y++) {
let school=x===0&&y===0;
buildings.push({
x:x*GRID+54, y:y*GRID+54,
w:school?96:67+rand()*27,
d:school?96:68+rand()*26,
h:school?27:20+rand()*35,
color:school?"#cc9978":colors[Math.floor(rand()*colors.length)],
school,
sign: school?"SANTA FELITA HIGH":
(x===2&&y===1?"ARGENT RADIO":
(x===4&&y===3?"BURGER STOP":
(x===1&&y===4?"AUTO WASH":"")))
});
}
for(let i=0;i.5?1:-1;
let lane=Math.floor(rand()*7)*GRID;
let pos=rand()*LAST;
let car=makeVehicle(
horizontal?pos:lane-direction*13,
horizontal?lane+direction*13:pos,
horizontal?(direction===1?0:Math.PI):
(direction===1?Math.PI/2:-Math.PI/2),
“car”,
[“#dd7778″,”#77b8c8″,”#b4c98b”,”#a799c9″,”#e7bb78″][i%5]
);
car.horizontal=horizontal;
car.direction=direction;
car.cruise=38+rand()*28;
if(dist(car,bus)tapes.push({x,y,taken:false}));
say(“HONEY: Coach’s stopwatch is running! Gold checkpoints, Merv. No scenic route!”,6);
}
function keysClear() {
for(const k of Object.keys(keys)) keys[k]=false;
}
function begin() {
reset();
active=true;
overlay.hidden=true;
beep(220,.12);
}
function end(win) {
active=false;
overlay.hidden=false;
heading.textContent=win?”VICTORY! SANTA FELITA!”:”THE CLOCK WON THIS ROUND”;
if(win) {
score+=Math.ceil(Math.max(clock,0))*25;
description.innerHTML=
`Noelle is aboard. Charlie sticks the landing.
Honey catches the boombox. Merv never stops.
SCORE ${score}
${reds} red-light runs · ${pickups} cassette pickups ·
${collisions} traffic bumps
“You’ve got to work as a team.”
Next stop: the Las Vegas competition.`;
beep(880,.25);
} else {
description.innerHTML=
`Honey: “We trained all year for this!”
${state===”bus”?
“Follow every gold checkpoint. Cut clean corners and collect cassettes for extra time.”:
“Catch the bus, hold Shift to match speed, and stay directly behind the rear doors.”}
Score: ${score} · Press R or try again.`;
beep(110,.3,”sawtooth”);
}
startButton.textContent=”ONE MORE RUN”;
}
function startChase() {
state=”chase”;
clock=60;
score+=500;
bus.x=720; bus.y=540; bus.angle=0;
bus.speed=76; bus.vx=76; bus.vy=0;
bike.x=540; bike.y=540; bike.angle=0;
bike.vx=0; bike.vy=0; bike.speed=0;
player=bike; invulnerable=2; dock=0;
flash=.35;
say(“CHARLIE: Typical. Missed the bus. NOELLE: I saved the permission form! Let’s catch them!”,7);
beep(660,.2);
}
function isRoad(x,y) {
let dx=Math.abs(x-Math.round(x/GRID)*GRID);
let dy=Math.abs(y-Math.round(y/GRID)*GRID);
return Math.min(dx,dy)<ROAD/2-4;
}
function horizontalGreen(ix,iy) {
return (elapsed+(ix+iy)*1.37)%105) {
let desired=Math.atan2(player.vy,player.vx);
player.angle+=wrapAngle(desired-player.angle)*Math.min(1,dt*11);
}
}
function moveBus(dt) {
let remaining=76*dt;
while(remaining>0) {
let target=chaseRoute[chaseTarget];
let dx=target.x-bus.x,dy=target.y-bus.y;
let d=Math.hypot(dx,dy);
if(d20&&awayLAST+140)c.x=-140;
if(c.xLAST+140)c.y=-140;
if(c.y<-140)c.y=LAST+140;
if(!timing&&!jumping&&invulnerable<=0) {
let dx=player.x-c.x,dy=player.y-c.y;
let forward=Math.cos(player.angle)*dx+Math.sin(player.angle)*dy;
let side=-Math.sin(player.angle)*dx+Math.cos(player.angle)*dy;
if(Math.abs(forward)<(state==="bus"?35:24)&&Math.abs(side)0) {
poise–;
say(“HONEY: Brace formation! The squad absorbs the bump. Keep moving!”,3);
} else {
clock-=3;
say(“MERV: A little paint exchange! Three seconds lost.”,3);
}
player.vx*=.28;player.vy*=.28;
beep(90,.12,”sawtooth”);
}
}
}
}
function runSignals() {
if(player.speed14||Math.abs(player.y-iy*GRID)>14)return;
let id=ix+”,”+iy;
if(signalVisits[id]!==undefined && elapsed-signalVisits[id]Math.abs(player.vy);
let green=horizontal?horizontalGreen(ix,iy):!horizontalGreen(ix,iy);
if(!green) {
reds++;score+=75;
burst(player.x,player.y,P.pink,5);
if(messageTimer<1) say("HONEY: Red means we're behind schedule! +75",2.5);
beep(520,.045);
}
}
function collectTapes() {
for(const t of tapes) if(!t.taken&&dist(t,player)29&&along<76&&side.78&&relative<34;
dock=clamp(dock+(good?dt:-dt*2),0,.8);
// Soft separation: the motorcycle cannot disappear inside the bus.
if(dist(bus,bike)=.8&&messageTimer.37&&v=.8) {
timing={time:0,value:0};
say(“HONEY: Catch the beat! Press E when the marker enters the GREEN zone.”,4);
beep(440,.07);
} else {
say(“Get directly behind the bus. Hold SHIFT and its direction to match speed.”,3);
}
}
function update(dt) {
if(!active||paused)return;
elapsed+=dt;
clock-=dt;
messageTimer=Math.max(0,messageTimer-dt);
invulnerable=Math.max(0,invulnerable-dt);
flash=Math.max(0,flash-dt);
shake=Math.max(0,shake-dt);
let worldDt=timing?dt*.22:dt;
if(state!==”bus”) moveBus(worldDt);
if(timing||jumping) {
let rear=rearPosition(46);
bike.x=rear.x;bike.y=rear.y;bike.angle=bus.angle;
bike.vx=bus.vx;bike.vy=bus.vy;bike.speed=76;
} else movePlayer(worldDt);
updateTraffic(worldDt);
if(!timing&&!jumping) {
runSignals();
collectTapes();
}
if(state===”bus”) {
if(dist(bus,route[checkpoint])=route.length) startChase();
else {
const lines=[
“IRIS: Route confirmed. Keep following the gold diamonds!”,
“HONEY: It’s all about poise. Looking good under pressure!”,
“MERV: Behind the eight ball! Under the gun!”,
“SHAUNA-RAE: Did anyone remember the birthday present?”,
“HONEY: The blue suitcase is packed. Where are Charlie and Noelle?”
];
say(lines[(checkpoint-1)%lines.length],4);
}
}
} else if(!timing&&!jumping) {
docking(dt);
}
if(timing) {
timing.time+=dt;
timing.value=(Math.sin(timing.time*5.3-Math.PI/2)+1)/2;
if(timing.time>6) {
timing=null;dock=0;clock-=5;
say(“HONEY: The moment passed! Line it up again.”,3);
}
}
if(jumping) {
jumping.time+=dt;
if(jumping.time>.95) {
if(jumping.who===”noelle”) {
noelleAboard=true;
jumping=null;dock=0;
burst(bus.x,bus.y,P.pink,24);
say(“NOELLE IS ABOARD! Honey catches the boombox. Charlie, don’t get left behind!”,6);
clock+=8;
} else {
jumping=null;
burst(bus.x,bus.y,P.yellow,45);
end(true);
}
}
}
for(const p of particles) {
p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;
p.z+=p.vz*dt;p.vz-=100*dt;
}
particles=particles.filter(p=>p.life>0);
let aim={x:player.x,y:player.y};
if(state!==”bus”&&dist(bus,bike)<280) {
aim.x=mix(bike.x,bus.x,.35);
aim.y=mix(bike.y,bus.y,.35);
}
camera.x=mix(camera.x,aim.x,1-Math.exp(-dt*5));
camera.y=mix(camera.y,aim.y,1-Math.exp(-dt*5));
if(clock<=0&&active) end(false);
}
/* ———- PIXEL-ART RENDERER ———- */
function box(x,y,w,d,h,color) {
let a=project(x,y),b=project(x+w,y),c=project(x+w,y+d),dd=project(x,y+d);
let at=project(x,y,h),bt=project(x+w,y,h);
let ct=project(x+w,y+d,h),dt=project(x,y+d,h);
poly([dd,c,ct,dt],"#445468");
poly([b,c,ct,bt],color);
poly([at,bt,ct,dt],color);
poly([at,bt,ct,dt],"#ffffff18");
g.strokeStyle="#273044";g.lineWidth=1;
g.beginPath();g.moveTo(at.x,at.y);g.lineTo(bt.x,bt.y);
g.lineTo(ct.x,ct.y);g.lineTo(dt.x,dt.y);g.closePath();g.stroke();
}
function drawBuilding(b) {
let center=project(b.x+b.w/2,b.y+b.d/2,b.h);
if(center.xW+180||center.yH+140)return;
rectWorld(b.x-4,b.y-4,b.w+13,b.d+13,”#233b3b”);
box(b.x,b.y,b.w,b.d,b.h,b.color);
rectWorld(b.x+9,b.y+9,b.w-18,b.d-18,
b.school?”#cfb091″:”#697b88″,b.h+1);
for(let i=12;i<b.w-8;i+=20) {
let p=project(b.x+i,b.y+b.d+.5,b.h-11);
g.fillStyle="#182f46";g.fillRect(p.x-3,p.y-3,7,7);
g.fillStyle="#b6dbe0";g.fillRect(p.x-2,p.y-2,3,3);
}
if(b.school) {
let p=project(b.x+b.w/2,b.y+b.d,b.h-4);
g.fillStyle=P.ink;g.fillRect(p.x-57,p.y-9,114,13);
text("SANTA FELITA HIGH",p.x-53,p.y+1,P.yellow,9);
let flag=project(b.x+5,b.y+b.d+11);
g.fillStyle=P.white;g.fillRect(flag.x,flag.y-42,2,42);
g.fillStyle=P.pink;g.fillRect(flag.x+2,flag.y-42,24,11);
text("SF",flag.x+6,flag.y-33,P.white,9);
} else if(b.sign) {
let p=project(b.x+b.w/2,b.y+b.d,b.h-3);
let len=b.sign.length*6+8;
g.fillStyle=P.ink;g.fillRect(p.x-len/2,p.y-10,len,13);
text(b.sign,p.x-len/2+4,p.y,P.cyan,9);
}
}
function drawGround() {
g.fillStyle=P.grass;g.fillRect(0,0,W,H);
for(let i=0;i<7;i++) {
rectWorld(-130,i*GRID-ROAD/2-6,LAST+260,ROAD+12,P.sidewalk);
rectWorld(i*GRID-ROAD/2-6,-130,ROAD+12,LAST+260,P.sidewalk);
}
for(let i=0;i<7;i++) {
rectWorld(-130,i*GRID-ROAD/2,LAST+260,ROAD,P.road);
rectWorld(i*GRID-ROAD/2,-130,ROAD,LAST+260,P.road);
}
for(let i=0;i<7;i++) for(let j=-2;j<37;j++) {
let pos=j*34;
if(Math.abs(pos-Math.round(pos/GRID)*GRID)<44)continue;
rectWorld(pos,i*GRID-1,14,2,"#b2a988");
rectWorld(i*GRID-1,pos,2,14,"#b2a988");
}
for(let ix=0;ix<7;ix++) for(let iy=0;iy<7;iy++) {
let x=ix*GRID,y=iy*GRID,p=project(x,y);
if(p.xW+120||p.yH+100)continue;
for(let s=-22;s<=22;s+=11) {
rectWorld(x-38,y+s,6,6,"#a8afa9");
rectWorld(x+s,y-38,6,6,"#a8afa9");
}
let hg=horizontalGreen(ix,iy);
rectWorld(x-27,y-27,3,17,hg?P.green:P.red);
rectWorld(x-27,y-27,17,3,hg?P.red:P.green);
let pole=project(x+38,y-38);
g.fillStyle="#152235";g.fillRect(pole.x,pole.y-20,2,20);
g.fillRect(pole.x-4,pole.y-25,10,10);
g.fillStyle=hg?P.green:P.red;
g.fillRect(pole.x-1,pole.y-22,4,4);
}
// School departure markings and roadside banner.
rectWorld(-29,152,58,7,P.yellow);
let sign=project(-30,110,18);
text("VEGAS OR BUST!",sign.x-38,sign.y,P.yellow,9);
for(const t of tapes) if(!t.taken) {
let p=project(t.x,t.y,8+Math.sin(elapsed*5)*3);
g.fillStyle="#17283d";g.fillRect(p.x-9,p.y-6,18,12);
g.fillStyle=P.cyan;g.fillRect(p.x-7,p.y-4,14,7);
g.fillStyle=P.ink;
g.fillRect(p.x-5,p.y-2,3,3);
g.fillRect(p.x+2,p.y-2,3,3);
text("+3",p.x-7,p.y-11,P.white,8);
}
if(state==="bus"&&checkpoint<route.length) {
let t=route[checkpoint],p=project(t.x,t.y);
let pulse=24+Math.sin(elapsed*6)*3;
poly([
{x:p.x,y:p.y-pulse/2},{x:p.x+pulse,y:p.y},
{x:p.x,y:p.y+pulse/2},{x:p.x-pulse,y:p.y}
],"#ffce4555");
g.strokeStyle=P.yellow;g.lineWidth=2;
g.beginPath();g.moveTo(p.x,p.y-pulse/2);g.lineTo(p.x+pulse,p.y);
g.lineTo(p.x,p.y+pulse/2);g.lineTo(p.x-pulse,p.y);
g.closePath();g.stroke();
text(String(checkpoint+1),p.x-4,p.y-23,P.yellow,14);
text(checkpoint===5?"OUTBOUND":"CHECKPOINT",p.x-31,p.y-37,P.white,8);
}
}
function local(v,forward,side,z=0) {
let co=Math.cos(v.angle),si=Math.sin(v.angle);
return project(v.x+co*forward-si*side,v.y+si*forward+co*side,z);
}
function rider(x,y,z,color,hair="#f5c277") {
let p=project(x,y,z);
g.fillStyle="#151c2b";g.fillRect(p.x-4,p.y-7,9,14);
g.fillStyle=color;g.fillRect(p.x-3,p.y-4,7,8);
g.fillStyle="#f4c1a1";g.fillRect(p.x-3,p.y-11,6,6);
g.fillStyle=hair;g.fillRect(p.x-3,p.y-13,6,4);
g.fillStyle="#fff4cf";g.fillRect(p.x-3,p.y+2,7,2);
}
function drawVehicle(v) {
let p=project(v.x,v.y);
if(p.xW+110||p.yH+90)return;
if(v===player&&invulnerable>0&&Math.floor(elapsed*14)%2===0)
g.globalAlpha=.55;
if(v.type===”bike”) {
poly([local(v,21,0),local(v,0,11),local(v,-21,0),local(v,0,-11)],”#16242a88″);
for(const f of [-13,13]) {
let w=local(v,f,0,3);
g.fillStyle=”#101524″;g.fillRect(w.x-5,w.y-4,10,8);
g.fillStyle=”#97a9b8″;g.fillRect(w.x-2,w.y-2,4,4);
}
poly([local(v,15,-4,6),local(v,15,4,6),
local(v,-14,4,6),local(v,-14,-4,6)],”#51405c”);
let skull=local(v,11,0,8);
g.fillStyle=P.white;g.fillRect(skull.x-2,skull.y-2,4,3);
if(!(jumping&&jumping.who===”charlie”)) {
rider(v.x+Math.cos(v.angle)*4,v.y+Math.sin(v.angle)*4,9,”#272537″,”#342638″);
}
if(!noelleAboard&&!(jumping&&jumping.who===”noelle”)) {
rider(v.x-Math.cos(v.angle)*10,v.y-Math.sin(v.angle)*10,10,P.cyan);
}
g.globalAlpha=1;
return;
}
let len=v.type===”bus”?60:31;
let width=v.type===”bus”?25:18;
let height=v.type===”bus”?20:11;
let corners=[
local(v,-len/2,-width/2),local(v,len/2,-width/2),
local(v,len/2,width/2),local(v,-len/2,width/2)
];
let tops=[
local(v,-len/2,-width/2,height),local(v,len/2,-width/2,height),
local(v,len/2,width/2,height),local(v,-len/2,width/2,height)
];
for(let i=0;i<4;i++) {
let j=(i+1)%4;
poly([corners[i],corners[j],tops[j],tops[i]],v.color);
poly([corners[i],corners[j],tops[j],tops[i]],i%2?"#00000022":"#00000044");
}
poly(tops,v.color);
poly([local(v,len/2-10,-width/2+2,height+1),
local(v,len/2-3,-width/2+2,height+1),
local(v,len/2-3,width/2-2,height+1),
local(v,len/2-10,width/2-2,height+1)],"#4b8599");
for(const side of [-1,1]) {
for(const f of [-len*.3,len*.3]) {
let wheel=local(v,f,side*(width/2+1),3);
g.fillStyle="#121c2c";g.fillRect(wheel.x-4,wheel.y-3,8,6);
}
if(v.type==="bus") {
for(let f=-21;f.2||timing||jumping?”#112133″:”#b98236″;
g.fillRect(door.x-6,door.y-6,12,13);
if(dock>.2||timing||jumping) {
g.fillStyle=P.pink;g.fillRect(door.x-5,door.y-3,3,5);
g.fillStyle=P.cyan;g.fillRect(door.x+3,door.y-3,3,5);
}
}
}
g.globalAlpha=1;
}
function drawJump() {
if(!jumping)return;
let t=clamp(jumping.time/.95,0,1);
let from=rearPosition(46),to=rearPosition(22);
let x=mix(from.x,to.x,t),y=mix(from.y,to.y,t);
rider(x,y,14+Math.sin(t*Math.PI)*38,
jumping.who===”noelle”?P.cyan:”#272537″,
jumping.who===”noelle”?”#f5c277″:”#342638″);
}
function minimap() {
const x=535,y=13,size=92,pad=6,scale=(size-pad*2)/LAST;
g.fillStyle=”#0d192cee”;g.fillRect(x-4,y-4,size+8,size+8);
for(let i=0;i<7;i++) {
g.fillStyle="#576478";
g.fillRect(x+pad+i*GRID*scale,y+pad,1,size-pad*2);
g.fillRect(x+pad,y+pad+i*GRID*scale,size-pad*2,1);
}
function dot(o,color,s=4) {
g.fillStyle=color;
g.fillRect(x+pad+o.x*scale-s/2,y+pad+o.y*scale-s/2,s,s);
}
if(state==="bus") {
g.strokeStyle="#ffce4580";
g.beginPath();
g.moveTo(x+pad+bus.x*scale,y+pad+bus.y*scale);
for(let i=checkpoint;i<route.length;i++)
g.lineTo(x+pad+route[i].x*scale,y+pad+route[i].y*scale);
g.stroke();
if(checkpoint<route.length)dot(route[checkpoint],P.white,6);
}
dot(bus,P.yellow,5);
if(state!=="bus")dot(bike,P.pink,4);
text("CITY GRID",x+13,y+size+12,P.white,8);
}
function targetArrow() {
let target=state==="bus"?route[checkpoint]:bus;
if(!target)return;
let p=project(target.x,target.y,28);
let off=p.xW-35||p.y305;
if(!off)return;
let dx=p.x-320,dy=p.y-195;
let a=Math.atan2(dy,dx);
let px=clamp(p.x,30,610),py=clamp(p.y,122,303);
poly([
{x:px+Math.cos(a)*10,y:py+Math.sin(a)*10},
{x:px+Math.cos(a+2.45)*9,y:py+Math.sin(a+2.45)*9},
{x:px+Math.cos(a-2.45)*9,y:py+Math.sin(a-2.45)*9}
],P.yellow);
text(state===”bus”?”ROUTE”:”BUS”,px-12,py-14,P.yellow,9);
}
function hud() {
g.fillStyle=”#101a2dee”;g.fillRect(10,10,239,71);
text(state===”bus”?”ACT 1 / BEAT THE STOPWATCH”:
noelleAboard?”ACT 2 / CHARLIE’S LAST LEAP”:”ACT 2 / CATCH THAT BUS”,
19,26,P.yellow,10);
text(“TIME”,19,42,P.white,9);
text(Math.max(0,clock).toFixed(1),19,67,clock=.8?”READY! PRESS E TO VAULT”:
“REAR DOORS: ALIGN + MATCH SPEED”,175,293,dock>=.8?P.green:P.white,10);
g.fillStyle=”#3a465c”;g.fillRect(176,301,287,6);
g.fillStyle=dock>=.8?P.green:P.cyan;
g.fillRect(176,301,287*(dock/.8),6);
}
if(timing) {
g.fillStyle=”#101a2df5″;g.fillRect(140,252,360,66);
text(“PRESS E ON THE GREEN BEAT!”,178,268,P.yellow,12);
g.fillStyle=”#ae4c68″;g.fillRect(160,283,320,15);
g.fillStyle=P.green;g.fillRect(160+320*.37,283,320*.26,15);
g.fillStyle=P.white;g.fillRect(158+timing.value*320,278,4,25);
text(noelleAboard?”CHARLIE’S LEAP”:”NOELLE’S STAR-FLYER VAULT”,220,311,P.cyan,9);
}
g.fillStyle=”#101a2df0″;g.fillRect(10,332,620,57);
let fallback=state===”bus”?
“Follow the gold diamonds. Cassettes add time. The squad can absorb three traffic bumps.”:
noelleAboard?
“Noelle is safe. Now line Charlie up behind the bus for one last jump!”:
“Charlie and Noelle are on the motorcycle. Follow the yellow bus on the minimap.”;
wrapped(messageTimer>0?message:fallback,21,350,596,14);
if(paused) {
g.fillStyle=”#0b1429aa”;g.fillRect(0,0,W,H);
text(“PAUSED”,260,185,P.yellow,28);
text(“P / PAUSE TO RESUME”,242,209,P.white,12);
}
}
function draw() {
g.save();
if(shake>0) g.translate(Math.sin(elapsed*96)*3,Math.cos(elapsed*79)*2);
drawGround();
let objects=buildings.map(b=>({
depth:b.x+b.y+(b.w+b.d)*.5,draw:()=>drawBuilding(b)
}));
for(const c of traffic) objects.push({
depth:c.x+c.y,draw:()=>drawVehicle(c)
});
objects.push({depth:bus.x+bus.y,draw:()=>drawVehicle(bus)});
if(state!==”bus”) objects.push({
depth:bike.x+bike.y,draw:()=>drawVehicle(bike)
});
objects.sort((a,b)=>a.depth-b.depth);
objects.forEach(o=>o.draw());
drawJump();
for(const p of particles) {
let s=project(p.x,p.y,Math.max(1,p.z));
g.fillStyle=p.color;g.fillRect(s.x,s.y,3,3);
}
g.restore();
if(flash>0) {
g.fillStyle=`rgba(255,245,220,${flash})`;
g.fillRect(0,0,W,H);
}
hud();
}
/* ———- INPUT / LOOP ———- */
function togglePause() {
if(!active)return;
paused=!paused;
keysClear();
}
window.addEventListener(“keydown”,e=>{
let k=e.key.toLowerCase();
if([“arrowup”,”arrowdown”,”arrowleft”,”arrowright”,” “,”shift”].includes(k))
e.preventDefault();
keys[k]=true;
if(e.repeat)return;
if(k===”e”)vaultAction();
if(k===”p”)togglePause();
if(k===”r”)begin();
if(k===”enter”&&!active)begin();
});
window.addEventListener(“keyup”,e=>keys[e.key.toLowerCase()]=false);
window.addEventListener(“blur”,()=>{
keysClear();
if(active)paused=true;
});
for(const b of document.querySelectorAll(“[data-key]”)) {
let k=b.dataset.key;
b.addEventListener(“pointerdown”,e=>{
e.preventDefault();b.setPointerCapture(e.pointerId);keys[k]=true;
});
b.addEventListener(“pointerup”,()=>keys[k]=false);
b.addEventListener(“pointercancel”,()=>keys[k]=false);
b.addEventListener(“lostpointercapture”,()=>keys[k]=false);
}
document.getElementById(“vaultButton”).onclick=vaultAction;
document.getElementById(“pauseButton”).onclick=togglePause;
document.getElementById(“soundButton”).onclick=()=>{
sound=!sound;
document.getElementById(“soundButton”).textContent=”SOUND: “+(sound?”ON”:”OFF”);
if(sound)beep(660,.1);
};
startButton.onclick=begin;
reset();
let last=performance.now();
function frame(now) {
let dt=Math.min((now-last)/1000,.04);
last=now;
update(dt);
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
