How to Create a Bubble Shooter Game Using HTML, CSS, and JavaScript

How to Create a Bubble Shooter Game Using HTML, CSS, and JavaScript

Bubble shooter games have been around for decades, and there is a good reason they never really go out of style. The rules are simple to understand within a few seconds, but getting good at them takes real skill. At Coding Stellix, we wanted to take this classic idea and rebuild it from the ground up using nothing but plain HTML, CSS, and JavaScript, with the canvas element handling all the visuals. This article walks through how the game, called Bubble Blitz, actually came together.

Starting With the Core Idea

Before writing a single line of code, it helps to break the game down into its basic parts. A bubble shooter needs a grid of colored bubbles arranged near the top of the screen, a shooter at the bottom that the player aims and fires, and a rule that says when three or more bubbles of the same color touch each other, they pop. On top of that, there needs to be a sense of pressure, usually done by slowly adding new rows of bubbles from the top, pushing everything closer to a danger line at the bottom.

Once those pieces are clear, the rest of the project becomes a matter of translating each piece into code, one system at a time.

Building the Hexagonal Grid

The trickiest part of any bubble shooter is the grid itself. Bubbles are circles, and circles pack together most efficiently in a honeycomb pattern rather than a plain square grid. This means every other row needs to be shifted sideways by half a bubble width, and the vertical spacing between rows has to be slightly less than the bubble’s full diameter, based on some simple trigonometry.

Once the math for row and column positions is worked out, each bubble in the grid can be represented as a simple entry in a data structure, keyed by its row and column number, with a value representing its color. This keeps the logic clean, since the visual position is always calculated from the row and column rather than stored separately, which avoids a lot of potential bugs later on.

Aiming and Shooting

For the shooting mechanic, the player needs to be able to drag or point toward a direction, see a preview line showing where the bubble will travel, and then release to fire. This is handled by listening for pointer events on the canvas, calculating the angle between the shooter position and the current pointer position, and clamping that angle so the player can only aim upward, never sideways into the wall or downward.

Once a bubble is fired, it needs to travel in a straight line based on that angle, bounce off the left and right walls like a ball, and stop when it either reaches the top of the play area or touches an existing bubble in the grid. Detecting that collision is really just a distance check between the moving bubble and every bubble already sitting in the grid, which is fast enough to run many times a second without any noticeable delay.

Snapping Into Place and Popping Matches

When a moving bubble collides with the grid, it needs to snap into the nearest open grid cell rather than sitting at some awkward diagonal position. This is done by estimating the closest row and column based on the collision point, then checking a small neighborhood of nearby cells to find the closest one that is actually empty.

Once the new bubble is placed, the game checks for matches. This is a classic flood fill problem: starting from the newly placed bubble, the code walks outward to every neighboring bubble of the same color, using the hexagonal neighbor pattern rather than a simple up-down-left-right grid, since each bubble actually touches six neighbors in this kind of layout. If three or more bubbles end up in that connected group, they all get removed at once.

There is one more clever piece here that many beginner tutorials skip: after bubbles are removed, some bubbles higher up in the grid might no longer be connected to anything at the very top row. In a real bubble shooter, those bubbles should fall away too, since nothing is holding them up anymore. This is solved with a second flood fill, this time starting from every bubble in the very first row and marking everything reachable from there as “connected.” Anything left over that was not reached gets removed as a bonus, which creates those satisfying chain reaction moments where one careful shot clears out a big chunk of the board at once.

Adding Sound Without Any Audio Files

One thing that makes a game feel far more alive is sound, but loading external audio files adds extra weight and extra requests to a page. Instead, Bubble Blitz generates every sound effect directly in the browser using the Web Audio API. Each sound, whether it is the shooting noise, a wall bounce, a pop, or a bigger combo celebration, is built from short oscillator tones with a quick fade in and fade out. Layering a few of these tones with tiny delays between them creates a much richer sound than a single tone would, and it means the entire game, sound included, is completely self contained in one file. A small mute toggle in the interface lets players turn this off instantly if they prefer to play in silence.

Designing for Every Screen Size

A game like this needs to work equally well on a small phone screen, a tablet held sideways, and a wide desktop monitor. The common mistake is to size everything based only on the width of the screen, which works fine on a typical phone in portrait mode but completely breaks on a short, wide window, since the tall column of bubbles simply will not fit vertically.

The better approach is to measure both the available width and the available height of the container that holds the game, then calculate the largest bubble size that would allow the full grid to fit inside both of those dimensions at once. If the width is the limiting factor, the size is based on that. If the height is more restrictive, such as in landscape mode on a phone, the size shrinks further so nothing gets cut off. This calculation runs again whenever the window resizes or the device rotates, so the board always readjusts itself cleanly no matter what device or orientation someone is using.

Alongside that, safe area padding was added for devices with notches or rounded corners, so none of the interface elements get hidden behind hardware cutouts, and small adjustments were made for very short viewports so text and buttons never overlap.

Final Thoughts

Building a bubble shooter from scratch is a genuinely good exercise for understanding grid math, collision detection, flood fill algorithms, and responsive canvas rendering all in one project. None of the individual pieces are especially complicated on their own, but combining them into something that feels polished and satisfying to play takes a fair bit of tuning and testing across different screen sizes.

This project reflects exactly the kind of practical, hands-on work Coding Stellix likes to share, since every part of it, from the physics to the sound design to the responsive layout, is something a developer can study, adapt, and bring into their own future projects.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Bubble Blitz | Coding Stellix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
  :root{
    --cream: #fff6ea;
    --peach: #ffe3c9;
    --coral: #ff6b6b;
    --tangerine: #ff9f1c;
    --lemon: #ffd60a;
    --mint: #06d6a0;
    --grape: #7b2cbf;
    --raspberry: #ff4d6d;
    --ink: #3a2e4d;
    --muted: #8b7fa3;
    --panel: rgba(255,255,255,0.75);
    --border: rgba(58,46,77,0.10);
  }

  *{ margin:0; padding:0; box-sizing:border-box; }

  html, body{
    height:100%;
    height:100dvh;
    overflow:hidden;
    font-family: 'Nunito', sans-serif;
    color: var(--ink);
    -webkit-tap-highlight-color: transparent;
  }

  .font-head{ font-family:'Fredoka', sans-serif; }

  #app{
    position:fixed;
    inset:0;
    display:flex;
    flex-direction:column;
    align-items:center;
    justify-content:flex-start;
    background:
      radial-gradient(circle at 15% 0%, rgba(255,159,28,0.18), transparent 45%),
      radial-gradient(circle at 90% 90%, rgba(123,44,191,0.14), transparent 45%),
      linear-gradient(180deg, var(--cream), var(--peach));
    padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left));
    overflow:hidden;
  }

  #topbar{
    width:100%;
    max-width: 460px;
    display:flex;
    align-items:center;
    justify-content:space-between;
    margin-bottom: 10px;
  }

  #brand{
    font-family:'Fredoka', sans-serif;
    font-weight:600;
    font-size: 14px;
  }
  #brand span{ color: var(--grape); }

  #stat-row{
    display:flex;
    gap:10px;
  }
  .stat-chip{
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 6px 12px;
    text-align:center;
    box-shadow: 0 4px 14px rgba(58,46,77,0.08);
  }
  .stat-chip .lbl{
    font-size: 9px;
    letter-spacing:1px;
    text-transform:uppercase;
    color: var(--muted);
    font-weight:700;
  }
  .stat-chip .val{
    font-family:'Fredoka', sans-serif;
    font-size: 16px;
    font-weight:600;
  }
  #level-val{ color: var(--tangerine); }
  #score-val{ color: var(--grape); }

  #board-wrap{
    position:relative;
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 22px;
    padding: 10px;
    box-shadow: 0 20px 50px rgba(58,46,77,0.14);
    flex: 1 1 auto;
    min-height:0;
    display:flex;
    align-items:center;
    justify-content:center;
  }

  canvas{
    display:block;
    border-radius: 14px;
    touch-action:none;
    max-width:100%;
    max-height:100%;
  }

  #combo-pop{
    position:absolute;
    font-family:'Fredoka', sans-serif;
    font-weight:700;
    font-size: 22px;
    color: var(--raspberry);
    pointer-events:none;
    opacity:0;
    z-index:6;
    text-shadow: 0 2px 0 rgba(255,255,255,0.6);
  }
  #combo-pop.show{
    animation: popFloat 0.7s ease-out forwards;
  }
  @keyframes popFloat{
    0%{ opacity:0; transform: translate(-50%,-50%) scale(0.6); }
    25%{ opacity:1; transform: translate(-50%,-50%) scale(1.1); }
    100%{ opacity:0; transform: translate(-50%,-90%) scale(1); }
  }

  #footer-tag{
    margin-top: 10px;
    font-size: 11px;
    letter-spacing: 1px;
    text-transform: uppercase;
    color: var(--muted);
    text-align:center;
    font-weight:700;
  }
  #footer-tag span{ color: var(--grape); }

  /* overlays */
  .overlay{
    position:fixed;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    text-align:center;
    background: rgba(58,46,77,0.35);
    backdrop-filter: blur(6px);
    z-index:40;
    padding: 20px;
    transition: opacity 0.3s ease, visibility 0.3s ease;
  }
  .overlay.hidden{ opacity:0; visibility:hidden; pointer-events:none; }

  .card{
    background: #fffdf9;
    border: 1px solid var(--border);
    border-radius: 24px;
    padding: 34px 30px;
    max-width: 380px;
    width:100%;
    box-shadow: 0 20px 60px rgba(58,46,77,0.25);
  }

  .card .big-emoji{ font-size: 54px; margin-bottom: 8px; }

  .card h1{
    font-family:'Fredoka', sans-serif;
    font-size: 32px;
    font-weight:700;
    margin-bottom: 6px;
  }
  .card h1 span{ color: var(--raspberry); }

  .card p{
    color: var(--muted);
    font-size: 14px;
    line-height:1.55;
    margin-bottom: 22px;
    font-weight:600;
  }

  .stat-line{
    font-size: 15px;
    margin-bottom: 6px;
    font-weight:700;
  }
  .stat-line b{ color: var(--grape); }

  .btn{
    font-family:'Fredoka', sans-serif;
    font-size: 16px;
    font-weight:600;
    color: #fff;
    background: linear-gradient(90deg, var(--raspberry), var(--tangerine));
    border:none;
    border-radius: 50px;
    padding: 13px 36px;
    cursor:pointer;
    box-shadow: 0 10px 24px rgba(255,107,107,0.35);
    transition: transform 0.15s ease;
  }
  .btn:active{ transform: scale(0.95); }

  .hint{
    margin-top: 18px;
    font-size: 11px;
    color: var(--muted);
    letter-spacing: 0.5px;
    font-weight:700;
  }

  @media (max-width:480px){
    .card{ padding: 26px 20px; }
    .card h1{ font-size: 26px; }
  }

  @media (max-height:640px){
    #topbar{ margin-bottom:6px; }
    .stat-chip{ padding:4px 9px; }
    .stat-chip .val{ font-size:13px; }
    #footer-tag{ margin-top:4px; font-size:9px; }
  }
</style>
</head>
<body>

<div id="app">
  <div id="topbar">
    <div id="brand">Coding <span>Stellix</span> β€” Bubble Blitz</div>
    <div id="stat-row">
      <div class="stat-chip"><div class="lbl">Level</div><div class="val" id="level-val">1</div></div>
      <div class="stat-chip"><div class="lbl">Score</div><div class="val" id="score-val">0</div></div>
      <div class="stat-chip" id="sound-toggle" style="cursor:pointer;"><div class="lbl">Sound</div><div class="val" id="sound-val">πŸ”Š</div></div>
    </div>
  </div>

  <div id="board-wrap">
    <canvas id="game"></canvas>
    <div id="combo-pop"></div>
  </div>

  <div id="footer-tag">Bubble Blitz &nbsp;β€’&nbsp; Made by <span>Coding Stellix</span></div>
</div>

<!-- START -->
<div class="overlay" id="start-screen">
  <div class="card">
    <div class="big-emoji">🎯🫧</div>
    <h1>Bubble <span>Blitz</span></h1>
    <p>Aim and shoot bubbles to match 3 or more of the same color. Clear the board before the bubbles reach the bottom! A fresh arcade game from Coding Stellix.</p>
    <button class="btn" id="start-btn">Start Blitz</button>
    <div class="hint">DRAG TO AIM β€’ RELEASE TO SHOOT</div>
  </div>
</div>

<!-- END -->
<div class="overlay hidden" id="end-screen">
  <div class="card">
    <div class="big-emoji" id="end-emoji">πŸŽ‰</div>
    <h1 id="end-title">Game <span>Over</span></h1>
    <p id="end-sub">Nice shooting! Here's your run.</p>
    <div class="stat-line">Score: <b id="end-score">0</b></div>
    <div class="stat-line" style="margin-bottom:22px;">Level Reached: <b id="end-level">1</b></div>
    <button class="btn" id="restart-btn">Play Again</button>
  </div>
</div>

<script>
(function(){
  const COLORS = ['#ff4d6d','#ff9f1c','#ffd60a','#06d6a0','#7b2cbf'];
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const boardWrap = document.getElementById('board-wrap');
  const comboPop = document.getElementById('combo-pop');

  // ===== Sound engine (Web Audio API, no external files) =====
  let audioCtx = null;
  let soundOn = true;
  function ensureAudio(){
    if(!audioCtx){
      try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
      catch(e){ audioCtx = null; }
    }
    if(audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
  }
  function tone(freq, dur, type, gainVal, delay){
    if(!soundOn || !audioCtx) return;
    const t0 = audioCtx.currentTime + (delay||0);
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type = type || 'sine';
    osc.frequency.setValueAtTime(freq, t0);
    gain.gain.setValueAtTime(0, t0);
    gain.gain.linearRampToValueAtTime(gainVal||0.15, t0+0.01);
    gain.gain.exponentialRampToValueAtTime(0.001, t0+dur);
    osc.connect(gain).connect(audioCtx.destination);
    osc.start(t0);
    osc.stop(t0+dur+0.02);
  }
  const sfx = {
    shoot(){ tone(520,0.09,'triangle',0.12); tone(720,0.06,'triangle',0.06,0.02); },
    bounce(){ tone(300,0.06,'sine',0.08); },
    land(){ tone(240,0.08,'sine',0.10); },
    pop(n){
      const base = 420 + Math.min(n,8)*40;
      for(let i=0;i<Math.min(n,6);i++){
        tone(base + i*70, 0.14, 'square', 0.08, i*0.04);
      }
    },
    mega(){ tone(300,0.18,'sawtooth',0.12); tone(600,0.2,'sawtooth',0.1,0.06); tone(900,0.22,'sawtooth',0.08,0.12); },
    newRow(){ tone(180,0.25,'sine',0.12); },
    levelUp(){ tone(440,0.12,'triangle',0.14); tone(660,0.14,'triangle',0.12,0.1); tone(880,0.18,'triangle',0.12,0.2); },
    gameOver(){ tone(320,0.22,'sawtooth',0.14); tone(220,0.3,'sawtooth',0.12,0.15); tone(140,0.4,'sawtooth',0.1,0.3); }
  };

  const soundToggle = document.getElementById('sound-toggle');
  const soundVal = document.getElementById('sound-val');
  soundToggle.addEventListener('click', ()=>{
    soundOn = !soundOn;
    soundVal.textContent = soundOn ? 'πŸ”Š' : 'πŸ”‡';
    if(soundOn) ensureAudio();
  });

  const COLS = 10;
  let R = 18; // bubble radius, recalculated on resize
  let colSpacing, rowHeight, offsetX, offsetY;

  let W, H;
  function resize(){
    // available space inside board-wrap (accounting for its padding)
    const wrapStyle = getComputedStyle(boardWrap);
    const padX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight);
    const padY = parseFloat(wrapStyle.paddingTop) + parseFloat(wrapStyle.paddingBottom);
    const availW = Math.max(220, boardWrap.clientWidth - padX);
    const availH = Math.max(260, boardWrap.clientHeight - padY);

    const visibleRows = 11;
    // try width-constrained size first
    let widthBasedW = Math.min(availW, 420);
    let rFromWidth = widthBasedW / COLS / 2;
    let hFromWidth = (rFromWidth + 6) + visibleRows*(rFromWidth*Math.sqrt(3)) + rFromWidth*2;

    if(hFromWidth <= availH){
      W = widthBasedW;
      R = rFromWidth;
    } else {
      // height-constrained: solve R from availH
      // availH = (R+6) + visibleRows*R*sqrt(3) + R*2
      const denom = 1 + visibleRows*Math.sqrt(3) + 2;
      R = Math.max(10, (availH - 6) / denom);
      W = Math.min(availW, R*2*COLS);
    }

    colSpacing = R*2;
    rowHeight = R*Math.sqrt(3);
    offsetX = R;
    offsetY = R + 6;
    H = offsetY + visibleRows*rowHeight + R*2;

    canvas.width = W*devicePixelRatio;
    canvas.height = H*devicePixelRatio;
    canvas.style.width = W+'px';
    canvas.style.height = H+'px';
    ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0);
  }
  window.addEventListener('resize', resize);
  window.addEventListener('orientationchange', ()=>setTimeout(resize,150));

  let grid = {}; // key "row,col" -> colorIndex
  let rows = 6;
  let level = 1;
  let score = 0;
  let running = false;
  let shooter = { x:0, y:0, angle:-Math.PI/2, color:0, nextColor:0 };
  let flying = null; // {x,y,vx,vy,color}
  let shotsUntilNewRow = 6;
  let aiming = false;
  let particles = [];
  let dangerRow = 10;

  function cellPos(row, col){
    const xOff = (row % 2 === 1) ? R : 0;
    return {
      x: offsetX + col*colSpacing + xOff,
      y: offsetY + row*rowHeight
    };
  }

  function neighbors(row,col){
    const even = row % 2 === 0;
    const deltas = even
      ? [[-1,0],[1,0],[0,-1],[-1,-1],[0,1],[-1,1]]
      : [[-1,0],[1,0],[0,-1],[1,-1],[0,1],[1,1]];
    return deltas.map(([dc,dr])=>[row+dr, col+dc]);
  }

  function key(r,c){ return r+','+c; }

  function randomStartColor(){
    // only pick colors currently present on the board (or all, if board empty) - keeps it always winnable-ish
    const present = new Set(Object.values(grid));
    const pool = present.size ? Array.from(present) : [0,1,2,3,4];
    return pool[Math.floor(Math.random()*pool.length)];
  }

  function initBoard(){
    grid = {};
    rows = 5 + Math.min(level-1, 4);
    for(let r=0;r<rows;r++){
      for(let c=0;c<COLS - (r%2===1?1:0);c++){
        grid[key(r,c)] = Math.floor(Math.random()*Math.min(5, 3+level));
      }
    }
    shotsUntilNewRow = 6;
  }

  function boardTopDangerY(){
    return cellPos(dangerRow,0).y;
  }

  function setupShooter(){
    shooter.x = W/2;
    shooter.y = H - R - 4;
    shooter.color = randomStartColor();
    shooter.nextColor = randomStartColor();
  }

  function fireBubble(angle){
    if(flying) return;
    ensureAudio();
    sfx.shoot();
    const speed = 620;
    flying = {
      x: shooter.x, y: shooter.y,
      vx: Math.cos(angle)*speed,
      vy: Math.sin(angle)*speed,
      color: shooter.color
    };
  }

  function nearestEmptyCell(x,y){
    // estimate row/col then search small neighborhood for closest empty
    let bestKey = null, bestDist = Infinity, bestRow=0, bestCol=0;
    const approxRow = Math.round((y-offsetY)/rowHeight);
    for(let r = Math.max(0,approxRow-2); r<=approxRow+2; r++){
      const xOff = (r%2===1)?R:0;
      const approxCol = Math.round((x-offsetX-xOff)/colSpacing);
      for(let c=approxCol-2;c<=approxCol+2;c++){
        if(c<0 || c>=COLS) continue;
        const k = key(r,c);
        if(grid[k] !== undefined) continue;
        const p = cellPos(r,c);
        const d = (p.x-x)*(p.x-x)+(p.y-y)*(p.y-y);
        if(d < bestDist){ bestDist = d; bestKey = k; bestRow=r; bestCol=c; }
      }
    }
    return { key:bestKey, row:bestRow, col:bestCol };
  }

  function popMatches(row,col){
    const color = grid[key(row,col)];
    const visited = new Set();
    const stack = [[row,col]];
    const group = [];
    while(stack.length){
      const [r,c] = stack.pop();
      const k = key(r,c);
      if(visited.has(k)) continue;
      visited.add(k);
      if(grid[k] !== color) continue;
      group.push([r,c]);
      for(const [nr,nc] of neighbors(r,c)){
        if(!visited.has(key(nr,nc))) stack.push([nr,nc]);
      }
    }
    if(group.length >= 3){
      group.forEach(([r,c])=>{
        const p = cellPos(r,c);
        spawnBurst(p.x,p.y,COLORS[color]);
        delete grid[key(r,c)];
      });
      score += group.length * 12;
      if(group.length >= 6) sfx.mega(); else sfx.pop(group.length);
      showCombo(group.length);
      dropFloating();
    }
  }

  function dropFloating(){
    // BFS from row 0 (connected to ceiling); anything not reached falls
    const connected = new Set();
    const stack = [];
    for(let c=0;c<COLS;c++){
      const k = key(0,c);
      if(grid[k] !== undefined) stack.push([0,c]);
    }
    while(stack.length){
      const [r,c] = stack.pop();
      const k = key(r,c);
      if(connected.has(k)) continue;
      if(grid[k] === undefined) continue;
      connected.add(k);
      for(const [nr,nc] of neighbors(r,c)){
        if(!connected.has(key(nr,nc))) stack.push([nr,nc]);
      }
    }
    let fell = 0;
    for(const k in grid){
      if(!connected.has(k)){
        const [r,c] = k.split(',').map(Number);
        const p = cellPos(r,c);
        spawnBurst(p.x,p.y,COLORS[grid[k]]);
        delete grid[k];
        fell++;
      }
    }
    if(fell>0) score += fell*18;
  }

  function spawnBurst(x,y,color){
    for(let i=0;i<8;i++){
      const ang = Math.random()*Math.PI*2;
      const spd = 1+Math.random()*3;
      particles.push({x,y,vx:Math.cos(ang)*spd,vy:Math.sin(ang)*spd,life:1,color});
    }
  }

  function showCombo(n){
    if(n < 3) return;
    const label = n>=6 ? 'MEGA POP!' : n>=4 ? 'GREAT POP!' : 'POP!';
    comboPop.textContent = label + ' +' + (n*12);
    comboPop.style.left = shooter.x+'px';
    comboPop.style.top = (H*0.4)+'px';
    comboPop.classList.remove('show');
    void comboPop.offsetWidth;
    comboPop.classList.add('show');
  }

  function addNewRow(){
    sfx.newRow();
    // shift all rows down by 1
    const newGrid = {};
    for(const k in grid){
      const [r,c] = k.split(',').map(Number);
      newGrid[key(r+1,c)] = grid[k];
    }
    for(let c=0;c<COLS;c++){
      newGrid[key(0,c)] = Math.floor(Math.random()*Math.min(5,3+level));
    }
    grid = newGrid;
    checkDanger();
  }

  function checkDanger(){
    for(const k in grid){
      const [r] = k.split(',').map(Number);
      if(r >= dangerRow){
        endGame();
        return;
      }
    }
  }

  function endGame(){
    running = false;
    sfx.gameOver();
    document.getElementById('end-score').textContent = Math.floor(score);
    document.getElementById('end-level').textContent = level;
    document.getElementById('end-screen').classList.remove('hidden');
  }

  function update(dt){
    if(flying){
      flying.x += flying.vx*dt;
      flying.y += flying.vy*dt;
      if(flying.x - R < 0){ flying.x = R; flying.vx *= -1; sfx.bounce(); }
      if(flying.x + R > W){ flying.x = W-R; flying.vx *= -1; sfx.bounce(); }
      if(flying.y - R <= 0){
        sfx.land();
        landBubble();
      } else {
        // check collision with grid bubbles
        for(const k in grid){
          const [r,c] = k.split(',').map(Number);
          const p = cellPos(r,c);
          const dx = p.x-flying.x, dy = p.y-flying.y;
          if(dx*dx+dy*dy < (R*1.9)*(R*1.9)){
            sfx.land();
            landBubble();
            break;
          }
        }
      }
    }
    for(const p of particles){
      p.x += p.vx; p.y += p.vy;
      p.vx *= 0.95; p.vy *= 0.95;
      p.life -= dt*2;
    }
    particles = particles.filter(p=>p.life>0);
  }

  function landBubble(){
    if(!flying) return;
    const target = nearestEmptyCell(flying.x, flying.y);
    if(target.key){
      grid[target.key] = flying.color;
      popMatches(target.row, target.col);
      checkDanger();
    }
    flying = null;
    shooter.color = shooter.nextColor;
    shooter.nextColor = randomStartColor();
    shotsUntilNewRow--;
    if(shotsUntilNewRow <= 0){
      shotsUntilNewRow = 6;
      addNewRow();
    }
    if(Object.keys(grid).length === 0){
      level++;
      score += 100;
      sfx.levelUp();
      initBoard();
    }
  }

  function draw(){
    ctx.clearRect(0,0,W,H);

    // danger line
    ctx.beginPath();
    ctx.setLineDash([6,6]);
    ctx.moveTo(0, boardTopDangerY());
    ctx.lineTo(W, boardTopDangerY());
    ctx.strokeStyle = 'rgba(255,77,109,0.4)';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.setLineDash([]);

    // grid bubbles
    for(const k in grid){
      const [r,c] = k.split(',').map(Number);
      const p = cellPos(r,c);
      drawBubble(p.x,p.y,COLORS[grid[k]]);
    }

    // flying bubble
    if(flying){
      drawBubble(flying.x, flying.y, COLORS[flying.color]);
    }

    // particles
    for(const p of particles){
      ctx.beginPath();
      ctx.arc(p.x,p.y,3*Math.max(p.life,0), 0, Math.PI*2);
      ctx.fillStyle = p.color;
      ctx.globalAlpha = Math.max(p.life,0);
      ctx.fill();
      ctx.globalAlpha = 1;
    }

    // aim line
    if(aiming && !flying){
      ctx.beginPath();
      ctx.setLineDash([5,7]);
      ctx.moveTo(shooter.x, shooter.y);
      const len = 200;
      ctx.lineTo(shooter.x + Math.cos(shooter.angle)*len, shooter.y + Math.sin(shooter.angle)*len);
      ctx.strokeStyle = 'rgba(58,46,77,0.35)';
      ctx.lineWidth = 2;
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // shooter bubble
    drawBubble(shooter.x, shooter.y, COLORS[shooter.color]);
    // next bubble preview
    ctx.beginPath();
    ctx.arc(shooter.x - R*2.4, shooter.y, R*0.6, 0, Math.PI*2);
    ctx.fillStyle = COLORS[shooter.nextColor];
    ctx.globalAlpha = 0.75;
    ctx.fill();
    ctx.globalAlpha = 1;
  }

  function drawBubble(x,y,color){
    ctx.beginPath();
    ctx.arc(x,y,R*0.92,0,Math.PI*2);
    ctx.fillStyle = color;
    ctx.shadowColor = 'rgba(0,0,0,0.15)';
    ctx.shadowBlur = 4;
    ctx.fill();
    ctx.shadowBlur = 0;
    // glass shine
    ctx.beginPath();
    ctx.arc(x-R*0.32, y-R*0.32, R*0.28, 0, Math.PI*2);
    ctx.fillStyle = 'rgba(255,255,255,0.55)';
    ctx.fill();
  }

  let lastTs = 0;
  function loop(ts){
    if(!lastTs) lastTs = ts;
    const dt = Math.min((ts-lastTs)/1000, 0.033);
    lastTs = ts;
    if(running){
      update(dt);
      draw();
      document.getElementById('score-val').textContent = Math.floor(score);
      document.getElementById('level-val').textContent = level;
    }
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);

  function pointerAngleFrom(clientX, clientY){
    const rect = canvas.getBoundingClientRect();
    const x = clientX - rect.left;
    const y = clientY - rect.top;
    let angle = Math.atan2(y - shooter.y, x - shooter.x);
    // clamp to upward arc
    const minA = -Math.PI + 0.15, maxA = -0.15;
    if(angle > 0) angle = angle - Math.PI*2;
    angle = Math.max(minA, Math.min(maxA, angle));
    return angle;
  }

  canvas.addEventListener('pointerdown', (e)=>{
    if(!running || flying) return;
    aiming = true;
    shooter.angle = pointerAngleFrom(e.clientX, e.clientY);
  });
  canvas.addEventListener('pointermove', (e)=>{
    if(!aiming || flying) return;
    shooter.angle = pointerAngleFrom(e.clientX, e.clientY);
  });
  canvas.addEventListener('pointerup', (e)=>{
    if(!running || flying) { aiming=false; return; }
    if(aiming){
      fireBubble(shooter.angle);
    }
    aiming = false;
  });

  function startGame(){
    resize();
    level = 1;
    score = 0;
    particles = [];
    flying = null;
    initBoard();
    setupShooter();
    running = true;
    document.getElementById('start-screen').classList.add('hidden');
    document.getElementById('end-screen').classList.add('hidden');
  }

  document.getElementById('start-btn').addEventListener('click', ()=>{ ensureAudio(); startGame(); });
  document.getElementById('restart-btn').addEventListener('click', ()=>{ ensureAudio(); startGame(); });

  resize();
  initBoard();
  setupShooter();
  draw();
})();
</script>

</body>
</html>

Leave a Reply

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

SHARE:-

Trending Post

Latest Post