git » sparkle-arcade » commit a4d40a1

new games; add sounds

author Alan Dipert
2025-11-16 23:37:02 UTC
committer Alan Dipert
2025-11-16 23:37:02 UTC
parent 88ed3252cc666a7c1b5fbb5bb1150ce24e29cc18

new games; add sounds

Makefile +1 -1
blossom-blocks/game.js +51 -0
fairy-finder/game.js +57 -0
index.html +13 -1
moonlit-garden-match/game.js +64 -8
starlit-stacker/game.js +401 -0
starlit-stacker/index.html +68 -0
starlit-stacker/styles.css +197 -0

diff --git a/Makefile b/Makefile
index d9605a4..e547dc2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 .PHONY: deploy clean check-git-clean
 
-DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match
+DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match starlit-stacker
 DEPLOY_TARGET ?= arsien23i2@dreamhost:tailrecursion.com/arcade/
 RSYNC_FLAGS ?= -av --delete --chmod=F644,D755 --exclude='.DS_Store'
 
diff --git a/blossom-blocks/game.js b/blossom-blocks/game.js
index e86ff6b..14ca702 100644
--- a/blossom-blocks/game.js
+++ b/blossom-blocks/game.js
@@ -53,6 +53,7 @@
   let dragAnchor = { row: 0, col: 0 };
   let ghostSnapped = false;
   let boardMetrics = null;
+  const audio = createAudio();
 
   function parsePixels(value) {
     if (!value) return 0;
@@ -406,6 +407,7 @@
       board[targetRow][targetCol] = { emoji: piece.emoji };
       placedCells += 1;
     });
+    playSound('place');
     updateBoardUI();
     const clearedLines = clearLines();
     const gained = placedCells * 5 + clearedLines * 15;
@@ -461,6 +463,7 @@
     if (rowsToClear.length || colsToClear.length) {
       setTimeout(updateBoardUI, 200);
       showFloatingBonus(rowsToClear.length + colsToClear.length);
+      playSound('clear');
     }
     return rowsToClear.length + colsToClear.length;
   }
@@ -485,6 +488,7 @@
       ],
       { duration: 900 }
     );
+    playSound('bonus');
     setTimeout(() => {
       floatingScore.style.opacity = '0';
     }, 900);
@@ -515,6 +519,7 @@
   }
 
   function startGame() {
+    audio.resume();
     isActive = true;
     score = 0;
     board = createEmptyBoard();
@@ -530,6 +535,9 @@
     startOverlay.querySelector('p').textContent = message;
     startOverlay.querySelector('h2').textContent = 'Blossom Blocks';
     startOverlay.classList.remove('hidden');
+    if (!fromNewGame) {
+      playSound('over');
+    }
   }
 
   newGameBtn.addEventListener('click', () => {
@@ -542,4 +550,47 @@
   initBoard();
   board = createEmptyBoard();
   updateBoardUI();
+
+  function playSound(type) {
+    if (audio) audio.play(type);
+  }
+
+  function createAudio() {
+    const AudioCtx = window.AudioContext || window.webkitAudioContext;
+    if (!AudioCtx) return null;
+    const ctx = new AudioCtx();
+    const master = ctx.createGain();
+    master.gain.value = 0.14;
+    master.connect(ctx.destination);
+    ctx.suspend();
+    const tones = {
+      place: 820,
+      clear: 560,
+      bonus: 960,
+      over: 280,
+    };
+    function chirp(freq, duration = 0.2) {
+      const osc = ctx.createOscillator();
+      const gain = ctx.createGain();
+      osc.type = 'triangle';
+      osc.frequency.value = freq;
+      osc.connect(gain);
+      gain.connect(master);
+      const start = ctx.currentTime;
+      gain.gain.setValueAtTime(0.001, start);
+      gain.gain.linearRampToValueAtTime(0.22, start + 0.03);
+      gain.gain.exponentialRampToValueAtTime(0.001, start + duration);
+      osc.start(start);
+      osc.stop(start + duration + 0.02);
+    }
+    return {
+      resume() {
+        if (ctx.state === 'suspended') ctx.resume();
+      },
+      play(event) {
+        const freq = tones[event];
+        if (freq) chirp(freq);
+      },
+    };
+  }
 })();
diff --git a/fairy-finder/game.js b/fairy-finder/game.js
index b6ce557..2d7481b 100644
--- a/fairy-finder/game.js
+++ b/fairy-finder/game.js
@@ -26,6 +26,7 @@
     timer: 0,
     timerInterval: null,
   };
+  const audio = createAudio();
 
   function stopTimer() {
     if (game.timerInterval) {
@@ -152,11 +153,13 @@
     if (cell.hasFairy) {
       revealCellElement(row, col, cell);
       revealAllFairies();
+      playSound('fairy');
       finishGame(false);
       return;
     }
     game.revealedSafe += 1;
     revealCellElement(row, col, cell);
+    playSound(cell.neighborCount === 0 ? 'open' : 'chime');
     if (cell.neighborCount === 0) {
       floodReveal(row, col);
     }
@@ -184,6 +187,7 @@
         neighbor.revealed = true;
         game.revealedSafe += 1;
         revealCellElement(nr, nc, neighbor);
+        playSound(neighbor.neighborCount === 0 ? 'open' : 'chime');
         if (neighbor.neighborCount === 0) {
           queue.push([nr, nc]);
         }
@@ -227,6 +231,7 @@
       button.classList.add('cell--hidden');
     }
     updateStats();
+    playSound(cell.flagged ? 'flag' : 'unflag');
   }
 
   function revealAllFairies(isWin = false) {
@@ -252,6 +257,7 @@
       : 'A shy fairy fluttered out! Try again for a perfect sweep?';
     overlayButton.textContent = hasWon ? 'Play again' : 'Try again';
     overlay.classList.remove('hidden');
+    playSound(hasWon ? 'win' : 'lose');
   }
 
   function updateStats() {
@@ -278,6 +284,7 @@
   }
 
   function startNewRound() {
+    audio.resume();
     prepareGame();
     overlay.classList.add('hidden');
   }
@@ -286,4 +293,54 @@
   overlayButton.addEventListener('click', startNewRound);
 
   prepareGame();
+
+  function playSound(type) {
+    if (audio) audio.play(type);
+  }
+
+  function createAudio() {
+    const AudioCtx = window.AudioContext || window.webkitAudioContext;
+    if (!AudioCtx) return null;
+    const ctx = new AudioCtx();
+    const master = ctx.createGain();
+    master.gain.value = 0.18;
+    master.connect(ctx.destination);
+    ctx.suspend();
+    const tones = {
+      chime: 880,
+      open: 640,
+      fairy: 520,
+      flag: 720,
+      unflag: 420,
+      win: 990,
+      lose: 300,
+    };
+    function ping(freq, duration = 0.2) {
+      const osc = ctx.createOscillator();
+      const gain = ctx.createGain();
+      osc.type = 'sine';
+      osc.frequency.value = freq;
+      osc.connect(gain);
+      gain.connect(master);
+      const start = ctx.currentTime;
+      gain.gain.setValueAtTime(0.001, start);
+      gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
+      gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
+      osc.start(start);
+      osc.stop(start + duration + 0.02);
+    }
+    return {
+      resume() {
+        if (ctx.state === 'suspended') ctx.resume();
+      },
+      play(event) {
+        if (event === 'fairy') {
+          [520, 640, 760].forEach((freq, i) => ping(freq, 0.15, i * 0.04));
+          return;
+        }
+        const freq = tones[event];
+        if (freq) ping(freq);
+      },
+    };
+  }
 })();
diff --git a/index.html b/index.html
index 922f372..044f57b 100644
--- a/index.html
+++ b/index.html
@@ -15,7 +15,7 @@
     <header class="hero">
       <p class="subhead">tailrecursion.com/arcade</p>
       <h1>Viviana's Sparkle Arcade</h1>
-      <p class="intro">Four cozy browser games crafted for Viviana’s magical playtime. Click a card to jump straight in—no downloads, no ads, just pure pastel fun.</p>
+      <p class="intro">Five cozy browser games crafted for Viviana’s magical playtime. Click a card to jump straight in—no downloads, no ads, just pure pastel fun.</p>
     </header>
 
     <section class="game-grid">
@@ -55,6 +55,18 @@
         <a class="btn" href="moonlit-garden-match/" aria-label="Play Moonlit Garden Match">Play Moonlit Garden Match</a>
       </article>
 
+      <article class="game-card" role="article">
+        <div class="badge">Stacker ⭐</div>
+        <h2>Starlit Stacker</h2>
+        <p>Tetris-style constellations drift down from the night sky. Rotate and slide them to clear entire rows of stars.</p>
+        <ul>
+          <li>Classic 10×20 board</li>
+          <li>Next-piece peek and hard drop</li>
+          <li>Local best-score tracking</li>
+        </ul>
+        <a class="btn" href="starlit-stacker/" aria-label="Play Starlit Stacker">Play Starlit Stacker</a>
+      </article>
+
       <article class="game-card" role="article">
         <div class="badge">Puzzle 🧚</div>
         <h2>Fairy Finder</h2>
diff --git a/moonlit-garden-match/game.js b/moonlit-garden-match/game.js
index a860464..cdd7f2c 100644
--- a/moonlit-garden-match/game.js
+++ b/moonlit-garden-match/game.js
@@ -14,6 +14,7 @@
   let score = 0;
   let moves = 25;
   let busy = false;
+  const audio = createAudio();
 
   function createBoard() {
     const newGrid = Array.from({ length: size }, () => Array(size).fill(null));
@@ -90,19 +91,17 @@
     busy = true;
     swap(a, b);
     const matches = findMatches();
+    moves = Math.max(0, moves - 1);
+    updateHUD();
+    selected = null;
+    renderBoard();
     if (matches.length === 0) {
-      moves = Math.max(0, moves - 1);
-      updateHUD();
-      selected = null;
-      renderBoard();
       busy = false;
+      playSound('bump');
       if (moves === 0) endGame();
       return;
     }
-    moves = Math.max(0, moves - 1);
-    updateHUD();
-    selected = null;
-    renderBoard();
+    playSound('swap');
     resolveMatches(matches, 1);
   }
 
@@ -181,6 +180,7 @@
     score += matchCells.length * 60 * chain;
     updateHUD();
     animateMatches(matchCells);
+    playSound(chain > 1 ? 'cascade' : 'match');
     setTimeout(() => {
       applyGravity();
       fillNew();
@@ -242,6 +242,7 @@
   }
 
   function startGame() {
+    audio.resume();
     grid = createBoard();
     score = 0;
     moves = 25;
@@ -255,8 +256,63 @@
   function endGame() {
     overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`;
     overlay.classList.remove('hidden');
+    playSound('end');
   }
 
   newGameBtn.addEventListener('click', startGame);
   startBtn.addEventListener('click', startGame);
+
+  function playSound(type) {
+    if (audio) audio.play(type);
+  }
+
+  function createAudio() {
+    const AudioCtx = window.AudioContext || window.webkitAudioContext;
+    if (!AudioCtx) return null;
+    const ctx = new AudioCtx();
+    const master = ctx.createGain();
+    master.gain.value = 0.18;
+    master.connect(ctx.destination);
+    ctx.suspend();
+    function blip(freq, duration = 0.18, delay = 0) {
+      const osc = ctx.createOscillator();
+      const gain = ctx.createGain();
+      osc.type = 'triangle';
+      osc.frequency.value = freq;
+      osc.connect(gain);
+      gain.connect(master);
+      const start = ctx.currentTime + delay;
+      gain.gain.setValueAtTime(0.001, start);
+      gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
+      gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
+      osc.start(start);
+      osc.stop(start + duration + 0.02);
+    }
+    return {
+      resume() {
+        if (ctx.state === 'suspended') ctx.resume();
+      },
+      play(event) {
+        switch (event) {
+          case 'bump':
+            blip(320);
+            break;
+          case 'swap':
+            blip(640);
+            break;
+          case 'match':
+            blip(900);
+            break;
+          case 'cascade':
+            [900, 1080].forEach((freq, i) => blip(freq, 0.2, i * 0.05));
+            break;
+          case 'end':
+            blip(280, 0.4);
+            break;
+          default:
+            break;
+        }
+      },
+    };
+  }
 })();
diff --git a/starlit-stacker/game.js b/starlit-stacker/game.js
new file mode 100644
index 0000000..e1fcb44
--- /dev/null
+++ b/starlit-stacker/game.js
@@ -0,0 +1,401 @@
+(() => {
+  const rows = 20;
+  const cols = 10;
+  const boardEl = document.getElementById('board');
+  const previewEl = document.getElementById('preview');
+  const scoreEl = document.getElementById('score');
+  const linesEl = document.getElementById('lines');
+  const bestEl = document.getElementById('best');
+  const newGameBtn = document.getElementById('new-game');
+  const overlay = document.getElementById('overlay');
+  const startBtn = document.getElementById('start');
+  const touchControls = document.getElementById('touch-controls');
+
+  const bestKey = 'starlitBestScore';
+
+  const shapes = [
+    [[1, 1, 1, 1]],
+    [[1, 0, 0], [1, 1, 1]],
+    [[0, 0, 1], [1, 1, 1]],
+    [[1, 1], [1, 1]],
+    [[0, 1, 1], [1, 1, 0]],
+    [[0, 1, 0], [1, 1, 1]],
+    [[1, 1, 0], [0, 1, 1]],
+  ];
+
+  const colors = ['#f9b5ff', '#9dd1ff', '#a3ffde', '#ffd4a8', '#ff9bd1', '#c4a5ff', '#a5ffe3'];
+
+  let board = createBoard();
+  let current = null;
+  let nextPiece = null;
+  let dropTimer = null;
+  let dropInterval = 800;
+  let score = 0;
+  let lines = 0;
+  let bestScore = Number(localStorage.getItem(bestKey)) || 0;
+  let isActive = false;
+  const audio = createAudio();
+
+  const cells = [];
+
+  function initGrid() {
+    boardEl.innerHTML = '';
+    for (let r = 0; r < rows; r += 1) {
+      const rowCells = [];
+      for (let c = 0; c < cols; c += 1) {
+        const cell = document.createElement('div');
+        cell.className = 'cell';
+        boardEl.appendChild(cell);
+        rowCells.push(cell);
+      }
+      cells.push(rowCells);
+    }
+  }
+
+  function createBoard() {
+    return Array.from({ length: rows }, () => Array(cols).fill(null));
+  }
+
+  function randomPiece() {
+    const idx = Math.floor(Math.random() * shapes.length);
+    return {
+      shape: shapes[idx].map((row) => row.slice()),
+      row: 0,
+      col: Math.floor(cols / 2) - 2,
+      color: colors[idx],
+    };
+  }
+
+  function spawnPiece() {
+    current = nextPiece || randomPiece();
+    current.row = -1;
+    current.col = Math.floor(cols / 2) - Math.ceil(current.shape[0].length / 2);
+    nextPiece = randomPiece();
+    renderPreview();
+    if (!canMove(current.shape, current.row + 1, current.col)) {
+      endGame();
+      return;
+    }
+    current.row += 1;
+  }
+
+  function renderPreview() {
+    previewEl.innerHTML = '';
+    const grid = Array.from({ length: 4 }, () => Array(4).fill(false));
+    nextPiece.shape.forEach((row, r) => {
+      row.forEach((val, c) => {
+        if (val && r < 4 && c < 4) grid[r][c] = true;
+      });
+    });
+    grid.forEach((row) => {
+      row.forEach((filled) => {
+        const cell = document.createElement('div');
+        cell.className = 'preview-cell';
+        if (filled) {
+          cell.style.background = nextPiece.color;
+        }
+        previewEl.appendChild(cell);
+      });
+    });
+  }
+
+  function canMove(shape, targetRow, targetCol) {
+    for (let r = 0; r < shape.length; r += 1) {
+      for (let c = 0; c < shape[r].length; c += 1) {
+        if (!shape[r][c]) continue;
+        const newRow = targetRow + r;
+        const newCol = targetCol + c;
+        if (newCol < 0 || newCol >= cols || newRow >= rows) return false;
+        if (newRow >= 0 && board[newRow][newCol]) return false;
+      }
+    }
+    return true;
+  }
+
+  function placePiece() {
+    const piece = current;
+    piece.shape.forEach((row, r) => {
+      row.forEach((val, c) => {
+        if (val) {
+          const boardRow = piece.row + r;
+          const boardCol = piece.col + c;
+          if (boardRow >= 0) {
+            board[boardRow][boardCol] = current.color;
+          }
+        }
+      });
+    });
+    current = null;
+    playSound('lock');
+    const cleared = clearLines();
+    if (cleared === 0) {
+      spawnPiece();
+    } else {
+      playSound(cleared >= 4 ? 'tetris' : 'clear');
+      setTimeout(spawnPiece, 60);
+    }
+  }
+
+  function rotate(shape) {
+    const size = shape.length;
+    const width = shape[0].length;
+    const newShape = Array.from({ length: width }, () => Array(size).fill(0));
+    for (let r = 0; r < size; r += 1) {
+      for (let c = 0; c < width; c += 1) {
+        newShape[c][size - 1 - r] = shape[r][c];
+      }
+    }
+    return newShape;
+  }
+
+  function tryRotate() {
+    const nextShape = rotate(current.shape);
+    if (canMove(nextShape, current.row, current.col)) {
+      current.shape = nextShape;
+    }
+  }
+
+  function clearLines() {
+    let cleared = 0;
+    for (let r = rows - 1; r >= 0; r -= 1) {
+      if (board[r].every(Boolean)) {
+        board.splice(r, 1);
+        board.unshift(Array(cols).fill(null));
+        cleared += 1;
+        r += 1;
+      }
+    }
+    if (cleared > 0) {
+      score += cleared * 100;
+      lines += cleared;
+      dropInterval = Math.max(200, 800 - lines * 10);
+      updateHUD();
+      restartDropTimer();
+    }
+    return cleared;
+  }
+
+  function moveDown() {
+    if (!current) return;
+    if (canMove(current.shape, current.row + 1, current.col)) {
+      current.row += 1;
+    } else {
+      placePiece();
+    }
+    render();
+  }
+
+  function hardDrop() {
+    if (!current) return;
+    while (canMove(current.shape, current.row + 1, current.col)) {
+      current.row += 1;
+    }
+    placePiece();
+    render();
+    playSound('drop');
+  }
+
+  function render() {
+    for (let r = 0; r < rows; r += 1) {
+      for (let c = 0; c < cols; c += 1) {
+        const cell = cells[r][c];
+        const color = board[r][c];
+        if (color) {
+          cell.classList.add('filled');
+          cell.style.background = color;
+        } else {
+          cell.classList.remove('filled');
+          cell.style.background = 'var(--cell-empty)';
+        }
+      }
+    }
+    if (current) {
+      current.shape.forEach((row, rIndex) => {
+        row.forEach((val, cIndex) => {
+          if (val) {
+            const br = current.row + rIndex;
+            const bc = current.col + cIndex;
+            if (br >= 0 && br < rows && bc >= 0 && bc < cols) {
+              const cell = cells[br][bc];
+              cell.classList.add('filled');
+              cell.style.background = current.color;
+            }
+          }
+        });
+      });
+    }
+  }
+
+  function updateHUD() {
+    scoreEl.textContent = score;
+    linesEl.textContent = lines;
+    if (score > bestScore) {
+      bestScore = score;
+      localStorage.setItem(bestKey, String(bestScore));
+    }
+    bestEl.textContent = bestScore;
+  }
+
+  function startGame() {
+    if (audio) audio.resume();
+    board = createBoard();
+    score = 0;
+    lines = 0;
+    dropInterval = 800;
+    updateHUD();
+    if (dropTimer) clearInterval(dropTimer);
+    current = null;
+    nextPiece = randomPiece();
+    spawnPiece();
+    render();
+    dropTimer = setInterval(moveDown, dropInterval);
+    isActive = true;
+    overlay.classList.add('hidden');
+  }
+
+  function endGame() {
+    isActive = false;
+    clearInterval(dropTimer);
+    dropTimer = null;
+    overlay.querySelector('p').textContent = `Final score: ${score} ⭐`;
+    overlay.classList.remove('hidden');
+  }
+
+  function restartDropTimer() {
+    if (dropTimer) {
+      clearInterval(dropTimer);
+    }
+    dropTimer = setInterval(moveDown, dropInterval);
+  }
+
+  function move(direction) {
+    if (!isActive) return;
+    const offset = direction === 'left' ? -1 : 1;
+    if (canMove(current.shape, current.row, current.col + offset)) {
+      current.col += offset;
+      render();
+      playSound('move');
+    }
+  }
+
+  function softDrop() {
+    if (canMove(current.shape, current.row + 1, current.col)) {
+      current.row += 1;
+      score += 1;
+      updateHUD();
+      render();
+      playSound('soft');
+    }
+  }
+
+  document.addEventListener('keydown', (event) => {
+    if (!isActive && event.code !== 'Space') return;
+    switch (event.code) {
+      case 'ArrowLeft':
+        event.preventDefault();
+        move('left');
+        break;
+      case 'ArrowRight':
+        event.preventDefault();
+        move('right');
+        break;
+      case 'ArrowUp':
+        event.preventDefault();
+        tryRotate();
+        render();
+        playSound('rotate');
+        break;
+      case 'ArrowDown':
+        event.preventDefault();
+        softDrop();
+        break;
+      case 'Space':
+        event.preventDefault();
+        hardDrop();
+        break;
+      default:
+        break;
+    }
+  });
+
+  touchControls.addEventListener('click', (event) => {
+    const button = event.target.closest('button');
+    if (!button) return;
+    const action = button.dataset.action;
+    if (!isActive && action !== 'drop') return;
+    switch (action) {
+      case 'left':
+        move('left');
+        break;
+      case 'right':
+        move('right');
+        break;
+      case 'rotate':
+        tryRotate();
+        render();
+        break;
+      case 'drop':
+        softDrop();
+        break;
+      default:
+        break;
+    }
+  });
+
+  newGameBtn.addEventListener('click', startGame);
+  startBtn.addEventListener('click', startGame);
+
+  bestEl.textContent = bestScore;
+  initGrid();
+  render();
+
+  function playSound(type) {
+    if (audio) audio.play(type);
+  }
+
+  function createAudio() {
+    const AudioCtx = window.AudioContext || window.webkitAudioContext;
+    if (!AudioCtx) return null;
+    const ctx = new AudioCtx();
+    const master = ctx.createGain();
+    master.gain.value = 0.18;
+    master.connect(ctx.destination);
+    ctx.suspend();
+    const tones = {
+      move: 760,
+      rotate: 840,
+      lock: 520,
+      clear: 960,
+      tetris: 0,
+      drop: 620,
+      soft: 700,
+    };
+    function schedule(freq, duration = 0.18, delay = 0) {
+      const osc = ctx.createOscillator();
+      const gain = ctx.createGain();
+      osc.type = 'triangle';
+      osc.frequency.value = freq;
+      osc.connect(gain);
+      gain.connect(master);
+      const start = ctx.currentTime + delay;
+      gain.gain.setValueAtTime(0.0001, start);
+      gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
+      gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
+      osc.start(start);
+      osc.stop(start + duration + 0.02);
+    }
+    return {
+      resume() {
+        if (ctx.state === 'suspended') ctx.resume();
+      },
+      play(type) {
+        if (type === 'tetris') {
+          [880, 980, 1100, 1300].forEach((freq, i) => schedule(freq, 0.2, i * 0.05));
+          return;
+        }
+        const freq = tones[type] || 600;
+        schedule(freq);
+      },
+    };
+  }
+})();
diff --git a/starlit-stacker/index.html b/starlit-stacker/index.html
new file mode 100644
index 0000000..cf0477a
--- /dev/null
+++ b/starlit-stacker/index.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Starlit Stacker</title>
+  <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=Baloo+2:wght@600&family=Nunito:wght@400;600&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="styles.css" />
+  <script src="game.js" defer></script>
+</head>
+<body>
+  <div class="starfield" aria-hidden="true">✨ 🌙 ⭐ 💜 💖</div>
+  <main class="layout">
+    <header class="hero">
+      <div>
+        <h1>Starlit Stacker</h1>
+        <p class="tagline">Drop glowing constellations into neat rows to keep the night sky tidy.</p>
+      </div>
+      <div class="panel">
+        <div>Score: <span id="score">0</span> ⭐</div>
+        <div>Lines: <span id="lines">0</span> ✨</div>
+        <div>Best: <span id="best">0</span> 🌙</div>
+        <button class="btn" id="new-game">New Game</button>
+      </div>
+    </header>
+
+    <section class="play">
+      <div class="board" id="board" aria-label="Tetris board" role="grid"></div>
+      <aside class="info">
+        <div class="next-box">
+          <h2>Next Shape</h2>
+          <div class="preview" id="preview"></div>
+        </div>
+        <div class="controls">
+          <h2>Controls</h2>
+          <ul>
+            <li>Left / Right: Move</li>
+            <li>Up: Rotate</li>
+            <li>Down: Soft drop</li>
+            <li>Space: Hard drop</li>
+          </ul>
+        </div>
+        <div class="instructions">
+          <h2>How to play</h2>
+          <p>Stack dreamy blocks to fill entire rows. Completed rows vanish in sparkles. Keep the night sky clear as long as you can!</p>
+        </div>
+      </aside>
+    </section>
+  </main>
+
+  <div class="overlay" id="overlay">
+    <div class="card">
+      <h2>Starlit Stacker</h2>
+      <p>Use the arrow keys or on-screen buttons to guide the glowing pieces. Fill rows to earn stars!</p>
+      <button class="btn btn-lg" id="start">Play</button>
+    </div>
+  </div>
+
+  <div class="touch-controls" id="touch-controls">
+    <button data-action="left">⟵</button>
+    <button data-action="rotate">⟳</button>
+    <button data-action="right">⟶</button>
+    <button data-action="drop">⬇</button>
+  </div>
+</body>
+</html>
diff --git a/starlit-stacker/styles.css b/starlit-stacker/styles.css
new file mode 100644
index 0000000..509ed7e
--- /dev/null
+++ b/starlit-stacker/styles.css
@@ -0,0 +1,197 @@
+:root {
+  --bg-top: #0b0530;
+  --bg-bottom: #2d1766;
+  --grid-border: rgba(255, 255, 255, 0.2);
+  --cell-empty: rgba(255, 255, 255, 0.08);
+  --accent: #ff95df;
+  --accent-2: #8cc7ff;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  font-family: 'Nunito', system-ui, sans-serif;
+  background: radial-gradient(circle at top, var(--bg-top), var(--bg-bottom));
+  min-height: 100vh;
+  color: #f8f0ff;
+}
+
+.starfield {
+  position: fixed;
+  inset: 0;
+  font-size: min(7rem, 15vw);
+  opacity: 0.05;
+  text-align: center;
+  pointer-events: none;
+}
+
+.layout {
+  max-width: 1000px;
+  margin: 0 auto;
+  padding: 1.5rem clamp(1rem, 4vw, 3rem) 2.5rem;
+  position: relative;
+  z-index: 1;
+}
+
+h1, h2 {
+  font-family: 'Baloo 2', 'Nunito', sans-serif;
+  color: #ff98e0;
+  margin: 0 0 0.5rem;
+}
+
+.hero {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.tagline { color: #d2c0ff; margin: 0; }
+
+.panel {
+  background: rgba(255, 255, 255, 0.08);
+  padding: 1rem 1.2rem;
+  border-radius: 1.2rem;
+  box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4);
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  font-weight: 600;
+}
+
+.btn {
+  border: none;
+  border-radius: 999px;
+  padding: 0.5rem 1.5rem;
+  background: linear-gradient(120deg, var(--accent), var(--accent-2));
+  color: white;
+  font-weight: 700;
+  cursor: pointer;
+  box-shadow: 0 15px 35px rgba(255, 149, 223, 0.4);
+}
+
+.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.1rem; }
+
+.play {
+  margin-top: 1.5rem;
+  display: grid;
+  grid-template-columns: minmax(240px, 2fr) minmax(200px, 1fr);
+  gap: 1.5rem;
+}
+
+.board {
+  width: min(340px, 80vw);
+  aspect-ratio: 10 / 20;
+  background: rgba(255, 255, 255, 0.04);
+  border-radius: 1rem;
+  padding: 0.5rem;
+  display: grid;
+  grid-template-columns: repeat(10, 1fr);
+  grid-template-rows: repeat(20, 1fr);
+  gap: 0.2rem;
+  border: 2px solid var(--grid-border);
+  box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.6);
+}
+
+.cell {
+  border-radius: 0.3rem;
+  background: var(--cell-empty);
+  transition: background 0.2s ease;
+}
+
+.cell.filled {
+  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.4);
+}
+
+.info {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.next-box, .controls, .instructions {
+  background: rgba(255, 255, 255, 0.07);
+  border-radius: 1.2rem;
+  padding: 1rem 1.2rem;
+  box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4);
+}
+
+.preview {
+  margin-top: 0.6rem;
+  width: 140px;
+  height: 140px;
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  grid-template-rows: repeat(4, 1fr);
+  gap: 0.25rem;
+}
+
+.preview-cell {
+  border-radius: 0.3rem;
+  background: rgba(255, 255, 255, 0.15);
+}
+
+.controls ul {
+  margin: 0;
+  padding-left: 1rem;
+}
+
+.overlay {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(9, 2, 30, 0.8);
+  z-index: 10;
+}
+
+.overlay.hidden { opacity: 0; pointer-events: none; }
+
+.card {
+  background: #1a0c34;
+  border-radius: 1.8rem;
+  padding: 2rem 3rem;
+  text-align: center;
+  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6);
+}
+
+.touch-controls {
+  position: fixed;
+  bottom: 1rem;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: 0.5rem;
+  z-index: 5;
+}
+
+.touch-controls button {
+  border: none;
+  border-radius: 0.8rem;
+  padding: 0.8rem 1rem;
+  background: rgba(255, 255, 255, 0.15);
+  color: white;
+  font-size: 1.2rem;
+  backdrop-filter: blur(8px);
+}
+
+@media (max-width: 900px) {
+  .play {
+    grid-template-columns: 1fr;
+  }
+  .info {
+    flex-direction: row;
+    flex-wrap: wrap;
+  }
+}
+
+@media (max-width: 600px) {
+  .touch-controls { display: flex; }
+}
+
+@media (min-width: 601px) {
+  .touch-controls { display: none; }
+}