← Public packages
@kentcdodds/roadster
Unofficial fan game: drive a stylized thruster roadster - launch 0-60, boost, hover, flip and hop over traffic.
src/page.ts
192 lines · 13.2 KB · TypeScriptfunction escapeAttr(value: string) {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
}
const css = `
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#0b0a14;color:#f4f1ea;font:600 14px/1.25 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;touch-action:none;user-select:none;-webkit-user-select:none}
canvas#game{position:fixed;inset:0;width:100%;height:100%;display:block}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
#hud{position:fixed;inset:0;pointer-events:none}
.panel{background:rgba(10,10,20,.55);border:1px solid rgba(255,255,255,.12);border-radius:14px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
#speedo{position:absolute;left:16px;top:16px;padding:12px 16px;width:230px}
#speed{font-size:54px;font-weight:800;letter-spacing:-1px;line-height:1}
#speed small{font-size:14px;font-weight:600;opacity:.7;margin-left:6px;letter-spacing:0}
.row{display:flex;justify-content:space-between;gap:8px;margin-top:6px;font-size:13px;opacity:.9}
.bar{height:8px;border-radius:5px;background:rgba(255,255,255,.12);overflow:hidden;margin-top:4px}
.bar i{display:block;height:100%;width:100%;background:linear-gradient(90deg,#9ee7ff,#f2f7ff);transform-origin:left;transition:background .2s}
.bar.low i{background:linear-gradient(90deg,#ff7a59,#ffc46b)}
#chips{display:flex;gap:6px;margin-top:8px}
.chip{font-size:11px;padding:3px 7px;border-radius:999px;border:1px solid rgba(255,255,255,.25);opacity:.4}
.chip.on{opacity:1;background:#e9c46a;color:#1a1408;border-color:#e9c46a}
#tools{position:absolute;right:16px;top:16px;display:flex;gap:8px;pointer-events:auto}
#tools button{width:44px;height:44px;border-radius:12px;border:1px solid rgba(255,255,255,.2);background:rgba(10,10,20,.55);color:#fff;font-size:18px;cursor:pointer}
#tools button.off{opacity:.45}
#challenges{position:absolute;right:16px;top:72px;width:250px;padding:10px 12px;pointer-events:auto}
#challenges h3{margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;opacity:.7}
#challenges ol{list-style:none;margin:0;padding:0}
#challenges li{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:8px;cursor:pointer;font-size:13px}
#challenges li:hover{background:rgba(255,255,255,.08)}
#challenges li .k{width:18px;height:18px;border-radius:5px;border:1px solid rgba(255,255,255,.3);font-size:11px;display:grid;place-items:center;flex:none}
#challenges li.done .k{background:#7ee081;color:#062;border-color:#7ee081}
#challenges li .t{flex:1}
#challenges li .b{opacity:.7;font-size:12px;min-width:52px;text-align:right}
#timer{position:absolute;left:50%;top:18px;transform:translateX(-50%);padding:8px 18px;text-align:center;visibility:hidden}
#timer .v{font-size:36px;font-weight:800}
#lights{display:flex;gap:8px;justify-content:center;margin-bottom:4px}
#lights span{width:16px;height:16px;border-radius:50%;background:#311}
#lights span.r{background:#ff3b30;box-shadow:0 0 12px #ff3b30}
#lights span.g{background:#34c759;box-shadow:0 0 12px #34c759}
#toast{position:absolute;left:50%;top:30%;transform:translate(-50%,-50%);font-size:34px;font-weight:900;text-align:center;text-shadow:0 4px 24px rgba(0,0,0,.6);opacity:0;transition:opacity .25s;white-space:nowrap}
#toast small{display:block;font-size:16px;font-weight:600;opacity:.85;margin-top:6px}
#hint{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);font-size:12px;opacity:.6;white-space:nowrap}
#fan{position:absolute;left:18px;top:216px;width:230px;font-size:11px;opacity:.5}
#overlay{position:fixed;inset:0;display:grid;place-items:center;background:radial-gradient(ellipse at center,rgba(20,14,40,.55),rgba(5,4,12,.92));z-index:5;pointer-events:auto}
#overlay.hidden{visibility:hidden}
.card{max-width:560px;margin:16px;padding:26px 28px}
.card h1{margin:0;font-size:44px;letter-spacing:.06em;font-weight:900}
.card h1 span{color:#e9c46a}
.card p{opacity:.85;margin:8px 0}
.card .keys{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:14px 0;font-size:13px}
.card kbd{font-family:ui-monospace,Menlo,monospace;background:rgba(255,255,255,.12);border-radius:5px;padding:1px 6px}
.card button{margin-top:10px;font-family:inherit;font-weight:800;font-size:18px;line-height:1;padding:14px 28px;border-radius:12px;border:0;background:#e9c46a;color:#1a1408;cursor:pointer}
.card .cta{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:10px}
.card .cta button{margin-top:0}
.card .pkg{color:#f4f1ea;text-decoration:none;font-size:13px;font-weight:700;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.06);opacity:.85}
.card .pkg:hover,.card .pkg:focus-visible{opacity:1;border-color:#e9c46a;color:#e9c46a;outline:none}
.card .fine{font-size:11px;opacity:.55;margin-top:14px}
#touch{display:none}
body.touch #touch{display:block}
body.touch #hint,body.touch .desk,body.touch #fan{display:none}
#joy{position:absolute;left:22px;bottom:26px;width:150px;height:150px;border-radius:50%;border:2px solid rgba(255,255,255,.25);background:rgba(255,255,255,.06);pointer-events:auto;touch-action:none}
#knob{position:absolute;left:50%;top:50%;width:62px;height:62px;margin:-31px 0 0 -31px;border-radius:50%;background:rgba(255,255,255,.35);border:2px solid rgba(255,255,255,.6)}
#btns{position:absolute;right:18px;bottom:22px;display:grid;grid-template-columns:repeat(2,74px);gap:10px;pointer-events:auto}
#btns button{height:74px;border-radius:18px;border:2px solid rgba(255,255,255,.3);background:rgba(255,255,255,.08);color:#fff;font-family:inherit;font-weight:800;font-size:12px;touch-action:none}
#btns button.on{background:#e9c46a;color:#1a1408;border-color:#e9c46a}
#b-list{display:none}
body.touch #b-list{display:block}
body.touch #tools{top:10px;right:10px;gap:6px}
body.touch #tools button{width:40px;height:40px;font-size:16px}
body.touch #speedo{left:10px;top:10px;width:140px;padding:8px 10px;border-radius:12px}
body.touch #speed{font-size:32px}
body.touch #speed small{font-size:11px}
body.touch .row{font-size:11px;margin-top:4px}
body.touch .row.alt,body.touch .row.best{display:none}
body.touch #chips{gap:4px;margin-top:6px}
body.touch .chip{font-size:9px;padding:2px 5px}
body.touch #fan{display:none}
body.touch #challenges{display:none;top:58px;right:10px;width:min(260px,calc(100vw - 20px));z-index:3}
body.touch #challenges.open{display:block}
body.touch #timer{top:150px;padding:6px 14px}
body.touch #timer .v{font-size:26px}
body.touch #toast{font-size:24px;top:48%;white-space:normal;width:90vw}
body.touch #toast small{font-size:13px}
body.touch .card{padding:18px}
body.touch .card h1{font-size:30px}
#err{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:20;max-width:90vw;padding:18px 20px;text-align:center;display:none;pointer-events:auto;background:rgba(40,10,10,.92)}
#err.show{display:block}
#err button{margin-top:12px;padding:10px 18px;border-radius:10px;border:0;background:#e9c46a;color:#1a1408;font-weight:800;font-family:inherit}
`
export type PageCtx = { origin: string; appBase: string }
const TITLE = 'Roadster: Thrust Lab (unofficial fan game)'
const DESC = 'Drive a thruster roadster in your browser: launch 0-60 in about a second, hover over traffic, flip off an inverted ramp, and fly the canyon.'
function headTags(ctx: PageCtx) {
const abs = (p: string) => escapeAttr(`${ctx.origin}${ctx.appBase}${p}`)
const rel = (p: string) => escapeAttr(`${ctx.appBase}${p}`)
return `<meta name="description" content="${escapeAttr(DESC)}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Kody">
<meta property="og:title" content="${escapeAttr(TITLE)}">
<meta property="og:description" content="${escapeAttr(DESC)}">
<meta property="og:url" content="${abs('/')}">
<meta property="og:image" content="${abs('/og-image.jpg')}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="A white wedge roadster hovering on lift thrusters over desert sand at sunset, with the title Roadster">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${escapeAttr(TITLE)}">
<meta name="twitter:description" content="${escapeAttr(DESC)}">
<meta name="twitter:image" content="${abs('/og-image.jpg')}">
<meta name="twitter:image:alt" content="A white wedge roadster hovering on lift thrusters over desert sand at sunset, with the title Roadster">
<link rel="icon" href="${rel('/icons/favicon.svg')}" type="image/svg+xml">
<link rel="icon" href="${rel('/icons/favicon-32.png')}" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="${rel('/icons/apple-touch-icon.png')}">`
}
export function renderPage(clientModuleUrl: string, ctx: PageCtx) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#0b0a14">
<title>${TITLE}</title>
${headTags(ctx)}
<style>${css}</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div id="speedo" class="panel">
<div id="speed" class="mono">0<small>MPH</small></div>
<div class="row"><span>COPV gas</span><span id="gasv" class="mono">600 L</span></div>
<div class="bar" id="gasbar"><i id="gas"></i></div>
<div class="row alt"><span>Altitude</span><span id="alt" class="mono">0.0 m</span></div>
<div class="row best"><span>Best 0-60</span><span id="best060" class="mono">--</span></div>
<div id="chips"><span class="chip" id="c-thrust">THRUST</span><span class="chip" id="c-lift">LIFT</span><span class="chip" id="c-track">TRACK</span></div>
</div>
<div id="tools">
<button id="b-sfx" title="Sound effects (M)" aria-label="Toggle sound effects">🔊</button>
<button id="b-music" title="Music (N)" aria-label="Toggle music">🎵</button>
<button id="b-list" title="Challenges" aria-label="Show challenges">🏁</button>
<button id="b-help" title="Controls (H)" aria-label="Show controls">?</button>
</div>
<div id="challenges" class="panel"><h3>Demo challenges</h3><ol id="clist"></ol></div>
<div id="timer" class="panel"><div id="lights"><span></span><span></span><span></span></div><div class="v mono" id="timerv">0.000 s</div></div>
<div id="toast"></div>
<div id="hint">W/S drive · A/D steer · Shift thrust · Space lift · T track · in air: W/S flip, A/D spin, Q/E roll · R reset · 1-7 teleport</div>
<div id="fan">Unofficial fan game · not affiliated with Tesla or SpaceX</div>
<div id="touch">
<div id="joy"><div id="knob"></div></div>
<div id="btns">
<button id="t-track">TRACK</button><button id="t-reset">RESET</button>
<button id="t-thrust">THRUST</button><button id="t-lift">LIFT</button>
</div>
</div>
</div>
<div id="overlay">
<div class="card panel">
<h1>ROADSTER <span>THRUST LAB</span></h1>
<p>Recreate the feats from the October 15, 2026 Roadster demo: launch to 60 in about a second, fire cold-gas thrusters, hover over a line of traffic, ride an inverted ramp, and land it with style.</p>
<div class="keys desk">
<span><kbd>W</kbd> <kbd>S</kbd> / arrows</span><span>Drive and brake (in the air: flip)</span>
<span><kbd>A</kbd> <kbd>D</kbd></span><span>Steer (in the air: spin)</span>
<span><kbd>Shift</kbd></span><span>Rear thrusters</span>
<span><kbd>Space</kbd></span><span>Lift thrusters: hover and soften landings</span>
<span><kbd>T</kbd></span><span>Track Mode: wing and suction for grip</span>
<span><kbd>Q</kbd> <kbd>E</kbd></span><span>Roll in the air</span>
<span><kbd>R</kbd> / <kbd>1</kbd>-<kbd>7</kbd></span><span>Reset / jump to a challenge</span>
</div>
<p class="touchonly" style="display:none">Left stick drives and steers (in the air it flips and spins). Hold THRUST and LIFT on the right.</p>
<div class="cta"><button id="start">Drive</button><a class="pkg" href="https://kody.codes/@kentcdodds/roadster" target="_blank" rel="noopener" title="Open this game's Kody package page in a new tab">About · Kody package ↗</a></div>
<div class="fine">Unofficial fan game. Not affiliated with or endorsed by Tesla, Inc. or SpaceX. The car is a generic model built from simple shapes.</div>
</div>
</div>
<div id="err" class="panel"><div id="errmsg"></div><button onclick="location.reload()">Reload</button></div>
<script>
(function(){
function show(msg){var e=document.getElementById('err');document.getElementById('errmsg').textContent=msg;e.className='panel show'}
window.__roadsterError=show;
if(navigator.maxTouchPoints>0||'ontouchstart' in window||matchMedia('(pointer: coarse)').matches)document.documentElement.classList.add('touch-pre');
window.addEventListener('error',function(ev){var t=ev.target;if(t&&t.tagName==='SCRIPT'){show('The game script failed to load. If Brave Shields or a content blocker is on for this site, turn it off and reload.')}else if(!window.__roadsterBooted){show('The game hit an error while starting: '+(ev.message||'unknown error'))}},true);
setTimeout(function(){if(!window.__roadsterBooted)show('The game did not start. The script may be blocked or still loading. Check your connection and any content blockers, then reload.')},12000);
})();
</script>
<script type="module" src="${escapeAttr(clientModuleUrl)}"></script>
</body>
</html>`
}