git » sparkle-arcade » commit 349a46a

add 2 more games

author Alan Dipert
2025-11-16 17:23:29 UTC
committer Alan Dipert
2025-11-16 17:23:29 UTC
parent 79f0ce6061a314adaa4e1ed167e735a9c4a10c63

add 2 more games

Makefile +1 -1
crystal-cloud-carousel/game.js +272 -0
crystal-cloud-carousel/index.html +60 -0
crystal-cloud-carousel/styles.css +199 -0
index.html +25 -1
moonlit-garden-match/game.js +262 -0
moonlit-garden-match/index.html +46 -0
moonlit-garden-match/styles.css +156 -0

diff --git a/Makefile b/Makefile
index 7531491..d9605a4 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
+DIST_ITEMS := index.html styles.css blossom-blocks fairy-finder crystal-cloud-carousel moonlit-garden-match
 DEPLOY_TARGET ?= arsien23i2@dreamhost:tailrecursion.com/arcade/
 RSYNC_FLAGS ?= -av --delete --chmod=F644,D755 --exclude='.DS_Store'
 
diff --git a/crystal-cloud-carousel/game.js b/crystal-cloud-carousel/game.js
new file mode 100644
index 0000000..f9c3465
--- /dev/null
+++ b/crystal-cloud-carousel/game.js
@@ -0,0 +1,272 @@
+(() => {
+  const lanesContainer = document.getElementById('lanes');
+  const scoreEl = document.getElementById('score');
+  const comboEl = document.getElementById('combo');
+  const timeEl = document.getElementById('time');
+  const newGameBtn = document.getElementById('new-game');
+  const overlay = document.getElementById('overlay');
+  const startBtn = document.getElementById('start');
+  const hitButtons = document.getElementById('hit-buttons');
+
+  const laneColors = ['#ff9adf', '#c7a6ff', '#8fcbff', '#ffaccd'];
+  const laneEmojis = ['πŸ’—', 'πŸ’œ', 'πŸ’™', 'πŸ’–'];
+  const lanes = Array.from(lanesContainer.querySelectorAll('.lane'));
+
+  const NOTE_SPEED = 180; // px per second
+  const SONG_DURATION = 45; // seconds
+  const TARGET_PAD = 90;
+  const BEAT_INTERVAL = 0.55; // seconds per beat
+  const lanePattern = [0, 1, 2, 1, 3, 2, 0, 2, 1, 3, 0, 2, 1, 0, 3, 1];
+
+  let notes = [];
+  let score = 0;
+  let combo = 0;
+  let isActive = false;
+  let remaining = SONG_DURATION;
+  let timerInterval = null;
+  let beatTimer = 0;
+  let patternIndex = 0;
+  let lastTimestamp = null;
+  let laneHeight = 0;
+  let targetStart = 0;
+  let targetEnd = 0;
+  let songPlayer = null;
+
+  function measureStage() {
+    const rect = lanes[0].getBoundingClientRect();
+    laneHeight = rect.height;
+    targetEnd = laneHeight - 70;
+    targetStart = targetEnd - TARGET_PAD;
+  }
+
+  function spawnNote(laneIndex) {
+    const lane = typeof laneIndex === 'number' ? laneIndex : Math.floor(Math.random() * lanes.length);
+    const noteEl = document.createElement('div');
+    noteEl.className = 'note';
+    noteEl.textContent = laneEmojis[lane];
+    noteEl.style.background = `linear-gradient(135deg, ${laneColors[lane]}, rgba(255,255,255,0.9))`;
+    lanes[lane].appendChild(noteEl);
+    notes.push({ lane, y: -30, el: noteEl });
+  }
+
+  function removeNote(note) {
+    note.el.remove();
+    notes = notes.filter((n) => n !== note);
+  }
+
+  function hitLane(lane) {
+    if (!isActive) return;
+    flashLane(lane);
+    const targetNote = notes.find((note) => note.lane === lane && note.y >= targetStart && note.y <= targetEnd);
+    if (targetNote) {
+      const distance = Math.abs(targetNote.y - targetEnd + TARGET_PAD / 2);
+      const bonus = distance < 20 ? 120 : 90;
+      score += bonus + combo * 2;
+      combo += 1;
+      updateHUD();
+      if (songPlayer) songPlayer.hitTone(lane);
+      showPop(`+${bonus}`, targetNote.el);
+      removeNote(targetNote);
+    } else {
+      combo = 0;
+      updateHUD();
+    }
+  }
+
+  function flashLane(lane) {
+    const laneEl = lanes[lane];
+    laneEl.classList.add('lane--active');
+    setTimeout(() => laneEl.classList.remove('lane--active'), 120);
+  }
+
+  function showPop(text, anchor) {
+    const pop = document.createElement('span');
+    pop.textContent = `${text} ✨`;
+    pop.style.position = 'absolute';
+    pop.style.left = '50%';
+    pop.style.transform = 'translate(-50%, -50%)';
+    pop.style.top = `${anchor.offsetTop}px`;
+    pop.style.color = '#ff5fb1';
+    pop.style.fontWeight = '700';
+    pop.style.pointerEvents = 'none';
+    anchor.parentElement.appendChild(pop);
+    pop.animate(
+      [
+        { opacity: 1, transform: 'translate(-50%, -20px)' },
+        { opacity: 0, transform: 'translate(-50%, -60px)' }
+      ],
+      { duration: 600 }
+    ).onfinish = () => pop.remove();
+  }
+
+  function updateHUD() {
+    scoreEl.textContent = score;
+    comboEl.textContent = combo;
+  }
+
+  function startSong() {
+    measureStage();
+    notes.forEach((note) => note.el.remove());
+    notes = [];
+    score = 0;
+    combo = 0;
+    remaining = SONG_DURATION;
+    beatTimer = 0;
+    patternIndex = 0;
+    lastTimestamp = null;
+    updateHUD();
+    timeEl.textContent = remaining;
+    if (timerInterval) clearInterval(timerInterval);
+    timerInterval = setInterval(() => {
+      remaining -= 1;
+      if (remaining <= 0) {
+        remaining = 0;
+        endSong();
+      }
+      timeEl.textContent = remaining;
+    }, 1000);
+    isActive = true;
+    overlay.classList.add('hidden');
+    if (!songPlayer) songPlayer = createSongPlayer();
+    songPlayer.start();
+  }
+
+  function endSong() {
+    if (!isActive) return;
+    isActive = false;
+    clearInterval(timerInterval);
+    timerInterval = null;
+    overlay.querySelector('p').textContent = `Final score: ${score} ✨ Combo: ${combo}`;
+    overlay.classList.remove('hidden');
+    if (songPlayer) songPlayer.stop();
+  }
+
+  function update(timestamp) {
+    if (!lastTimestamp) lastTimestamp = timestamp;
+    const delta = (timestamp - lastTimestamp) / 1000;
+    lastTimestamp = timestamp;
+    if (isActive) {
+      beatTimer += delta;
+      while (beatTimer >= BEAT_INTERVAL) {
+        const lane = lanePattern[patternIndex % lanePattern.length];
+        patternIndex += 1;
+        spawnNote(lane);
+        beatTimer -= BEAT_INTERVAL;
+      }
+      notes.slice().forEach((note) => {
+        note.y += NOTE_SPEED * delta;
+        note.el.style.transform = `translateY(${note.y}px)`;
+        if (note.y > laneHeight + 40) {
+          removeNote(note);
+          combo = 0;
+          updateHUD();
+        } else if (note.y > targetEnd + 40) {
+          removeNote(note);
+          combo = 0;
+          updateHUD();
+        }
+      });
+      if (remaining <= 0 && notes.length === 0) {
+        endSong();
+      }
+    }
+    requestAnimationFrame(update);
+  }
+
+  function handleKey(event) {
+    if (event.repeat) return;
+    const keyMap = ['1', '2', '3', '4'];
+    const index = keyMap.indexOf(event.key);
+    if (index !== -1) {
+      hitLane(index);
+    }
+  }
+
+  hitButtons.addEventListener('click', (event) => {
+    const button = event.target.closest('button');
+    if (!button) return;
+    const lane = Number(button.dataset.index);
+    hitLane(lane);
+  });
+
+  lanes.forEach((laneEl) => {
+    laneEl.addEventListener('pointerdown', () => {
+      const lane = Number(laneEl.dataset.index);
+      hitLane(lane);
+    });
+  });
+
+  document.addEventListener('keydown', handleKey);
+  newGameBtn.addEventListener('click', startSong);
+  startBtn.addEventListener('click', startSong);
+
+  requestAnimationFrame(update);
+
+  function createSongPlayer() {
+    const pattern = [523.25, 659.25, 587.33, 659.25, 783.99, 659.25, 587.33, 523.25];
+    const totalDuration = pattern.length * BEAT_INTERVAL;
+    return {
+      ctx: null,
+      gain: null,
+      nextTime: 0,
+      timeoutId: null,
+      isPlaying: false,
+      ensureContext() {
+        if (!this.ctx) {
+          const AudioCtx = window.AudioContext || window.webkitAudioContext;
+          this.ctx = new AudioCtx();
+          this.gain = this.ctx.createGain();
+          this.gain.gain.value = 0.16;
+          this.gain.connect(this.ctx.destination);
+        }
+      },
+      start() {
+        this.ensureContext();
+        if (this.isPlaying) return;
+        this.ctx.resume();
+        this.isPlaying = true;
+        this.nextTime = this.ctx.currentTime + 0.05;
+        this.loop();
+      },
+      loop() {
+        if (!this.isPlaying) return;
+        let time = this.nextTime;
+        pattern.forEach((freq) => {
+          this.scheduleNote(freq, time, BEAT_INTERVAL * 0.75);
+          time += BEAT_INTERVAL;
+        });
+        this.nextTime = time;
+        this.timeoutId = setTimeout(() => this.loop(), totalDuration * 1000);
+      },
+      scheduleNote(freq, start, duration) {
+        const osc = this.ctx.createOscillator();
+        const gainNode = this.ctx.createGain();
+        osc.type = 'triangle';
+        osc.frequency.value = freq;
+        osc.connect(gainNode);
+        gainNode.connect(this.gain);
+        gainNode.gain.setValueAtTime(0.0001, start);
+        gainNode.gain.linearRampToValueAtTime(0.25, start + 0.03);
+        gainNode.gain.exponentialRampToValueAtTime(0.001, start + duration);
+        osc.start(start);
+        osc.stop(start + duration + 0.02);
+      },
+      hitTone(lane) {
+        this.ensureContext();
+        const freq = 440 + lane * 80;
+        this.scheduleNote(freq, this.ctx.currentTime, 0.25);
+      },
+      stop() {
+        if (!this.isPlaying) return;
+        this.isPlaying = false;
+        if (this.timeoutId) {
+          clearTimeout(this.timeoutId);
+          this.timeoutId = null;
+        }
+        if (this.ctx && this.ctx.state !== 'closed') {
+          this.ctx.suspend();
+        }
+      },
+    };
+  }
+})();
diff --git a/crystal-cloud-carousel/index.html b/crystal-cloud-carousel/index.html
new file mode 100644
index 0000000..81116ef
--- /dev/null
+++ b/crystal-cloud-carousel/index.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Crystal Cloud Carousel</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="sky-charms" aria-hidden="true">🌈 ✨ ☁️ πŸ’œ ⭐ πŸ’–</div>
+  <main class="wrap">
+    <header class="top">
+      <div>
+        <h1>Crystal Cloud Carousel</h1>
+        <p class="tag">Tap sparkles in time to keep Viviana’s carousel twirling through the clouds!</p>
+      </div>
+      <div class="stats">
+        <div>Score: <span id="score">0</span> ✨</div>
+        <div>Combo: <span id="combo">0</span> πŸ’–</div>
+        <div>Time: <span id="time">45</span>s ⏱️</div>
+        <button class="btn" id="new-game">New Song</button>
+      </div>
+    </header>
+
+    <section class="stage">
+      <div class="lanes" id="lanes" aria-label="Music lanes">
+        <div class="lane" data-index="0"><span class="label">1</span></div>
+        <div class="lane" data-index="1"><span class="label">2</span></div>
+        <div class="lane" data-index="2"><span class="label">3</span></div>
+        <div class="lane" data-index="3"><span class="label">4</span></div>
+        <div class="target-line" aria-hidden="true"></div>
+      </div>
+      <div class="hit-buttons" id="hit-buttons">
+        <button data-index="0">πŸ’—</button>
+        <button data-index="1">πŸ’œ</button>
+        <button data-index="2">πŸ’™</button>
+        <button data-index="3">πŸ’–</button>
+      </div>
+    </section>
+
+    <aside class="instructions">
+      <h2>How to play</h2>
+      <p>Notes glide down the sky lanes. Tap the matching button when a note touches the sparkle line. Keep the beat to build your combo and spin the carousel faster!</p>
+      <p>Use the number keys 1-4 or click the heart buttons. Try to finish the 45-second song with a dazzling score.</p>
+    </aside>
+  </main>
+
+  <div class="overlay" id="overlay">
+    <div class="card">
+      <h2>Crystal Cloud Carousel</h2>
+      <p>Ready to ride? Tap the sparkles with the beat to earn stars for Viviana’s sky pony.</p>
+      <button class="btn btn-lg" id="start">Play Song</button>
+    </div>
+  </div>
+</body>
+</html>
diff --git a/crystal-cloud-carousel/styles.css b/crystal-cloud-carousel/styles.css
new file mode 100644
index 0000000..5b56e3e
--- /dev/null
+++ b/crystal-cloud-carousel/styles.css
@@ -0,0 +1,199 @@
+:root {
+  --bg-top: #f9e2ff;
+  --bg-bottom: #cfe8ff;
+  --lane-bg: rgba(255, 255, 255, 0.2);
+  --lane-active: rgba(255, 255, 255, 0.5);
+  --accent: #ff9edc;
+  --accent-2: #a4c8ff;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  font-family: 'Nunito', system-ui, sans-serif;
+  min-height: 100vh;
+  background: linear-gradient(180deg, var(--bg-top), var(--bg-bottom));
+  color: #4b2d62;
+  overflow-x: hidden;
+}
+
+.sky-charms {
+  position: fixed;
+  inset: 0;
+  font-size: 6rem;
+  opacity: 0.05;
+  text-align: center;
+  padding-top: 2rem;
+  letter-spacing: 1.5rem;
+  pointer-events: none;
+}
+
+.wrap {
+  max-width: 1100px;
+  margin: 0 auto;
+  padding: 2rem clamp(1rem, 5vw, 4rem) 4rem;
+  position: relative;
+  z-index: 1;
+}
+
+h1, h2 {
+  font-family: 'Baloo 2', 'Nunito', sans-serif;
+  margin: 0;
+  color: #ff62c3;
+}
+
+.top {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 1rem;
+  align-items: center;
+}
+
+.tag {
+  margin-top: 0.4rem;
+  color: #745483;
+}
+
+.stats {
+  background: rgba(255, 255, 255, 0.7);
+  padding: 0.8rem 1.2rem;
+  border-radius: 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+  font-weight: 600;
+  box-shadow: 0 15px 35px rgba(173, 137, 255, 0.35);
+}
+
+.btn {
+  border: none;
+  border-radius: 999px;
+  padding: 0.5rem 1.5rem;
+  font-weight: 700;
+  font-size: 1rem;
+  background: linear-gradient(120deg, var(--accent), var(--accent-2));
+  color: white;
+  cursor: pointer;
+  box-shadow: 0 15px 30px rgba(168, 198, 255, 0.45);
+  transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.btn:hover { transform: translateY(-2px); }
+
+.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.2rem; }
+
+.stage {
+  margin-top: 2.5rem;
+  background: rgba(255, 255, 255, 0.4);
+  border-radius: 2rem;
+  padding: 2rem;
+  box-shadow: inset 0 0 30px rgba(255, 255, 255, 0.6), 0 25px 50px rgba(136, 160, 255, 0.4);
+}
+
+.lanes {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(4, minmax(60px, 1fr));
+  gap: 1rem;
+  height: 380px;
+}
+
+.lane {
+  position: relative;
+  background: var(--lane-bg);
+  border-radius: 1.5rem;
+  overflow: hidden;
+  border: 2px dashed rgba(255, 255, 255, 0.4);
+}
+
+.lane--active {
+  background: var(--lane-active);
+  box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.6);
+}
+
+.lane .label {
+  position: absolute;
+  top: 0.4rem;
+  left: 50%;
+  transform: translateX(-50%);
+  font-weight: 700;
+  color: #fff;
+  opacity: 0.7;
+}
+
+.target-line {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 70px;
+  border-bottom: 6px solid rgba(255, 255, 255, 0.8);
+  pointer-events: none;
+  box-shadow: 0 0 15px rgba(255, 255, 255, 0.7);
+}
+
+.note {
+  position: absolute;
+  width: 70%;
+  left: 15%;
+  padding: 0.4rem 0;
+  border-radius: 999px;
+  text-align: center;
+  font-size: 1.4rem;
+  box-shadow: 0 8px 20px rgba(255, 130, 210, 0.45);
+}
+
+.hit-buttons {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(70px, 1fr));
+  gap: 1rem;
+  margin-top: 1.5rem;
+}
+
+.hit-buttons button {
+  border: none;
+  border-radius: 1rem;
+  font-size: 1.7rem;
+  padding: 0.6rem;
+  cursor: pointer;
+  background: rgba(255, 255, 255, 0.8);
+  box-shadow: 0 12px 25px rgba(143, 154, 255, 0.4);
+  transition: transform 0.15s ease;
+}
+
+.hit-buttons button:active { transform: translateY(2px); }
+
+.instructions {
+  margin-top: 2rem;
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 1.5rem;
+  padding: 1.5rem 2rem;
+  box-shadow: 0 18px 45px rgba(150, 180, 255, 0.35);
+}
+
+.overlay {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(233, 208, 255, 0.8);
+  z-index: 20;
+  transition: opacity 0.3s ease;
+}
+
+.overlay.hidden { opacity: 0; pointer-events: none; }
+
+.card {
+  background: white;
+  border-radius: 2rem;
+  padding: 2rem 3rem;
+  text-align: center;
+  box-shadow: 0 30px 60px rgba(175, 140, 255, 0.45);
+}
+
+@media (max-width: 700px) {
+  .lanes { height: 320px; }
+  .target-line { bottom: 60px; }
+}
diff --git a/index.html b/index.html
index f575097..922f372 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">Two 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">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>
     </header>
 
     <section class="game-grid">
@@ -31,6 +31,30 @@
         <a class="btn" href="blossom-blocks/" aria-label="Play Blossom Blocks">Play Blossom Blocks</a>
       </article>
 
+      <article class="game-card" role="article">
+        <div class="badge">Rhythm ✨</div>
+        <h2>Crystal Cloud Carousel</h2>
+        <p>Keep the beat by tapping sparkles as they slide down four glowing lanes. Build combos to spin Viviana’s sky pony faster.</p>
+        <ul>
+          <li>45-second kid-friendly song</li>
+          <li>Click hearts or press 1-4 keys</li>
+          <li>Combo bonuses &amp; pop-up sparkles</li>
+        </ul>
+        <a class="btn" href="crystal-cloud-carousel/" aria-label="Play Crystal Cloud Carousel">Play Crystal Cloud Carousel</a>
+      </article>
+
+      <article class="game-card" role="article">
+        <div class="badge">Match-3 πŸŒ™</div>
+        <h2>Moonlit Garden Match</h2>
+        <p>Swap glowing mushrooms, butterflies, and fairy lights to free sleepy critters before you run out of moonlit moves.</p>
+        <ul>
+          <li>6Γ—6 dreamy board</li>
+          <li>Chain reactions for big scores</li>
+          <li>25-move bedtime challenge</li>
+        </ul>
+        <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">Puzzle 🧚</div>
         <h2>Fairy Finder</h2>
diff --git a/moonlit-garden-match/game.js b/moonlit-garden-match/game.js
new file mode 100644
index 0000000..a860464
--- /dev/null
+++ b/moonlit-garden-match/game.js
@@ -0,0 +1,262 @@
+(() => {
+  const size = 6;
+  const scoreEl = document.getElementById('score');
+  const movesEl = document.getElementById('moves');
+  const boardEl = document.getElementById('board');
+  const newGameBtn = document.getElementById('new-game');
+  const overlay = document.getElementById('overlay');
+  const startBtn = document.getElementById('start');
+
+  const tiles = ['πŸŒ™', 'πŸ„', 'πŸ¦‹', '🌸', 'πŸ’«', '🌿'];
+
+  let grid = [];
+  let selected = null;
+  let score = 0;
+  let moves = 25;
+  let busy = false;
+
+  function createBoard() {
+    const newGrid = Array.from({ length: size }, () => Array(size).fill(null));
+    for (let r = 0; r < size; r += 1) {
+      for (let c = 0; c < size; c += 1) {
+        let value;
+        do {
+          value = randomTile();
+          newGrid[r][c] = value;
+        } while (createsMatch(newGrid, r, c));
+      }
+    }
+    return newGrid;
+  }
+
+  function randomTile() {
+    return tiles[Math.floor(Math.random() * tiles.length)];
+  }
+
+  function createsMatch(gridRef, r, c) {
+    const value = gridRef[r][c];
+    if (!value) return false;
+    const horiz = c >= 2 && gridRef[r][c - 1] === value && gridRef[r][c - 2] === value;
+    const vert = r >= 2 && gridRef[r - 1][c] === value && gridRef[r - 2][c] === value;
+    return horiz || vert;
+  }
+
+  function renderBoard() {
+    boardEl.innerHTML = '';
+    for (let r = 0; r < size; r += 1) {
+      for (let c = 0; c < size; c += 1) {
+        const tile = document.createElement('button');
+        tile.type = 'button';
+        tile.className = 'tile';
+        tile.textContent = grid[r][c];
+        tile.dataset.row = r;
+        tile.dataset.col = c;
+        if (selected && selected.row === r && selected.col === c) {
+          tile.classList.add('selected');
+        }
+        tile.addEventListener('click', () => handleSelect(r, c));
+        boardEl.appendChild(tile);
+      }
+    }
+  }
+
+  function handleSelect(row, col) {
+    if (busy) return;
+    if (!selected) {
+      selected = { row, col };
+      renderBoard();
+      return;
+    }
+    if (selected.row === row && selected.col === col) {
+      selected = null;
+      renderBoard();
+      return;
+    }
+    if (!areNeighbors(selected, { row, col })) {
+      selected = { row, col };
+      renderBoard();
+      return;
+    }
+    attemptSwap(selected, { row, col });
+  }
+
+  function areNeighbors(a, b) {
+    const dr = Math.abs(a.row - b.row);
+    const dc = Math.abs(a.col - b.col);
+    return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
+  }
+
+  function attemptSwap(a, b) {
+    busy = true;
+    swap(a, b);
+    const matches = findMatches();
+    if (matches.length === 0) {
+      moves = Math.max(0, moves - 1);
+      updateHUD();
+      selected = null;
+      renderBoard();
+      busy = false;
+      if (moves === 0) endGame();
+      return;
+    }
+    moves = Math.max(0, moves - 1);
+    updateHUD();
+    selected = null;
+    renderBoard();
+    resolveMatches(matches, 1);
+  }
+
+  function swap(a, b) {
+    const temp = grid[a.row][a.col];
+    grid[a.row][a.col] = grid[b.row][b.col];
+    grid[b.row][b.col] = temp;
+  }
+
+  function findMatches() {
+    const matches = [];
+    // horizontal
+    for (let r = 0; r < size; r += 1) {
+      let run = 1;
+      for (let c = 1; c < size; c += 1) {
+        if (grid[r][c] && grid[r][c] === grid[r][c - 1]) {
+          run += 1;
+        } else {
+          if (run >= 3) {
+            matches.push(...rangeCells(r, c - run, run, 'row'));
+          }
+          run = 1;
+        }
+      }
+      if (run >= 3) {
+        matches.push(...rangeCells(r, size - run, run, 'row'));
+      }
+    }
+    // vertical
+    for (let c = 0; c < size; c += 1) {
+      let run = 1;
+      for (let r = 1; r < size; r += 1) {
+        if (grid[r][c] && grid[r][c] === grid[r - 1][c]) {
+          run += 1;
+        } else {
+          if (run >= 3) {
+            matches.push(...rangeCells(r - run, c, run, 'col'));
+          }
+          run = 1;
+        }
+      }
+      if (run >= 3) {
+        matches.push(...rangeCells(size - run, c, run, 'col'));
+      }
+    }
+    const seen = new Set();
+    return matches.filter(({ row, col }) => {
+      const key = `${row}-${col}`;
+      if (seen.has(key)) return false;
+      seen.add(key);
+      return true;
+    });
+  }
+
+  function rangeCells(startRowOrCol, fixed, length, mode) {
+    const cells = [];
+    for (let i = 0; i < length; i += 1) {
+      if (mode === 'row') {
+        cells.push({ row: startRowOrCol, col: fixed + i });
+      } else {
+        cells.push({ row: startRowOrCol + i, col: fixed });
+      }
+    }
+    return cells;
+  }
+
+  function resolveMatches(matchCells, chain) {
+    if (matchCells.length === 0) {
+      busy = false;
+      if (moves === 0) endGame();
+      return;
+    }
+    matchCells.forEach(({ row, col }) => {
+      grid[row][col] = null;
+    });
+    score += matchCells.length * 60 * chain;
+    updateHUD();
+    animateMatches(matchCells);
+    setTimeout(() => {
+      applyGravity();
+      fillNew();
+      renderBoard();
+      const nextMatches = findMatches();
+      if (nextMatches.length > 0) {
+        resolveMatches(nextMatches, chain + 1);
+      } else {
+        busy = false;
+        if (moves === 0) endGame();
+      }
+    }, 350);
+  }
+
+  function animateMatches(cells) {
+    const map = new Map();
+    Array.from(boardEl.children).forEach((tile) => {
+      const r = Number(tile.dataset.row);
+      const c = Number(tile.dataset.col);
+      map.set(`${r}-${c}`, tile);
+    });
+    cells.forEach(({ row, col }) => {
+      const tile = map.get(`${row}-${col}`);
+      if (tile) tile.classList.add('matching');
+    });
+  }
+
+  function applyGravity() {
+    for (let c = 0; c < size; c += 1) {
+      let writeRow = size - 1;
+      for (let r = size - 1; r >= 0; r -= 1) {
+        if (grid[r][c]) {
+          grid[writeRow][c] = grid[r][c];
+          if (writeRow !== r) {
+            grid[r][c] = null;
+          }
+          writeRow -= 1;
+        }
+      }
+      for (let r = writeRow; r >= 0; r -= 1) {
+        grid[r][c] = null;
+      }
+    }
+  }
+
+  function fillNew() {
+    for (let r = 0; r < size; r += 1) {
+      for (let c = 0; c < size; c += 1) {
+        if (!grid[r][c]) {
+          grid[r][c] = randomTile();
+        }
+      }
+    }
+  }
+
+  function updateHUD() {
+    scoreEl.textContent = score;
+    movesEl.textContent = moves;
+  }
+
+  function startGame() {
+    grid = createBoard();
+    score = 0;
+    moves = 25;
+    selected = null;
+    busy = false;
+    updateHUD();
+    renderBoard();
+    overlay.classList.add('hidden');
+  }
+
+  function endGame() {
+    overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`;
+    overlay.classList.remove('hidden');
+  }
+
+  newGameBtn.addEventListener('click', startGame);
+  startBtn.addEventListener('click', startGame);
+})();
diff --git a/moonlit-garden-match/index.html b/moonlit-garden-match/index.html
new file mode 100644
index 0000000..9d7e329
--- /dev/null
+++ b/moonlit-garden-match/index.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Moonlit Garden Match</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="moon-glow" aria-hidden="true">πŸŒ™ ✨ 🌿 πŸ’œ πŸ„ πŸ’–</div>
+  <main class="shell">
+    <header class="hero">
+      <div>
+        <h1>Moonlit Garden Match</h1>
+        <p class="tagline">Swap glowing mushrooms and fairy lights to rescue sleepy critters.</p>
+      </div>
+      <div class="stats">
+        <div>Score: <span id="score">0</span> πŸŒ™</div>
+        <div>Moves left: <span id="moves">25</span> ✨</div>
+        <button class="btn" id="new-game">New Garden</button>
+      </div>
+    </header>
+
+    <section class="board-wrap">
+      <div class="board" id="board" aria-label="Match 3 board" role="grid"></div>
+      <aside class="instructions">
+        <h2>How to play</h2>
+        <p>Tap two neighbors to swap them. Match 3 or more identical charms to collect them. Matches free the critters and add moves to your glowing score bar.</p>
+        <p>Use up the 25 moves wisely. Clear several matches in one turn for bonus sparkles!</p>
+      </aside>
+    </section>
+  </main>
+
+  <div class="overlay" id="overlay">
+    <div class="panel">
+      <h2>Moonlit Garden Match</h2>
+      <p>Swap glowing tiles to tuck the critters into bed under the moonlight.</p>
+      <button class="btn btn-lg" id="start">Start Matching</button>
+    </div>
+  </div>
+</body>
+</html>
diff --git a/moonlit-garden-match/styles.css b/moonlit-garden-match/styles.css
new file mode 100644
index 0000000..640db0e
--- /dev/null
+++ b/moonlit-garden-match/styles.css
@@ -0,0 +1,156 @@
+:root {
+  --bg1: #0f0428;
+  --bg2: #2c1b54;
+  --board-bg: rgba(255, 255, 255, 0.15);
+  --text: #f9ecff;
+  --accent: #ff8ad5;
+  --accent-2: #83d8ff;
+  --cell-size: min(70px, 12vw);
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  font-family: 'Nunito', system-ui, sans-serif;
+  background: radial-gradient(circle at top, #3d216d, #150428);
+  color: var(--text);
+}
+
+.moon-glow {
+  position: fixed;
+  inset: 0;
+  font-size: 6rem;
+  opacity: 0.05;
+  text-align: center;
+  pointer-events: none;
+  padding-top: 2rem;
+}
+
+.shell {
+  max-width: 1100px;
+  margin: 0 auto;
+  padding: 2rem clamp(1rem, 5vw, 4rem) 4rem;
+  position: relative;
+  z-index: 1;
+}
+
+h1, h2 {
+  font-family: 'Baloo 2', 'Nunito', sans-serif;
+  margin: 0;
+  color: #ff9ae3;
+}
+
+.hero {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.tagline { margin-top: 0.4rem; color: #d7c2f6; }
+
+.stats {
+  background: rgba(255, 255, 255, 0.15);
+  padding: 0.8rem 1.2rem;
+  border-radius: 1rem;
+  box-shadow: 0 15px 35px rgba(0, 0, 0, 0.35);
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+  font-weight: 600;
+}
+
+.btn {
+  padding: 0.5rem 1.5rem;
+  border-radius: 999px;
+  border: none;
+  background: linear-gradient(120deg, var(--accent), var(--accent-2));
+  color: white;
+  font-weight: 700;
+  cursor: pointer;
+  box-shadow: 0 15px 35px rgba(255, 138, 213, 0.4);
+}
+
+.btn-lg { padding: 0.8rem 2.5rem; font-size: 1.1rem; }
+
+.board-wrap {
+  margin-top: 2.5rem;
+  display: grid;
+  grid-template-columns: minmax(300px, 1fr) minmax(240px, 0.8fr);
+  gap: 1.5rem;
+}
+
+.board {
+  background: var(--board-bg);
+  border-radius: 1.8rem;
+  padding: 1rem;
+  display: grid;
+  grid-template-columns: repeat(6, var(--cell-size));
+  grid-template-rows: repeat(6, var(--cell-size));
+  gap: 0.5rem;
+  box-shadow: inset 0 0 35px rgba(255, 255, 255, 0.1), 0 30px 50px rgba(3, 5, 35, 0.6);
+}
+
+.tile {
+  width: 100%;
+  height: 100%;
+  border-radius: 1rem;
+  background: rgba(255, 255, 255, 0.18);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 1.8rem;
+  cursor: pointer;
+  transition: transform 0.15s ease, box-shadow 0.2s ease;
+  border: 2px solid transparent;
+}
+
+.tile.selected {
+  transform: scale(1.08);
+  border-color: rgba(255, 255, 255, 0.7);
+  box-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
+}
+
+.tile.matching {
+  animation: twinkle 0.6s ease infinite alternate;
+}
+
+@keyframes twinkle {
+  from { transform: scale(1); opacity: 1; }
+  to { transform: scale(1.1); opacity: 0.6; }
+}
+
+.instructions {
+  background: rgba(255, 255, 255, 0.08);
+  border-radius: 1.5rem;
+  padding: 1.5rem 2rem;
+  box-shadow: 0 20px 40px rgba(2, 0, 24, 0.7);
+}
+
+.overlay {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(10, 3, 30, 0.8);
+  z-index: 10;
+}
+
+.overlay.hidden { opacity: 0; pointer-events: none; }
+
+.panel {
+  background: #1c0e38;
+  border-radius: 1.8rem;
+  padding: 2rem 3rem;
+  text-align: center;
+  box-shadow: 0 30px 60px rgba(0, 0, 0, 0.6);
+}
+
+@media (max-width: 900px) {
+  .board-wrap { grid-template-columns: 1fr; }
+  .board { justify-self: center; }
+}