Run details
- Model
- z-ai/glm-5.3-prime
- Route
- OpenRouter API, one user message
- Served by
- Alibaba
- Effort
- max, OpenRouter's default for this model
- Limits
- 60 min · 131,072 output tokens (the model's limit)
- Date
- 23 Oct 2026
- Time
- 12 min 56 s
- Size
- 43 kB
- Tokens
- 88 in, 70,188 out (54,060 of them thinking)
- List price
- $0.62 at today's price ($2.8 in / $8.8 out per M)
- Paid
- $0.62
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
43 kB 1,014 lines Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EMBERBREAK — a breakout, fired hot</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--ink:#EDE2CE; --dim:#93876F; --faint:#6E6452;
--ember:#FF5A1F; --ember2:#FFA24A;
--line:#2E2820; --panel:#1B1611; --field:#100D0A;
}
html,body{height:100%}
body{
background:#141110; color:var(--ink);
font-family:"Space Mono","IBM Plex Mono","JetBrains Mono","Cascadia Mono",ui-monospace,Menlo,Consolas,monospace;
display:flex; align-items:center; justify-content:center;
min-height:100vh; padding:12px; overflow:hidden;
-webkit-user-select:none; user-select:none;
}
.stage{display:flex;flex-direction:column;gap:10px}
/* ---------- HUD ---------- */
.hud{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px}
.brand{display:flex;gap:10px;align-items:center;justify-self:start}
.brand svg{width:22px;height:22px;flex:none}
.word{font-weight:700;letter-spacing:.3em;font-size:13px;margin-right:-.3em}
.lvl{font-size:9px;letter-spacing:.2em;color:var(--dim);margin-top:3px}
.score{text-align:center}
.score .label{font-size:8.5px;letter-spacing:.34em;color:var(--dim);margin-right:-.34em}
.score .value{font-size:26px;font-weight:700;letter-spacing:.04em;font-variant-numeric:tabular-nums;line-height:1.05;transform-origin:center}
.right{display:flex;flex-direction:column;align-items:flex-end;gap:5px;justify-self:end}
.best{font-size:9.5px;letter-spacing:.18em;color:var(--dim)}
.best span{color:var(--ink)}
.heatrow{display:flex;align-items:center;gap:8px}
.heatbar{width:110px;height:6px;border:1px solid #35AD? }
.heatbar{width:110px;height:6px;border:1px solid #352D24;background:#221D17;position:relative;overflow:hidden}
.heatbar i{display:block;height:100%;width:0;background:#8A7A64}
.mult{font-size:11px;font-weight:700;min-width:26px;text-align:right;color:var(--dim)}
.mult.hot{color:var(--ember)}
.mult.blaze{color:var(--ember2)}
.lives{display:flex;gap:7px;padding-top:2px}
.life{width:9px;height:9px;background:var(--ember);transform:rotate(45deg)}
.life.dead{background:transparent;border:1px solid #4A4038}
/* ---------- field ---------- */
.field{position:relative;border:1px solid var(--line);background:var(--field);line-height:0;touch-action:none}
canvas{display:block}
/* ---------- overlays ---------- */
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(10,8,6,.6);line-height:1.4}
.overlay[hidden]{display:none}
.panel{background:var(--panel);border:1px solid #3A3128;padding:34px 44px;text-align:center;max-width:540px;animation:rise .45s cubic-bezier(.2,.9,.25,1) both}
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.title-mark{display:flex;align-items:center;justify-content:center;gap:14px}
.title-mark svg{width:30px;height:30px}
.title-word{font-size:28px;font-weight:700;letter-spacing:.3em;margin-right:-.3em}
.tag{color:#9A8F7B;font-size:12px;margin:12px 0 24px;letter-spacing:.04em}
.how{list-style:none;font-size:10.5px;letter-spacing:.1em;color:#C6BA9F;display:flex;flex-direction:column;gap:9px;margin-bottom:26px}
.how b{color:var(--ink);border:1px solid #3A3128;padding:1px 6px;margin:0 2px;font-weight:700}
.cta{color:var(--ember);font-size:12px;letter-spacing:.3em;margin-right:-.3em;animation:pulse 1.7s ease-in-out infinite}
@keyframes pulse{50%{opacity:.3}}
.bestline{margin-top:16px;font-size:10px;letter-spacing:.22em;color:var(--faint)}
.levelcard{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:rgba(10,8,6,.34);pointer-events:none;line-height:1.3}
.levelcard[hidden]{display:none}
.lv-num{font-size:10px;letter-spacing:.5em;margin-right:-.5em;color:var(--ember)}
.lv-name{font-size:32px;font-weight:700;color:var(--ink);letter-spacing:.24em;margin-right:-.24em;animation:trackin .65s cubic-bezier(.2,.8,.2,1) both}
@keyframes trackin{from{letter-spacing:.6em;margin-right:-.6em;opacity:0}to{letter-spacing:.24em;margin-right:-.24em;opacity:1}}
.lv-flavor{font-size:11px;color:#9A8F7B}
.p-t{font-size:18px;font-weight:700;letter-spacing:.5em;margin-right:-.5em}
.p-s{font-size:11px;color:#9A8F7B;margin-top:10px}
.end-title{font-size:24px;font-weight:700;letter-spacing:.22em;margin-right:-.22em;color:var(--ember)}
.end-sub{font-size:10.5px;letter-spacing:.14em;color:#9A8F7B;margin-top:8px}
.big{font-size:32px;font-weight:700;font-variant-numeric:tabular-nums;margin:18px 0 6px}
.stats{margin:16px auto 26px;max-width:300px}
.stat{display:flex;justify-content:space-between;padding:7px 0;border-top:1px solid var(--line);font-size:10.5px;letter-spacing:.14em}
.stat:last-child{border-bottom:1px solid var(--line)}
.stat span{color:#8F8471}
.stat b{font-variant-numeric:tabular-nums}
.stat b.newbest{color:var(--ember2)}
button.action{background:var(--ember);color:#1A0E06;border:none;font:inherit;font-weight:700;letter-spacing:.22em;padding:12px 24px;cursor:pointer;font-size:11px}
button.action:hover{background:var(--ember2)}
/* ---------- footer ---------- */
.foot{display:flex;justify-content:space-between;align-items:center;padding:0 2px;font-size:9px;letter-spacing:.16em;color:#7E7462;gap:12px}
.foot button{background:none;border:1px solid #3A332A;color:#B7AB94;font:inherit;letter-spacing:.16em;padding:3px 9px;cursor:pointer;display:flex;align-items:center;gap:7px}
.foot button:hover{border-color:var(--ember);color:var(--ember)}
.foot svg{width:13px;height:13px}
@media (max-width:620px){
.panel{padding:24px 22px}
.title-word{font-size:20px}
.score .value{font-size:19px}
.word{font-size:10px}.lvl{display:none}
.heatbar{width:70px}
.foot{font-size:7.5px}
}
</style>
</head>
<body>
<div class="stage" id="stage">
<header class="hud" id="hud">
<div class="brand">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#FF5A1F" d="M12 1.6C14.8 5.8 18.4 8 18.4 12.6a6.4 6.4 0 0 1-12.8 0C5.6 8 9.2 5.8 12 1.6Z"/><path fill="#141110" d="M12 8.4c1.6 2.2 3.1 3.4 3.1 5.4a3.1 3.1 0 0 1-6.2 0c0-2 1.5-3.2 3.1-5.4Z"/></svg>
<div>
<div class="word">EMBERBREAK</div>
<div class="lvl" id="hudLevel">KILN 01 · FIRST FIRE</div>
</div>
</div>
<div class="score">
<div class="label">SCORE</div>
<div class="value" id="hudScore">0</div>
</div>
<div class="right">
<div class="best">BEST <span id="hudBest">0</span></div>
<div class="heatrow">
<div class="heatbar"><i id="heatFill"></i></div>
<span class="mult" id="hudMult">×1</span>
</div>
<div class="lives" id="hudLives"></div>
</div>
</header>
<div class="field" id="field">
<canvas id="cv" width="960" height="600"></canvas>
<div class="levelcard" id="levelCard" hidden>
<div class="lv-num" id="lvNum">KILN 01</div>
<div class="lv-name" id="lvName">FIRST FIRE</div>
<div class="lv-flavor" id="lvFlavor"></div>
</div>
<div class="overlay" id="ovTitle">
<div class="panel">
<div class="title-mark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#FF5A1F" d="M12 1.6C14.8 5.8 18.4 8 18.4 12.6a6.4 6.4 0 0 1-12.8 0C5.6 8 9.2 5.8 12 1.6Z"/><path fill="#1B1611" d="M12 8.4c1.6 2.2 3.1 3.4 3.1 5.4a3.1 3.1 0 0 1-6.2 0c0-2 1.5-3.2 3.1-5.4Z"/></svg>
<span class="title-word">EMBERBREAK</span>
</div>
<p class="tag">A breakout, fired hot. Six kilns. One ball.</p>
<ul class="how">
<li><b>MOUSE</b> or <b>← →</b> move the paddle</li>
<li><b>SPACE</b> / <b>CLICK</b> strike the ball</li>
<li><b>P</b> pause <b>M</b> sound <b>R</b> restart</li>
</ul>
<p class="cta">PRESS SPACE TO STOKE THE FIRE</p>
<p class="bestline" id="titleBest" hidden></p>
</div>
</div>
<div class="overlay" id="ovPause" hidden>
<div class="panel">
<div class="p-t">PAUSED</div>
<div class="p-s">the forge holds its breath — P to resume</div>
</div>
</div>
<div class="overlay" id="ovEnd" hidden>
<div class="panel">
<div class="end-title" id="endTitle">THE FIRE DIED</div>
<div class="end-sub" id="endSub"></div>
<div class="big" id="endScore">0</div>
<div class="stats">
<div class="stat"><span id="stBestLabel">BEST</span><b id="stBest">0</b></div>
<div class="stat"><span>MAX CHAIN</span><b id="stChain">0</b></div>
<div class="stat"><span>BRICKS FIRED</span><b id="stBricks">0</b></div>
<div class="stat"><span>TIME</span><b id="stTime">0:00</b></div>
</div>
<button class="action" id="btnRestart">STOKE IT AGAIN — R</button>
</div>
</div>
</div>
<footer class="foot">
<div>MOUSE / ← → MOVE · SPACE LAUNCH · P PAUSE · M SOUND</div>
<button id="btnSound" title="toggle sound">
<svg id="icoOn" viewBox="0 0 24 24"><path fill="currentColor" d="M4 9v6h4l5 4V5L8 9H4z"/><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M16 9a4.2 4.2 0 0 1 0 6M18.4 7a7.5 7.5 0 0 1 0 10"/></svg>
<svg id="icoOff" viewBox="0 0 24 24" hidden><path fill="currentColor" d="M4 9v6h4l5 4V5L8 9H4z"/><path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M16.5 9.5l5 5m0-5l-5 5"/></svg>
<span>SOUND</span>
</button>
</footer>
</div>
<script>
(()=>{ 'use strict';
/* ================= constants & DOM ================= */
const W=960, H=600, TAU=Math.PI*2;
const COLS=12, MARGIN=14, TOP=60, GAP=5;
const BW=(W-MARGIN*2-GAP*(COLS-1))/COLS, BH=24;
const FX0=6, FX1=W-6, FY0=6;
const FONT='"Space Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace';
const $=id=>document.getElementById(id);
const canvas=$('cv'), ctx=canvas.getContext('2d');
const stage=$('stage'), hudEl=$('hud'), footEl=document.querySelector('.foot'), field=$('field');
const hudScore=$('hudScore'), hudBest=$('hudBest'), hudLevel=$('hudLevel'),
heatFill=$('heatFill'), hudMult=$('hudMult'), hudLives=$('hudLives');
const ovTitle=$('ovTitle'), ovPause=$('ovPause'), ovEnd=$('ovEnd'), levelCard=$('levelCard');
const lvNum=$('lvNum'), lvName=$('lvName'), lvFlavor=$('lvFlavor');
const endTitle=$('endTitle'), endSub=$('endSub'), endScore=$('endScore'),
stBestLabel=$('stBestLabel'), stBest=$('stBest'), stChain=$('stChain'),
stBricks=$('stBricks'), stTime=$('stTime');
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const easeOutCubic=t=>1-Math.pow(1-t,3);
function rr(c,x,y,w,h,r){ 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();
}
/* subtle material grain, pre-rendered once */
const grain=(()=>{ const c=document.createElement('canvas'); c.width=c.height=140;
const g=c.getContext('2d'); const d=g.createImageData(140,140);
for(let i=0;i<d.data.length;i+=4){ const v=205+Math.random()*50|0;
d.data[i]=v; d.data[i+1]=v; d.data[i+2]=v-14; d.data[i+3]=Math.random()*13; }
g.putImageData(d,0,0); return ctx.createPattern(c,'repeat'); })();
/* ================= synthesized audio ================= */
const SFX={
ctx:null, master:null, muted:false, noiseBuf:null,
ensure(){ if(this.ctx){ if(this.ctx.state==='suspended') this.ctx.resume(); return; }
const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
this.ctx=new AC();
const comp=this.ctx.createDynamicsCompressor();
this.master=this.ctx.createGain(); this.master.gain.value=this.muted?0:0.55;
this.master.connect(comp); comp.connect(this.ctx.destination);
const len=this.ctx.sampleRate*0.4|0;
this.noiseBuf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d=this.noiseBuf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
},
tone(o){ if(!this.ctx||this.muted) return; const t=this.ctx.currentTime+(o.at||0);
const osc=this.ctx.createOscillator(), g=this.ctx.createGain();
osc.type=o.type||'sine'; osc.frequency.setValueAtTime(o.f0,t);
if(o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(20,o.f1),t+o.dur);
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(o.vol,t+0.008);
g.gain.exponentialRampToValueAtTime(0.0001,t+o.dur);
osc.connect(g); g.connect(this.master); osc.start(t); osc.stop(t+o.dur+0.05);
},
noise(o){ if(!this.ctx||this.muted) return; const t=this.ctx.currentTime+(o.at||0);
const src=this.ctx.createBufferSource(); src.buffer=this.noiseBuf;
const f=this.ctx.createBiquadFilter(); f.type=o.ftype||'bandpass';
f.frequency.setValueAtTime(o.f,t); f.Q.value=o.q||0.8;
if(o.f1) f.frequency.exponentialRampToValueAtTime(o.f1,t+o.dur);
const g=this.ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(o.vol,t+0.006);
g.gain.exponentialRampToValueAtTime(0.0001,t+o.dur);
src.connect(f); f.connect(g); g.connect(this.master); src.start(t); src.stop(t+o.dur+0.05);
},
wall(){ this.tone({f0:560,f1:500,dur:0.035,type:'sine',vol:0.09}); },
paddle(sp){ this.tone({f0:150+sp*0.07,f1:110,dur:0.07,type:'triangle',vol:0.34});
this.noise({f:420,dur:0.05,vol:0.1,ftype:'lowpass'}); },
brickHit(hp){ this.tone({f0:180+hp*55,f1:150,dur:0.045,type:'square',vol:0.12}); },
brickBreak(combo){ const p=Math.min(combo,16)*38;
this.noise({f:1500+p,dur:0.16,vol:0.38,q:0.6});
this.tone({f0:640+p,f1:110,dur:0.12,type:'sawtooth',vol:0.15});
this.tone({f0:420+p,f1:420+p*1.6,dur:0.09,type:'sine',vol:0.11,at:0.02});
},
launch(){ this.noise({f:300,f1:2400,dur:0.18,vol:0.2,q:1.2});
this.tone({f0:220,f1:440,dur:0.1,type:'triangle',vol:0.14}); },
powerup(){ this.tone({f0:392,f1:392,dur:0.09,type:'triangle',vol:0.2});
this.tone({f0:587,f1:587,dur:0.12,type:'triangle',vol:0.2,at:0.08}); },
life(){ this.tone({f0:523,f1:784,dur:0.15,type:'triangle',vol:0.18,at:0.1}); },
lifeLost(){ this.tone({f0:260,f1:52,dur:0.5,type:'sawtooth',vol:0.26});
this.noise({f:220,dur:0.35,vol:0.22,ftype:'lowpass'}); },
clear(){ [392,494,587,784].forEach((f,i)=>this.tone({f0:f,f1:f,dur:0.14,type:'triangle',vol:0.18,at:i*0.09})); },
win(){ [392,523,659,784,1046].forEach((f,i)=>this.tone({f0:f,f1:f,dur:0.2,type:'triangle',vol:0.2,at:i*0.11})); },
over(){ [311,262,208,156].forEach((f,i)=>this.tone({f0:f,f1:f*0.94,dur:0.24,type:'triangle',vol:0.18,at:i*0.14})); },
toggle(){ this.muted=!this.muted; if(this.master) this.master.gain.value=this.muted?0:0.55; }
};
/* ================= levels ================= */
const LEVELS=[
{name:'FIRST FIRE', flavor:'Every kiln begins with a single spark.', speed:1.0, rows:[
'....1111....',
'..11122111..',
'.1122222211.',
'111122221111']},
{name:'KILN CHECKERS', flavor:'Mind the gaps — they bite back.', speed:1.05, rows:[
'3.3.3.3.3.3.',
'.1.1.1.1.1.1',
'2.2.2.2.2.2.',
'.1.1.1.1.1.1',
'2.2.2.2.2.2.']},
{name:'THE ARCH', flavor:'Under the arch, the ball runs wild.', speed:1.08, rows:[
'222222222222',
'2.3......3.2',
'2.3......3.2',
'2.3......3.2',
'..3......3..']},
{name:'THE WALL', flavor:'Rows of fired clay. Hold the line.', speed:1.12, rows:[
'111111111111',
'122222222221',
'123333333321',
'122222222221',
'111111111111']},
{name:'MAGMA DRIFT', flavor:'The kiln breathes. So should you.', speed:1.15,
moving:{0:0.9, 2:-1.3, 4:1.6}, rows:[
'3.3.3.3.3.3.',
'.2.2.2.2.2.2',
'2.2.2.2.2.2.',
'.1.1.1.1.1.1',
'1.1.1.1.1.1.']},
{name:'THE FURNACE', flavor:'The heart of the kiln. Crack it open.', speed:1.2, rows:[
'....1111....',
'..11222211..',
'.1122222211.',
'112233332211',
'.1122222211.',
'..11222211..',
'....1111....']},
];
const HP_COL={1:'#E1662A',2:'#A5512D',3:'#59524B'};
const BRICK_VALUE={1:50,2:90,3:150};
const POWS={
W:{bg:'#E8DCC6',fg:'#1B1611',label:'W'},
F:{bg:'#FF5A1F',fg:'#241005',label:'F'},
M:{bg:'#FFA24A',fg:'#241005',label:'M'},
L:{bg:'#D8CDB8',fg:'#8C2F12',label:'+'}
};
/* ================= state ================= */
let state='title', prevState='play';
let level=0, levelSpeed=320, levelTime=0;
let score=0, displayScore=0, scoreBump=0, shownMult=1;
let best=0; try{ best=+(localStorage.getItem('emberbreak-best')||0); }catch(e){}
let lives=3, combo=0, maxCombo=0, heat=0, bricksBroken=0, timePlayed=0;
let bricks=[], balls=[], powerups=[], parts=[], attractQueue=[], movingRows=[];
let shakeTrauma=0, freeze=0, timeScale=1, tsTimer=0;
let flash={r:255,g:120,b:40,a:0};
let introT=0, respawnT=0, clearT=0;
let frame=0, T=0, RS=1;
const keys={L:false,R:false};
let mouseActive=false, mouseX=W/2;
const paddle={x:W/2, y:H-44, w:118, h:14, vx:0, squash:0, glow:0, tilt:0, wideT:0};
/* motes: ambient embers rising from the floor, density follows heat */
const motes=[]; for(let i=0;i<64;i++) motes.push({
x0:rnd(FX0+6,FX1-6), y:rnd(0,H), vy:rnd(14,42), ph:rnd(TAU), amp:rnd(4,16), a:rnd(0.04,0.13), x:0});
motes.forEach(m=>m.x=m.x0);
const multFromHeat=()=> heat>=70?4 : heat>=45?3 : heat>=20?2 : 1;
const multColor=m=> m>=4?'255,162,74' : m>=3?'255,90,31' : m>=2?'199,123,58' : '237,226,206';
const fmt=n=>Math.round(n).toLocaleString('en-US');
function addShake(a){ shakeTrauma=Math.min(1,shakeTrauma+a); }
function flashSet(r,g,b,a){ flash.r=r; flash.g=g; flash.b=b; flash.a=a; }
function slowmo(sc,dur){ timeScale=sc; tsTimer=dur; }
function bump(){ scoreBump=1; }
/* ================= particles ================= */
function spawnSparks(x,y,n,spd,hot){
for(let i=0;i<n;i++){ const a=rnd(TAU), s=spd*rnd(0.35,1);
parts.push({k:'s',x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s*0.8-40,life:rnd(0.5,1),hot:!!hot}); }
}
function spawnShards(b){
const n=5+b.maxHp*2;
for(let i=0;i<n;i++) parts.push({k:'d',
x:b.x+rnd(4,b.w-4), y:b.y+rnd(2,b.h-2),
vx:rnd(-170,170), vy:rnd(-280,-60), rot:rnd(TAU), vr:rnd(-8,8),
w:rnd(5,13), h:rnd(3,7), col:b.col, life:rnd(0.8,1.15)});
}
function addRing(x,y){ parts.push({k:'r',x,y,r:6,life:1}); }
function addText(str,x,y,col){ parts.push({k:'t',str,x,y,life:1,col}); }
function updateParts(dt){
for(let i=parts.length-1;i>=0;i--){ const p=parts[i];
if(p.k==='s'){ p.vy+=380*dt; p.vx*=Math.pow(0.25,dt); p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=dt*1.7; }
else if(p.k==='d'){ p.vy+=1100*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.rot+=p.vr*dt; p.life-=dt*0.8; }
else if(p.k==='r'){ p.r+=150*dt; p.life-=dt*2.4; }
else if(p.k==='t'){ p.y-=42*dt; p.life-=dt*1.1; }
if(p.life<=0||p.y>H+34) parts.splice(i,1);
}
if(parts.length>420) parts.splice(0,parts.length-420);
}
/* ================= build / flow ================= */
function brickFill(hp){
const base=HP_COL[hp], d=(((Math.random()*9)|0)-4)*3;
const n=parseInt(base.slice(1),16);
return `rgb(${clamp((n>>16)+d,0,255)},${clamp(((n>>8)&255)+d,0,255)},${clamp((n&255)+d,0,255)})`;
}
function setupLevel(i){
level=i; const L=LEVELS[i]; levelSpeed=320*L.speed; levelTime=0;
bricks=[]; powerups=[]; attractQueue=[]; movingRows=[];
timeScale=1; tsTimer=0; freeze=0; combo=0;
L.rows.forEach((row,r)=>{ [...row].forEach((ch,c)=>{ if(ch==='.') return;
const hp=+ch;
bricks.push({col:c,row:r, x0:MARGIN+c*(BW+GAP), x:0, y:TOP+r*(BH+GAP),
w:BW,h:BH, hp, maxHp:hp, alive:true, spawn:0, spawnDelay:0.12+r*0.08+c*0.028,
flash:0, pop:0, cracks:[], lastHit:-1, seed:Math.random()*10, col:brickFill(hp)});
});});
bricks.forEach(b=>b.x=b.x0);
if(L.moving){ for(const r in L.moving){
const rowBricks=bricks.filter(b=>b.row===+r); if(!rowBricks.length) continue;
const x0min=Math.min(...rowBricks.map(b=>b.x0));
const x0max=Math.max(...rowBricks.map(b=>b.x0+b.w));
movingRows.push({spd:L.moving[r], phase:+r*1.37, amp:34,
lo:FX0+1-x0min, hi:FX1-1-x0max, rowBricks});
}}
balls=[];
paddle.x=W/2; paddle.wideT=0; paddle.vx=0;
hudLevel.textContent=`KILN 0${i+1} · ${L.name}`;
}
function newBall(){ return {x:paddle.x,y:paddle.y-10,r:7,vx:0,vy:0,stuck:true,
speed:levelSpeed,fire:0,trail:[],deform:0,defAxis:0}; }
function freeBall(){ const b=newBall(); b.stuck=false; const a=rnd(-0.6,0.6);
b.vx=Math.sin(a)*330; b.vy=-Math.cos(a)*330; return b; }
function loadLevel(i){
setupLevel(i);
balls=[newBall()];
lvNum.textContent='KILN 0'+(i+1);
lvName.textContent=LEVELS[i].name;
lvFlavor.textContent=LEVELS[i].flavor;
levelCard.hidden=false;
introT=1.6; state='intro';
}
function endIntro(){ levelCard.hidden=true; state='serve'; }
function startGame(){
ovTitle.hidden=true; ovEnd.hidden=true; ovPause.hidden=true; levelCard.hidden=true;
score=0; displayScore=0; lives=3; combo=0; maxCombo=0; heat=0; shownMult=1;
bricksBroken=0; timePlayed=0; parts.length=0;
renderLives();
loadLevel(0);
SFX.tone({f0:300,f1:520,dur:0.12,type:'triangle',vol:0.16});
}
function launchAngle(){ return clamp(paddle.vx*0.0007,-0.35,0.35)+Math.sin(T*1.2)*0.14; }
function launchBall(){
const b=balls[0]; if(!b||!b.stuck) return;
const a=launchAngle()+rnd(-0.04,0.04);
b.vx=Math.sin(a)*b.speed; b.vy=-Math.cos(a)*b.speed;
b.stuck=false; state='play';
addRing(b.x,b.y); SFX.launch(); addShake(0.05); paddle.squash=0.8;
}
/* ================= brick damage ================= */
function makeCrack(b){
const pts=[]; let x,y,ang;
const side=(Math.random()*4)|0;
if(side===0){x=rnd(0,b.w);y=0;ang=Math.PI/2;}
else if(side===1){x=b.w;y=rnd(0,b.h);ang=Math.PI;}
else if(side===2){x=rnd(0,b.w);y=b.h;ang=-Math.PI/2;}
else {x=0;y=rnd(0,b.h);ang=0;}
ang+=rnd(-0.7,0.7); pts.push([x,y]);
let cx=x, cy=y;
const steps=2+(Math.random()*2|0);
for(let i=0;i<steps;i++){ const len=rnd(5,11); ang+=rnd(-0.8,0.8);
cx=clamp(cx+Math.cos(ang)*len,1,b.w-1); cy=clamp(cy+Math.sin(ang)*len,1,b.h-1);
pts.push([cx,cy]); }
return pts;
}
function hitBrick(ball,b,attract){
if(!b.alive || b.spawn<1 || b.lastHit===frame) return;
b.lastHit=frame; b.hp--; b.flash=1; b.pop=1;
const cx=b.x+b.w/2, cy=b.y+b.h/2;
if(b.hp>0){
b.cracks.push(makeCrack(b));
spawnSparks(cx,cy,4,150,false);
if(!attract){ addShake(0.05); heat=Math.min(100,heat+5); SFX.brickHit(b.hp); }
} else destroyBrick(b,attract);
}
function destroyBrick(b,attract){
b.alive=false;
const cx=b.x+b.w/2, cy=b.y+b.h/2, tier=b.maxHp;
spawnShards(b);
spawnSparks(cx,cy,10+tier*4,260+tier*40,tier>=2);
addRing(cx,cy);
if(attract){ attractQueue.push({b,t:2.2+rnd(1.5)}); return; }
bricksBroken++; combo++; maxCombo=Math.max(maxCombo,combo);
heat=Math.min(100,heat+13);
const m=multFromHeat(), pts=BRICK_VALUE[tier]*m;
score+=pts; bump();
addText('+'+pts+(m>1?' ×'+m:''), cx, cy-6, multColor(m));
addShake(0.1+tier*0.05);
freeze=Math.min(0.075,0.028+combo*0.003); // hit-stop grows with the chain
flashSet(255,150,60,0.05+tier*0.015);
SFX.brickBreak(combo);
for(const n of bricks) // heat ripple on neighbours
if(n.alive && n!==b && Math.abs(n.row-b.row)<=1 && Math.abs(n.col-b.col)<=1)
n.flash=Math.max(n.flash,0.35);
if(Math.random()<0.16 && powerups.length<3)
powerups.push({x:cx,y:cy,vy:120,t:0,type:'WFFMMM L'.replace(/ /g,'')[ (Math.random()*8)|0 ]});
if(!bricks.some(x=>x.alive) && state==='play') levelClear();
}
/* ================= power-ups ================= */
function applyPower(type){
addShake(0.08); SFX.powerup(); addRing(paddle.x,paddle.y-6);
spawnSparks(paddle.x,paddle.y-8,10,180,false);
if(type==='W'){ paddle.wideT=10; addText('WIDE FORGE',paddle.x,paddle.y-28,'237,226,206'); }
else if(type==='F'){ balls.forEach(b=>b.fire=6); flashSet(255,90,31,0.12);
addText('FIREBALL',paddle.x,paddle.y-28,'255,90,31'); }
else if(type==='M'){
const cur=balls.filter(b=>!b.stuck), add=[];
for(const b of cur){ for(const da of [-0.38,0.38]){
if(balls.length+add.length>=6) break;
const a=Math.atan2(b.vy,b.vx)+da;
add.push({x:b.x,y:b.y,r:7,vx:Math.cos(a)*b.speed,vy:Math.sin(a)*b.speed,
stuck:false,speed:b.speed,fire:0,trail:[],deform:0,defAxis:0});
}}
balls.push(...add);
addText('SPLIT ×'+balls.length,paddle.x,paddle.y-28,'255,162,74');
}
else if(type==='L'){ if(lives<5){lives++; renderLives();}
addText('+1 EMBER',paddle.x,paddle.y-28,'255,90,31'); SFX.life(); }
}
function updatePowerups(dt){
for(let i=powerups.length-1;i>=0;i--){ const p=powerups[i];
p.t+=dt; p.y+=p.vy*dt; p.x+=Math.sin(p.t*3)*10*dt;
if(p.y+9>paddle.y && p.y<paddle.y+paddle.h+12 && Math.abs(p.x-paddle.x)<paddle.w/2+12){
applyPower(p.type); powerups.splice(i,1); continue; }
if(p.y>H+20) powerups.splice(i,1);
}
}
/* ================= loss / clear / end ================= */
function loseLife(){
lives--; renderLives();
heat=0; combo=0; shownMult=1;
flashSet(255,60,30,0.3); addShake(0.55); SFX.lifeLost(); slowmo(0.35,0.55);
if(lives<=0) showEnd(false);
else { state='respawn'; respawnT=1.05; }
}
function levelClear(){
state='levelclear'; clearT=2.0; slowmo(0.3,0.8);
addShake(0.4); flashSet(255,170,80,0.16); SFX.clear();
const bonus=250+level*150; score+=bonus; bump();
addText('KILN CLEARED +'+bonus, W/2, H*0.42,'237,226,206');
powerups.length=0; paddle.wideT=0;
}
function showEnd(win){
state=win?'victory':'gameover';
endTitle.textContent=win?'THE FORGE IS YOURS':'THE FIRE DIED';
endSub.textContent=win?'ALL SIX KILNS CRACKED OPEN'
:`COLD AT KILN 0${level+1} · ${LEVELS[level].name}`;
endScore.textContent=fmt(score);
stChain.textContent='×'+Math.max(maxCombo,0);
stBricks.textContent=fmt(bricksBroken);
const m=Math.floor(timePlayed/60), s=Math.floor(timePlayed%60);
stTime.textContent=m+':'+String(s).padStart(2,'0');
let isNew=false;
if(score>best){ best=score; isNew=true;
try{ localStorage.setItem('emberbreak-best',best); }catch(e){} }
hudBest.textContent=fmt(best);
stBestLabel.textContent=isNew?'NEW BEST':'BEST';
stBest.textContent=fmt(best);
stBest.className=isNew?'newbest':'';
ovEnd.hidden=false;
win?SFX.win():SFX.over();
}
/* ================= updates ================= */
function updatePaddle(dt){
const dir=(keys.R?1:0)-(keys.L?1:0);
if(dir!==0){ mouseActive=false; paddle.vx=clamp(paddle.vx+dir*4600*dt,-760,760); }
else if(!mouseActive){ paddle.vx*=Math.pow(0.0002,dt); if(Math.abs(paddle.vx)<4) paddle.vx=0; }
if(mouseActive){
const half=paddle.w/2, target=clamp(mouseX,FX0+half,FX1-half);
const nx=lerp(paddle.x,target,1-Math.pow(0.00008,dt));
paddle.vx=(nx-paddle.x)/Math.max(dt,1e-4); paddle.x=nx;
} else paddle.x+=paddle.vx*dt;
const half=paddle.w/2;
if(paddle.x<FX0+half){paddle.x=FX0+half;paddle.vx=0;}
if(paddle.x>FX1-half){paddle.x=FX1-half;paddle.vx=0;}
const tw=paddle.wideT>0?176:118;
paddle.w+=(tw-paddle.w)*Math.min(1,dt*10);
if(paddle.wideT>0) paddle.wideT-=dt;
paddle.squash=Math.max(0,paddle.squash-dt*6);
paddle.glow=Math.max(0,paddle.glow-dt*1.6);
paddle.tilt=clamp(paddle.vx*0.00007,-0.05,0.05);
}
function paddleHit(b,attract){
const rel=clamp((b.x-paddle.x)/(paddle.w/2),-1,1);
const ang=rel*1.02+clamp(paddle.vx*0.0007,-0.3,0.3);
b.speed=Math.min(b.speed+3, levelSpeed+150);
b.vx=Math.sin(ang)*b.speed; b.vy=-Math.cos(ang)*b.speed;
b.y=paddle.y-b.r-0.5;
b.deform=1; b.defAxis=Math.PI/2;
paddle.squash=1; paddle.glow=1;
addRing(b.x,paddle.y-2);
if(!attract){ SFX.paddle(b.speed); addShake(0.04); combo=0; heat*=0.55; }
}
function wallFx(b,attract,vertical){
b.deform=1; b.defAxis=vertical?Math.PI/2:0;
if(!attract) SFX.wall();
spawnSparks(b.x,b.y,2,110,false);
}
function resolveBallBrick(ball,b){
const cx=clamp(ball.x,b.x,b.x+b.w), cy=clamp(ball.y,b.y,b.y+b.h);
const dx=ball.x-cx, dy=ball.y-cy, d2=dx*dx+dy*dy;
if(d2>ball.r*ball.r) return false;
let nx,ny;
if(d2>0.0001){ const d=Math.sqrt(d2); nx=dx/d; ny=dy/d;
const pen=ball.r-d; ball.x+=nx*pen; ball.y+=ny*pen; }
else{
const l=ball.x-b.x, r_=b.x+b.w-ball.x, t=ball.y-b.y, bo=b.y+b.h-ball.y;
const m=Math.min(l,r_,t,bo);
if(m===l){nx=-1;ny=0;ball.x=b.x-ball.r;}
else if(m===r_){nx=1;ny=0;ball.x=b.x+b.w+ball.r;}
else if(m===t){nx=0;ny=-1;ball.y=b.y-ball.r;}
else {nx=0;ny=1;ball.y=b.y+b.h+ball.r;}
}
const dot=ball.vx*nx+ball.vy*ny;
if(dot<0){ ball.vx-=2*dot*nx; ball.vy-=2*dot*ny;
ball.deform=1; ball.defAxis=Math.atan2(ny,nx); }
return true;
}
function updateBalls(dt,opts){
for(let i=balls.length-1;i>=0;i--){ const b=balls[i];
if(b.stuck){ b.x=paddle.x; b.y=paddle.y-b.r-3; continue; }
if(b.fire>0) b.fire-=dt;
const steps=Math.max(1,Math.ceil((b.speed*dt)/5)), sdt=dt/steps;
for(let s=0;s<steps;s++){
b.x+=b.vx*sdt; b.y+=b.vy*sdt;
if(b.x-b.r<FX0){b.x=FX0+b.r; if(b.vx<0){b.vx=-b.vx; wallFx(b,opts.attract,false);}}
if(b.x+b.r>FX1){b.x=FX1-b.r; if(b.vx>0){b.vx=-b.vx; wallFx(b,opts.attract,false);}}
if(b.y-b.r<FY0){b.y=FY0+b.r; if(b.vy<0){b.vy=-b.vy; wallFx(b,opts.attract,true);}}
if(opts.bottom && b.y+b.r>H-3){b.y=H-3-b.r; if(b.vy>0){b.vy=-b.vy; spawnSparks(b.x,H-4,3,120,false);}}
if(!opts.noPaddle && b.vy>0 && b.y+b.r>paddle.y && b.y-b.r<paddle.y+paddle.h+6
&& Math.abs(b.x-paddle.x)<paddle.w/2+b.r) paddleHit(b,opts.attract);
for(const br of bricks){
if(!br.alive) continue;
if(b.fire>0){ // fireball pierces straight through
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;
if(dx*dx+dy*dy<=b.r*b.r) hitBrick(b,br,opts.attract);
} else if(resolveBallBrick(b,br)) hitBrick(b,br,opts.attract);
}
}
if(Math.abs(b.vy)<b.speed*0.12){ // never let rallies go dead-flat
const sgn=Math.sign(b.vy)||-1;
b.vy=sgn*b.speed*0.12;
const m=Math.hypot(b.vx,b.vy); b.vx*=b.speed/m; b.vy*=b.speed/m;
}
b.deform=Math.max(0,b.deform-dt*7);
b.trail.push({x:b.x,y:b.y,l:1});
if(b.trail.length>24) b.trail.splice(0,b.trail.length-24);
for(const t of b.trail) t.l-=dt*4.5;
if(b.fire>0 && Math.random()<dt*30)
parts.push({k:'s',x:b.x+rnd(-3,3),y:b.y+rnd(-3,3),vx:rnd(-30,30),vy:rnd(20,70),life:rnd(0.3,0.6),hot:true});
if(!opts.bottom && b.y-b.r>H+8) balls.splice(i,1);
}
if(!opts.attract && state==='play' && balls.length===0) loseLife();
}
function updateBricksAnim(dt){
if(dt>0) levelTime+=dt;
for(const b of bricks){
if(b.spawnDelay>0) b.spawnDelay-=dt;
else if(b.spawn<1) b.spawn=Math.min(1,b.spawn+dt/0.32);
if(b.flash>0) b.flash=Math.max(0,b.flash-dt*4.5);
if(b.pop>0) b.pop=Math.max(0,b.pop-dt*5.5);
}
for(const mr of movingRows){
let o=Math.sin(levelTime*mr.spd*1.5+mr.phase)*mr.amp;
o=clamp(o,mr.lo,mr.hi);
for(const b of mr.rowBricks) b.x=b.x0+o;
}
}
function updateMotes(rdt){
for(const m of motes){
m.y-=m.vy*rdt; m.x=m.x0+Math.sin(T*0.7+m.ph)*m.amp;
if(m.y<-6){ m.y=H+rnd(4,40); m.x0=rnd(FX0+6,FX1-6); m.x=m.x0; }
}
}
function update(dt,rdt){
frame++;
updateHUD(rdt);
updateMotes(rdt);
const active=['title','intro','serve','play','respawn','levelclear'].includes(state);
updateBricksAnim(active?dt:0);
if(state!=='paused') updateParts(active?dt:rdt*0.6);
switch(state){
case 'title':{
const b=balls[0];
if(b){
const target=clamp(b.x+Math.sin(T*0.9)*26, FX0+paddle.w/2, FX1-paddle.w/2);
const old=paddle.x;
paddle.x+=(target-paddle.x)*(1-Math.pow(0.002,dt));
paddle.vx=(paddle.x-old)/Math.max(dt,1e-4);
}
updateBalls(dt,{bottom:true,attract:true});
for(let i=attractQueue.length-1;i>=0;i--){ const q=attractQueue[i]; q.t-=dt;
if(q.t<=0){ const b=q.b; b.alive=true; b.hp=b.maxHp; b.cracks.length=0;
b.spawn=0; b.flash=0.6; attractQueue.splice(i,1); } }
break; }
case 'intro':
updatePaddle(dt); introT-=rdt; if(introT<=0) endIntro();
break;
case 'serve':
updatePaddle(dt); updatePowerups(dt); heat=Math.max(0,heat-dt*6.5);
break;
case 'play':
timePlayed+=dt;
updatePaddle(dt); updateBalls(dt,{}); updatePowerups(dt);
heat=Math.max(0,heat-dt*6.5);
break;
case 'respawn':
updatePaddle(dt); updatePowerups(dt);
respawnT-=rdt;
if(respawnT<=0){ balls=[newBall()]; state='serve'; }
break;
case 'levelclear':
updatePaddle(dt); updateBalls(dt,{});
clearT-=rdt;
if(clearT<=0){
if(level+1>=LEVELS.length) showEnd(true);
else loadLevel(level+1);
}
break;
}
}
/* ================= HUD ================= */
function renderLives(){
const slots=Math.max(3,lives); let h='';
for(let i=0;i<slots;i++) h+=`<span class="life${i<lives?'':' dead'}"></span>`;
hudLives.innerHTML=h;
}
function updateHUD(rdt){
displayScore+=(score-displayScore)*Math.min(1,rdt*10);
if(Math.abs(score-displayScore)<0.6) displayScore=score;
hudScore.textContent=fmt(displayScore);
scoreBump=Math.max(0,scoreBump-rdt*3);
hudScore.style.transform=`scale(${1+scoreBump*0.06})`;
heatFill.style.width=Math.round(heat)+'%';
const m=multFromHeat();
if(m>shownMult && state==='play'){
addText('CHAIN ×'+m, W/2, H*0.52, m>=4?'255,162,74':'255,90,31');
SFX.tone({f0:330+m*110,f1:495+m*110,dur:0.12,type:'triangle',vol:0.2});
}
shownMult=m;
hudMult.textContent='×'+m;
hudMult.className='mult'+(m>=3?' blaze':m>=2?' hot':'');
heatFill.style.background=m>=4?'#FFA24A':m>=3?'#FF5A1F':m>=2?'#C77B3A':'#8A7A64';
}
/* ================= render ================= */
function drawMotes(){
const n=Math.round(6+heat*0.5);
for(let i=0;i<n && i<64;i++){ const m=motes[i];
const a=m.a*(0.6+0.4*Math.sin(T*2+m.ph));
ctx.fillStyle=`rgba(255,150,70,${a})`;
ctx.beginPath(); ctx.arc(m.x,m.y,0.9,0,TAU); ctx.fill();
}
}
function drawBricks(){
for(const b of bricks){
if(!b.alive||b.spawn<=0) continue;
const e=easeOutCubic(b.spawn);
const yoff=-(1-e)*16;
const sc=(0.85+0.15*e)*(1+0.055*b.pop);
ctx.save();
ctx.translate(b.x+b.w/2, b.y+b.h/2+yoff);
ctx.scale(sc,sc); ctx.globalAlpha=e;
rr(ctx,-b.w/2,-b.h/2,b.w,b.h,4);
ctx.fillStyle=b.col; ctx.fill();
ctx.fillStyle='rgba(255,244,224,0.08)';
rr(ctx,-b.w/2+2,-b.h/2+2,b.w-4,3,1.5); ctx.fill();
ctx.fillStyle='rgba(0,0,0,0.28)';
rr(ctx,-b.w/2+2,b.h/2-4.5,b.w-4,3,1.5); ctx.fill();
const pulse=0.5+0.5*Math.sin(T*6+b.seed*7);
if(b.hp===1){ // nearly-shattered bricks glow
ctx.fillStyle=`rgba(255,110,40,${0.22+0.3*pulse})`;
rr(ctx,-b.w/2+5,-b.h/2+5,b.w-10,b.h-10,2.5); ctx.fill();
}
if(b.cracks.length){
ctx.lineWidth=1.2;
ctx.strokeStyle=b.hp===1?`rgba(255,150,60,${0.5+0.4*pulse})`:'rgba(22,16,11,0.55)';
ctx.beginPath();
for(const c of b.cracks){
ctx.moveTo(c[0][0]-b.w/2,c[0][1]-b.h/2);
for(let i=1;i<c.length;i++) ctx.lineTo(c[i][0]-b.w/2,c[i][1]-b.h/2);
}
ctx.stroke();
}
if(b.flash>0){
ctx.fillStyle=`rgba(255,236,210,${b.flash*0.7})`;
rr(ctx,-b.w/2,-b.h/2,b.w,b.h,4); ctx.fill();
}
ctx.restore();
}
}
function drawPowerups(){
for(const p of powerups){
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(Math.sin(p.t*5)*0.14);
const c=POWS[p.type];
rr(ctx,-14,-9,28,18,9); ctx.fillStyle=c.bg; ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.35)'; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle=c.fg; ctx.font='700 11px '+FONT;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(c.label,0,0.5);
ctx.restore();
}
}
function drawPaddle(){
ctx.save();
ctx.translate(paddle.x, paddle.y+paddle.h/2);
ctx.rotate(paddle.tilt);
ctx.scale(1+0.33*paddle.squash, 1-0.38*paddle.squash);
const w=paddle.w, h=paddle.h;
rr(ctx,-w/2,-h/2,w,h,7); ctx.fillStyle='#E8DCC6'; ctx.fill();
ctx.fillStyle='rgba(255,252,244,0.5)';
rr(ctx,-w/2+3,-h/2+1.5,w-6,2.5,1.5); ctx.fill();
ctx.fillStyle='#FF5A1F'; // the paddle's hearth line
rr(ctx,-w/2+3,h/2-3.5,w-6,3.5,2); ctx.fill();
if(paddle.glow>0){
ctx.globalCompositeOperation='lighter'; ctx.globalAlpha=0.35*paddle.glow;
rr(ctx,-w/2,h/2-6,w,6,3); ctx.fillStyle='#FF5A1F'; ctx.fill();
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
}
ctx.restore();
if(paddle.wideT>0){
const blink=paddle.wideT<2?Math.sin(T*16)>0:true;
if(blink){ const frac=clamp(paddle.wideT/10,0,1);
ctx.fillStyle='rgba(255,90,31,0.55)';
ctx.fillRect(paddle.x-paddle.w*frac/2, paddle.y+paddle.h+5, paddle.w*frac, 2.5); }
}
}
function drawBalls(){
if(state==='intro') return;
for(const b of balls){
const fk=b.fire>0?clamp(b.fire/1.2,0,1):0;
if(b.trail.length){
ctx.globalCompositeOperation='lighter';
for(const t of b.trail){ if(t.l<=0) continue;
const a=t.l*t.l*(0.10+0.30*fk);
ctx.fillStyle=fk>0?`rgba(255,110,40,${a})`:`rgba(235,225,205,${a})`;
ctx.beginPath(); ctx.arc(t.x,t.y,b.r*(0.35+0.65*t.l),0,TAU); ctx.fill(); }
ctx.globalCompositeOperation='source-over';
}
if(fk>0 && (b.fire>1.4||Math.sin(T*26)>-0.2)){
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(255,90,30,${0.16*fk})`;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r*2.6,0,TAU); ctx.fill();
ctx.fillStyle=`rgba(255,170,70,${0.22*fk})`;
ctx.beginPath(); ctx.arc(b.x,b.y,b.r*1.5,0,TAU); ctx.fill();
ctx.globalCompositeOperation='source-over';
}
ctx.save(); ctx.translate(b.x,b.y); ctx.rotate(b.defAxis);
ctx.scale(1+0.38*b.deform, 1-0.32*b.deform); // squash & stretch on impact
ctx.fillStyle='#F6EFE0';
ctx.beginPath(); ctx.arc(0,0,b.r,0,TAU); ctx.fill();
ctx.fillStyle=fk>0?'#FF7A2E':'rgba(255,122,46,0.45)';
ctx.beginPath(); ctx.arc(0,0,b.r*0.45,0,TAU); ctx.fill();
ctx.restore();
}
if(state==='serve'){
const b=balls[0];
if(b){ const a=launchAngle();
ctx.strokeStyle='rgba(237,226,206,0.3)'; ctx.lineWidth=1.5;
ctx.setLineDash([2,7]); ctx.beginPath();
ctx.moveTo(b.x+Math.sin(a)*10, b.y-Math.cos(a)*10);
ctx.lineTo(b.x+Math.sin(a)*78, b.y-Math.cos(a)*78);
ctx.stroke(); ctx.setLineDash([]); }
}
}
function drawParts(){
for(const p of parts){ if(p.k!=='d') continue;
ctx.save(); ctx.globalAlpha=clamp(p.life*1.6,0,1);
ctx.translate(p.x,p.y); ctx.rotate(p.rot);
ctx.fillStyle=p.col; ctx.fillRect(-p.w/2,-p.h/2,p.w,p.h); ctx.restore(); }
for(const p of parts){ if(p.k!=='r') continue;
ctx.strokeStyle=`rgba(255,120,50,${p.life*0.55})`; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,TAU); ctx.stroke(); }
ctx.globalCompositeOperation='lighter'; ctx.lineCap='round';
for(const p of parts){ if(p.k!=='s') continue;
const a=clamp(p.life,0,1);
ctx.strokeStyle=p.hot?`rgba(255,210,120,${a})`:`rgba(255,120,50,${a*0.9})`;
ctx.lineWidth=2; ctx.beginPath();
ctx.moveTo(p.x,p.y); ctx.lineTo(p.x-p.vx*0.03,p.y-p.vy*0.03); ctx.stroke(); }
ctx.globalCompositeOperation='source-over';
ctx.font='700 13px '+FONT; ctx.textAlign='center'; ctx.textBaseline='middle';
for(const p of parts){ if(p.k!=='t') continue;
ctx.fillStyle=`rgba(${p.col},${clamp(p.life*1.6,0,1)})`;
ctx.fillText(p.str,p.x,p.y); }
}
function drawHazard(){
let danger=0;
for(const b of balls) if(!b.stuck) danger=Math.max(danger,clamp((b.y-(H-170))/170,0,1));
let a=0.1+0.85*danger;
if(state==='respawn') a=Math.max(a,0.45+0.25*Math.sin(T*10));
ctx.fillStyle=`rgba(255,90,31,${a})`;
ctx.fillRect(FX0,H-2.5,FX1-FX0,2.5);
}
function render(){
ctx.setTransform(RS,0,0,RS,0,0);
ctx.fillStyle='#100D0A'; ctx.fillRect(0,0,W,H);
ctx.fillStyle=grain; ctx.fillRect(0,0,W,H);
drawMotes();
const m=shakeTrauma*shakeTrauma;
if(m>0.0005){
const dx=(Math.random()*2-1)*m*11, dy=(Math.random()*2-1)*m*9, rot=(Math.random()*2-1)*m*0.016;
ctx.translate(W/2+dx,H/2+dy); ctx.rotate(rot); ctx.translate(-W/2,-H/2);
}
ctx.strokeStyle='rgba(237,226,206,0.07)'; ctx.lineWidth=1;
ctx.strokeRect(3.5,3.5,W-7,H-7);
drawBricks();
drawPowerups();
drawPaddle();
drawBalls();
drawParts();
drawHazard();
ctx.setTransform(RS,0,0,RS,0,0);
if(flash.a>0){
ctx.fillStyle=`rgba(${flash.r},${flash.g},${flash.b},${Math.min(0.5,flash.a)})`;
ctx.fillRect(0,0,W,H);
}
}
/* ================= input ================= */
function primaryAction(){
if(state==='title') startGame();
else if(state==='intro') endIntro();
else if(state==='serve') launchBall();
else if(state==='paused') togglePause();
}
function togglePause(){
if(state==='paused'){ state=prevState; ovPause.hidden=true; }
else if(['play','serve','respawn','levelclear'].includes(state)){
prevState=state; state='paused'; ovPause.hidden=false; }
}
function syncSound(){ $('icoOn').hidden=SFX.muted; $('icoOff').hidden=!SFX.muted; }
window.addEventListener('keydown',e=>{
SFX.ensure();
if(e.code==='ArrowLeft'||e.code==='ArrowRight'||e.code==='Space') e.preventDefault();
if(e.repeat) return;
switch(e.code){
case 'ArrowLeft': case 'KeyA': keys.L=true; mouseActive=false; break;
case 'ArrowRight': case 'KeyD': keys.R=true; mouseActive=false; break;
case 'Space': case 'Enter': primaryAction(); break;
case 'KeyP': case 'Escape': togglePause(); break;
case 'KeyM': SFX.toggle(); syncSound(); break;
case 'KeyR': if(state==='gameover'||state==='victory') startGame(); break;
}
});
window.addEventListener('keyup',e=>{
if(e.code==='ArrowLeft'||e.code==='KeyA') keys.L=false;
if(e.code==='ArrowRight'||e.code==='KeyD') keys.R=false;
});
field.addEventListener('pointermove',e=>{
const r=canvas.getBoundingClientRect();
mouseX=(e.clientX-r.left)/r.width*W; mouseActive=true;
});
field.addEventListener('pointerdown',e=>{
SFX.ensure();
if(e.target.closest('button')) return;
const r=canvas.getBoundingClientRect();
mouseX=(e.clientX-r.left)/r.width*W; mouseActive=true;
primaryAction();
});
$('btnSound').addEventListener('click',e=>{ SFX.ensure(); SFX.toggle(); syncSound(); e.currentTarget.blur(); });
$('btnRestart').addEventListener('click',e=>{ startGame(); e.currentTarget.blur(); });
document.addEventListener('visibilitychange',()=>{
if(document.hidden && ['play','serve','respawn','levelclear'].includes(state)) togglePause();
});
/* ================= sizing & loop ================= */
function fit(){
const dpr=Math.min(2,window.devicePixelRatio||1);
const availH=window.innerHeight-hudEl.offsetHeight-footEl.offsetHeight-46;
const availW=Math.min(window.innerWidth-24,960);
const sc=Math.max(0.3,Math.min(availW/W,availH/H));
const cw=Math.round(W*sc), ch=Math.round(H*sc);
canvas.style.width=cw+'px'; canvas.style.height=ch+'px';
stage.style.width=cw+'px';
canvas.width=Math.round(cw*dpr); canvas.height=Math.round(ch*dpr);
RS=canvas.width/W;
}
window.addEventListener('resize',fit);
let last=performance.now();
function loop(now){
requestAnimationFrame(loop);
let rdt=(now-last)/1000; last=now;
if(rdt<0) rdt=0; if(rdt>1/25) rdt=1/25;
T+=rdt;
flash.a=Math.max(0,flash.a-rdt*2.4);
shakeTrauma=Math.max(0,shakeTrauma-rdt*2.3);
if(freeze>0){ freeze-=rdt; render(); return; } // hit-stop: the world holds still
if(tsTimer>0) tsTimer-=rdt;
else timeScale=Math.min(1,timeScale+rdt*2.4);
update(rdt*timeScale,rdt);
render();
}
/* ================= boot ================= */
renderLives();
hudBest.textContent=fmt(best);
if(best>0){ const tb=$('titleBest'); tb.hidden=false; tb.textContent='BEST '+fmt(best); }
setupLevel(0);
balls=[freeBall()];
state='title';
syncSound();
fit();
requestAnimationFrame(t=>{ last=t; requestAnimationFrame(loop); });
})();
</script>
</body>
</html>

