[object Object]

← back to Games Agentabrams

Add Rodeo Drive Runner — glam Beverly Hills endless lane-dodge arcade (Canvas 2D)

8039f7691ea3e67b182651a9726ae38542ca19cc · 2026-07-25 09:12:05 -0700 · Steve Abrams

Files touched

Diff

commit 8039f7691ea3e67b182651a9726ae38542ca19cc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 09:12:05 2026 -0700

    Add Rodeo Drive Runner — glam Beverly Hills endless lane-dodge arcade (Canvas 2D)
---
 games-data.js                           |   9 +
 games.json                              |   9 +
 games/particle-text-starling/index.html | 215 ++++++++++++++++++
 games/rodeo-runner/RodeoDriveRunner.aa  | 379 ++++++++++++++++++++++++++++++++
 games/rodeo-runner/index.html           | 379 ++++++++++++++++++++++++++++++++
 5 files changed, 991 insertions(+)

diff --git a/games-data.js b/games-data.js
index 319b694..9c9024b 100644
--- a/games-data.js
+++ b/games-data.js
@@ -292,6 +292,15 @@ window.__GAMES_DATA__ = {
       "desc": "A glam Beverly Hills pop-culture trivia game — beat the clock on 90210, Rodeo Drive, and Pink Palace questions, stack a streak multiplier, and climb from Peach Pit Local to Rodeo Drive Royalty. Self-contained HTML, zero runtime dependencies.",
       "path": "games/trivia-90210/",
       "added": "2026-07-25"
+    },
+    {
+      "id": "rodeo-runner",
+      "file": "RodeoDriveRunner.aa",
+      "title": "Rodeo Drive Runner",
+      "icon": "🏎️",
+      "desc": "A glam Beverly Hills endless runner — cruise a gold convertible down Rodeo Drive, swerve the traffic, and snag champagne and diamonds to stack a combo multiplier. Speed ramps the longer you last. Pure Canvas 2D, zero runtime dependencies.",
+      "path": "games/rodeo-runner/",
+      "added": "2026-07-25"
     }
   ]
 };
diff --git a/games.json b/games.json
index db5e502..119d7b5 100644
--- a/games.json
+++ b/games.json
@@ -290,6 +290,15 @@
       "desc": "A glam Beverly Hills pop-culture trivia game — beat the clock on 90210, Rodeo Drive, and Pink Palace questions, stack a streak multiplier, and climb from Peach Pit Local to Rodeo Drive Royalty. Self-contained HTML, zero runtime dependencies.",
       "path": "games/trivia-90210/",
       "added": "2026-07-25"
+    },
+    {
+      "id": "rodeo-runner",
+      "file": "RodeoDriveRunner.aa",
+      "title": "Rodeo Drive Runner",
+      "icon": "🏎️",
+      "desc": "A glam Beverly Hills endless runner — cruise a gold convertible down Rodeo Drive, swerve the traffic, and snag champagne and diamonds to stack a combo multiplier. Speed ramps the longer you last. Pure Canvas 2D, zero runtime dependencies.",
+      "path": "games/rodeo-runner/",
+      "added": "2026-07-25"
     }
   ]
 }
diff --git a/games/particle-text-starling/index.html b/games/particle-text-starling/index.html
index d570864..049fce3 100644
--- a/games/particle-text-starling/index.html
+++ b/games/particle-text-starling/index.html
@@ -393,6 +393,221 @@
     build(w);
   });
 
+  // ================= Save GIF + Copy code (dependency-free) =================
+  var gifBtn = document.getElementById("gif");
+  var codeBtn = document.getElementById("code");
+  var toastEl = document.getElementById("toast");
+  var recording = false;
+
+  function toast(msg, ms) {
+    toastEl.textContent = msg; toastEl.style.opacity = "1";
+    clearTimeout(toast._t); toast._t = setTimeout(function () { toastEl.style.opacity = "0"; }, ms || 1600);
+  }
+
+  // Re-scatter so the captured clip shows a full murmuration-into-text formation.
+  function scatterAll() {
+    for (var i = 0; i < particles.length; i++) {
+      var p = particles[i];
+      p.x = Math.random() * W; p.y = Math.random() * H;
+      p.vx = (Math.random() - 0.5) * 4; p.vy = (Math.random() - 0.5) * 4;
+    }
+  }
+
+  // --- median-cut quantizer -> 256-color palette (cols: packed 0xRRGGBB ints) ---
+  function buildPalette(cols) {
+    var boxes = [{ lo: 0, hi: cols.length }];
+    function ranges(box) {
+      var rmn = 255, rmx = 0, gmn = 255, gmx = 0, bmn = 255, bmx = 0;
+      for (var i = box.lo; i < box.hi; i++) {
+        var c = cols[i], r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
+        if (r < rmn) rmn = r; if (r > rmx) rmx = r;
+        if (g < gmn) gmn = g; if (g > gmx) gmx = g;
+        if (b < bmn) bmn = b; if (b > bmx) bmx = b;
+      }
+      return { r: rmx - rmn, g: gmx - gmn, b: bmx - bmn };
+    }
+    while (boxes.length < 256) {
+      var bi = -1, brange = -1, bch = 16;
+      for (var i = 0; i < boxes.length; i++) {
+        var bx = boxes[i]; if (bx.hi - bx.lo < 2) continue;
+        var rg = ranges(bx), m = Math.max(rg.r, rg.g, rg.b);
+        if (m > brange) { brange = m; bi = i; bch = (rg.r >= rg.g && rg.r >= rg.b) ? 16 : (rg.g >= rg.b ? 8 : 0); }
+      }
+      if (bi < 0) break;
+      var box = boxes[bi];
+      var sub = cols.slice(box.lo, box.hi);
+      var sh = bch;
+      sub.sort(function (a, b) { return ((a >> sh) & 255) - ((b >> sh) & 255); });
+      for (var k = 0; k < sub.length; k++) cols[box.lo + k] = sub[k];
+      var mid = (box.lo + box.hi) >> 1;
+      boxes.splice(bi, 1, { lo: box.lo, hi: mid }, { lo: mid, hi: box.hi });
+    }
+    var pal = [];
+    for (var i2 = 0; i2 < boxes.length; i2++) {
+      var b2 = boxes[i2], rs = 0, gs = 0, bs = 0, n = b2.hi - b2.lo || 1;
+      for (var j = b2.lo; j < b2.hi; j++) { var c2 = cols[j]; rs += (c2 >> 16) & 255; gs += (c2 >> 8) & 255; bs += c2 & 255; }
+      pal.push([Math.round(rs / n), Math.round(gs / n), Math.round(bs / n)]);
+    }
+    while (pal.length < 256) pal.push([0, 0, 0]);
+    return pal;
+  }
+
+  function nearest(pal, r, g, b, cache) {
+    var key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2);
+    var v = cache[key]; if (v >= 0) return v;
+    var best = 0, bd = 1e9;
+    for (var i = 0; i < 256; i++) {
+      var p = pal[i], dr = r - p[0], dg = g - p[1], db = b - p[2], d = dr * dr + dg * dg + db * db;
+      if (d < bd) { bd = d; best = i; if (d === 0) break; }
+    }
+    cache[key] = best; return best;
+  }
+
+  // --- standard GIF LZW encoder (variable code size, clear/EOI, dict reset at 4096) ---
+  function lzwEncode(minCode, idx) {
+    var out = [], cur = 0, bits = 0;
+    function put(code, size) { cur |= code << bits; bits += size; while (bits >= 8) { out.push(cur & 255); cur = cur >>> 8; bits -= 8; } }
+    var clear = 1 << minCode, eoi = clear + 1;
+    var size, next, dict;
+    function reset() { size = minCode + 1; next = eoi + 1; dict = new Map(); }
+    reset();
+    put(clear, size);
+    if (idx.length) {
+      var prev = idx[0];
+      for (var i = 1; i < idx.length; i++) {
+        var c = idx[i], key = (prev << 8) | c;
+        if (dict.has(key)) { prev = dict.get(key); }
+        else {
+          put(prev, size);
+          dict.set(key, next++);
+          if (next === (1 << size) && size < 12) size++;
+          if (next > 4095) { put(clear, size); reset(); }
+          prev = c;
+        }
+      }
+      put(prev, size);
+    }
+    put(eoi, size);
+    if (bits > 0) out.push(cur & 255);
+    return out;
+  }
+
+  // --- GIF89a writer (256-color global table, infinite loop) ---
+  function GifWriter(w, h, pal) {
+    var out = [];
+    function byte(v) { out.push(v & 255); }
+    function word(v) { out.push(v & 255, (v >> 8) & 255); }
+    function str(s) { for (var i = 0; i < s.length; i++) byte(s.charCodeAt(i)); }
+    str("GIF89a"); word(w); word(h); byte(0xF7); byte(0); byte(0);
+    for (var i = 0; i < 256; i++) { var c = pal[i] || [0, 0, 0]; byte(c[0]); byte(c[1]); byte(c[2]); }
+    byte(0x21); byte(0xFF); byte(0x0B); str("NETSCAPE2.0"); byte(0x03); byte(0x01); word(0); byte(0); // loop forever
+    return {
+      addFrame: function (idx, delayCs) {
+        byte(0x21); byte(0xF9); byte(0x04); byte(0x00); word(delayCs); byte(0); byte(0); // graphic control
+        byte(0x2C); word(0); word(0); word(w); word(h); byte(0); // image descriptor
+        var MIN = 8; byte(MIN); // LZW min code size (8-bit palette)
+        var buf = lzwEncode(MIN, idx);
+        for (var p = 0; p < buf.length;) { var n = Math.min(255, buf.length - p); byte(n); for (var q = 0; q < n; q++) byte(buf[p + q]); p += n; }
+        byte(0); // block terminator
+      },
+      finish: function () { byte(0x3B); return new Uint8Array(out); }
+    };
+  }
+
+  function recordGif() {
+    if (recording) return;
+    recording = true; gifBtn.disabled = true; codeBtn.disabled = true;
+    var scale = Math.min(1, 480 / W);
+    var gw = Math.max(2, Math.round(W * scale)), gh = Math.max(2, Math.round(H * scale));
+    gw -= gw % 2; gh -= gh % 2;
+    var off = document.createElement("canvas"); off.width = gw; off.height = gh;
+    var octx = off.getContext("2d");
+    var sx = gw / W, sy = gh / H;                 // map particle coords into the GIF frame
+    var dotR = Math.max(1, Math.min(gw, gh) / 230); // crisp, visible dot at GIF scale
+    var frames = [], TOTAL = 40, DELAY = 6;       // ~16fps, ~2.4s
+    octx.fillStyle = "#05060a"; octx.fillRect(0, 0, gw, gh);
+    scatterAll();
+    var f = 0;
+    var iv = setInterval(function () {
+      // render particles straight at GIF resolution (preserves brightness + additive glow)
+      octx.globalCompositeOperation = "source-over";
+      octx.fillStyle = "rgba(5,6,10,0.34)"; octx.fillRect(0, 0, gw, gh); // motion trail
+      octx.globalCompositeOperation = "lighter";
+      for (var pi = 0; pi < particles.length; pi++) {
+        var pp = particles[pi];
+        octx.fillStyle = pp.color;
+        octx.beginPath();
+        octx.arc(pp.x * sx, pp.y * sy, dotR, 0, 6.2832);
+        octx.fill();
+      }
+      frames.push(octx.getImageData(0, 0, gw, gh).data.slice());
+      if (++f >= TOTAL) { clearInterval(iv); encode(); }
+      else toast("Recording GIF… " + Math.round(f / TOTAL * 100) + "%", 900);
+    }, DELAY * 10);
+
+    function encode() {
+      window.__frames = frames; window.__gwgh = [gw, gh]; // DEBUG (temp)
+      toast("Encoding GIF…", 6000);
+      setTimeout(function () {
+        // Sample for the palette: keep EVERY bright particle pixel (they're sparse but are the
+        // whole point), and only a thin slice of the dark background — otherwise median-cut spends
+        // all 256 slots on near-black and quantizes the glow away.
+        var cols = [], dc = 0;
+        for (var fi = 0; fi < frames.length; fi += 2) {
+          var d = frames[fi];
+          for (var i = 0; i < d.length; i += 4) {
+            var r = d[i], g = d[i + 1], b = d[i + 2];
+            var bright = r > g ? r : g; if (b > bright) bright = b;
+            if (bright > 40) cols.push((r << 16) | (g << 8) | b);
+            else if ((dc++ & 15) === 0) cols.push((r << 16) | (g << 8) | b);
+          }
+        }
+        if (cols.length < 512) for (var i = 0; i < 512; i++) cols.push(0x05060a);
+        var pal = buildPalette(cols);
+        window.__pal = pal; window.__colslen = cols.length; // DEBUG (temp)
+        var cache = new Int16Array(262144); for (var i = 0; i < cache.length; i++) cache[i] = -1;
+        var gif = GifWriter(gw, gh, pal);
+        for (var fi2 = 0; fi2 < frames.length; fi2++) {
+          var d2 = frames[fi2], idx = new Uint8Array(d2.length / 4);
+          for (var i2 = 0, j = 0; i2 < d2.length; i2 += 4, j++) idx[j] = nearest(pal, d2[i2], d2[i2 + 1], d2[i2 + 2], cache);
+          gif.addFrame(idx, DELAY);
+        }
+        var bytes = gif.finish();
+        var blob = new Blob([bytes], { type: "image/gif" });
+        var url = URL.createObjectURL(blob);
+        var a = document.createElement("a");
+        a.href = url;
+        a.download = "starling-" + ((input.value || "particles").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30) || "particles") + ".gif";
+        document.body.appendChild(a); a.click(); a.remove();
+        setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
+        recording = false; gifBtn.disabled = false; codeBtn.disabled = false;
+        toast("GIF saved · " + ((bytes.length / 1024) | 0) + " KB", 2400);
+      }, 60);
+    }
+  }
+
+  function copyCode() {
+    function done() { toast("Source code copied to clipboard"); }
+    function fallback(src) {
+      try {
+        var ta = document.createElement("textarea");
+        ta.value = src; ta.style.position = "fixed"; ta.style.opacity = "0";
+        document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); done();
+      } catch (e) { toast("Copy failed — see console"); console.log(src); }
+    }
+    function put(src) {
+      if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(src).then(done, function () { fallback(src); });
+      else fallback(src);
+    }
+    // Prefer the on-disk source; fall back to the live DOM (works on file://).
+    fetch(location.href).then(function (r) { return r.text(); })
+      .then(put)
+      .catch(function () { put("<!DOCTYPE html>\n" + document.documentElement.outerHTML); });
+  }
+
+  gifBtn.addEventListener("click", recordGif);
+  codeBtn.addEventListener("click", copyCode);
+
   // fade the hint after a bit
   setTimeout(function () { hintEl.style.opacity = "0.35"; }, 6000);
 
diff --git a/games/rodeo-runner/RodeoDriveRunner.aa b/games/rodeo-runner/RodeoDriveRunner.aa
new file mode 100644
index 0000000..7e003a2
--- /dev/null
+++ b/games/rodeo-runner/RodeoDriveRunner.aa
@@ -0,0 +1,379 @@
+<!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); }
+.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">🌴&nbsp;🏎️&nbsp;🌴</div>
+    <div class="blurb">Cruise Rodeo Drive at full throttle. Swerve the traffic, snag 🥂 &amp; 💎 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>
+    <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!";
+    if(navigator.share){ navigator.share({title:"Rodeo Drive Runner",text:text}).catch(function(){}); }
+    else if(navigator.clipboard){ navigator.clipboard.writeText(text).then(function(){ document.getElementById("shareLine").textContent="📋 Score copied."; }).catch(function(){}); }
+  }
+
+  // ---- wire ----
+  document.getElementById("startBtn").addEventListener("click", start);
+  document.getElementById("againBtn").addEventListener("click", start);
+  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>
diff --git a/games/rodeo-runner/index.html b/games/rodeo-runner/index.html
new file mode 100644
index 0000000..7e003a2
--- /dev/null
+++ b/games/rodeo-runner/index.html
@@ -0,0 +1,379 @@
+<!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); }
+.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">🌴&nbsp;🏎️&nbsp;🌴</div>
+    <div class="blurb">Cruise Rodeo Drive at full throttle. Swerve the traffic, snag 🥂 &amp; 💎 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>
+    <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!";
+    if(navigator.share){ navigator.share({title:"Rodeo Drive Runner",text:text}).catch(function(){}); }
+    else if(navigator.clipboard){ navigator.clipboard.writeText(text).then(function(){ document.getElementById("shareLine").textContent="📋 Score copied."; }).catch(function(){}); }
+  }
+
+  // ---- wire ----
+  document.getElementById("startBtn").addEventListener("click", start);
+  document.getElementById("againBtn").addEventListener("click", start);
+  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>

← 2c42688 90210 Trivia: expand bank to 35 verified questions, swap til  ·  back to Games Agentabrams  ·  Add Beverly Hills Slots (PinkPalaceSlots.aa) — source-backed 10428c6 →