Run details
- Model
- baidu/ernie-light-dt-1.0-intl
- Route
- OpenRouter API, one user message
- Served by
- Baidu
- Effort
- max, OpenRouter's default for this model
- Limits
- 60 min · 131,072 output tokens (the model's limit)
- Date
- 14 Nov 2026
- Time
- 12 min 3 s
- Size
- 38 kB
- Tokens
- 88 in, 79,624 out (65,649 of them thinking)
- List price
- $0.31 at today's price ($1.13 in / $3.94 out per M)
- Paid
- $0.31
Exact prompt
Make a Breakout game I can play right now with the keyboard or the mouse. Give it several levels, a score and some juice. It must be one complete, self-contained HTML file: inline all CSS and JavaScript, and make no network requests of any kind (no CDNs, web fonts or remote images). Reply with the file in a single ```html code block.
Source
38 kB 1,052 lines Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>BALLISTIC — a brick-breaking arcade</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{
background:#0c0a09;color:#efe6d4;
font-family:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,"Liberation Mono",monospace;
display:flex;align-items:center;justify-content:center;
user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;
}
.cab{display:flex;flex-direction:column;gap:11px;padding:14px;max-width:100vw}
header{position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:16px;
border-bottom:1px solid #2b241c;padding-bottom:10px;align-self:stretch}
.brand{min-width:0}
h1{font-size:16px;font-weight:700;letter-spacing:.46em;white-space:nowrap}
.sub{font-size:9px;letter-spacing:.24em;color:#8a7f6c;margin-top:5px;text-transform:uppercase;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.board{display:flex;gap:20px;flex:none}
.cell{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
.lbl{font-size:8px;letter-spacing:.3em;color:#8a7f6c}
.val{display:block;font-size:17px;font-weight:700;letter-spacing:.04em;font-variant-numeric:tabular-nums;line-height:1}
#comboCell{visibility:hidden}
#comboCell.show{visibility:visible}
#comboCell .val{color:#ff5c29}
#comboCell.pulse .val{animation:comboPop .18s ease-out}
@keyframes comboPop{from{transform:scale(1.45)}to{transform:scale(1)}}
#best.newbest{color:#ff5c29}
.lives{display:flex;gap:5px;align-items:center;height:17px}
.lives i{width:8px;height:8px;border-radius:50%;border:1px solid #5d5546;display:block}
.lives i.on{background:#efe6d4;border-color:#efe6d4}
#prog{position:absolute;left:0;bottom:-1px;height:2px;width:0%;background:#ff5c29;transition:width .35s ease}
#stage{position:relative;align-self:center;background:#141110;border:1px solid #2b241c;
border-radius:8px;overflow:hidden;cursor:none;touch-action:none}
#game{display:block}
footer{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#5d5546;align-self:stretch}
.hintTxt{font-size:9px;letter-spacing:.2em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.btns{display:flex;gap:8px;flex:none}
button{font-family:inherit;font-size:9px;letter-spacing:.22em;color:#8a7f6c;background:none;
border:1px solid #2b241c;border-radius:4px;padding:6px 11px;cursor:pointer}
button:hover{color:#efe6d4;border-color:#5d5546}
button:active{transform:translateY(1px)}
@media (max-width:620px){
h1{font-size:12px;letter-spacing:.3em}
.val{font-size:13px}
.board{gap:12px}
.sub{font-size:8px}
}
</style>
</head>
<body>
<main class="cab">
<header>
<div class="brand">
<h1>BALLISTIC</h1>
<div class="sub" id="levelName">PRESS SPACE OR CLICK TO START</div>
</div>
<div class="board">
<div class="cell"><span class="lbl">SCORE</span><span class="val" id="score">000000</span></div>
<div class="cell"><span class="lbl">BEST</span><span class="val" id="best">000000</span></div>
<div class="cell" id="comboCell"><span class="lbl">COMBO</span><span class="val" id="comboVal">×1</span></div>
<div class="cell"><span class="lbl">LIVES</span><span class="lives" id="lives"></span></div>
</div>
<div id="prog"></div>
</header>
<div id="stage">
<canvas id="game" width="800" height="600" role="img" aria-label="Brick arcade play field"></canvas>
</div>
<footer>
<div class="hintTxt">MOUSE / ← → — MOVE · SPACE — LAUNCH · P — PAUSE · M — SOUND · R — RESTART</div>
<div class="btns">
<button id="pauseBtn" type="button">PAUSE</button>
<button id="soundBtn" type="button">SOUND ON</button>
</div>
</footer>
</main>
<script>
'use strict';
/* ============================ helpers ============================ */
const W=800, H=600;
const FONT='ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, "Liberation Mono", monospace';
const RM=matchMedia('(prefers-reduced-motion: reduce)').matches;
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?v:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const easeOutBack=t=>{const c=1.70158;t-=1;return 1+(c+1)*t*t*t+c*t;};
const easeOutCubic=t=>1-Math.pow(1-t,3);
function shade(hex,f){ // darken a hex colour toward the court background
const n=parseInt(hex.slice(1),16), t=[0x14,0x11,0x10];
return 'rgb('+Math.round(lerp((n>>16)&255,t[0],f))+','+
Math.round(lerp((n>>8)&255,t[1],f))+','+Math.round(lerp(n&255,t[2],f))+')';
}
function rr(c,x,y,w,h,r){ // rounded rect path
r=Math.min(r,w/2,h/2);
c.beginPath();c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);
c.closePath();
}
function fmt(n){return String(Math.max(0,Math.floor(n))).padStart(6,'0');}
// letter-spaced canvas text with per-letter animation
function trackedText(c,text,cx,cy,size,track,fn,color){
c.save();
c.font='700 '+size+'px '+FONT;
c.fillStyle=color;c.textAlign='center';c.textBaseline='middle';
const cw=c.measureText('0').width;
let x=cx-(text.length*cw+(text.length-1)*track)/2+cw/2;
for(let i=0;i<text.length;i++){
const st=fn?fn(i):{a:1,dy:0};
c.globalAlpha=Math.max(0,Math.min(1,st.a===undefined?1:st.a));
c.fillText(text[i],x,cy+(st.dy||0));
x+=cw+track;
}
c.restore();
}
/* ============================ level data ============================ */
const LEVELS=[
{name:'KEYSTONE', pal:['#ffd23f','#ff9a2b','#ff5c29'],
rowPal:['#ffd23f','#ffb03a','#ff5c29','#ffb03a','#ffd23f'],
rows:['..111111..','.11111111.','1111111111','1111111111','.11111111.']},
{name:'CHECKER', pal:['#ffd23f','#ff9a2b','#ff5c29'],
rows:['1212121212','2121212121','1212121212','2121212121','1212121212']},
{name:'ZIGGURAT', pal:['#ffb03a','#ffd23f','#ff5c29'],
rows:['....33....','...2112...','..211112..','.21111112.','3111111113']},
{name:'THE GATES', pal:['#ffb03a','#3fbf9f','#ff5c29'],
rows:['2.1.22.1.2','2.1.22.1.2','2.1.22.1.2','2.1.22.1.2','2.1.22.1.2','2.1.22.1.2']},
{name:'THE VAULT', pal:['#e8dcc2','#ffb03a','#ff5c29'],
rows:['11..22..11','1.222222.1','.22333322.','.23333332.','.22333322.','1.222222.1','11..22..11']},
{name:'FORTRESS', pal:['#ffb03a','#3fbf9f','#ff5c29'],
rows:['X22222222X','X2......2X','X2.3333.2X','X2.3..3.2X','X2.3333.2X','X2......2X','X22222222X']},
{name:'SUPERNOVA', pal:['#ffd23f','#ff9a2b','#ff3b2e'],
rows:['X22222222X','2111111112','2113333112','2133333312','2111111112','X22222222X']},
];
/* ============================ audio (all synthesized) ============================ */
const Snd=(()=>{
let ac=null,master=null,noiseBuf=null,muted=false;
function init(){
if(ac){if(ac.state==='suspended')ac.resume();return;}
try{
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC)return;
ac=new AC();
master=ac.createGain();master.gain.value=0.35;master.connect(ac.destination);
const len=Math.floor(ac.sampleRate*0.4);
noiseBuf=ac.createBuffer(1,len,ac.sampleRate);
const d=noiseBuf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
}catch(e){ac=null;}
}
function tone(f0,f1,dur,type,vol,delay){
if(!ac||muted)return;
try{
const t=ac.currentTime+(delay||0);
const o=ac.createOscillator(),g=ac.createGain();
o.type=type;o.frequency.setValueAtTime(f0,t);
if(f1&&f1!==f0)o.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t+dur);
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g);g.connect(master);o.start(t);o.stop(t+dur+0.03);
}catch(e){}
}
function noise(dur,vol,f0,f1){
if(!ac||muted||!noiseBuf)return;
try{
const t=ac.currentTime;
const s=ac.createBufferSource();s.buffer=noiseBuf;s.loop=true;
const f=ac.createBiquadFilter();f.type='lowpass';
f.frequency.setValueAtTime(f0,t);
f.frequency.exponentialRampToValueAtTime(Math.max(40,f1),t+dur);
const g=ac.createGain();
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
s.connect(f);f.connect(g);g.connect(master);s.start(t);s.stop(t+dur+0.03);
}catch(e){}
}
return{
init,
get muted(){return muted;},
setMuted(v){muted=!!v;},
toggle(){muted=!muted;try{localStorage.setItem('bb.mute',muted?'1':'0');}catch(e){}return muted;},
wall(){tone(190,120,0.06,'triangle',0.15);},
paddle(){tone(240,430,0.07,'square',0.10);},
launch(){tone(300,540,0.09,'triangle',0.12);},
brickHit(c){tone(300+Math.min(600,c*26),240,0.05,'square',0.09);},
brickBreak(c){noise(0.16,0.22,3200,180);tone(500+Math.min(900,c*45),160,0.12,'square',0.12);},
steel(){tone(1500,900,0.05,'sine',0.10);tone(2300,1800,0.03,'sine',0.05);},
pow(){tone(660,660,0.07,'square',0.09);tone(990,990,0.09,'square',0.09,0.08);},
lost(){tone(300,60,0.5,'sawtooth',0.16);noise(0.3,0.12,900,120);},
clear(){[523,659,784,1046].forEach((f,i)=>tone(f,f,0.12,'square',0.10,i*0.09));},
win(){[392,494,587,784,988,1175].forEach((f,i)=>tone(f,f,0.14,'square',0.10,i*0.10));},
gameOver(){[330,262,208,165].forEach((f,i)=>tone(f,f*0.98,0.2,'sawtooth',0.11,i*0.13));},
};
})();
/* ============================ DOM refs & layout ============================ */
const $=id=>document.getElementById(id);
const canvas=$('game'), ctx=canvas.getContext('2d');
const elScore=$('score'),elBest=$('best'),elComboCell=$('comboCell'),elComboVal=$('comboVal'),
elLives=$('lives'),elLevel=$('levelName'),elProg=$('prog'),
elSound=$('soundBtn'),elPause=$('pauseBtn'),stageEl=$('stage');
const headerEl=document.querySelector('header'),footerEl=document.querySelector('footer');
function layout(){
const headH=headerEl.offsetHeight,footH=footerEl.offsetHeight;
const availW=Math.max(200,window.innerWidth-28-14);
const availH=Math.max(200,window.innerHeight-headH-footH-14*2-11*2-2);
const s=Math.max(0.25,Math.min(availW/W,availH/H));
const cw=Math.max(200,Math.floor(W*s)),ch=Math.max(150,Math.floor(H*s));
const dpr=Math.min(window.devicePixelRatio||1,2);
canvas.style.width=cw+'px';canvas.style.height=ch+'px';
canvas.width=Math.round(cw*dpr);canvas.height=Math.round(ch*dpr);
}
addEventListener('resize',layout);
addEventListener('orientationchange',()=>setTimeout(layout,120));
/* ============================ game state ============================ */
const S={
state:'title',time:0,stateT:0,paused:false,
freeze:0,shake:0,slowmo:1,slowTimer:0,slowEase:1,slowUntil:0,wideUntil:0,
level:0,score:0,dispScore:0,best:0,lives:3,combo:0,comboFlash:0,
bottomFlash:0,perfect:true,newBest:false,total:1,remaining:0,aim:-Math.PI/2,
};
const paddle={x:W/2,tx:W/2,vx:0,w:96,tw:96,h:14,y:H-44,squash:1,squashV:0};
const balls=[],bricks=[],parts=[],pops=[],pows=[],waves=[];
let demo=[],banner=null;
const keys={left:false,right:false};
const POWTYPES=[
{k:'W',w:0.34,col:'#3fbf9f',label:'WIDE PADDLE'},
{k:'M',w:0.27,col:'#ff5c29',label:'MULTI BALL'},
{k:'S',w:0.26,col:'#ffb03a',label:'SLOW BALL'},
{k:'+',w:0.13,col:'#efe6d4',label:'EXTRA LIFE'},
];
// ambient reactive dot field
const dots=[];
for(let y=30;y<H-14;y+=40)for(let x=30;x<W-14;x+=40)dots.push({x,y});
// pre-rendered vignette
const vig=document.createElement('canvas');vig.width=W;vig.height=H;
{
const v=vig.getContext('2d');
const g=v.createRadialGradient(W/2,H/2,H*0.42,W/2,H/2,H*0.95);
g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.45)');
v.fillStyle=g;v.fillRect(0,0,W,H);
}
/* ============================ HUD ============================ */
function updateLives(){
let h='';
const n=Math.max(3,S.lives);
for(let i=0;i<n;i++)h+='<i class="'+(i<S.lives?'on':'')+'"></i>';
elLives.innerHTML=h;
}
function updateCombo(){
if(S.combo>=2){
elComboVal.textContent='×'+S.combo;
elComboCell.classList.add('show');
elComboCell.classList.remove('pulse');void elComboCell.offsetWidth;
elComboCell.classList.add('pulse');
}else{
elComboCell.classList.remove('show','pulse');
elComboVal.textContent='×1';
}
}
function setSoundLabel(){elSound.textContent=Snd.muted?'SOUND OFF':'SOUND ON';}
function lightPos(){
if(S.state==='title'&&demo.length)return demo[0];
return balls[0]||null;
}
/* ============================ level building ============================ */
function brickBaseCol(br){
const L=LEVELS[S.level];
return L.rowPal?L.rowPal[br.row]:L.pal[br.maxHp-1];
}
function buildLevel(n){
bricks.length=0;pows.length=0;parts.length=0;pops.length=0;waves.length=0;balls.length=0;
const L=LEVELS[n],bw=70,bh=26,gx=6,gy=8;
const ox=(W-(bw*10+gx*9))/2,oy=72;
let destructible=0;
L.rows.forEach((row,r)=>{
for(let c=0;c<10;c++){
const ch=row[c];
if(ch==='.')continue;
const steel=ch==='X';
if(!steel)destructible++;
bricks.push({
x:ox+c*(bw+gx),y:oy+r*(bh+gy),w:bw,h:bh,
hp:steel?Infinity:+ch,maxHp:steel?Infinity:+ch,steel,alive:true,
flash:0,cracks:[],t:0,delay:r*0.07+c*0.018,row:r,col:c
});
}
});
S.total=destructible;S.remaining=destructible;
}
function resetPaddle(){
paddle.w=96;paddle.tw=96;paddle.x=W/2;paddle.tx=W/2;
paddle.vx=0;paddle.squash=1;paddle.squashV=0;
S.wideUntil=0;
}
function resetDemo(){
demo=[
{x:180,y:160,vx:265,vy:-210,trail:[]},
{x:620,y:420,vx:-230,vy:245,trail:[]},
];
}
function newBall(){
const sp=340+S.level*16;
return{x:paddle.x,y:paddle.y-paddle.h/2-10,vx:0,vy:0,speed:sp,base:sp,r:7,stuck:true,trail:[]};
}
function startLevel(n){
S.level=n;S.perfect=true;S.combo=0;updateCombo();
buildLevel(n);resetPaddle();
S.slowUntil=0;S.slowEase=1;S.slowTimer=0;S.slowmo=1;
S.state='intro';S.stateT=0;
banner={
lines:[
{text:'LEVEL '+(n+1)+' OF '+LEVELS.length,size:13,dim:true},
{text:LEVELS[n].name,size:44,big:true}
],
hold:1.0,fade:0.3,scrim:true,rule:true,t:0
};
elLevel.textContent='LEVEL '+(n+1)+' / '+LEVELS.length+' — '+LEVELS[n].name;
elProg.style.width='0%';
}
function startRun(){
S.score=0;S.dispScore=0;S.lives=3;S.paused=false;S.newBest=false;
elScore.textContent=fmt(0);
elBest.classList.remove('newbest');elBest.textContent=fmt(S.best);
elPause.textContent='PAUSE';
updateLives();startLevel(0);
}
function toTitle(){
S.state='title';S.stateT=0;S.paused=false;
S.score=0;S.dispScore=0;S.lives=3;S.combo=0;
banner=null;balls.length=0;pows.length=0;parts.length=0;pops.length=0;waves.length=0;
resetPaddle();resetDemo();
updateLives();updateCombo();
elScore.textContent=fmt(0);
elBest.classList.remove('newbest');elBest.textContent=fmt(S.best);
elLevel.textContent='PRESS SPACE OR CLICK TO START';
elProg.style.width='0%';elPause.textContent='PAUSE';
}
function toServe(){
S.state='serve';S.stateT=0;S.aim=-Math.PI/2;
balls.length=0;balls.push(newBall());
}
function launch(){
const b=balls[0];
if(!b||!b.stuck)return;
b.stuck=false;
const sp=b.speed*S.slowEase;
b.vx=Math.cos(S.aim)*sp;b.vy=Math.sin(S.aim)*sp;
S.state='play';S.stateT=0;
Snd.launch();
}
function saveBest(){
S.newBest=S.score>S.best;
if(S.newBest)S.best=S.score;
try{localStorage.setItem('bb.best',String(S.best));}catch(e){}
elBest.textContent=fmt(S.best);
elBest.classList.remove('newbest');
}
/* ============================ physics & gameplay ============================ */
function addShake(v){S.shake=Math.min(1,S.shake+v*(RM?0.4:1));}
function spawnParticles(x,y,n,col,sp0,sp1){
if(RM)n=Math.ceil(n/2);
for(let i=0;i<n;i++){
const a=rnd(TAU),s=rnd(sp0,sp1);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-60,
s:rnd(3,9),h:rnd(3,9),rot:rnd(TAU),vr:rnd(-6,6),life:0,ttl:rnd(0.45,0.9),col});
}
}
function burstBall(b){spawnParticles(b.x,Math.min(b.y,H-8),12,'#efe6d4',140,340);}
function makeCrack(w,h){
const pts=[],x0=rnd(w*0.2,w*0.8),x1=rnd(w*0.2,w*0.8);
for(let i=0;i<=4;i++)pts.push([clamp(lerp(x0,x1,i/4)+(i>0&&i<4?rnd(-5,5):0),2,w-2),h*i/4]);
return pts;
}
function addPop(x,y,main,sub,col,size,ttl){
pops.push({x:clamp(x,54,W-54),y,t:0,ttl:ttl||0.9,main,sub,col:col||'#efe6d4',size:size||13});
}
function dropPow(br){
let r=Math.random(),acc=0,type=POWTYPES[0];
for(const t of POWTYPES){acc+=t.w;if(r<=acc){type=t;break;}}
pows.push({x:br.x+br.w/2,y:br.y+br.h/2,vy:135,ph:rnd(TAU),type});
}
function splitBalls(){
const cur=balls.slice();
for(const b of cur){
if(b.stuck||balls.length>=6)continue;
const sp=b.speed,ang=Math.atan2(b.vy,b.vx);
for(const da of[-0.45,0.45]){
if(balls.length>=6)break;
balls.push({x:b.x,y:b.y,vx:Math.cos(ang+da)*sp*S.slowEase,vy:Math.sin(ang+da)*sp*S.slowEase,
speed:sp,base:b.base,r:7,stuck:false,trail:[]});
}
}
}
function catchPow(t,x,y){
S.score+=50;
addPop(x,y-14,t.label,null,t.col,12,1.0);
Snd.pow();
if(t.k==='W'){paddle.tw=150;S.wideUntil=S.time+12;}
else if(t.k==='S'){S.slowUntil=S.time+6;}
else if(t.k==='M'){splitBalls();}
else if(t.k==='+'){S.lives=Math.min(5,S.lives+1);updateLives();}
}
function collideWalls(b){
let hit=false;
if(b.x<b.r){b.x=b.r;b.vx=Math.abs(b.vx);hit=true;}
else if(b.x>W-b.r){b.x=W-b.r;b.vx=-Math.abs(b.vx);hit=true;}
if(b.y<b.r){b.y=b.r;b.vy=Math.abs(b.vy);hit=true;}
if(hit){
addShake(0.05);Snd.wall();
if(waves.length<8)waves.push({x:b.x,y:b.y,t:0});
}
}
function collidePaddle(b){
if(b.vy<=0)return;
const half=paddle.w/2,ph=paddle.h;
const cx=clamp(b.x,paddle.x-half,paddle.x+half);
const cy=clamp(b.y,paddle.y-ph/2,paddle.y+ph/2);
const dx=b.x-cx,dy=b.y-cy;
if(dx*dx+dy*dy>b.r*b.r)return;
b.y=paddle.y-ph/2-b.r-0.5;
const off=clamp((b.x-paddle.x)/half,-1,1);
const ang=-Math.PI/2+off*1.05; // up to ~60° from vertical, based on hit position
b.speed=Math.min(b.speed*1.028,b.base*1.6); // subtle speed-up per paddle hit
const sp=b.speed*S.slowEase;
b.vx=Math.cos(ang)*sp+clamp(paddle.vx*0.10,-80,80);
b.vy=Math.sin(ang)*sp;
if(b.vy>-40)b.vy=-40; // never let it fly horizontally
paddle.squashV=-5.5;
addShake(0.05);Snd.paddle();
S.combo=0;updateCombo();
}
function hitBrick(br,b){
br.flash=1;
if(br.steel){
addShake(0.07);Snd.steel();
spawnParticles(b.x,b.y,4,'#8a8272',80,220);
return;
}
br.hp--;
if(br.hp<=0){destroyBrick(br);}
else{
br.cracks.push(makeCrack(br.w,br.h));
addShake(0.06);
S.freeze=Math.max(S.freeze,0.02*(RM?0.5:1));
Snd.brickHit(S.combo);
}
}
function collideBricks(b){
for(const br of bricks){
if(!br.alive)continue;
if(b.x+b.r<br.x||b.x-b.r>br.x+br.w||b.y+b.r<br.y||b.y-b.r>br.y+br.h)continue;
const cx=clamp(b.x,br.x,br.x+br.w),cy=clamp(b.y,br.y,br.y+br.h);
const dx=b.x-cx,dy=b.y-cy,d2=dx*dx+dy*dy;
if(d2>b.r*b.r)continue;
let nx,ny,pen;
if(d2>1e-6){
const d=Math.sqrt(d2);nx=dx/d;ny=dy/d;pen=b.r-d;
}else{ // ball center inside brick: resolve along smallest axis
const l=b.x-br.x,r=br.x+br.w-b.x,t=b.y-br.y,bt=br.y+br.h-b.y;
const m=Math.min(l,r,t,bt);
if(m===l){nx=-1;ny=0;pen=l+b.r;}
else if(m===r){nx=1;ny=0;pen=r+b.r;}
else if(m===t){nx=0;ny=-1;pen=t+b.r;}
else{nx=0;ny=1;pen=bt+b.r;}
}
b.x+=nx*pen;b.y+=ny*pen;
const dot=b.vx*nx+b.vy*ny;
if(dot<0){b.vx-=2*dot*nx;b.vy-=2*dot*ny;}
hitBrick(br,b);
break;
}
}
function destroyBrick(br){
br.alive=false;
S.combo++;S.comboFlash=1;
const base=40+20*(br.maxHp-1);
const pts=base*S.combo;
S.score+=pts;
const px=br.x+br.w/2,py=br.y+br.h/2;
spawnParticles(px,py,16+br.maxHp*4,brickBaseCol(br),150,430);
waves.push({x:px,y:py,t:0});
addPop(px,br.y-4,'+'+pts,S.combo>=4?'×'+S.combo:null,'#efe6d4');
if(S.combo%5===0)addPop(W/2,H*0.30,'COMBO ×'+S.combo,null,'#ff5c29',16,1.1);
addShake(0.10+Math.min(0.16,S.combo*0.012));
S.freeze=Math.max(S.freeze,(0.045+Math.min(0.05,S.combo*0.004))*(RM?0.5:1));
Snd.brickBreak(S.combo);
updateCombo();
if(Math.random()<0.16)dropPow(br);
S.remaining--;
elProg.style.width=((S.total-S.remaining)/S.total*100)+'%';
if(S.remaining<=0)levelCleared();
}
function levelCleared(){
S.state='clear';S.stateT=0;
S.slowTimer=0.55; // brief slow-motion savor
S.freeze=Math.max(S.freeze,0.16);
for(const b of balls)burstBall(b);
balls.length=0;
const bonus=500+S.lives*250+(S.perfect?500:0);
S.score+=bonus;
banner={
lines:[
{text:LEVELS[S.level].name+' — CLEAR',size:40,big:true},
{text:'BONUS +'+bonus,size:15,dim:true}
].concat(S.perfect?[{text:'PERFECT — NO LIVES LOST',size:12,accent:true}]:[]),
hold:1.6,fade:0.4,scrim:true,rule:true,t:0
};
addShake(0.22);
Snd.clear();
elLevel.textContent='LEVEL '+(S.level+1)+' — CLEAR!';
}
function nextLevel(){
if(S.level+1<LEVELS.length)startLevel(S.level+1);
else winGame();
}
function winGame(){
S.state='win';S.stateT=0;
saveBest();
banner={
lines:[
{text:'ALL CLEAR',size:52,big:true},
{text:LEVELS.length+' LEVELS · CHAMPION',size:13,dim:true},
{text:'FINAL SCORE '+fmt(S.score),size:15,dim:true}
],
hold:1e9,fade:1e9,scrim:true,t:0
};
elLevel.textContent='ALL CLEAR — CHAMPION';
Snd.win();
}
function lifeLost(){
S.combo=0;updateCombo();
S.bottomFlash=1;
addShake(0.55);
S.freeze=Math.max(S.freeze,0.12*(RM?0.5:1));
Snd.lost();
S.perfect=false;
S.lives--;
updateLives();
if(S.lives<=0){gameOver();}
else{
resetPaddle();S.slowUntil=0;
S.state='serve';S.stateT=0;S.aim=-Math.PI/2;
balls.length=0;balls.push(newBall());
}
}
function gameOver(){
S.state='gameover';S.stateT=0;
saveBest();
banner={
lines:[
{text:'GAME OVER',size:50,big:true},
{text:'SCORE '+fmt(S.score),size:15,dim:true},
{text:S.newBest?'NEW BEST':'BEST '+fmt(S.best),size:13,accent:S.newBest}
],
hold:1e9,fade:1e9,scrim:true,t:0
};
elLevel.textContent='GAME OVER';
Snd.gameOver();
addShake(0.3);
}
/* ============================ per-frame updates ============================ */
function updatePaddle(dt){
const dir=(keys.right?1:0)-(keys.left?1:0);
if(dir)paddle.tx+=dir*640*dt;
const half=paddle.w/2+12;
paddle.tx=clamp(paddle.tx,half,W-half);
const px=paddle.x;
paddle.x=lerp(paddle.x,paddle.tx,1-Math.exp(-dt*22));
paddle.x=clamp(paddle.x,half,W-half);
paddle.vx=(paddle.x-px)/Math.max(dt,1e-4);
paddle.w=lerp(paddle.w,paddle.tw,1-Math.exp(-dt*10));
paddle.squashV+=(1-paddle.squash)*260*dt;
paddle.squashV*=Math.exp(-13*dt);
paddle.squash=clamp(paddle.squash+paddle.squashV*dt,0.5,1.55);
}
function updateDemo(dt){
for(const b of demo){
b.x+=b.vx*dt;b.y+=b.vy*dt;
let hit=false;
if(b.x<12){b.x=12;b.vx=Math.abs(b.vx);hit=true;}
if(b.x>W-12){b.x=W-12;b.vx=-Math.abs(b.vx);hit=true;}
if(b.y<12){b.y=12;b.vy=Math.abs(b.vy);hit=true;}
if(b.y>H-12){b.y=H-12;b.vy=-Math.abs(b.vy);hit=true;}
if(hit&&waves.length<5)waves.push({x:b.x,y:b.y,t:0});
b.trail.push({x:b.x,y:b.y,age:0,life:0.14});
for(const t of b.trail)t.age+=dt;
b.trail=b.trail.filter(t=>t.age<t.life);
}
}
function stepBall(b,dt){
if(b.stuck){b.x=paddle.x;b.y=paddle.y-paddle.h/2-b.r-3;return;}
const target=b.speed*S.slowEase;
const cur=Math.hypot(b.vx,b.vy)||1;
const k=target/cur;b.vx*=k;b.vy*=k;
const steps=Math.max(1,Math.ceil(target*dt/5)); // sub-steps prevent tunnelling
const sdt=dt/steps;
for(let i=0;i<steps;i++){
b.x+=b.vx*sdt;b.y+=b.vy*sdt;
collideWalls(b);
collidePaddle(b);
collideBricks(b);
}
const tl=(0.12+Math.min(0.16,S.combo*0.02))*(RM?0.55:1); // trail grows with combo
b.trail.push({x:b.x,y:b.y,age:0,life:tl});
for(const t of b.trail)t.age+=dt;
b.trail=b.trail.filter(t=>t.age<t.life);
}
function updateBalls(dt){
const keep=[];
let lostAny=false;
for(const b of balls){
stepBall(b,dt);
if(S.state!=='play')return; // level cleared / life lost mid-loop
if(b.y>H+26){lostAny=true;burstBall(b);}
else keep.push(b);
}
balls.length=0;
for(const k of keep)balls.push(k);
if(lostAny&&balls.length===0)lifeLost();
}
function updatePows(dt){
for(const p of pows){
p.y+=p.vy*dt;p.ph+=dt*3.2;
if(p.y>H+24){p.dead=true;}
else if(Math.abs(p.x-paddle.x)<paddle.w/2+14&&Math.abs(p.y-paddle.y)<paddle.h/2+15){
p.dead=true;catchPow(p.type,p.x,p.y);
}
}
for(let i=pows.length-1;i>=0;i--)if(pows[i].dead)pows.splice(i,1);
}
function updateParts(dt){
for(const p of parts){
p.life+=dt;p.vy+=880*dt;
p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;
}
for(let i=parts.length-1;i>=0;i--)if(parts[i].life>=parts[i].ttl)parts.splice(i,1);
if(parts.length>450)parts.splice(0,parts.length-450);
}
function updateWaves(dt){
for(const w of waves)w.t+=dt/0.55;
for(let i=waves.length-1;i>=0;i--)if(waves[i].t>=1)waves.splice(i,1);
}
function updatePops(dt){
for(const p of pops)p.t+=dt;
for(let i=pops.length-1;i>=0;i--)if(pops[i].t>=pops[i].ttl)pops.splice(i,1);
}
function update(sdt,rdt){
S.stateT+=rdt;
if(banner)banner.t+=rdt;
updatePaddle(sdt);
if(S.time>S.wideUntil&&paddle.tw>96)paddle.tw=96;
switch(S.state){
case 'title':updateDemo(rdt);break;
case 'intro':{
for(const br of bricks)if(br.alive&&S.stateT>br.delay)br.t=Math.min(1,br.t+rdt/0.32);
if(S.stateT>1.55)toServe();
break;
}
case 'serve':{
const b=balls[0];
if(b){b.x=paddle.x;b.y=paddle.y-paddle.h/2-b.r-3;}
S.aim=-Math.PI/2+Math.sin(S.stateT*2.3)*0.55; // oscillating launch angle: time it
updatePows(rdt);
break;
}
case 'play':updateBalls(sdt);updatePows(sdt);break;
case 'clear':updatePows(sdt);if(S.stateT>2.35)nextLevel();break;
}
updateParts(sdt);
updateWaves(rdt);
updatePops(rdt);
for(const br of bricks)if(br.flash>0)br.flash=Math.max(0,br.flash-rdt*7);
S.comboFlash=Math.max(0,S.comboFlash-rdt*2.5);
S.bottomFlash=Math.max(0,S.bottomFlash-rdt*1.4);
// animated score counter
if(S.dispScore!==S.score){
S.dispScore+=(S.score-S.dispScore)*Math.min(1,rdt*9);
if(Math.abs(S.score-S.dispScore)<1)S.dispScore=S.score;
elScore.textContent=fmt(S.dispScore);
if(S.score>S.best){elBest.textContent=fmt(S.dispScore);elBest.classList.add('newbest');}
}
}
/* ============================ rendering ============================ */
function drawDots(){
const lp=lightPos();
ctx.fillStyle='#efe6d4';
for(const d of dots){
let a=0.05;
if(lp){
const dd=Math.hypot(d.x-lp.x,d.y-lp.y);
if(dd<130)a+=0.10*(1-dd/130); // the field glows around the ball
}
for(const w of waves){ // ...and ripples from every impact
const dist=Math.hypot(d.x-w.x,d.y-w.y);
const df=Math.abs(dist-(8+w.t*150));
if(df<26)a+=0.38*(1-w.t)*(1-df/26);
}
ctx.globalAlpha=Math.min(0.65,a);
ctx.fillRect(d.x-1,d.y-1,2,2);
}
ctx.globalAlpha=1;
}
function drawWaves(){
ctx.lineWidth=1.5;ctx.strokeStyle='#ff5c29';
for(const w of waves){
ctx.globalAlpha=(1-w.t)*0.38;
ctx.beginPath();ctx.arc(w.x,w.y,6+w.t*150,0,TAU);ctx.stroke();
}
ctx.globalAlpha=1;
}
function drawBricks(){
for(const br of bricks){
if(!br.alive)continue;
let y=br.y,al=1;
if(br.t<1){ // cascade-in with overshoot
const E=easeOutBack(br.t);
y=br.y-46*(1-E);al=Math.min(1,br.t*1.4);
}
ctx.globalAlpha=al;
if(br.steel){
rr(ctx,br.x,y,br.w,br.h,3);ctx.fillStyle='#332f29';ctx.fill();
ctx.save();
rr(ctx,br.x,y,br.w,br.h,3);ctx.clip();
ctx.strokeStyle='rgba(238,230,212,0.12)';ctx.lineWidth=1;
ctx.beginPath();
for(let d=-br.h;d<br.w+br.h;d+=9){ctx.moveTo(br.x+d,y+br.h+1);ctx.lineTo(br.x+d+br.h,y-1);}
ctx.stroke();ctx.restore();
ctx.strokeStyle='rgba(0,0,0,0.5)';ctx.lineWidth=1;
rr(ctx,br.x+0.5,y+0.5,br.w-1,br.h-1,3);ctx.stroke();
}else{
let col=brickBaseCol(br);
if(br.hp<br.maxHp)col=shade(col,0.12+0.4*(br.maxHp-br.hp)/br.maxHp); // damage darkens
rr(ctx,br.x,y,br.w,br.h,3);ctx.fillStyle=col;ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.22)';ctx.fillRect(br.x+1,y+br.h-3,br.w-2,3);
ctx.fillStyle='rgba(255,255,255,0.10)';ctx.fillRect(br.x+1,y+1,br.w-2,2);
if(br.cracks.length){
ctx.strokeStyle='rgba(10,8,7,0.5)';ctx.lineWidth=1.1;
for(const c of br.cracks){
ctx.beginPath();ctx.moveTo(br.x+c[0][0],y+c[0][1]);
for(let i=1;i<c.length;i++)ctx.lineTo(br.x+c[i][0],y+c[i][1]);
ctx.stroke();
}
}
}
if(br.flash>0){ // white hit flash (visible during hit-stop)
rr(ctx,br.x,y,br.w,br.h,3);
ctx.fillStyle='rgba(255,246,225,'+(br.flash*0.85).toFixed(3)+')';ctx.fill();
}
ctx.globalAlpha=1;
}
}
function drawPows(){
for(const p of pows){
ctx.save();
ctx.translate(p.x,p.y);ctx.rotate(Math.sin(p.ph)*0.18);
rr(ctx,-15,-9,30,18,9);ctx.fillStyle=p.type.col;ctx.fill();
ctx.strokeStyle='rgba(12,10,9,0.4)';ctx.lineWidth=1;ctx.stroke();
ctx.fillStyle='#141110';ctx.font='700 11px '+FONT;
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(p.type.k==='+'?'+':p.type.k,0,0.5);
ctx.restore();
}
}
function drawParts(){
for(const p of parts){
const k=1-p.life/p.ttl;
if(k<=0)continue;
ctx.save();
ctx.translate(p.x,p.y);ctx.rotate(p.rot);ctx.globalAlpha=k;
ctx.fillStyle=p.col;
const s=p.s*k+0.5;
ctx.fillRect(-s/2,-p.h*k/2,s,p.h*k);
ctx.restore();
}
}
function drawBallList(list,r){
for(const b of list){
for(const tr of b.trail){
const k=1-tr.age/tr.life;
if(k<=0)continue;
ctx.globalAlpha=k*k*0.32;ctx.fillStyle='#ff5c29';
ctx.beginPath();ctx.arc(tr.x,tr.y,r*k*0.95+0.4,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;ctx.fillStyle='#efe6d4';
ctx.beginPath();ctx.arc(b.x,b.y,r,0,TAU);ctx.fill();
ctx.strokeStyle='rgba(12,10,9,0.45)';ctx.lineWidth=1;ctx.stroke();
}
}
function drawPaddle(){
const sq=paddle.squash,lean=clamp(paddle.vx*0.00022,-0.1,0.1);
ctx.save();
ctx.translate(paddle.x,paddle.y);ctx.rotate(lean);
ctx.scale(1+(1-sq)*0.7,sq); // squash & stretch spring
const w=paddle.w,h=paddle.h;
rr(ctx,-w/2,-h/2,w,h,7);ctx.fillStyle='#efe6d4';ctx.fill();
ctx.fillStyle='rgba(12,10,9,0.20)';ctx.fillRect(-w/2+3,h/2-4,w-6,3);
ctx.fillStyle='#ff5c29';rr(ctx,-7,-2.5,14,5,2.5);ctx.fill();
if(S.time<S.wideUntil){ // teal caps while WIDE is active
ctx.fillStyle='#3fbf9f';
rr(ctx,-w/2,-h/2,9,h,4);ctx.fill();
rr(ctx,w/2-9,-h/2,9,h,4);ctx.fill();
}
ctx.restore();
}
function drawPops(){
ctx.textAlign='center';ctx.textBaseline='middle';
for(const p of pops){
const k=p.t/p.ttl;
ctx.globalAlpha=1-k;
const y=p.y-28*easeOutCubic(k);
const sc=0.5+0.5*easeOutBack(Math.min(1,p.t*5));
ctx.save();ctx.translate(p.x,y);ctx.scale(sc,sc);
ctx.font='700 '+p.size+'px '+FONT;ctx.fillStyle=p.col;
ctx.fillText(p.main,0,0);
if(p.sub){ctx.font='700 10px '+FONT;ctx.fillStyle='#ff5c29';ctx.fillText(p.sub,0,13);}
ctx.restore();
}
ctx.globalAlpha=1;
}
function drawBanner(){
const b=banner;if(!b)return;
let a=1;
if(b.t>b.hold)a=Math.max(0,1-(b.t-b.hold)/b.fade);
if(b.scrim){ctx.fillStyle='rgba(9,8,7,0.5)';ctx.fillRect(0,0,W,H);}
ctx.save();ctx.globalAlpha=a;
let y=H*0.33,ruleY=0;
b.lines.forEach((ln,li)=>{
const cy=y+ln.size/2;
if(ln.big)ruleY=cy+ln.size/2+14;
trackedText(ctx,ln.text,W/2,cy,ln.size,Math.max(2,ln.size*0.22),i=>{
const tt=clamp((b.t-0.05-li*0.09-i*0.028)/0.3,0,1);
return {a:tt*a,dy:(1-easeOutBack(tt))*-26};
},ln.accent?'#ff5c29':(ln.dim?'#8a7f6c':'#efe6d4'));
y+=ln.size+18;
if(ln.big)y+=10;
});
if(b.rule){
const rw=210*easeOutCubic(clamp((b.t-0.4)/0.45,0,1));
ctx.fillStyle='#ff5c29';ctx.fillRect(W/2-rw/2,ruleY,rw,2);
}
ctx.restore();
}
function drawHint(text,y){
const a=0.35+0.4*Math.abs(Math.sin(S.time*2.4));
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.font='700 13px '+FONT;
ctx.fillStyle='rgba(238,230,212,'+a.toFixed(3)+')';
ctx.fillText(text,W/2,y);
}
function drawServeAim(){
const b=balls[0];if(!b)return;
for(let k=0;k<4;k++){
const d=20+k*15;
ctx.globalAlpha=(0.55-k*0.11)*(0.7+0.3*Math.abs(Math.sin(S.time*3)));
ctx.fillStyle='#efe6d4';
ctx.beginPath();
ctx.arc(b.x+Math.cos(S.aim)*d,b.y+Math.sin(S.aim)*d,2.4-k*0.35,0,TAU);
ctx.fill();
}
ctx.globalAlpha=1;
drawHint('SPACE / CLICK — LAUNCH',H*0.66);
}
function drawTitle(){
const t=S.stateT;
const cy=H*0.40;
trackedText(ctx,'BALLISTIC',W/2,cy,62,14,i=>{
const tt=clamp((t-0.15-i*0.05)/0.4,0,1);
return {a:tt,dy:(1-easeOutBack(tt))*-30};
},'#efe6d4');
const rw=240*easeOutCubic(clamp((t-0.55)/0.45,0,1));
ctx.fillStyle='#ff5c29';ctx.fillRect(W/2-rw/2,cy+34,rw,3);
if(S.best>0){
ctx.font='700 12px '+FONT;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillStyle='#5d5546';ctx.fillText('HIGH SCORE '+fmt(S.best),W/2,cy-76);
}
trackedText(ctx,'A BRICK-BREAKING ARCADE · SEVEN LEVELS',W/2,cy+62,13,3,
i=>({a:clamp(t-0.8-i*0.02,0,1),dy:0}),'#8a7f6c');
drawHint('PRESS SPACE OR CLICK TO START',H*0.78);
ctx.font='700 10px '+FONT;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillStyle='#4d463b';
ctx.fillText('MOUSE OR ARROW KEYS TO MOVE THE PADDLE',W/2,H*0.86);
}
function drawPause(){
ctx.fillStyle='rgba(9,8,7,0.6)';ctx.fillRect(0,0,W,H);
trackedText(ctx,'PAUSED',W/2,H*0.42,40,12,()=>({a:1,dy:0}),'#efe6d4');
ctx.font='700 12px '+FONT;ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillStyle='#8a7f6c';
ctx.fillText('P OR CLICK — RESUME',W/2,H*0.42+46);
}
function drawCorners(){
ctx.strokeStyle='rgba(255,92,41,0.4)';ctx.lineWidth=2;
const m=10,L=16;
for(const[px,py,dx,dy]of[[m,m,1,1],[W-m,m,-1,1],[m,H-m,1,-1],[W-m,H-m,-1,-1]]){
ctx.beginPath();ctx.moveTo(px+dx*L,py);ctx.lineTo(px,py);ctx.lineTo(px,py+dy*L);ctx.stroke();
}
}
function render(){
const sx=canvas.width/W,sy=canvas.height/H;
ctx.setTransform(sx,0,0,sy,0,0);
// camera shake (trauma² model)
const sh=S.shake*S.shake;
if(sh>0.00025)ctx.translate((Math.random()*2-1)*sh*17,(Math.random()*2-1)*sh*12);
ctx.fillStyle='#141110';ctx.fillRect(-40,-40,W+80,H+80);
drawDots();drawWaves();
const L=lightPos();
if(L){ // warm light carried by the ball
const g=ctx.createRadialGradient(L.x,L.y,0,L.x,L.y,120);
g.addColorStop(0,'rgba(255,210,150,0.06)');g.addColorStop(1,'rgba(255,210,150,0)');
ctx.fillStyle=g;ctx.fillRect(L.x-120,L.y-120,240,240);
}
drawBricks();drawPows();drawParts();
drawBallList(balls,7);
if(S.state==='title')drawBallList(demo,6);
if(S.state!=='title'){
drawPaddle();
ctx.strokeStyle='rgba(255,92,41,0.14)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(14,H-30);ctx.lineTo(W-14,H-30);ctx.stroke();
if(S.bottomFlash>0){
ctx.fillStyle='rgba(255,70,30,'+(S.bottomFlash*0.22).toFixed(3)+')';
ctx.fillRect(0,H-80,W,80);
ctx.fillStyle='rgba(255,70,30,'+(S.bottomFlash*0.5).toFixed(3)+')';
ctx.fillRect(0,H-4,W,4);
}
}
// court frame — pulses with the combo
const ba=0.14+Math.min(0.45,S.comboFlash*0.55)+(S.combo>=8?0.10+0.07*Math.sin(S.time*9):0);
ctx.strokeStyle='rgba(238,230,212,'+ba.toFixed(3)+')';ctx.lineWidth=1.5;
ctx.strokeRect(0.75,0.75,W-1.5,H-1.5);
drawCorners();
drawPops();
ctx.drawImage(vig,0,0,W,H);
if(S.state==='title'){drawTitle();}
else{
if(banner&&(S.state==='intro'||S.state==='clear'||S.state==='gameover'||S.state==='win'))drawBanner();
if(S.state==='serve')drawServeAim();
if(S.state==='gameover')drawHint('SPACE OR CLICK — PLAY AGAIN',H*0.62);
if(S.state==='win')drawHint('SPACE OR CLICK — TITLE',H*0.62);
}
if(S.paused)drawPause();
}
/* ============================ main loop ============================ */
let last=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000;last=now;
if(!(dt>0)||dt>0.05)dt=0.0167;
S.time+=dt;
// time-scale easing (clear-slowmo + slow-ball power-up)
const st=S.slowTimer>0?0.3:1;
S.slowTimer=Math.max(0,S.slowTimer-dt);
S.slowmo+=(st-S.slowmo)*Math.min(1,dt*5);
const sf=S.slowUntil>S.time?0.62:1;
S.slowEase+=(sf-S.slowEase)*Math.min(1,dt*4);
S.shake*=Math.exp(-dt*4.2);
if(S.freeze>0){S.freeze-=dt;} // hit-stop: the world holds its breath
else if(!S.paused){update(dt*S.slowmo,dt);}
render();
}
/* ============================ input ============================ */
function primary(){
Snd.init();
if(S.paused){togglePause();return;}
switch(S.state){
case 'title':startRun();break;
case 'serve':launch();break;
case 'clear':if(S.stateT>0.8)nextLevel();break;
case 'gameover':if(S.stateT>0.6)startRun();break;
case 'win':if(S.stateT>0.6)toTitle();break;
}
}
function togglePause(){
if(!(S.state==='play'||S.state==='serve'))return;
S.paused=!S.paused;
elPause.textContent=S.paused?'RESUME':'PAUSE';
}
function autoPause(){
keys.left=keys.right=false;
if((S.state==='play'||S.state==='serve')&&!S.paused)togglePause();
}
window.addEventListener('keydown',e=>{
const k=e.key;
if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown'||k===' ')e.preventDefault();
if(k==='ArrowLeft'||k==='a'||k==='A')keys.left=true;
else if(k==='ArrowRight'||k==='d'||k==='D')keys.right=true;
else if(k===' '||k==='Spacebar')primary();
else if(k==='p'||k==='P')togglePause();
else if(k==='m'||k==='M'){Snd.init();Snd.toggle();setSoundLabel();}
else if(k==='r'||k==='R'){if(S.state!=='title')startRun();}
Snd.init();
});
window.addEventListener('keyup',e=>{
const k=e.key;
if(k==='ArrowLeft'||k==='a'||k==='A')keys.left=false;
if(k==='ArrowRight'||k==='d'||k==='D')keys.right=false;
});
stageEl.addEventListener('pointermove',e=>{
const r=canvas.getBoundingClientRect();
paddle.tx=(e.clientX-r.left)/r.width*W;
});
stageEl.addEventListener('pointerdown',e=>{
Snd.init();
const r=canvas.getBoundingClientRect();
paddle.tx=(e.clientX-r.left)/r.width*W;
primary();
});
window.addEventListener('pointerdown',()=>Snd.init(),{passive:true});
window.addEventListener('blur',autoPause);
document.addEventListener('visibilitychange',()=>{if(document.hidden)autoPause();});
elSound.addEventListener('click',()=>{Snd.init();Snd.toggle();setSoundLabel();elSound.blur();});
elPause.addEventListener('click',()=>{togglePause();elPause.blur();});
/* ============================ boot ============================ */
try{S.best=parseInt(localStorage.getItem('bb.best'))||0;}catch(e){}
try{if(localStorage.getItem('bb.mute')==='1')Snd.setMuted(true);}catch(e){}
setSoundLabel();
elBest.textContent=fmt(S.best);
updateLives();
layout();
toTitle();
requestAnimationFrame(frame);
</script>
</body>
</html>
