// Firefly Jar: catch the fireflies while they glow. // A Twiddle Play tutorial game. Plain JavaScript, no libraries. // ---------- The canvas ---------- const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); let W = 0; // canvas width in CSS pixels let H = 0; // canvas height in CSS pixels function resize() { // Draw at the screen's real pixel density (capped at 2 for speed), so nothing looks blurry. const dpr = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // from now on, 1 unit = 1 CSS pixel makeStars(); } // ---------- The night sky ---------- let stars = []; function makeStars() { stars = []; for (let i = 0; i < 60; i++) { stars.push({ x: Math.random() * W, y: Math.random() * H * 0.7, r: 0.4 + Math.random() * 1.2 }); } } function drawBackground() { const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#0b1030'); sky.addColorStop(1, '#23306a'); ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H); ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; for (const s of stars) { ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } // dark hills along the bottom ctx.fillStyle = '#081420'; ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(0, H - 80); ctx.quadraticCurveTo(W * 0.3, H - 130, W * 0.55, H - 85); ctx.quadraticCurveTo(W * 0.8, H - 45, W, H - 100); ctx.lineTo(W, H); ctx.closePath(); ctx.fill(); } // ---------- Fireflies ---------- const TOP = 90; // fireflies stay below the score line... const BOTTOM = 150; // ...and above the jar at the bottom let fireflies = []; let speed = 50; // pixels per second let blinkSpeed = 1.7; // how fast the glow cycles function makeFirefly() { return { x: 30 + Math.random() * (W - 60), y: TOP + Math.random() * Math.max(1, H - TOP - BOTTOM), angle: Math.random() * Math.PI * 2, // the direction it flies blink: Math.random() * Math.PI * 2, // where it is in its glow cycle }; } function isLit(f) { return Math.sin(f.blink) > 0.3; } function moveFirefly(f, dt) { f.angle += (Math.random() - 0.5) * 4 * dt; // wander a little f.x += Math.cos(f.angle) * speed * dt; f.y += Math.sin(f.angle) * speed * dt; f.blink += blinkSpeed * dt; // turn back at the edges of the play area if (f.x < 20 || f.x > W - 20) f.angle = Math.PI - f.angle; if (f.y < TOP || f.y > H - BOTTOM) f.angle = -f.angle; f.x = Math.max(20, Math.min(W - 20, f.x)); f.y = Math.max(TOP, Math.min(H - BOTTOM, f.y)); } function drawFirefly(f) { const glow = Math.max(0, Math.sin(f.blink)); // 0 = dark, 1 = brightest ctx.globalCompositeOperation = 'lighter'; // add light instead of paint ctx.globalAlpha = glow * 0.35; ctx.fillStyle = '#ffdc6e'; ctx.beginPath(); ctx.arc(f.x, f.y, 10 + glow * 14, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = isLit(f) ? '#fff3b0' : '#56607e'; ctx.beginPath(); ctx.arc(f.x, f.y, 5, 0, Math.PI * 2); ctx.fill(); } // ---------- Sparkles ---------- let sparks = []; function burst(x, y) { for (let i = 0; i < 12; i++) { const a = Math.random() * Math.PI * 2; const v = 60 + Math.random() * 120; sparks.push({ x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.5 }); } } function updateSparks(dt) { for (const s of sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt; } sparks = sparks.filter(function (s) { return s.life > 0; }); } function drawSparks() { ctx.fillStyle = '#ffe27a'; for (const s of sparks) { ctx.globalAlpha = s.life / 0.5; ctx.fillRect(s.x - 2, s.y - 2, 4, 4); } ctx.globalAlpha = 1; } // ---------- Rounds ---------- const ROUND_TIME = 20; // seconds per round let state = 'title'; // 'title', 'play', 'clear' or 'timeup' let round = 1; let goal = 0; // fireflies to catch this round let caught = 0; let timeLeft = 0; // seconds let roundStartScore = 0; let flash = 0; // red flash after tapping a sleeping firefly function spawnFireflies(count) { fireflies = []; for (let i = 0; i < count; i++) fireflies.push(makeFirefly()); } function startRound() { goal = 4 + round * 2; // 6, 8, 10... speed = 40 + round * 10; // faster every round blinkSpeed = 1.4 + round * 0.3; // shorter glows every round caught = 0; timeLeft = ROUND_TIME; roundStartScore = score; spawnFireflies(Math.min(4 + round, 12)); state = 'play'; hidePanel(); } function roundClear() { const bonus = Math.ceil(timeLeft) * 5; score += bonus; round += 1; state = 'clear'; showPanel('Round ' + (round - 1) + ' clear!', 'Time bonus +' + bonus + '. Score: ' + score + '.', 'Next round', startRound); } function timeUp() { state = 'timeup'; showPanel('Out of time!', 'You caught ' + caught + ' of ' + goal + '.', 'Try again', retryRound); } function retryRound() { score = roundStartScore; // take back this round's points startRound(); } function showTitle() { state = 'title'; speed = 50; blinkSpeed = 1.7; spawnFireflies(8); showPanel('Firefly Jar', 'Tap the fireflies while they glow. Tapping a dark one costs 2 seconds.', 'Play', startRound); } // ---------- Screens ---------- const panel = document.getElementById('panel'); const panelTitle = document.getElementById('panel-title'); const panelText = document.getElementById('panel-text'); const mainButton = document.getElementById('panel-main'); let mainAction = null; mainButton.addEventListener('click', function () { if (mainAction) mainAction(); }); function showPanel(title, text, buttonLabel, action) { panelTitle.textContent = title; panelText.textContent = text; mainButton.textContent = buttonLabel; mainAction = action; panel.hidden = false; } function hidePanel() { panel.hidden = true; } // ---------- Catching ---------- let score = 0; // Pointer events cover touch, mouse and pen with one listener. canvas.addEventListener('pointerdown', function (e) { const rect = canvas.getBoundingClientRect(); tryCatch(e.clientX - rect.left, e.clientY - rect.top); }); function tryCatch(x, y) { if (state !== 'play') return; // Find the nearest firefly within a finger's reach (44 px). let nearest = null; let nearestDist = 44; for (const f of fireflies) { const d = Math.hypot(f.x - x, f.y - y); if (d < nearestDist) { nearest = f; nearestDist = d; } } if (!nearest) return; // tapped empty sky if (isLit(nearest)) { fireflies.splice(fireflies.indexOf(nearest), 1); // into the jar it goes... fireflies.push(makeFirefly()); // ...and a new one flies in score += 10; caught += 1; burst(nearest.x, nearest.y); } else { timeLeft = Math.max(0, timeLeft - 2); // a sleeping firefly costs 2 seconds flash = 0.3; } } // ---------- HUD and jar ---------- function drawHud() { ctx.fillStyle = '#ffffff'; ctx.font = '700 18px system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Round ' + round, 16, 34); ctx.font = '700 22px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(String(score), W / 2, 34); // the timer bar const barWidth = W - 32; ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; ctx.fillRect(16, 62, barWidth, 8); ctx.fillStyle = timeLeft < 5 ? '#ff6b81' : '#ffe27a'; ctx.fillRect(16, 62, barWidth * Math.min(1, timeLeft / ROUND_TIME), 8); } function drawJar() { const w = 64; const h = 84; const x = W / 2 - w / 2; const y = H - h - 24; const fill = Math.min(caught / goal, 1); // the light inside ctx.globalAlpha = 0.85; ctx.fillStyle = '#ffdc6e'; ctx.fillRect(x + 4, y + 4 + (h - 8) * (1 - fill), w - 8, (h - 8) * fill); ctx.globalAlpha = 1; // the glass and the lid ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = 3; ctx.strokeRect(x, y, w, h); ctx.fillStyle = '#9b7344'; ctx.fillRect(x - 5, y - 12, w + 10, 12); // how many so far ctx.fillStyle = '#ffffff'; ctx.font = '700 16px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(caught + ' / ' + goal, W / 2, y - 22); } // ---------- The game loop ---------- let lastTime = 0; function update(dt) { for (const f of fireflies) moveFirefly(f, dt); updateSparks(dt); flash = Math.max(0, flash - dt); if (state !== 'play') return; timeLeft -= dt; if (caught >= goal) { roundClear(); } else if (timeLeft <= 0) { timeLeft = 0; timeUp(); } } function draw() { drawBackground(); for (const f of fireflies) drawFirefly(f); drawSparks(); if (state === 'play') { drawJar(); drawHud(); } if (flash > 0) { ctx.globalAlpha = flash; ctx.fillStyle = '#ff4d6d'; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; } } function frame(time) { // dt = seconds since the last frame, capped so one slow frame can't make things jump const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; lastTime = time; update(dt); draw(); requestAnimationFrame(frame); } // ---------- Start ---------- resize(); window.addEventListener('resize', resize); showTitle(); requestAnimationFrame(frame);