← back to 90210 Agentabrams
games/rodeo-runner/RodeoDriveRunner.aa
385 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Rodeo Drive Runner — 90210 Endless Cruise</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@500;600;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;height:100%;}
html,body{height:100%;overflow:hidden;}
body{
touch-action:manipulation;-webkit-tap-highlight-color:transparent;
-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;
font-family:"Inter",-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;
background:#0d0907; color:#efe3c8;
display:flex; align-items:center; justify-content:center;
}
:root{
--ivory:#efe3c8; --ivory-dk:#c9b07a; --gold:#e9c46a; --gold-hi:#f6de9b;
--peach:#f4a261; --burnt:#e76f51; --ink:#1a1410;
--font-disp:"Playfair Display",Georgia,serif;
}
#wrap{ position:relative; width:min(560px,100vw); height:100%; max-height:100vh; }
canvas{ display:block; width:100%; height:100%; touch-action:none; }
/* Overlays */
.overlay{
position:absolute; inset:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; text-align:center;
padding:24px; gap:6px;
background:radial-gradient(circle at 50% 30%, rgba(26,20,16,.55), rgba(8,6,4,.82) 70%);
backdrop-filter:blur(2px); z-index:5;
}
.overlay.hidden{ display:none; }
.kicker{ font-size:12px; letter-spacing:.34em; text-transform:uppercase; color:var(--ivory-dk); }
.title{
font-family:var(--font-disp); font-weight:900; line-height:.98;
font-size:clamp(30px,9vw,52px); margin:4px 0;
background:linear-gradient(180deg,var(--gold-hi),var(--peach) 70%,var(--burnt));
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
filter:drop-shadow(0 4px 10px rgba(0,0,0,.4));
}
.palms{ font-size:24px; letter-spacing:6px; margin:2px 0 8px; }
.blurb{ font-size:13px; color:var(--ivory-dk); max-width:34ch; line-height:1.6; margin-bottom:16px; }
.big-score{ font-family:var(--font-disp); font-weight:900; font-size:clamp(40px,13vw,68px); color:var(--gold); line-height:1; }
.sub{ font-size:13px; color:var(--ivory-dk); }
.gold{ color:var(--gold); font-weight:700; }
.btn{
font-weight:700; font-size:14px; letter-spacing:.06em; text-transform:uppercase;
color:var(--ink); cursor:pointer; border:none; margin-top:16px;
padding:14px 30px; border-radius:999px;
background:linear-gradient(180deg,var(--gold-hi),var(--peach));
box-shadow:0 12px 28px rgba(0,0,0,.45);
transition:transform .14s, filter .14s;
}
.btn:hover{ transform:translateY(-2px); filter:brightness(1.06); }
.btn:active{ transform:translateY(0); }
.btn.ghost{ background:transparent; color:var(--ivory); border:1px solid rgba(233,196,106,.5); box-shadow:none; margin-top:10px; }
.btn.ghost:hover{ border-color:var(--gold); filter:none; }
.controls-hint{ font-size:11px; color:var(--ivory-dk); margin-top:14px; letter-spacing:.08em; opacity:.85; }
.new-best{ display:inline-block; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
color:var(--ink); background:linear-gradient(180deg,var(--gold-hi),var(--peach));
padding:4px 12px; border-radius:999px; margin-bottom:10px; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div class="overlay" id="ovStart">
<div class="kicker">Beverly Hills · 90210</div>
<div class="title">Rodeo Drive<br>Runner</div>
<div class="palms">🌴 🏎️ 🌴</div>
<div class="blurb">Cruise Rodeo Drive at full throttle. Swerve the traffic, snag 🥂 & 💎 to stack a combo, and see how far you get before you kiss a bumper.</div>
<button class="btn" id="startBtn">Start Cruising</button>
<div class="controls-hint">← → or A / D · tap left / right · swipe</div>
</div>
<div class="overlay hidden" id="ovOver">
<div class="kicker">Crash on Rodeo</div>
<div id="bestBadge"></div>
<div class="big-score" id="finalScore">0</div>
<div class="sub"><span id="finalDist">0</span> m cruised · best combo <span class="gold" id="finalCombo">0×</span></div>
<div class="sub" id="bestLine" style="margin-top:8px"></div>
<button class="btn" id="againBtn">Cruise Again</button>
<button class="btn ghost" id="shareBtn">Share Score</button>
<div class="controls-hint" id="shareLine"></div>
</div>
</div>
<script>
(function(){
"use strict";
var LS_BEST = "rodeoRunner.best";
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var wrap = document.getElementById("wrap");
var ovStart = document.getElementById("ovStart");
var ovOver = document.getElementById("ovOver");
// ---- dimensions ----
var W=0, H=0, dpr=1;
var road = { x:0, w:0 }; // road left + width
var LANES = 3;
function laneCenter(i){ return road.x + road.w*(i+0.5)/LANES; }
function resize(){
dpr = Math.min(window.devicePixelRatio||1, 2);
W = wrap.clientWidth; H = wrap.clientHeight;
canvas.width = Math.round(W*dpr); canvas.height = Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
var roadW = Math.min(W*0.74, 420);
road.w = roadW; road.x = (W-roadW)/2;
carW = Math.min(road.w/LANES*0.62, 66);
carH = carW*1.7;
playerY = H - carH*1.35;
}
// ---- entities ----
var carW, carH, playerY;
var state = "start"; // start | play | over
var playerLane = 1, playerX = 0, targetX = 0;
var obstacles = [], pickups = [], dashes = [], palms = [];
var scrollY = 0, speed = 0, dist = 0, score = 0, combo = 0, bestCombo = 0;
var spawnT = 0, pickupT = 0, elapsed = 0, godMode = false;
var best = parseInt(localStorage.getItem(LS_BEST)||"0",10) || 0;
var CAR_COLORS = ["#c0392b","#2c3e50","#16a085","#8e44ad","#d35400","#2980b9","#7f8c8d","#c39bd3"];
function reset(){
obstacles=[]; pickups=[]; dashes=[]; palms=[];
playerLane=1; playerX=targetX=laneCenter(1);
scrollY=0; speed=260; dist=0; score=0; combo=0; bestCombo=0;
spawnT=0.7; pickupT=1.4; elapsed=0;
// pre-seed lane dashes and side palms
for(var y=-40;y<H+40;y+=64) dashes.push(y);
for(var yp=-80;yp<H+80;yp+=150){ palms.push({y:yp,side:0}); palms.push({y:yp+75,side:1}); }
}
function start(){
resize(); reset();
state="play";
ovStart.classList.add("hidden");
ovOver.classList.add("hidden");
last = performance.now();
}
// ---- input ----
function move(dir){
if(state!=="play") return;
playerLane = Math.max(0, Math.min(LANES-1, playerLane + dir));
targetX = laneCenter(playerLane);
}
document.addEventListener("keydown", function(e){
var k=e.key.toLowerCase();
if(k==="arrowleft"||k==="a"){ e.preventDefault(); move(-1); }
else if(k==="arrowright"||k==="d"){ e.preventDefault(); move(1); }
else if((k==="enter"||k===" ")){ if(state!=="play"){ e.preventDefault(); start(); } }
});
// pointer: tap halves + swipe
var downX=null;
canvas.addEventListener("pointerdown", function(e){ downX=e.clientX; });
canvas.addEventListener("pointerup", function(e){
if(state!=="play"){ return; }
if(downX==null) return;
var dx=e.clientX-downX;
if(Math.abs(dx)>24){ move(dx>0?1:-1); }
else { move(e.clientX < wrap.getBoundingClientRect().left + W/2 ? -1 : 1); }
downX=null;
});
// ---- spawning (always leaves at least one lane open) ----
function spawnObstacles(){
var busy = {};
var n = Math.random()<0.32 ? 2 : 1; // never 3 -> a gap always exists
for(var i=0;i<n;i++){
var lane;
do { lane = (Math.random()*LANES)|0; } while(busy[lane]);
busy[lane]=1;
obstacles.push({ lane:lane, y:-carH, color:CAR_COLORS[(Math.random()*CAR_COLORS.length)|0] });
}
}
function spawnPickup(){
var lane=(Math.random()*LANES)|0;
var kind = Math.random()<0.35 ? "💎" : "🥂";
var val = kind==="💎" ? 250 : 100;
pickups.push({ lane:lane, y:-30, kind:kind, val:val });
}
// ---- loop ----
var last=performance.now();
function frame(now){
requestAnimationFrame(frame);
var dt = Math.min((now-last)/1000, 0.05); last=now;
if(state==="play") update(dt);
render();
}
function update(dt){
elapsed += dt;
speed = 260 + Math.min(elapsed*10, 320); // ramps to ~580 px/s
var dy = speed*dt;
dist += dy/40;
score += dy*0.12;
// scroll decorations
for(var i=0;i<dashes.length;i++){ dashes[i]+=dy; if(dashes[i]>H+40) dashes[i]-=(H+80); }
for(var p=0;p<palms.length;p++){ palms[p].y+=dy*0.9; if(palms[p].y>H+80) palms[p].y-=(H+160); }
// obstacles
spawnT-=dt;
if(spawnT<=0){ spawnObstacles(); spawnT = Math.max(0.42, 0.95 - elapsed*0.012); }
for(var o=obstacles.length-1;o>=0;o--){
obstacles[o].y += dy;
if(obstacles[o].y > H+carH){ obstacles.splice(o,1); continue; }
if(!godMode && hit(obstacles[o])){ return crash(); }
}
// pickups
pickupT-=dt;
if(pickupT<=0){ spawnPickup(); pickupT = 0.9 + Math.random()*0.9; }
for(var k=pickups.length-1;k>=0;k--){
pickups[k].y += dy;
if(pickups[k].y > H+30){ pickups.splice(k,1); continue; }
if(collect(pickups[k])){
combo++; if(combo>bestCombo) bestCombo=combo;
score += pickups[k].val * (1 + (combo-1)*0.2);
pickups.splice(k,1);
}
}
// ease player toward target lane
playerX += (targetX-playerX) * Math.min(1, dt*16);
}
function hit(ob){
if(ob.lane!==playerLane) return false;
var oy=ob.y, py=playerY;
return (oy+carH*0.9 > py) && (oy < py+carH*0.9);
}
function collect(pk){
if(pk.lane!==playerLane) return false;
return (pk.y+22 > playerY) && (pk.y < playerY+carH);
}
function crash(){
state="over";
var finalScore = Math.round(score);
document.getElementById("finalScore").textContent = finalScore.toLocaleString();
document.getElementById("finalDist").textContent = Math.round(dist).toLocaleString();
document.getElementById("finalCombo").textContent = bestCombo + "×";
var bb=document.getElementById("bestBadge"), bl=document.getElementById("bestLine");
if(finalScore > best){ best=finalScore; localStorage.setItem(LS_BEST,String(best));
bb.innerHTML='<span class="new-best">✨ New Personal Best</span>'; bl.textContent="";
} else { bb.innerHTML=""; bl.innerHTML='Best: <span class="gold">'+best.toLocaleString()+'</span>'; }
document.getElementById("shareLine").textContent="";
ovOver.classList.remove("hidden");
}
// ---- render ----
function render(){
// sky / asphalt
var g=ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,"#241a12"); g.addColorStop(0.5,"#17110c"); g.addColorStop(1,"#0d0907");
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
// road
ctx.fillStyle="#20232a";
roundRect(road.x, -10, road.w, H+20, 0); ctx.fill();
// road edge glow (gold curbs)
ctx.fillStyle="rgba(233,196,106,.5)";
ctx.fillRect(road.x-3,0,3,H); ctx.fillRect(road.x+road.w,0,3,H);
// lane dashes (gold)
ctx.fillStyle="rgba(246,222,155,.55)";
for(var l=1;l<LANES;l++){
var lx=road.x+road.w*l/LANES;
for(var i=0;i<dashes.length;i++){ ctx.fillRect(lx-2, dashes[i], 4, 30); }
}
// side palms
ctx.font="26px system-ui,Apple Color Emoji,Segoe UI Emoji";
ctx.textAlign="center"; ctx.textBaseline="middle";
for(var p=0;p<palms.length;p++){
var px = palms[p].side===0 ? road.x-22 : road.x+road.w+22;
if(px>6 && px<W-6) ctx.fillText("🌴", px, palms[p].y);
}
// pickups
for(var k=0;k<pickups.length;k++){
ctx.font="24px system-ui,Apple Color Emoji,Segoe UI Emoji";
ctx.fillText(pickups[k].kind, laneCenter(pickups[k].lane), pickups[k].y);
}
// obstacle cars
for(var o=0;o<obstacles.length;o++){
drawCar(laneCenter(obstacles[o].lane), obstacles[o].y, obstacles[o].color, false);
}
// player convertible
if(state!=="start") drawCar(playerX, playerY, "#e9c46a", true);
// HUD
if(state==="play"){
ctx.textAlign="left"; ctx.textBaseline="alphabetic";
ctx.font="700 20px 'Playfair Display',Georgia,serif";
ctx.fillStyle="#efe3c8";
ctx.fillText(Math.round(score).toLocaleString(), 16, 32);
ctx.font="600 11px Inter,sans-serif"; ctx.fillStyle="#c9b07a";
ctx.fillText("SCORE", 16, 46);
if(combo>1){
ctx.textAlign="right";
ctx.font="700 18px 'Playfair Display',serif"; ctx.fillStyle="#f4a261";
ctx.fillText("🔥 "+combo+"×", W-16, 32);
}
}
}
function drawCar(cx, cy, color, isPlayer){
var w=carW, h=carH, x=cx-w/2, y=cy;
ctx.save();
// shadow
ctx.fillStyle="rgba(0,0,0,.35)"; roundRect(x+3, y+6, w, h, 10); ctx.fill();
// body
ctx.fillStyle=color; roundRect(x, y, w, h, 10); ctx.fill();
// top highlight
var gg=ctx.createLinearGradient(x,y,x,y+h);
gg.addColorStop(0,"rgba(255,255,255,.25)"); gg.addColorStop(0.5,"rgba(255,255,255,0)");
ctx.fillStyle=gg; roundRect(x,y,w,h,10); ctx.fill();
// windshield
ctx.fillStyle="rgba(20,26,34,.85)";
roundRect(x+w*0.16, y+h*0.16, w*0.68, h*0.26, 5); ctx.fill();
// rear window
roundRect(x+w*0.16, y+h*0.6, w*0.68, h*0.22, 5); ctx.fill();
if(isPlayer){ // gold trim
ctx.strokeStyle="rgba(255,255,255,.6)"; ctx.lineWidth=1.5;
roundRect(x,y,w,h,10); ctx.stroke();
}
ctx.restore();
}
function roundRect(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
// ---- share ----
function share(){
var s=Math.round(score);
var text="I cruised Rodeo Drive for "+s.toLocaleString()+" points in Rodeo Drive Runner. 🏎️💎 Beat me on 90210 row!";
var url="https://90210.agentabrams.com/#rodeo-runner";
if(navigator.share){ navigator.share({title:"Rodeo Drive Runner",text:text,url:url}).catch(function(){}); }
else if(navigator.clipboard){ navigator.clipboard.writeText(text+" "+url).then(function(){ document.getElementById("shareLine").textContent="📋 Score + link copied."; }).catch(function(){}); }
}
// ---- wire ----
document.getElementById("startBtn").addEventListener("click", start);
document.getElementById("againBtn").addEventListener("click", start);
document.getElementById("shareBtn").addEventListener("click", share);
window.addEventListener("resize", function(){ resize(); if(state==="play"){ targetX=laneCenter(playerLane);} });
resize();
requestAnimationFrame(frame);
// Automation/test hook (mirrors other hub games).
window.__runner = {
start:start,
left:function(){ move(-1); },
right:function(){ move(1); },
crash:function(){ if(state==="play") crash(); },
god:function(on){ godMode=!!on; },
state:function(){ return { state:state, score:Math.round(score), dist:Math.round(dist), combo:combo, bestCombo:bestCombo, lane:playerLane, best:best }; }
};
})();
</script>
</body>
</html>