How to Create a Brick Breaker Game in HTML, CSS and JavaScript

How to Create a Brick Breaker Game in HTML, CSS and JavaScript

Brick breaker is the game everybody assumes they can build in an afternoon. A ball, a paddle, some rectangles β€” how hard can it be? Then the ball starts tunnelling through bricks sideways, the paddle refuses to follow the mouse, and the whole thing feels like a physics simulation with a grudge.

I built Stellix Breaker recently and hit almost every one of those walls. Here is the honest version of how it came together, including the two bugs that cost me the most time, because those are the parts nobody puts in tutorials.

Work in fixed logical coordinates

This is the single decision that saves the most pain later.

Do not write the game in screen pixels. Pick a fixed logical size β€” mine is 800 by 560 β€” and write every position, size and speed in those units. Then scale the canvas drawing context so that logical space fills whatever real size the canvas happens to be.

The payoff is that the game behaves identically everywhere. A ball moving at 330 units per second crosses the field in the same time on a phone as on a desktop. Without this, physics tuned on your laptop turns into a slideshow on a small screen, or an unplayable blur on a large one.

The rule that follows: never let JavaScript decide the canvas size by measuring the page. I tried that, and it created a feedback loop β€” the canvas height changed the page height, which changed the space available, which changed the canvas height. On some screens the board shrank to almost nothing. The fix is to let CSS own the layout (width one hundred percent, a fixed aspect ratio, and a maximum width derived from the viewport height) and let JavaScript do exactly one job: match the canvas bitmap to whatever size CSS ended up choosing. A ResizeObserver watching the canvas keeps the two in sync forever, through font loading, rotation, and zoom.

Build levels as data, not drawings

Bricks live on a grid: eleven columns, a fixed brick size, and a gap. A level is just a rule about which grid cells get a brick and how many hits each one takes.

That makes twelve levels cheap. Plain rows. A pyramid where each row is narrower. A checkerboard using whether row plus column is even. A tunnel with hollow middle. A diamond using Manhattan distance from the centre. A wave using a sine of the column. A fortress that is only edges. Each one is a couple of lines of condition, and each one plays completely differently.

Multi-hit bricks add depth for free. Store both current hit points and the original value. Colour by remaining hits β€” mine go mint for one, amber for two, magenta for three β€” and draw small pips on the brick showing how many hits are left. Players learn the colour code in about five seconds without any explanation.

Beyond the twelfth level, reuse the layouts but raise every brick’s toughness by one. Infinite content, no extra design work.

Make collisions behave

Here is the bug that ruins most home-made versions: the ball hits a brick from the side and gets reflected vertically, so it slides along the wall breaking a whole row like a chainsaw.

The fix is to decide which axis to reflect on by measuring how far the ball has penetrated on each axis, then bouncing on the axis with the smaller overlap. A ball that has barely crossed the brick’s left edge but is deep in it vertically clearly arrived from the side, so the horizontal velocity flips. Then push the ball back out by that overlap so it cannot get stuck inside the brick on the next frame.

Also break out of the collision loop after the first brick you hit. Resolving several at once in one frame produces bounces that look like glitches.

The paddle is the whole game

Never bounce the ball off the paddle at a mirrored angle. That produces one predictable trajectory and no skill.

Instead, measure where on the paddle the ball landed as a value from minus one at the left tip to plus one at the right. Turn that into an angle β€” straight up at the centre, up to about sixty degrees off vertical at the tips β€” and set the ball’s velocity from that angle at the current speed. Now the player aims with the paddle, and hitting with the edge to reach an awkward corner becomes a real technique.

Keep the speed constant when you do this. Set direction from the angle and magnitude from the level’s speed, so bounces never accelerate the ball accidentally.

Controls that actually work on every device

This is where I lost the most time, and both problems are worth knowing about in advance.

First: track pointer movement on the whole window, not just on the canvas. My first version only listened on the canvas, so the paddle froze the moment the cursor drifted slightly above or below the play area. Listening on the window and mapping the pointer’s horizontal position into logical space fixes it, and the paddle follows the mouse everywhere on the page.

Second β€” and this one had me completely stuck β€” I used pointer capture on the game container so a drag would keep working if a finger slid outside it. That works beautifully for dragging, and it silently broke every button. When a container captures the pointer, the browser sends the follow-up events to the container instead of the element under the finger, and the click event on child buttons never fires. My start button looked perfectly fine and did absolutely nothing.

The fix is a guard: before capturing, check whether the press landed on a button or an overlay, and if it did, do nothing and let the press through. Then bind buttons to both click and touch-end, with a short lock so a single tap cannot fire the handler twice on mobile, where browsers often send both.

Add keyboard control too β€” arrow keys with the movement applied inside the game loop rather than a separate timer, so it stays smooth β€” and let Enter or space start the game and advance between levels. Some people never touch the mouse.

Power-ups, particles and feel

Four power-ups are plenty: a wider paddle, a slow ball, an extra life, and multi-ball that splits your ball into three by cloning it at slightly different angles. Drop one from roughly one in six destroyed bricks, let it fall, and check whether it overlaps the paddle.

Every one of them needs to be readable in a glance, so give each a colour and a tiny drawn glyph, and print a short legend under the game.

For feel, two cheap tricks do most of the work. Spawn a dozen small coloured squares when a brick dies, push them outward with a random speed, add gravity to their vertical velocity each frame and fade them out. And shake the canvas β€” offset the whole drawing by a couple of random pixels and let that offset decay β€” on every brick death, harder when you lose a life.

Sound that ships in the file

You do not need audio files. Oscillators and filtered noise cover everything.

What matters more than the individual sounds is that they carry information. In Stellix Breaker the paddle hit changes pitch depending on how far from the centre you struck, so you can hear that you caught it on the edge. Side walls and the ceiling have different tones. A combo counter climbs a musical scale as long as you keep breaking bricks without missing, and resets when you do. The last life gets a low warning tone. None of that is decoration; it is feedback.

Create the audio context on the first interaction, since browsers block audio before that, make every sound function exit quietly if the context is missing, and always ship a mute button.

Before you call it done

Cap the frame delta time, or a moment of lag will let the ball teleport through a brick. Pause automatically when the tab loses focus. Make touch targets at least forty-four pixels. Collapse the score row and the legend to two columns on narrow phones, and hide the non-essentials in landscape where vertical space is scarce.

Then play it for ten minutes straight. Every remaining problem shows up in those ten minutes, and none of them show up in code review.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<title>Stellix Breaker β€” Coding Stellix</title>
<meta name="description" content="Stellix Breaker β€” a brick breaker arcade game by Coding Stellix. Twelve hand-built levels, falling power-ups, multi-ball, particle debris and a full WebAudio sound engine in one HTML file." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@700&display=swap" rel="stylesheet">
<style>
:root{
  /* --- new neon candy palette --- */
  --bg:#0a0620;
  --deep:#150c33;
  --panel:rgba(255,255,255,.055);
  --stroke:rgba(255,255,255,.12);
  --ink:#f3ecff;
  --muted:#9b8fc4;
  --mint:#2ff3c8;
  --magenta:#ff3d9a;
  --amber:#ffcf3d;
  --sky:#6bb8ff;
  --shadow:0 26px 70px rgba(0,0,0,.6);
}
html[data-theme="light"]{
  --bg:#f4f1ff;
  --deep:#e8e3fb;
  --panel:rgba(24,10,60,.05);
  --stroke:rgba(24,10,60,.12);
  --ink:#170c33;
  --muted:#5f5486;
  --shadow:0 24px 60px rgba(70,40,140,.18);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{overscroll-behavior:none}
body{
  font-family:'Jost',sans-serif;background:var(--bg);color:var(--ink);
  min-height:100dvh;display:flex;flex-direction:column;align-items:center;
  padding:12px 12px calc(20px + env(safe-area-inset-bottom));
  overflow-x:hidden;transition:background .35s ease,color .35s ease;
}
body::before{
  content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
  background:
    radial-gradient(58vw 46vw at 4% -6%, rgba(255,61,154,.24), transparent 62%),
    radial-gradient(52vw 42vw at 100% 4%, rgba(47,243,200,.20), transparent 60%),
    radial-gradient(58vw 46vw at 50% 114%, rgba(255,207,61,.16), transparent 64%);
}
html[data-theme="light"] body::before{opacity:.5}
.wrap{position:relative;z-index:1;width:100%;max-width:820px;display:flex;flex-direction:column;gap:12px}

/* ---------- header ---------- */
header{display:flex;align-items:center;justify-content:space-between;gap:10px}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.mark{width:38px;height:38px;border-radius:12px;flex:none;display:grid;place-items:center;
  background:linear-gradient(140deg,var(--magenta),var(--amber));box-shadow:0 8px 26px rgba(255,61,154,.4)}
.mark svg{width:20px;height:20px}
.brand b{display:block;font-size:.94rem;font-weight:600;line-height:1.1;white-space:nowrap}
.brand small{display:block;font-size:.65rem;letter-spacing:.24em;text-transform:uppercase;color:var(--muted)}
.hbtns{display:flex;gap:8px;flex:none}
.icon-btn{width:44px;height:44px;border-radius:13px;border:1px solid var(--stroke);background:var(--panel);
  color:var(--ink);cursor:pointer;display:grid;place-items:center;transition:transform .18s,border-color .18s}
.icon-btn:hover{transform:translateY(-2px);border-color:var(--mint)}
.icon-btn:focus-visible{outline:2px solid var(--amber);outline-offset:3px}
.icon-btn svg{width:19px;height:19px}

/* ---------- title ---------- */
.title{text-align:center}
.title h1{font-size:clamp(1.7rem,7.5vw,2.6rem);font-weight:700;letter-spacing:-.03em;line-height:1;
  background:linear-gradient(100deg,var(--mint),var(--sky) 45%,var(--magenta));
  -webkit-background-clip:text;background-clip:text;color:transparent}
.title p{color:var(--muted);font-size:.86rem;margin-top:4px;font-weight:300}

/* ---------- hud ---------- */
.hud{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.sc{background:var(--panel);border:1px solid var(--stroke);border-radius:14px;padding:8px 6px;text-align:center;backdrop-filter:blur(10px)}
.sc span{display:block;font-size:.57rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
.sc strong{font-family:'Space Mono',monospace;font-size:1.02rem;letter-spacing:-.03em}
.sc.lives strong{color:var(--magenta)}
.sc.lives.low strong{animation:blink 1s steps(2,end) infinite}
@keyframes blink{50%{opacity:.35}}

/* ---------- arena ---------- */
.arena{position:relative;border-radius:18px;overflow:hidden;border:1px solid var(--stroke);
  background:var(--deep);box-shadow:var(--shadow);margin:0 auto;cursor:grab;
  width:100%;
  /* never taller than the room left over once the rest of the page is placed */
  max-width:clamp(240px, calc((100vh - 330px) * 1.4286), 820px);
  touch-action:none;user-select:none;-webkit-user-select:none}
@supports (height:100dvh){
  .arena{max-width:clamp(240px, calc((100dvh - 330px) * 1.4286), 820px)}
}
#cv{display:block;width:100%;height:auto;aspect-ratio:800 / 560;touch-action:none}
.arena.grabbing{cursor:grabbing}

.overlay{position:absolute;inset:0;display:none;place-items:center;text-align:center;padding:18px;z-index:4;
  background:rgba(10,6,32,.9);backdrop-filter:blur(6px)}
html[data-theme="light"] .overlay{background:rgba(244,241,255,.93)}
.overlay.show{display:grid;animation:pop .28s ease}
@keyframes pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
.overlay .tag{font-size:.6rem;letter-spacing:.26em;text-transform:uppercase;color:var(--mint);margin-bottom:6px}
.overlay h2{font-size:clamp(1.3rem,5vw,1.7rem);font-weight:700;letter-spacing:-.02em}
.overlay p{color:var(--muted);font-size:.84rem;margin:8px 0 14px;line-height:1.55}
.overlay .final{font-family:'Space Mono',monospace;font-size:1.7rem;margin-bottom:10px}

/* ---------- buttons ---------- */
.btn{font-family:'Jost',sans-serif;font-size:.86rem;font-weight:500;min-height:44px;padding:10px 18px;border-radius:12px;
  cursor:pointer;border:1px solid var(--stroke);background:var(--panel);color:var(--ink);
  display:inline-flex;align-items:center;justify-content:center;gap:7px;transition:transform .16s,border-color .16s}
.btn:hover{transform:translateY(-2px);border-color:var(--mint)}
.btn:focus-visible{outline:2px solid var(--amber);outline-offset:3px}
.btn.primary{background:linear-gradient(120deg,var(--mint),var(--sky));border-color:transparent;color:#0a0620;
  font-weight:600;box-shadow:0 10px 30px rgba(47,243,200,.3)}
.btn svg{width:15px;height:15px}
.tools{display:flex;gap:8px}
.tools .btn{flex:1}

/* ---------- power-up legend ---------- */
.legend{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.pw{background:var(--panel);border:1px solid var(--stroke);border-radius:13px;padding:8px 6px;text-align:center}
.pw i{display:block;width:20px;height:20px;border-radius:6px;margin:0 auto 4px}
.pw b{display:block;font-size:.71rem;font-weight:600}
.pw span{display:block;font-size:.59rem;color:var(--muted);font-weight:300}

.tip{text-align:center;font-size:.75rem;color:var(--muted);font-weight:300;min-height:1.1em}
footer{text-align:center;font-size:.73rem;color:var(--muted);font-weight:300;border-top:1px solid var(--stroke);padding-top:11px}
footer b{color:var(--ink);font-weight:600}

/* ---------- responsive ---------- */
@media (max-width:480px){
  .title p{display:none}
  .hud{grid-template-columns:repeat(2,1fr)}
  .legend{grid-template-columns:repeat(2,1fr)}
  .brand small{display:none}
}
@media (max-height:560px) and (orientation:landscape){
  .title,.legend,.tip,footer{display:none}
  .wrap{gap:6px}
  body{padding-top:6px}
  .arena{max-width:clamp(240px, calc((100vh - 165px) * 1.4286), 820px)}
}
@supports (height:100dvh){
  @media (max-height:560px) and (orientation:landscape){
    .arena{max-width:clamp(240px, calc((100dvh - 165px) * 1.4286), 820px)}
  }
}
@media (max-width:480px){
  .arena{max-width:clamp(240px, calc((100vh - 300px) * 1.4286), 820px)}
}
@supports (height:100dvh){
  @media (max-width:480px){
    .arena{max-width:clamp(240px, calc((100dvh - 300px) * 1.4286), 820px)}
  }
}
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <div class="brand">
      <div class="mark">
        <svg viewBox="0 0 24 24" fill="#0a0620"><rect x="2" y="4" width="9" height="5" rx="1.4"/><rect x="13" y="4" width="9" height="5" rx="1.4" opacity=".55"/><rect x="2" y="11" width="9" height="5" rx="1.4" opacity=".55"/><rect x="13" y="11" width="9" height="5" rx="1.4"/><circle cx="12" cy="20.5" r="2.4"/></svg>
      </div>
      <div><b>Coding Stellix</b><small>Breaker</small></div>
    </div>
    <div class="hbtns">
      <button class="icon-btn" id="soundBtn" aria-label="Turn sound off" title="Sound">
        <svg id="soundIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>
        </svg>
      </button>
      <button class="icon-btn" id="themeBtn" aria-label="Switch to light mode" title="Switch theme">
        <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
        </svg>
      </button>
    </div>
  </header>

  <div class="title">
    <h1>Stellix Breaker</h1>
    <p>Twelve hand-built walls. Catch the power-ups, keep the ball alive.</p>
  </div>

  <div class="hud">
    <div class="sc"><span>Score</span><strong id="sScore">0</strong></div>
    <div class="sc"><span>Level</span><strong id="sLevel">1</strong></div>
    <div class="sc"><span>Bricks</span><strong id="sBricks">0</strong></div>
    <div class="sc lives" id="livesBox"><span>Lives</span><strong id="sLives">3</strong></div>
  </div>

  <div class="arena" id="arena">
    <canvas id="cv"></canvas>
    <div class="overlay show" id="ovStart">
      <div>
        <div class="tag">Coding Stellix</div>
        <h2>Stellix Breaker</h2>
        <p>Slide your mouse or finger anywhere to steer the paddle.<br>Tap or press space to launch the ball.</p>
        <button class="btn primary" id="startBtn">Start game</button>
      </div>
    </div>
    <div class="overlay" id="ovPause">
      <div>
        <div class="tag">Paused</div>
        <h2>Ball on hold</h2>
        <p>Press P or the button to carry on.</p>
        <button class="btn primary" id="resumeBtn">Resume</button>
      </div>
    </div>
    <div class="overlay" id="ovLevel">
      <div>
        <div class="tag">Level cleared</div>
        <h2 id="lvlTitle">Wall down</h2>
        <p id="lvlText">Next wall coming up.</p>
        <button class="btn primary" id="nextBtn">Next level</button>
      </div>
    </div>
    <div class="overlay" id="ovOver">
      <div>
        <div class="tag" id="overTag">Game over</div>
        <h2 id="overTitle">Out of lives</h2>
        <div class="final" id="finalScore">0</div>
        <p id="overLine">Reached level 1</p>
        <button class="btn primary" id="againBtn">Play again</button>
      </div>
    </div>
  </div>

  <div class="tools">
    <button class="btn" id="pauseBtn">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5v14M15 5v14"/></svg>
      Pause
    </button>
    <button class="btn" id="restartBtn">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>
      Restart
    </button>
  </div>

  <div class="legend">
    <div class="pw"><i style="background:#2ff3c8"></i><b>Wide</b><span>Bigger paddle</span></div>
    <div class="pw"><i style="background:#ffcf3d"></i><b>Multi</b><span>Three balls</span></div>
    <div class="pw"><i style="background:#6bb8ff"></i><b>Slow</b><span>Ball eases off</span></div>
    <div class="pw"><i style="background:#ff3d9a"></i><b>Life</b><span>One more try</span></div>
  </div>

  <p class="tip" id="tipLine">Hit the ball with the edge of the paddle for a sharper angle.</p>
  <footer>Built by <b>Coding Stellix</b></footer>
</div>

<script>
(function(){
"use strict";

/* ================= logical space ================= */
const W=800, H=560;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const arena=document.getElementById('arena');
const $=id=>document.getElementById(id);
const ovStart=$('ovStart'), ovPause=$('ovPause'), ovLevel=$('ovLevel'), ovOver=$('ovOver'), tipLine=$('tipLine');

/* new palette */
const MINT='#2ff3c8', MAGENTA='#ff3d9a', AMBER='#ffcf3d', SKY='#6bb8ff';
const HPCOL={1:MINT,2:AMBER,3:MAGENTA};

/* ================= sound engine ================= */
const SFX={
  ctx:null, on:true, master:null,
  init(){
    if(!this.ctx){
      const AC=window.AudioContext||window.webkitAudioContext;
      if(AC){
        try{
          this.ctx=new AC();
          this.master=this.ctx.createGain();
          this.master.gain.value=0.85;
          this.master.connect(this.ctx.destination);
        }catch(e){ this.ctx=null; }
      }
    }
    if(this.ctx && this.ctx.state==='suspended') this.ctx.resume();
  },
  out(){ return this.master||this.ctx.destination; },
  tone(f,dur,type,vol,slide,delay){
    if(!this.on||!this.ctx) return;
    const t=this.ctx.currentTime+(delay||0);
    const o=this.ctx.createOscillator(), g=this.ctx.createGain();
    o.type=type||'sine'; o.frequency.setValueAtTime(f,t);
    if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,slide),t+dur);
    g.gain.setValueAtTime(0.0001,t);
    g.gain.exponentialRampToValueAtTime(vol||0.07,t+0.008);
    g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    o.connect(g); g.connect(this.out());
    o.start(t); o.stop(t+dur+0.03);
  },
  noise(dur,vol,cut,sweepTo){
    if(!this.on||!this.ctx) return;
    const c=this.ctx, t=c.currentTime, len=Math.max(1,Math.floor(c.sampleRate*dur));
    const buf=c.createBuffer(1,len,c.sampleRate), d=buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/len,1.7);
    const s=c.createBufferSource(); s.buffer=buf;
    const f=c.createBiquadFilter(); f.type='lowpass';
    f.frequency.setValueAtTime(cut||2400,t);
    if(sweepTo) f.frequency.exponentialRampToValueAtTime(Math.max(120,sweepTo),t+dur);
    const g=c.createGain(); g.gain.value=vol||0.1;
    s.connect(f); f.connect(g); g.connect(this.out()); s.start(t);
  },
  chord(freqs,dur,type,vol,step){
    freqs.forEach((f,i)=>this.tone(f,dur,type,vol,null,i*(step||0)));
  },

  /* --- new, more expressive set --- */
  paddle(edge){                                   // pitch rises toward the paddle edge
    const base=250+Math.abs(edge)*260;
    this.tone(base,0.07,'square',0.06,base*1.5);
    this.noise(0.05,0.05,1400);
  },
  wallSide(){ this.tone(300,0.05,'triangle',0.05,240); },
  wallTop(){ this.tone(480,0.05,'triangle',0.05,600); },
  chip(combo){                                    // climbing ladder while the combo holds
    const steps=[523,587,659,698,784,880,988,1047];
    this.tone(steps[Math.min(steps.length-1,combo)],0.06,'square',0.055);
  },
  smash(hp,combo){
    this.noise(0.16,0.12,3000,600);
    const root=hp===3?196:hp===2?262:330;
    this.tone(root,0.13,'square',0.075,root*0.5);
    this.tone(root*2,0.09,'triangle',0.045,null,0.02);
    if(combo>=5) this.tone(1320,0.09,'sine',0.05,1980,0.04);
  },
  comboUp(n){ this.chord([659,880,1175],0.12,'triangle',0.055,0.05); },
  launch(){ this.noise(0.22,0.10,600,4000); this.tone(180,0.22,'sawtooth',0.05,720); },
  ready(){ this.chord([523,784],0.16,'triangle',0.05,0.07); },
  pwWide(){ this.tone(330,0.22,'triangle',0.07,660); },
  pwMulti(){ [660,880,1100].forEach((f,i)=>this.tone(f,0.1,'square',0.06,null,i*0.05)); },
  pwSlow(){ this.tone(880,0.34,'sine',0.07,220); },
  pwLife(){ this.chord([523,659,784,1047],0.18,'triangle',0.06,0.06); },
  lost(){ this.noise(0.3,0.10,900,200); [330,247,175].forEach((f,i)=>this.tone(f,0.26,'sawtooth',0.075,null,i*0.12)); },
  lastLife(){ this.tone(120,0.5,'sawtooth',0.06,90); },
  clear(){ [523,659,784,1047,1319].forEach((f,i)=>this.tone(f,0.2,'square',0.075,null,i*0.075)); this.noise(0.35,0.12,3600,800); },
  over(){ [392,311,262,196,147].forEach((f,i)=>this.tone(f,0.32,'sawtooth',0.085,null,i*0.14)); }
};

/* ================= state ================= */
const G={
  state:'ready',
  score:0, level:1, lives:3, combo:0, comboTimer:0,
  bricks:[], balls:[], drops:[], bits:[],
  paddle:{x:W/2, tx:W/2, w:118, h:15, y:H-42, base:118, boost:0},
  slow:0, shake:0
};

/* ================= levels ================= */
const COLS=11, MARGIN=44, TOP=70, GAP=8, BH=24;
const BW=(W-MARGIN*2-(COLS-1)*GAP)/COLS;

function brick(c,r,hp){
  return { x:MARGIN+c*(BW+GAP), y:TOP+r*(BH+GAP), w:BW, h:BH, hp:hp, max:hp, alive:true, flash:0 };
}
function layout(level){
  const out=[], L=((level-1)%12)+1;
  const tier=Math.min(3,1+Math.floor((level-1)/12));
  const push=(c,r,hp)=>out.push(brick(c,r,Math.min(3,hp+tier-1)));
  if(L===1){ for(let r=0;r<4;r++) for(let c=0;c<COLS;c++) push(c,r,1); }
  else if(L===2){ for(let r=0;r<5;r++) for(let c=0;c<COLS;c++) if(c>=r&&c<COLS-r) push(c,r,r>=3?2:1); }
  else if(L===3){ for(let r=0;r<6;r++) for(let c=0;c<COLS;c++) if((r+c)%2===0) push(c,r,1); }
  else if(L===4){ for(let r=0;r<6;r++) for(let c=0;c<COLS;c++) if(c<2||c>COLS-3||r===0||r===5) push(c,r,r===0?2:1); }
  else if(L===5){ for(let r=0;r<7;r++) for(let c=0;c<COLS;c++){ const d=Math.abs(c-5)+Math.abs(r-3); if(d<=3) push(c,r,d<=1?3:1); } }
  else if(L===6){ for(let r=0;r<6;r++) for(let c=0;c<COLS;c++) if((c+(r%2?1:0))%3!==0) push(c,r,r%2?2:1); }
  else if(L===7){ for(let r=0;r<6;r++) for(let c=0;c<COLS;c++) if(Math.abs(Math.sin(c*0.55)*3+3-r)<1.6) push(c,r,2); }
  else if(L===8){ for(let r=0;r<7;r++) for(let c=0;c<COLS;c++) if(c%2===0||r%3===0) push(c,r,c===5?3:1); }
  else if(L===9){ for(let r=0;r<6;r++) for(let c=0;c<COLS;c++) if(!(c>2&&c<8&&r>1&&r<4)) push(c,r,r<2?2:1); }
  else if(L===10){ for(let r=0;r<7;r++) for(let c=0;c<COLS;c++) if((c<=r&&c<6)||(COLS-1-c<=r&&c>=5)) push(c,r,r>4?3:1); }
  else if(L===11){ for(let r=0;r<7;r++) for(let c=0;c<COLS;c++) if(r===0||r===6||c===0||c===COLS-1||(r===3&&c%2===0)) push(c,r,2); }
  else { for(let r=0;r<7;r++) for(let c=0;c<COLS;c++) if(Math.random()<0.78) push(c,r,1+((c+r)%3===0?2:0)); }
  return out;
}

/* ================= entities ================= */
function newBall(x,y,vx,vy){ return {x,y,r:8,vx,vy,stuck:false,trail:[]}; }
function speed(){ return (330+(G.level-1)*16)*(G.slow>0?0.72:1); }
function serveBall(){
  const b=newBall(G.paddle.x,G.paddle.y-15,0,0);
  b.stuck=true; G.balls=[b]; G.state='serve';
}
function release(){
  if(G.state!=='serve') return;
  const sp=speed(), a=-Math.PI/2+(Math.random()*0.5-0.25);
  const b=G.balls[0];
  b.stuck=false; b.vx=Math.cos(a)*sp; b.vy=Math.sin(a)*sp;
  G.state='playing'; SFX.launch();
}
function spawnDrop(x,y){
  const kinds=[{k:'wide',c:MINT},{k:'multi',c:AMBER},{k:'slow',c:SKY},{k:'life',c:MAGENTA}];
  const p=kinds[(Math.random()*kinds.length)|0];
  G.drops.push({x:x,y:y,w:26,h:26,vy:130,k:p.k,c:p.c,spin:0});
}
function applyDrop(k){
  if(k==='wide'){ G.paddle.boost=12; G.paddle.w=Math.min(210,G.paddle.base*1.5); SFX.pwWide(); tipLine.textContent='Wide paddle for twelve seconds.'; }
  else if(k==='multi'){
    const src=G.balls[0];
    if(src&&!src.stuck){
      for(let i=0;i<2;i++){
        const a=Math.atan2(src.vy,src.vx)+(i?0.55:-0.55), sp=speed();
        G.balls.push(newBall(src.x,src.y,Math.cos(a)*sp,Math.sin(a)*sp));
      }
    }
    SFX.pwMulti(); tipLine.textContent='Three balls. Chaos is a strategy.';
  }
  else if(k==='slow'){ G.slow=8; rescale(); SFX.pwSlow(); tipLine.textContent='Ball slowed for eight seconds.'; }
  else { G.lives++; updateHUD(); SFX.pwLife(); tipLine.textContent='Extra life banked.'; }
}
function rescale(){
  const sp=speed();
  G.balls.forEach(b=>{
    if(b.stuck) return;
    const m=Math.hypot(b.vx,b.vy)||1;
    b.vx=b.vx/m*sp; b.vy=b.vy/m*sp;
  });
}
function burst(x,y,color,n){
  for(let i=0;i<n;i++){
    const a=Math.random()*Math.PI*2, s=40+Math.random()*230;
    G.bits.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-70,s:2+Math.random()*4,c:color,life:1});
  }
  if(G.bits.length>700) G.bits.splice(0,G.bits.length-700);
}

/* ================= collisions ================= */
function hitBrick(b){
  b.hp--; b.flash=1;
  if(b.hp<=0){
    b.alive=false;
    G.combo++; G.comboTimer=1.6;
    G.score+=25*G.level+G.combo*5;
    burst(b.x+b.w/2,b.y+b.h/2,HPCOL[b.max]||MINT,15);
    SFX.smash(b.max,G.combo);
    if(G.combo>0 && G.combo%5===0){ SFX.comboUp(G.combo); tipLine.textContent=G.combo+' bricks without a miss.'; }
    G.shake=Math.max(G.shake,4);
    if(Math.random()<0.16) spawnDrop(b.x+b.w/2-13,b.y+b.h/2);
  } else {
    G.score+=8;
    SFX.chip(G.combo);
    burst(b.x+b.w/2,b.y+b.h/2,HPCOL[b.hp]||MINT,4);
  }
  updateHUD();
}
function ballBrick(ball){
  for(const b of G.bricks){
    if(!b.alive) continue;
    if(ball.x+ball.r<b.x||ball.x-ball.r>b.x+b.w||ball.y+ball.r<b.y||ball.y-ball.r>b.y+b.h) continue;
    const ox=Math.min(ball.x+ball.r-b.x, b.x+b.w-(ball.x-ball.r));
    const oy=Math.min(ball.y+ball.r-b.y, b.y+b.h-(ball.y-ball.r));
    if(ox<oy){ ball.vx*=-1; ball.x+=ball.vx>0?ox:-ox; }
    else { ball.vy*=-1; ball.y+=ball.vy>0?oy:-oy; }
    hitBrick(b);
    return true;
  }
  return false;
}

/* ================= update ================= */
function step(dt){
  const p=G.paddle;

  // paddle follows the pointer target, quick but smooth
  p.x += (p.tx-p.x)*Math.min(1,dt*22);
  p.x = Math.max(p.w/2, Math.min(W-p.w/2, p.x));

  if(G.slow>0){ G.slow-=dt; if(G.slow<=0){ G.slow=0; rescale(); } }
  if(p.boost>0){ p.boost-=dt; if(p.boost<=0){ p.boost=0; p.w=p.base; } }
  if(G.comboTimer>0){ G.comboTimer-=dt; if(G.comboTimer<=0) G.combo=0; }

  for(let i=G.balls.length-1;i>=0;i--){
    const b=G.balls[i];
    if(b.stuck){ b.x=p.x; b.y=p.y-15; continue; }
    b.x+=b.vx*dt; b.y+=b.vy*dt;
    b.trail.push({x:b.x,y:b.y}); if(b.trail.length>9) b.trail.shift();

    if(b.x-b.r<0){ b.x=b.r; b.vx*=-1; SFX.wallSide(); }
    if(b.x+b.r>W){ b.x=W-b.r; b.vx*=-1; SFX.wallSide(); }
    if(b.y-b.r<0){ b.y=b.r; b.vy*=-1; SFX.wallTop(); }

    if(b.vy>0 && b.y+b.r>=p.y && b.y-b.r<=p.y+p.h && b.x>=p.x-p.w/2-5 && b.x<=p.x+p.w/2+5){
      const rel=Math.max(-1,Math.min(1,(b.x-p.x)/(p.w/2)));
      const a=-Math.PI/2+rel*1.05, sp=speed();
      b.vx=Math.cos(a)*sp; b.vy=Math.sin(a)*sp; b.y=p.y-b.r-1;
      SFX.paddle(rel); burst(b.x,p.y,MINT,4);
    }

    ballBrick(b);
    if(b.y-b.r>H) G.balls.splice(i,1);
  }

  if(G.balls.length===0 && G.state==='playing'){
    G.lives--; G.combo=0; updateHUD(); SFX.lost(); G.shake=8;
    if(G.lives<=0) gameOver();
    else {
      p.w=p.base; p.boost=0; G.slow=0; serveBall();
      if(G.lives===1){ SFX.lastLife(); tipLine.textContent='Last life. Play it safe.'; }
      else tipLine.textContent='Lives left: '+G.lives+'. Steady now.';
    }
  }

  for(let i=G.drops.length-1;i>=0;i--){
    const d=G.drops[i];
    d.y+=d.vy*dt; d.spin+=dt*3;
    if(d.y>H){ G.drops.splice(i,1); continue; }
    if(d.y+d.h>=p.y && d.y<=p.y+p.h && d.x+d.w>=p.x-p.w/2 && d.x<=p.x+p.w/2){
      applyDrop(d.k); burst(d.x+13,d.y+13,d.c,12); G.drops.splice(i,1);
    }
  }

  for(let i=G.bits.length-1;i>=0;i--){
    const b=G.bits[i];
    b.x+=b.vx*dt; b.y+=b.vy*dt; b.vy+=530*dt; b.life-=dt*1.5;
    if(b.life<=0||b.y>H+20) G.bits.splice(i,1);
  }

  G.bricks.forEach(b=>{ if(b.flash>0) b.flash-=dt*4; });
  if(G.state==='playing' && G.bricks.every(b=>!b.alive)) levelCleared();
}

/* ================= render ================= */
function roundRect(x,y,w,h,r){
  ctx.beginPath(); ctx.moveTo(x+r,y);
  ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
  ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function draw(){
  ctx.clearRect(0,0,W,H);
  let ox=0,oy=0;
  if(G.shake>0){ ox=(Math.random()-.5)*G.shake; oy=(Math.random()-.5)*G.shake; G.shake*=0.86; if(G.shake<.3)G.shake=0; }
  ctx.save(); ctx.translate(ox,oy);

  ctx.strokeStyle='rgba(255,255,255,.045)'; ctx.lineWidth=1;
  for(let y=TOP-30;y<H;y+=40){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }

  G.bricks.forEach(b=>{
    if(!b.alive) return;
    const col=HPCOL[b.hp]||MINT;
    ctx.fillStyle=col; roundRect(b.x,b.y,b.w,b.h,6); ctx.fill();
    ctx.fillStyle='rgba(255,255,255,.30)'; roundRect(b.x+b.w*0.10,b.y+4,b.w*0.80,5,2.5); ctx.fill();
    if(b.max>1){
      ctx.fillStyle='rgba(0,0,0,.4)';
      for(let i=0;i<b.hp;i++){ ctx.beginPath(); ctx.arc(b.x+b.w/2+(i-(b.hp-1)/2)*8,b.y+b.h-7,2.1,0,Math.PI*2); ctx.fill(); }
    }
    if(b.flash>0){ ctx.fillStyle='rgba(255,255,255,'+Math.max(0,b.flash*0.7)+')'; roundRect(b.x,b.y,b.w,b.h,6); ctx.fill(); }
  });

  G.bits.forEach(b=>{ ctx.globalAlpha=Math.max(0,b.life); ctx.fillStyle=b.c; ctx.fillRect(b.x,b.y,b.s,b.s); ctx.globalAlpha=1; });

  G.drops.forEach(d=>{
    ctx.save();
    ctx.translate(d.x+13,d.y+13); ctx.rotate(Math.sin(d.spin)*0.18);
    ctx.fillStyle=d.c; roundRect(-13,-13,26,26,8); ctx.fill();
    ctx.strokeStyle='rgba(0,0,0,.6)'; ctx.lineWidth=2; ctx.lineCap='round'; ctx.beginPath();
    if(d.k==='wide'){ ctx.moveTo(-7,0); ctx.lineTo(7,0); ctx.moveTo(-7,0); ctx.lineTo(-4,-3); ctx.moveTo(-7,0); ctx.lineTo(-4,3); ctx.moveTo(7,0); ctx.lineTo(4,-3); ctx.moveTo(7,0); ctx.lineTo(4,3); }
    else if(d.k==='multi'){ ctx.arc(-5,2,2.6,0,Math.PI*2); ctx.moveTo(2.6,2); ctx.arc(0,2,2.6,0,Math.PI*2); ctx.moveTo(7.6,-3); ctx.arc(5,-3,2.6,0,Math.PI*2); }
    else if(d.k==='slow'){ ctx.arc(0,0,6,0,Math.PI*2); ctx.moveTo(0,-3); ctx.lineTo(0,0); ctx.lineTo(3.5,1.5); }
    else { ctx.moveTo(-6,0); ctx.lineTo(6,0); ctx.moveTo(0,-6); ctx.lineTo(0,6); }
    ctx.stroke(); ctx.restore();
  });

  // paddle with a gradient
  const p=G.paddle;
  const pg=ctx.createLinearGradient(p.x-p.w/2,0,p.x+p.w/2,0);
  pg.addColorStop(0,MINT); pg.addColorStop(1,SKY);
  ctx.fillStyle=pg; roundRect(p.x-p.w/2,p.y,p.w,p.h,7); ctx.fill();
  ctx.fillStyle='rgba(255,255,255,.38)'; roundRect(p.x-p.w/2+6,p.y+3,p.w-12,4,2); ctx.fill();

  // balls with trails
  G.balls.forEach(b=>{
    b.trail.forEach((t,i)=>{
      ctx.globalAlpha=(i+1)/b.trail.length*0.28;
      ctx.fillStyle=AMBER;
      ctx.beginPath(); ctx.arc(t.x,t.y,b.r*0.7,0,Math.PI*2); ctx.fill();
    });
    ctx.globalAlpha=1;
    ctx.save(); ctx.shadowBlur=18; ctx.shadowColor=AMBER;
    const g=ctx.createRadialGradient(b.x-2,b.y-3,1,b.x,b.y,b.r);
    g.addColorStop(0,'#ffffff'); g.addColorStop(1,AMBER);
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
    ctx.restore();
  });

  if(G.combo>=3){
    ctx.fillStyle=MAGENTA; ctx.font='700 16px "Space Mono", monospace'; ctx.textAlign='left';
    ctx.fillText('x'+G.combo, 14, H-14);
  }
  if(G.state==='serve'){
    ctx.fillStyle='rgba(255,255,255,.55)'; ctx.font='500 15px Jost, sans-serif'; ctx.textAlign='center';
    ctx.fillText('Tap or press space to launch', W/2, H-95);
  }
  ctx.restore();
}

/* ================= loop ================= */
let last=0;
function loop(now){
  requestAnimationFrame(loop);
  let dt=(now-last)/1000; last=now;
  if(!isFinite(dt)||dt<0) dt=0;
  dt=Math.min(dt,0.032);
  if(G.state==='playing'||G.state==='serve') step(dt);
  draw();
}

/* ================= flow ================= */
function updateHUD(){
  $('sScore').textContent=G.score;
  $('sLevel').textContent=G.level;
  $('sLives').textContent=G.lives;
  $('sBricks').textContent=G.bricks.filter(b=>b.alive).length;
  $('livesBox').classList.toggle('low',G.lives<=1);
}
function loadLevel(){
  G.bricks=layout(G.level);
  G.drops=[]; G.bits=[]; G.slow=0; G.combo=0;
  G.paddle.w=G.paddle.base; G.paddle.boost=0; G.paddle.x=G.paddle.tx=W/2;
  serveBall(); updateHUD(); SFX.ready();
}
function startGame(){ G.score=0; G.level=1; G.lives=3; loadLevel(); }
function levelCleared(){
  G.state='levelend'; SFX.clear();
  G.score+=200*G.level; updateHUD();
  $('lvlTitle').textContent='Level '+G.level+' cleared';
  $('lvlText').textContent='Score so far: '+G.score+'. The next wall is tougher.';
  setTimeout(()=>ovLevel.classList.add('show'),320);
}
function gameOver(){
  G.state='over'; SFX.over();
  $('finalScore').textContent=G.score;
  $('overLine').textContent='Reached level '+G.level;
  $('overTag').textContent=G.level>=5?'Solid run':'Game over';
  setTimeout(()=>ovOver.classList.add('show'),320);
}
function togglePause(){
  if(G.state==='playing'||G.state==='serve'){ G.prev=G.state; G.state='paused'; ovPause.classList.add('show'); }
  else if(G.state==='paused'){ G.state=G.prev||'playing'; ovPause.classList.remove('show'); }
}

/* ================= pointer control (mouse, touch, pen) ================= */
function aimAt(clientX){
  const rect=cv.getBoundingClientRect();
  if(!rect.width) return;
  const x=(clientX-rect.left)/rect.width*W;
  G.paddle.tx=Math.max(G.paddle.w/2, Math.min(W-G.paddle.w/2, x));
}
let dragging=false;
function onUI(t){
  if(!t) return false;
  if(t.closest) return !!t.closest('.overlay, button');
  // very old browsers: walk up manually
  let n=t;
  while(n){
    if(n.tagName==='BUTTON') return true;
    if(n.classList && n.classList.contains('overlay')) return true;
    n=n.parentNode;
  }
  return false;
}
function grab(on){ dragging=on; on?arena.classList.add('grabbing'):arena.classList.remove('grabbing'); }

if(window.PointerEvent){
  // tracking the whole window means the paddle keeps following even when the
  // cursor drifts above or below the arena
  window.addEventListener('pointermove',e=>{
    if(e.pointerType==='mouse' || dragging) aimAt(e.clientX);
  },{passive:true});
  arena.addEventListener('pointerdown',e=>{
    SFX.init();
    // a press on an overlay or a button must reach that button:
    // capturing the pointer here would stop the click event ever firing
    if(onUI(e.target)) return;
    grab(true);
    if(arena.setPointerCapture){ try{ arena.setPointerCapture(e.pointerId); }catch(err){} }
    aimAt(e.clientX);
    if(G.state==='serve') release();
  });
  window.addEventListener('pointerup',()=>grab(false));
  window.addEventListener('pointercancel',()=>grab(false));
} else {
  // older browsers without pointer events
  window.addEventListener('mousemove',e=>aimAt(e.clientX),{passive:true});
  arena.addEventListener('mousedown',e=>{ SFX.init(); if(onUI(e.target)) return; aimAt(e.clientX); if(G.state==='serve') release(); });
  arena.addEventListener('touchstart',e=>{ SFX.init(); if(onUI(e.target)) return; aimAt(e.touches[0].clientX); if(G.state==='serve') release(); },{passive:true});
  arena.addEventListener('touchmove',e=>{ aimAt(e.touches[0].clientX); },{passive:true});
}
arena.addEventListener('contextmenu',e=>e.preventDefault());

/* keyboard */
let keyL=false,keyR=false;
window.addEventListener('keydown',e=>{
  SFX.init();
  if(e.key==='ArrowLeft'){ keyL=true; e.preventDefault(); }
  else if(e.key==='ArrowRight'){ keyR=true; e.preventDefault(); }
  else if(e.key===' '||e.key==='Enter'){
    e.preventDefault();
    if(G.state==='ready'){ ovStart.classList.remove('show'); startGame(); }
    else if(G.state==='levelend'){ ovLevel.classList.remove('show'); G.level++; loadLevel(); }
    else if(G.state==='over'){ ovOver.classList.remove('show'); startGame(); }
    else release();
  }
  else if(e.key==='p'||e.key==='P') togglePause();
});
window.addEventListener('keyup',e=>{
  if(e.key==='ArrowLeft') keyL=false;
  if(e.key==='ArrowRight') keyR=false;
});
(function keyLoop(){
  requestAnimationFrame(keyLoop);
  if(G.state!=='playing'&&G.state!=='serve') return;
  if(keyL) G.paddle.tx=Math.max(G.paddle.w/2,G.paddle.tx-13);
  if(keyR) G.paddle.tx=Math.min(W-G.paddle.w/2,G.paddle.tx+13);
})();

/* buttons */
// one binder used for every button: fires once, works with mouse, touch and keyboard
function onTap(el,fn){
  if(!el) return;
  let lock=0;
  const run=e=>{
    if(e){ e.stopPropagation(); if(e.preventDefault) e.preventDefault(); }
    const now=Date.now();
    if(now-lock<350) return;      // stop a tap firing twice
    lock=now;
    SFX.init();
    fn();
  };
  el.addEventListener('click',run);
  el.addEventListener('touchend',run);
  el.addEventListener('pointerdown',e=>e.stopPropagation());
  el.addEventListener('mousedown',e=>e.stopPropagation());
}

onTap($('startBtn'),()=>{ ovStart.classList.remove('show'); startGame(); });
onTap($('nextBtn'),()=>{ ovLevel.classList.remove('show'); G.level++; loadLevel(); });
onTap($('againBtn'),()=>{ ovOver.classList.remove('show'); startGame(); tipLine.textContent='Fresh start. Watch the paddle edges.'; });
onTap($('resumeBtn'),()=>togglePause());
onTap($('pauseBtn'),togglePause);
onTap($('restartBtn'),()=>{
  ovOver.classList.remove('show'); ovLevel.classList.remove('show'); ovPause.classList.remove('show');
  startGame();
});

/* sound + theme */
const soundBtn=$('soundBtn'), soundIcon=$('soundIcon');
const SND_ON='<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>';
const SND_OFF='<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M22 9l-6 6M16 9l6 6"/>';
soundBtn.addEventListener('click',()=>{
  SFX.on=!SFX.on; SFX.init();
  soundIcon.innerHTML=SFX.on?SND_ON:SND_OFF;
  soundBtn.setAttribute('aria-label',SFX.on?'Turn sound off':'Turn sound on');
  soundBtn.style.color=SFX.on?'':'var(--muted)';
  if(SFX.on) SFX.ready();
  tipLine.textContent=SFX.on?'Sound on.':'Sound off.';
});
const themeBtn=$('themeBtn'), themeIcon=$('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUNI='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.addEventListener('click',()=>{
  const light=document.documentElement.getAttribute('data-theme')==='light';
  document.documentElement.setAttribute('data-theme',light?'dark':'light');
  themeIcon.innerHTML=light?SUNI:MOON;
  themeBtn.setAttribute('aria-label',light?'Switch to light mode':'Switch to dark mode');
});

/* pause when the tab or app goes away */
document.addEventListener('visibilitychange',()=>{
  if(document.hidden && (G.state==='playing'||G.state==='serve')) togglePause();
});

/* ================= responsive sizing ================= */
function resize(){
  const r=cv.getBoundingClientRect();
  const cssW=r.width || cv.clientWidth;
  if(!cssW){ requestAnimationFrame(resize); return; }
  const cssH=cssW*H/W;
  const dpr=Math.min(window.devicePixelRatio||1,2);
  const bw=Math.max(1,Math.round(cssW*dpr)), bh=Math.max(1,Math.round(cssH*dpr));
  if(cv.width!==bw||cv.height!==bh){ cv.width=bw; cv.height=bh; }
  const s=cssW/W*dpr;
  ctx.setTransform(s,0,0,s,0,0);
}
window.addEventListener('resize',resize);
window.addEventListener('orientationchange',()=>setTimeout(resize,150));
// the surest way to stay in sync with CSS
if(window.ResizeObserver){
  try{ new ResizeObserver(resize).observe(cv); }catch(e){}
}
if(window.visualViewport) window.visualViewport.addEventListener('resize',resize);

/* ================= boot ================= */
G.bricks=layout(1);
serveBall(); G.state='ready';
resize();
requestAnimationFrame(()=>{ resize(); });
window.addEventListener('load',resize);
updateHUD();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Leave a Reply

Your email address will not be published. Required fields are marked *

SHARE:-

Trending Post

Latest Post