Train snakeOne-line
Qwen3.8 Flash
by QwenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 48 s
- Cost
- $0.0063
- Output tokens
- 13,255
- Spent thinking
- 0 tokens
- Speed
- 81.8 tok/s
- Console errors
- 0
- First token after
- 5.8 s
- First visible text after
- 5.8 s
- Prompt tokens
- 382
- Code size
- 33.9 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
{"effort":"none"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
“make me a snake game where the snake is a little train with a locomotive and colorful wagons, picking up passengers waiting on the platform”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 789 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Choo-Choo Express — a little train snake</title>
<style>
:root{
--sky-top:#79c4f2; --sky-mid:#bfe6ff; --horizon:#eafaff;
--grass:#5aa84a; --grass-dk:#4a903f; --grass-lt:#6fb85a;
--ink:#12324a; --cream:#fffdf6; --red:#d8412f; --yellow:#ffd23f;
--gold:#ffcf3f; --green:#2fae5b; --blue:#2b7fd4;
--panel:rgba(255,253,246,.86);
--shadow:0 10px 24px rgba(18,50,74,.28);
--font:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
--mono:'Courier New',ui-monospace,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:var(--font); color:var(--ink);
background:radial-gradient(120% 90% at 50% -10%, #d8f0ff 0%, #9fd0ef 45%, #6fb6dd 100%);
overflow:hidden; -webkit-font-smoothing:antialiased;
}
.app{display:flex;flex-direction:column;height:100vh;height:100dvh;width:100%;overflow:hidden}
/* ---------- Header / ticket board ---------- */
header.board{
flex:0 0 auto; display:flex;align-items:center;gap:clamp(10px,2.4vw,26px);
padding:clamp(8px,1.4vw,14px) clamp(12px,3vw,28px);
background:linear-gradient(#12324a,#0d2740);
border-bottom:5px solid var(--gold);
box-shadow:0 6px 18px rgba(0,0,0,.28); position:relative; z-index:5;
}
.brand{display:flex;align-items:center;gap:12px;min-width:0}
.logo{
width:clamp(40px,6vw,54px);height:clamp(40px,6vw,54px);flex:0 0 auto;
display:grid;place-items:center;font-size:clamp(24px,3.6vw,34px);
background:radial-gradient(circle at 35% 30%,#ffe9a8,var(--gold));
border-radius:14px;box-shadow:inset 0 0 0 3px #fff,0 4px 10px rgba(0,0,0,.35);
animation:bob 2.4s ease-in-out infinite;
}
@keyframes bob{50%{transform:translateY(-3px) rotate(-3deg)}}
.name h1{
font-size:clamp(17px,2.4vw,26px);letter-spacing:.5px;line-height:1;color:#fff;
text-transform:uppercase;text-shadow:0 2px 0 #0a1f33;white-space:nowrap;
}
.name p{font-size:clamp(10px,1.3vw,13px);color:#9fd0ef;letter-spacing:.6px;margin-top:3px;white-space:nowrap}
.board-stats{margin-left:auto;display:flex;align-items:stretch;gap:clamp(6px,1.4vw,14px)}
.stat{
display:flex;flex-direction:column;justify-content:center;align-items:center;
min-width:clamp(58px,8vw,86px);padding:6px 10px;border-radius:10px;
background:linear-gradient(#1b3f5c,#12324a);border:1px solid #2b5a80;
box-shadow:inset 0 1px 0 rgba(255,255,255,.12);
}
.stat .k{font-size:9px;letter-spacing:1.6px;color:#7fb0d6;text-transform:uppercase}
.stat .v{font-family:var(--mono);font-weight:bold;color:var(--gold);font-size:clamp(16px,2.2vw,24px);line-height:1.1}
.stat.best .v{color:#c9f2d4}
.stat.passengers .v{color:#ffe08a}
.controls{display:flex;gap:8px;align-items:center}
.btn{
font-family:var(--font);font-weight:bold;cursor:pointer;border:none;border-radius:10px;
padding:9px 14px;font-size:clamp(12px,1.5vw,15px);color:#3a2400;
background:linear-gradient(#ffe08a,#ffc93f);box-shadow:0 4px 0 #c99a12,0 6px 12px rgba(0,0,0,.3);
transition:transform .08s,box-shadow .08s;letter-spacing:.3px;white-space:nowrap;
}
.btn:hover{filter:brightness(1.05)}
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 #c99a12}
.btn.small{padding:9px 12px;background:linear-gradient(#cfe6ff,#9cc8f0);box-shadow:0 4px 0 #5a8bb5,0 6px 12px rgba(0,0,0,.25);color:#0d2740}
.btn.ghost{background:transparent;color:#9fd0ef;box-shadow:inset 0 0 0 2px #35618a}
.btn.ghost:hover{color:#fff}
/* ---------- Stage ---------- */
main.stage{flex:1 1 auto;position:relative;min-height:0;overflow:hidden}
canvas#game{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
/* ---------- Overlays ---------- */
.overlay{
position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:14px;padding:20px;text-align:center;
background:radial-gradient(120% 120% at 50% 30%, rgba(18,50,74,.15), rgba(18,50,74,.55));
backdrop-filter:blur(2px);transition:opacity .35s;
}
.overlay.hidden{opacity:0;pointer-events:none}
.card{
background:var(--panel);border-radius:22px;padding:clamp(20px,4vw,38px);
box-shadow:var(--shadow);max-width:min(560px,92vw);position:relative;
border:3px solid #fff;outline:3px dashed var(--gold);outline-offset:-14px;
animation:pop .4s cubic-bezier(.2,1.3,.4,1);
}
@keyframes pop{from{transform:scale(.9) translateY(10px);opacity:0}}
.card::before,.card::after{content:"";position:absolute;width:14px;height:14px;border-radius:50%;background:var(--red);top:50%;transform:translateY(-50%);box-shadow:inset 0 0 0 3px #fff}
.card::before{left:-8px}.card::after{right:-8px}
.card .big{font-size:clamp(28px,6vw,52px);font-weight:900;letter-spacing:1px;line-height:1;color:var(--red);text-transform:uppercase;text-shadow:2px 2px 0 #fff,3px 3px 0 rgba(0,0,0,.12)}
.card .sub{font-size:clamp(13px,1.8vw,17px);color:#3a5a74;margin-top:10px;line-height:1.5}
.card .score-line{font-family:var(--mono);font-weight:bold;font-size:clamp(18px,3vw,26px);color:var(--ink);margin-top:14px}
.card .score-line b{color:var(--red)}
.chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px}
.chip{display:inline-flex;align-items:center;gap:6px;background:#eef6ff;border:2px solid #cfe0f2;border-radius:999px;padding:6px 12px;font-size:clamp(11px,1.4vw,13px);font-weight:bold;color:#274a66}
.kbd{font-family:var(--mono);background:#12324a;color:#fff;border-radius:6px;padding:2px 7px;font-size:12px;box-shadow:0 2px 0 #0a1f33}
.pulse{animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{50%{transform:scale(1.05)}}
.hint{font-size:clamp(12px,1.6vw,15px);color:#fff;font-weight:bold;text-shadow:0 2px 6px rgba(0,0,0,.5);letter-spacing:.4px}
.hint .dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--gold);margin-right:6px;box-shadow:0 0 0 3px rgba(255,207,63,.35);animation:pulse 1s infinite}
/* ---------- D-pad (touch) ---------- */
.dpad{position:absolute;z-index:4;bottom:16px;right:16px;width:150px;height:150px;display:none;user-select:none}
.dbtn{position:absolute;width:52px;height:52px;border:none;cursor:pointer;border-radius:14px;
background:rgba(255,253,246,.82);box-shadow:0 4px 10px rgba(0,0,0,.3),inset 0 0 0 2px #fff;
color:#12324a;font-size:22px;font-weight:900;display:grid;place-items:center;touch-action:none}
.dbtn:active{background:var(--gold);transform:scale(.94)}
.dbtn.up{top:0;left:49px}.dbtn.down{bottom:0;left:49px}.dbtn.left{top:49px;left:0}.dbtn.right{top:49px;right:0}
/* ---------- Footer legend ---------- */
footer.legend{
flex:0 0 auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:center;
padding:8px 14px;background:linear-gradient(#0d2740,#0a1f33);color:#9fd0ef;
font-size:clamp(11px,1.4vw,13px);letter-spacing:.3px;border-top:3px solid var(--gold)
}
footer.legend b{color:#fff}
.swatch{display:inline-block;width:14px;height:14px;border-radius:4px;vertical-align:-2px;margin:0 3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)}
@media (max-width:640px){
.name p{display:none}
.board-stats .stat{min-width:52px;padding:5px 7px}
.btn{padding:8px 10px}
.controls .btn.hide-s{display:none}
}
@media (pointer:coarse){ .dpad{display:block} }
</style>
</head>
<body>
<div class="app">
<header class="board">
<div class="brand">
<div class="logo">🚂</div>
<div class="name">
<h1>Choo-Choo Express</h1>
<p>Pick up passengers · keep the wagons rolling · don't crash!</p>
</div>
</div>
<div class="board-stats">
<div class="stat"><span class="k">Score</span><span class="v" id="score">0</span></div>
<div class="stat best"><span class="k">Best</span><span class="v" id="best">0</span></div>
<div class="stat passengers"><span class="k">Pax</span><span class="v" id="pax">0</span></div>
</div>
<div class="controls">
<button class="btn small hide-s" id="muteBtn" title="Toggle sound">🔊</button>
<button class="btn" id="playBtn">Start ▸</button>
</div>
</header>
<main class="stage">
<canvas id="game"></canvas>
<div class="dpad" id="dpad">
<button class="dbtn up" data-dir="up">▲</button>
<button class="dbtn left" data-dir="left">◀</button>
<button class="dbtn right" data-dir="right">▶</button>
<button class="dbtn down" data-dir="down">▼</button>
</div>
<!-- Start / attract -->
<div class="overlay" id="startOverlay">
<div class="card">
<div class="big">All aboard!</div>
<p class="sub">You're the conductor of a tiny steam train. Steer it around the station and scoop up every passenger waiting on the platforms before you run out of track.</p>
<div class="chips">
<span class="chip">🕹️ Arrows / WASD to steer</span>
<span class="chip">⌨️ Space = pause</span>
<span class="chip">📱 Swipe or tap the pad</span>
</div>
<p class="score-line" style="margin-top:18px">Watch the demo roll for a moment… then hit start!</p>
<button class="btn pulse" id="startBig" style="margin-top:14px;font-size:18px;padding:12px 26px">Click or press any key to start ▸</button>
</div>
</div>
<!-- Game over -->
<div class="overlay hidden" id="overOverlay">
<div class="card">
<div class="big" style="color:#12324a">💥 End of the line</div>
<p class="sub" id="deathMsg">The train jumped the rails!</p>
<p class="score-line">Final score <b id="finalScore">0</b> · Passengers carried <b id="finalPax">0</b></p>
<p class="score-line" style="font-size:16px;color:#3a5a74">Personal best <b id="finalBest" style="color:var(--red)">0</b></p>
<button class="btn pulse" id="againBtn" style="margin-top:16px;font-size:18px;padding:12px 26px">↻ Run again</button>
</div>
</div>
</main>
<footer class="legend">
<span>🚂 Locomotive</span>
<span><i class="swatch" style="background:#d8412f"></i><i class="swatch" style="background:#2b7fd4"></i><i class="swatch" style="background:#2fae5b"></i><i class="swatch" style="background:#ffcf3f"></i> Wagons grow as you pick up passengers</span>
<span><b>TIP:</b> shorter gaps between stations = faster trains = bigger scores</span>
</footer>
</div>
<script>
"use strict";
/* ============================ Setup ============================ */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let W = 0, H = 0; // css pixels
let COLS = 20, ROWS = 13, cell = 30;
let ox = 0, oy = 0; // playfield origin in css px
function resize(){
const stage = canvas.parentElement;
W = stage.clientWidth; H = stage.clientHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W*dpr); canvas.height = Math.round(H*dpr);
canvas.style.width = W+'px'; canvas.style.height = H+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
computeGrid();
}
function computeGrid(){
let cols = Math.max(12, Math.min(26, Math.floor(W/44)));
let rows = Math.max(9, Math.min(18, Math.floor(H/44)));
if(cols !== COLS || rows !== ROWS){ COLS=cols; ROWS=rows; initFieldData(); }
cell = Math.min((W-2)/COLS, (H-2)/ROWS);
ox = (W - COLS*cell)/2;
oy = (H - ROWS*cell)/2;
}
/* ============================ Config ============================ */
const BASE_TICK = 155, MIN_TICK = 72;
const DIRS = { up:[0,-1], down:[0,1], left:[-1,0], right:[1,0] };
const COLORS = ['#d8412f','#2b7fd4','#2fae5b','#ffcf3f','#8e5bd0','#ee7a1f','#17a3a3'];
const SKIN = ['#eab676','#c98a52','#8a5a34','#f0c79a','#b07a4a'];
const SHIRT = ['#e2493b','#3a7ec6','#37a95a','#e8b62f','#8a56c9','#ef7f2a','#16a5a5','#d94f8a','#5a6b7a'];
const HAIR = ['#2a1a12','#4a2f1c','#111','#6b4a2a','#b07a3a','#3a2a2a'];
const DEATH_MSGS = [
"The train jumped the rails!",
"Too many wagons, not enough track!",
"Signal failure — full stop!",
"That platform was occupied by the engine!",
"Derailment! Mind the gap."
];
/* ============================ State ============================ */
let mode = 'attract'; // attract | playing | paused | dead
let snake = [], dir=[1,0], nextDir=[1,0];
let passenger=null, spawnTimer=0;
let score=0, best=0, tick=BASE_TICK, acc=0, lastTime=0;
let particles=[], clouds=[], birds=[];
let shake=0, flash=0, deathReason='';
let field={}; // precomputed scenery arrays
let muted=false;
/* ============================ Field scenery ============================ */
function rnd(a,b){return a+Math.random()*(b-a);}
function initFieldData(){
field.clouds = [];
for(let i=0;i<5;i++) field.clouds.push({x:rnd(0,W),y:rnd(oy-40,oy+ROWS*cell*0.5),s:rnd(0.7,1.4),v:rnd(6,16)});
field.birds = [];
for(let i=0;i<3;i++) field.birds.push({x:rnd(0,W),y:rnd(oy-30,oy+ROWS*cell*0.4),v:rnd(24,40),ph:rnd(0,6)});
field.trees = [];
const nTrees = Math.floor(rnd(3,6));
for(let i=0;i<nTrees;i++){
const gx=Math.floor(rnd(1,COLS-1)), gy=Math.floor(rnd(1,ROWS-1));
field.trees.push({gx,gy});
}
field.flowers=[];
const nF=Math.floor(rnd(10,18));
for(let i=0;i<nF;i++) field.flowers.push({gx:Math.floor(rnd(0,COLS)),gy:Math.floor(rnd(0,ROWS)),c:COLORS[Math.floor(rnd(0,COLORS.length))]});
}
/* ============================ Game logic ============================ */
function resetGame(startMode){
const cy = Math.floor(ROWS/2);
snake = [{x:3,y:cy},{x:2,y:cy},{x:1,y:cy}];
dir=[1,0]; nextDir=[1,0];
score=0; tick=BASE_TICK; acc=0;
passenger=null; spawnTimer=0;
particles=[]; shake=0; flash=0;
mode=startMode;
updateHUD();
spawnPassenger();
}
function blocked(x,y){
if(x<0||y<0||x>=COLS||y>=ROWS) return true;
for(const s of snake) if(s.x===x&&s.y===y) return true;
if(field.trees.some(t=>t.gx===x&&t.gy===y)) return true;
return false;
}
function spawnPassenger(){
let tries=0;
while(tries++<200){
const x=Math.floor(rnd(0,COLS)), y=Math.floor(rnd(0,ROWS));
if(!blocked(x,y)){
passenger={x,y,t:0,skin:SKIN[Math.floor(rnd(0,SKIN.length))],shirt:SHIRT[Math.floor(rnd(0,SHIRT.length))],hair:HAIR[Math.floor(rnd(0,HAIR.length))]};
return;
}
}
}
function step(){
// commit direction (no reversing)
if(!(nextDir[0]===-dir[0] && nextDir[1]===-dir[1])) dir=nextDir.slice();
const head=snake[0];
const nx=head.x+dir[0], ny=head.y+dir[1];
if(nx<0||ny<0||nx>=COLS||ny>=ROWS||snake.some(s=>s.x===nx&&s.y===ny)||field.trees.some(t=>t.gx===nx&&t.gy===ny)){
die(snake.some(s=>s.x===nx&&s.y===ny)?'self':'wall');
return;
}
snake.unshift({x:nx,y:ny});
if(passenger && nx===passenger.x && ny===passenger.y){
collect(nx,ny);
} else {
snake.pop();
}
}
function collect(x,y){
const pts = 10 + Math.floor((BASE_TICK-tick)/6);
score += pts;
tick = Math.max(MIN_TICK, BASE_TICK - snake.length*3.2);
burst(x,y,'#ffcf3f',pts);
passenger=null;
spawnTimer=0;
spawnPassenger();
updateHUD();
}
function die(reason){
deathReason=reason;
mode='dead';
if(score>best){ best=score; updateHUD(); }
const msgEl=document.getElementById('deathMsg');
msgEl.textContent = reason==='wall' ? "Off the edge of the map!" :
reason==='tree' ? "Crashed into a tree!" :
DEATH_MSGS[Math.floor(Math.random()*DEATH_MSGS.length)];
document.getElementById('finalScore').textContent=score;
document.getElementById('finalPax').textContent=Math.max(0,snake.length-3);
document.getElementById('finalBest').textContent=best;
show('overOverlay');
shake=16; flash=1;
beep(160,0.3,'sawtooth'); setTimeout(()=>beep(110,0.3,'sawtooth'),120); setTimeout(()=>beep(70,0.4,'square'),260);
}
/* ============================ AI (attract mode) ============================ */
function aiStep(){
const head=snake[0];
let tgt = passenger ? {x:passenger.x,y:passenger.y} : null;
if(!tgt){ // wander toward center-ish
tgt={x:Math.floor(COLS/2),y:Math.floor(ROWS/2)};
}
const opts=[[1,0],[-1,0],[0,1],[0,-1]];
let scored=[];
for(const o of opts){
if(o[0]===-dir[0]&&o[1]===-dir[1]) continue;
const nx=head.x+o[0], ny=head.y+o[1];
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(snake.some(s=>s.x===nx&&s.y===ny)) continue;
if(field.trees.some(t=>t.gx===nx&&t.gy===ny)) continue;
let free=0;
for(const f of [[1,0],[-1,0],[0,1],[0,-1]]){
const fx=nx+f[0],fy=ny+f[1];
if(fx>=0&&fy>=0&&fx<COLS&&fy<ROWS&&!snake.some(s=>s.x===fx&&s.y===fy)&&!field.trees.some(t=>t.gx===fx&&t.gy===fy)) free++;
}
const dist=Math.abs(nx-tgt.x)+Math.abs(ny-tgt.y);
scored.push({o,score:free*3-dist});
}
if(!scored.length){ // stuck -> try anything valid incl reverse? just die gracefully by moving forward
return;
}
scored.sort((a,b)=>b.score-a.score);
nextDir=scored[0].o;
}
/* ============================ Particles ============================ */
function burst(gx,gy,color,n){
const cx=ox+(gx+0.5)*cell, cy=oy+(gy+0.5)*cell;
for(let i=0;i<16;i++){
const a=rnd(0,Math.PI*2), sp=rnd(40,190);
particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-rnd(20,90),life:rnd(.5,1.1),max:1,r:rnd(2,5),c:i%3?color:'#fff'});
}
for(let i=0;i<6;i++){
particles.push({x:cx+rnd(-6,6),y:cy,vx:rnd(-20,20),vy:rnd(-40,-15),life:rnd(.6,1),max:1,r:rnd(3,6),c:Math.random()<.5?'#12324a':'#fff',star:true});
}
}
function puff(x,y){
particles.push({x:x+rnd(-3,3),y:y+rnd(-3,3),vx:rnd(-8,8),vy:rnd(-30,-14),life:rnd(.7,1.3),max:1.3,r:rnd(3,7),c:'rgba(255,255,255,0.9)',smoke:true});
}
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.life-=dt; if(p.life<=0){particles.splice(i,1);continue;}
p.vy+=(p.smoke?-8:520)*dt; p.vx*=0.99;
p.x+=p.vx*dt; p.y+=p.vy*dt;
}
}
/* ============================ Rendering ============================ */
function rr(x,y,w,h,r){ r=Math.min(r,w/2,h/2); 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(); }
function drawBackground(){
// sky
const g=ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#79c4f2');g.addColorStop(.45,'#bfe6ff');g.addColorStop(.72,'#eafaff');g.addColorStop(1,'#dff2d8');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
// sun
ctx.save();
const sx=W*0.86, sy=oy*0.5+20;
const sg=ctx.createRadialGradient(sx,sy,4,sx,sy,60);
sg.addColorStop(0,'rgba(255,240,180,.95)');sg.addColorStop(1,'rgba(255,220,120,0)');
ctx.fillStyle=sg;ctx.beginPath();ctx.arc(sx,sy,60,0,7);ctx.fill();
ctx.fillStyle='#ffe98a';ctx.beginPath();ctx.arc(sx,sy,26,0,7);ctx.fill();
ctx.restore();
// distant hills
ctx.fillStyle='#a9dca0';
hill(oy-6, H*0.14, 1);
ctx.fillStyle='#8fce8a';
hill(oy+4, H*0.10, 1.7);
// clouds
for(const c of field.clouds) drawCloud(c.x,c.y,c.s);
// birds
for(const b of field.birds) drawBird(b.x,b.y,b.ph);
}
function hill(base,amp,freq){
ctx.beginPath();ctx.moveTo(0,base+amp);
for(let x=0;x<=W;x+=20){ ctx.lineTo(x, base - amp*Math.sin(x/W*Math.PI*freq)-amp*0.2); }
ctx.lineTo(W,H);ctx.lineTo(0,H);ctx.closePath();ctx.fill();
}
function drawCloud(x,y,s){
ctx.save();ctx.translate(x,y);ctx.scale(s,s);
ctx.fillStyle='rgba(255,255,255,.92)';
ctx.beginPath();ctx.arc(0,0,16,0,7);ctx.arc(18,-6,13,0,7);ctx.arc(34,2,15,0,7);ctx.arc(16,8,14,0,7);ctx.fill();
ctx.restore();
}
function drawBird(x,y,ph){
ctx.save();ctx.translate(x,y);ctx.strokeStyle='#2a4a63';ctx.lineWidth=2;ctx.lineCap='round';
const w=Math.sin(ph)*4;
ctx.beginPath();ctx.moveTo(-6,0);ctx.quadraticCurveTo(-3,-w,0,0);ctx.quadraticCurveTo(3,-w,6,0);ctx.stroke();
ctx.restore();
}
function drawGround(){
// grass tiles with subtle checker
for(let y=0;y<ROWS;y++){
for(let x=0;x<COLS;x++){
ctx.fillStyle=((x+y)&1)?'#5aa84a':'#61b051';
ctx.fillRect(ox+x*cell,oy+y*cell,cell+1,cell+1);
}
}
// flowers
for(const f of field.flowers){
const cx=ox+(f.gx+.5)*cell, cy=oy+(f.gy+.5)*cell, r=cell*0.12;
ctx.fillStyle=f.c;
for(let i=0;i<5;i++){const a=i/5*Math.PI*2;ctx.beginPath();ctx.arc(cx+Math.cos(a)*r,cy+Math.sin(a)*r,r*.7,0,7);ctx.fill();}
ctx.fillStyle='#ffe066';ctx.beginPath();ctx.arc(cx,cy,r*.7,0,7);ctx.fill();
}
// trees
for(const t of field.trees){
const cx=ox+(t.gx+.5)*cell, cy=oy+(t.gy+.5)*cell;
ctx.fillStyle='rgba(0,0,0,.12)';ctx.beginPath();ctx.ellipse(cx,cy+cell*.35,cell*.4,cell*.16,0,0,7);ctx.fill();
ctx.fillStyle='#7a4a24';rr(cx-cell*.06,cy-cell*.02,cell*.12,cell*.42,cell*.05);ctx.fill();
ctx.fillStyle='#2f7d3a';ctx.beginPath();ctx.arc(cx,cy-cell*.15,cell*.34,0,7);ctx.fill();
ctx.fillStyle='#3f9a4c';ctx.beginPath();ctx.arc(cx-cell*.14,cy-cell*.05,cell*.22,0,7);ctx.arc(cx+cell*.14,cy-cell*.08,cell*.2,0,7);ctx.fill();
}
// playfield frame
ctx.strokeStyle='rgba(18,50,74,.25)';ctx.lineWidth=2;
ctx.strokeRect(ox-1,oy-1,COLS*cell+2,ROWS*cell+2);
}
function drawPlatform(){
if(!passenger) return;
const cx=ox+(passenger.x+.5)*cell, cy=oy+(passenger.y+.5)*cell;
const pw=cell*1.15, ph=cell*0.5;
// shadow
ctx.fillStyle='rgba(0,0,0,.15)';ctx.beginPath();ctx.ellipse(cx,cy+ph*0.5,pw*0.6,ph*0.4,0,0,7);ctx.fill();
// slab
ctx.fillStyle='#d9d2c4';rr(cx-pw/2,cy-ph/2,pw,ph,4);ctx.fill();
ctx.fillStyle='#c3bcac';rr(cx-pw/2,cy+ph*0.1,pw,ph*0.4,3);ctx.fill();
// yellow safety stripe
ctx.fillStyle='#ffcf3f';for(let i=-2;i<=2;i++){rr(cx+i*pw/5-3,cy-ph/2-2,6,4,1);ctx.fill();}
// sign post
ctx.fillStyle='#8a8f98';ctx.fillRect(cx+pw*0.42,cy-ph*1.6,2,ph*1.4);
ctx.fillStyle='#12324a';rr(cx+pw*0.30,cy-ph*2.1,pw*0.42,ph*0.6,3);ctx.fill();
ctx.fillStyle='#fff';ctx.font=`bold ${Math.max(7,cell*0.22)}px var(--mono)`;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText("STOP",cx+pw*0.51,cy-ph*1.8);
// waiting person
drawPerson(cx,cy-passenger.t*0.001,passenger,true);
// bobbing motion hint arrow
const wob=Math.sin(performance.now()/200)*3;
ctx.fillStyle='rgba(255,207,63,.9)';
ctx.beginPath();ctx.moveTo(cx,cy-ph-8+wob);ctx.lineTo(cx-5,cy-ph-16+wob);ctx.lineTo(cx+5,cy-ph-16+wob);ctx.closePath();ctx.fill();
}
function drawPerson(cx,cy,p,isWaiting){
const s=cell*0.9;
ctx.save();ctx.translate(cx,cy);
if(isWaiting){const b=Math.sin(performance.now()/220+p.x)*1.5;ctx.translate(0,b);}
ctx.fillStyle='rgba(0,0,0,.15)';ctx.beginPath();ctx.ellipse(0,s*0.34,s*0.24,s*0.08,0,0,7);ctx.fill();
// legs
ctx.fillStyle='#2a3a4a';rr(-s*0.14,s*0.02,s*0.11,s*0.3,2);ctx.fill();rr(s*0.03,s*0.02,s*0.11,s*0.3,2);ctx.fill();
// body
ctx.fillStyle=p.shirt;rr(-s*0.19,-s*0.12,s*0.38,s*0.24,s*0.07);ctx.fill();
// arms
ctx.fillStyle=p.skin;rr(-s*0.27,-s*0.1,s*0.09,s*0.2,3);ctx.fill();rr(s*0.18,-s*0.1,s*0.09,s*0.2,3);ctx.fill();
// suitcase for some
if(p.x%2===0){ctx.fillStyle='#b5651d';rr(s*0.22,s*0.02,s*0.16,s*0.14,2);ctx.fill();ctx.strokeStyle='#7a4412';ctx.lineWidth=1;ctx.strokeRect(s*0.22,s*0.02,s*0.16,s*0.14);}
// head
ctx.fillStyle=p.skin;ctx.beginPath();ctx.arc(0,-s*0.26,s*0.15,0,7);ctx.fill();
// hair
ctx.fillStyle=p.hair;ctx.beginPath();ctx.arc(0,-s*0.3,s*0.15,Math.PI,0);ctx.fill();
// face
ctx.fillStyle='#1a1a1a';ctx.beginPath();ctx.arc(-s*0.05,-s*0.25,1.3,0,7);ctx.arc(s*0.05,-s*0.25,1.3,0,7);ctx.fill();
ctx.strokeStyle='#1a1a1a';ctx.lineWidth=1;ctx.beginPath();ctx.arc(0,-s*0.2,s*0.06,0.2,Math.PI-0.2);ctx.stroke();
ctx.restore();
}
/* ---- Train rendering (smooth interpolation) ---- */
let renderPos=[]; // interpolated positions per segment
function lerp(a,b,t){return a+(b-a)*t;}
function computeRenderPos(alpha){
const n=snake.length;
renderPos.length=n;
if(n===0)return;
// head moves from previous head pos to current along dir
const prevHead={x:snake[0].x-dir[0],y:snake[0].y-dir[1]};
renderPos[0]={x:lerp(prevHead.x,snake[0].x,alpha),y:lerp(prevHead.y,snake[0].y,alpha)};
for(let i=1;i<n;i++){
renderPos[i]={x:snake[i].x,y:snake[i].y};
}
}
function segCenter(i){const p=renderPos[i];return{x:ox+(p.x+.5)*cell,y:oy+(p.y+.5)*cell};}
function drawTrain(){
if(snake.length===0)return;
const n=snake.length;
// shadows first
ctx.fillStyle='rgba(0,0,0,.16)';
for(let i=n-1;i>=0;i--){const c=segCenter(i);ctx.beginPath();ctx.ellipse(c.x,c.y+cell*0.28,cell*0.36,cell*0.14,0,0,7);ctx.fill();}
// wheels connecting segments (drawn under cars)
ctx.fillStyle='#1a2530';
for(let i=n-1;i>=1;i--){
const a=segCenter(i), b=segCenter(i-1);
const mx=(a.x+b.x)/2,my=(a.y+b.y)/2;
ctx.beginPath();ctx.arc(mx-3,my,cell*0.12,0,7);ctx.arc(mx+3,my,cell*0.12,0,7);ctx.fill();
}
// carriages (back to front so head overlaps nicely)
for(let i=n-1;i>=1;i--){
drawWagon(segCenter(i),i);
}
// locomotive
drawLocomotive(segCenter(0),dir);
// steam puffs occasionally
if(mode==='playing'||mode==='attract'){
const c=segCenter(0);
if(Math.random()<0.5) puff(c.x-dir[0]*cell*0.1, c.y-cell*0.28);
}
}
function drawWagon(c,i){
const col=COLORS[(i-1)%COLORS.length];
const w=cell*0.86,h=cell*0.62;
ctx.save();ctx.translate(c.x,c.y);
// roof band
ctx.fillStyle='rgba(0,0,0,.18)';rr(-w/2,-h/2,w,h,4);ctx.fill();
// body
ctx.fillStyle=col;rr(-w/2,-h/2,w,h,4);ctx.fill();
// top highlight
ctx.fillStyle='rgba(255,255,255,.28)';rr(-w/2,-h/2,w,h*0.28,4);ctx.fill();
// windows with passengers inside
ctx.fillStyle='rgba(255,255,255,.92)';
const wx=w*0.24, wy=h*0.4;
for(let k=-1;k<=1;k++){
ctx.save();ctx.translate(k*w*0.3, -h*0.02);
rr(-wx/2,-wy/2,wx,wy,3);ctx.fill();
// little passenger head
ctx.fillStyle=SHIRT[(i+k+9)%SHIRT.length];ctx.beginPath();ctx.arc(0,wy*0.05,wx*0.28,0,7);ctx.fill();
ctx.fillStyle=SKIN[(i+k)%SKIN.length];ctx.beginPath();ctx.arc(0,-wy*0.1,wx*0.22,0,7);ctx.fill();
ctx.restore();
}
// bottom trim + coupling dots
ctx.fillStyle='rgba(0,0,0,.25)';rr(-w/2,h/2-3,w,3,1);ctx.fill();
ctx.restore();
}
function drawLocomotive(c,d){
const ang=Math.atan2(d[1],d[0]);
ctx.save();ctx.translate(c.x,c.y);ctx.rotate(ang);
const L=cell*1.05, B=cell*0.78; // length along travel, breadth across
// chassis shadow strip
ctx.fillStyle='#1a2530';rr(-L*0.5,-B*0.5,L,B*0.18,3);ctx.fill();
// boiler (body)
ctx.fillStyle='#12324a';rr(-L*0.42,-B*0.42,L*0.72,B*0.84,6);ctx.fill();
ctx.fillStyle='#1b4a6b';rr(-L*0.42,-B*0.42,L*0.72,B*0.3,6);ctx.fill();
// red cab
ctx.fillStyle='#d8412f';rr(-L*0.5,-B*0.46,L*0.2,B*0.92,5);ctx.fill();
ctx.fillStyle='#b5321f';rr(-L*0.5,-B*0.46,L*0.2,B*0.3,5);ctx.fill();
// gold stripe
ctx.fillStyle='#ffcf3f';rr(-L*0.42,B*0.02,L*0.72,B*0.1,2);ctx.fill();
// front bumper
ctx.fillStyle='#1a2530';rr(L*0.3,-B*0.4,L*0.08,B*0.8,2);ctx.fill();
// cow-catcher triangle
ctx.fillStyle='#c9c9c9';ctx.beginPath();ctx.moveTo(L*0.38,-B*0.34);ctx.lineTo(L*0.55,0);ctx.lineTo(L*0.38,B*0.34);ctx.closePath();ctx.fill();
// chimney
ctx.fillStyle='#1a2530';rr(L*0.06,-B*0.62,B*0.22,B*0.24,3);ctx.fill();
ctx.fillStyle='#0d1b28';rr(L*0.02,-B*0.66,B*0.3,B*0.12,3);ctx.fill();
// dome
ctx.fillStyle='#ffcf3f';ctx.beginPath();ctx.arc(-L*0.12,-B*0.34,B*0.16,Math.PI,0);ctx.fill();
// headlamp glow
ctx.fillStyle='#fff6c8';ctx.beginPath();ctx.arc(L*0.34,0,B*0.14,0,7);ctx.fill();
// window on cab
ctx.fillStyle='#bfe6ff';rr(-L*0.47,-B*0.3,B*0.16,B*0.34,2);ctx.fill();
// driver silhouette
ctx.fillStyle='#12324a';ctx.beginPath();ctx.arc(-L*0.44,-B*0.12,B*0.09,0,7);ctx.fill();
// wheels
ctx.fillStyle='#0d1b28';
for(const wx of [-L*0.34,-L*0.02,L*0.2]){ctx.beginPath();ctx.arc(wx,B*0.42,B*0.16,0,7);ctx.fill();
ctx.fillStyle='#ffcf3f';ctx.beginPath();ctx.arc(wx,B*0.42,B*0.06,0,7);ctx.fill();ctx.fillStyle='#0d1b28';}
ctx.restore();
}
function drawParticles(){
for(const p of particles){
const a=Math.max(0,p.life/p.max);
if(p.star){
ctx.globalAlpha=a;ctx.fillStyle=p.c;
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.life*8);
ctx.beginPath();for(let i=0;i<5;i++){const an=i/5*Math.PI*2-Math.PI/2;ctx.lineTo(Math.cos(an)*p.r*1.8,Math.sin(an)*p.r*1.8);const an2=an+Math.PI/5;ctx.lineTo(Math.cos(an2)*p.r*0.7,Math.sin(an2)*p.r*0.7);}ctx.closePath();ctx.fill();ctx.restore();
}else{
ctx.globalAlpha=p.smoke?a*0.6:a;
ctx.fillStyle=p.c;ctx.beginPath();ctx.arc(p.x,p.y,p.r*(p.smoke?(1.4-a):1),0,7);ctx.fill();
}
}
ctx.globalAlpha=1;
}
/* ============================ HUD ============================ */
const el=id=>document.getElementById(id);
function updateHUD(){el('score').textContent=score;el('best').textContent=best;el('pax').textContent=Math.max(0,snake.length-3);}
/* ============================ Overlays ============================ */
function show(id){el(id).classList.remove('hidden');}
function hide(id){el(id).classList.add('hidden');}
/* ============================ Input ============================ */
function queueDir(name){
const d=DIRS[name]; if(!d)return;
if(d[0]===-dir[0]&&d[1]===-dir[1])return;
nextDir=d.slice();
if(mode==='attract') beginPlay();
}
window.addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(['arrowup','w'].includes(k)){queueDir('up');e.preventDefault();}
else if(['arrowdown','s'].includes(k)){queueDir('down');e.preventDefault();}
else if(['arrowleft','a'].includes(k)){queueDir('left');e.preventDefault();}
else if(['arrowright','d'].includes(k)){queueDir('right');e.preventDefault();}
else if(k===' '){e.preventDefault();togglePause();}
else if(k==='enter'){if(mode==='dead'||mode==='attract')beginPlay();}
else if(e.key==='Escape'){toggleMute();}
else if(mode==='attract'&&e.key.length===1){beginPlay();}
});
// touch / swipe
let tStart=null;
canvas.addEventListener('touchstart',e=>{tStart={x:e.touches[0].clientX,y:e.touches[0].clientY};},{passive:true});
canvas.addEventListener('touchmove',e=>{
if(!tStart)return;const t=e.touches[0];const dx=t.clientX-tStart.x,dy=t.clientY-tStart.y;
if(Math.abs(dx)>28||Math.abs(dy)>28){
if(Math.abs(dx)>Math.abs(dy))queueDir(dx>0?'right':'left');else queueDir(dy>0?'down':'up');
tStart={x:t.clientX,y:t.clientY};
}
},{passive:true});
canvas.addEventListener('touchend',()=>{tStart=null;if(mode==='attract')beginPlay();});
document.querySelectorAll('.dbtn').forEach(b=>{
const go=e=>{e.preventDefault();queueDir(b.dataset.dir);};
b.addEventListener('pointerdown',go);
});
// buttons
el('playBtn').addEventListener('click',()=>{ if(mode==='dead')beginPlay(); else togglePause(); });
el('startBig').addEventListener('click',beginPlay);
el('againBtn').addEventListener('click',beginPlay);
el('muteBtn').addEventListener('click',toggleMute);
canvas.addEventListener('click',()=>{ if(mode==='attract')beginPlay(); });
function beginPlay(){resetGame('playing');hide('startOverlay');hide('overOverlay');initAudio();updatePlayBtn();}
function togglePause(){
if(mode==='playing'){mode='paused';show('pauseOverlay');}
else if(mode==='paused'){mode='playing';hide('pauseOverlay');}
updatePlayBtn();
}
function updatePlayBtn(){el('playBtn').textContent = mode==='playing'?'❚❚ Pause':mode==='paused'?'Resume ▸':mode==='dead'?'Run again ↻':'Start ▸';}
/* ============================ Audio (WebAudio, no storage) ============================ */
let AC=null;
function initAudio(){try{if(!AC)AC=new (window.AudioContext||window.webkitAudioContext)();if(AC.state==='suspended')AC.resume();}catch(e){}}
function beep(freq,dur,type='triangle',vol=0.15){
if(muted||!AC)return;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.value=freq;
g.gain.setValueAtTime(vol,AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001,AC.currentTime+dur);
o.connect(g).connect(AC.destination);o.start();o.stop(AC.currentTime+dur);
}
function toggleMute(){muted=!muted;el('muteBtn').textContent=muted?'🔇':'🔊';}
/* ============================ Main loop ============================ */
function frame(now){
requestAnimationFrame(frame);
if(!lastTime)lastTime=now;
let dt=(now-lastTime)/1000; lastTime=now;
dt=Math.min(dt,0.05);
// ambient
for(const c of field.clouds){c.x+=c.v*dt;if(c.x>W+60)c.x=-60;}
for(const b of field.birds){b.x+=b.v*dt;b.ph+=dt*6;if(b.x>W+30)b.x=-30;}
if(passenger)passenger.t+=dt*1000;
if(mode==='playing'||mode==='attract'){
acc+=dt*1000;
const effTick = mode==='attract'?Math.max(BASE_TICK*0.9,MIN_TICK+30):tick;
let guard=0;
while(acc>=effTick && guard<4){
acc-=effTick;
if(mode==='attract')aiStep();
step();
if(mode==='dead')break;
guard++;
}
if(mode==='attract'){spawnTimer+=effTick;if(spawnTimer>1200&&!passenger){spawnPassenger();spawnTimer=0;}}
} else if(mode==='paused'){
acc=0;
}
updateParticles(dt);
if(shake>0)shake=Math.max(0,shake-dt*40);
if(flash>0)flash=Math.max(0,flash-dt*2.2);
// render
const alpha = (mode==='playing'||mode==='attract')?Math.min(1,acc/tick):1;
computeRenderPos(alpha);
ctx.clearRect(0,0,W,H);
ctx.save();
if(shake>0){ctx.translate(rnd(-shake,shake)*0.4,rnd(-shake,shake)*0.4);}
drawBackground();
drawGround();
drawPlatform();
drawTrain();
drawParticles();
ctx.restore();
if(flash>0){ctx.fillStyle=`rgba(255,80,60,${flash*0.35})`;ctx.fillRect(0,0,W,H);}
// attract watermark hint near top-center
if(mode==='attract'){
ctx.save();ctx.globalAlpha=.9;
ctx.fillStyle='rgba(18,50,74,.75)';
const txt='Auto-demo running…';ctx.font='bold 13px var(--font)';ctx.textAlign='center';
const tw=ctx.measureText(txt).width+22;
rr(W/2-tw/2,10,tw,24,12);ctx.fill();
ctx.fillStyle='#ffcf3f';ctx.fillText(txt,W/2,26);
ctx.restore();
}
}
/* ============================ Boot ============================ */
window.addEventListener('resize',resize);
resize();
initFieldData();
resetGame('attract');
// add a pause overlay element dynamically (keeps HTML tidy)
(function addPauseOverlay(){
const div=document.createElement('div');div.id='pauseOverlay';div.className='overlay hidden';
div.innerHTML='<div class="card"><div class="big" style="color:#2b7fd4">Paused</div><p class="sub">Take a breather at the station.</p><button class="btn pulse" id="resumeBtn" style="margin-top:12px;font-size:16px;padding:10px 22px">Resume ▸</button></div>';
document.querySelector('.stage').appendChild(div);
div.addEventListener('click',()=>{if(mode==='paused')togglePause();});
})();
requestAnimationFrame(frame);
</script>
</body>
</html>