/*
* Simple Flappy Bird clone written in pure JavaScript.
* The game uses HTML5 canvas to render a scrolling background, pipes, a pixel art bird, and simple ground.
* Tapping (clicking) or pressing the space bar will cause the bird to flap upward.
* Collision with a pipe or the ground ends the game.
* Scoring is achieved by successfully navigating through gaps between pipes.
*/
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const scoreElem = document.getElementById('score');
// Additional UI elements for enhanced UI
// Only high score, ability button and customization modal remain after removing coins/power displays
const highscoreElem = document.getElementById('highscore');
const coinsElem = document.getElementById('coins');
const abilityBtn = document.getElementById('ability-btn');
const shopBtn = document.getElementById('shop-btn');
const shopModal = document.getElementById('shop-modal');
// Generic content area inside the modal that will display either characters or abilities
const shopContentElem = document.getElementById('shop-content');
// Tab buttons for switching between characters and abilities
const tabCharBtn = document.getElementById('tab-char');
const tabPowerBtn = document.getElementById('tab-power');
const closeShopBtn = document.getElementById('close-shop');
// Particle effect elements
const effectContainer = document.getElementById('effect-container');
const effectImg = document.getElementById('effect-img');
// Dimensions
const W = canvas.width;
const H = canvas.height;
const groundHeight = 100;
// Game state
let gameState = 'start'; // start, play, over
let frameCount = 0;
let score = 0;
// Persistent data
// High score persistence
let highScore = parseInt(localStorage.getItem('flappyHighScore')) || 0;
// Coins persistence – used to purchase abilities
let coins = parseInt(localStorage.getItem('flappyCoins')) || 0;
// Load unlocked characters or default to all available
let unlocked = {};
try {
unlocked = JSON.parse(localStorage.getItem('flappyUnlocked')) || {};
} catch (e) {
unlocked = {};
}
// Character definitions. Prices removed since characters are always free.
const characters = [
{ id: 'yellow', displayName: 'Classic', frames: ['bird0', 'bird1'] },
{ id: 'blue', displayName: 'Blue', frames: ['blue_bird1', 'blue_bird2'] },
{ id: 'red', displayName: 'Red', frames: ['red_bird1', 'red_bird2'] },
{ id: 'green', displayName: 'Green', frames: ['green_bird1', 'green_bird2'] },
];
// Unlock all characters by default
characters.forEach(ch => { unlocked[ch.id] = true; });
let currentChar = localStorage.getItem('flappyCharacter') || 'yellow';
// Persistent state for abilities. Only giant and bomb abilities exist. The user must unlock
// these with coins before they can be selected.
let selectedAbility = localStorage.getItem('flappySelectedAbility') || null;
// Unlocked abilities stored as object mapping id->true. Load from localStorage or initialise empty.
let unlockedAbilities = {};
try {
unlockedAbilities = JSON.parse(localStorage.getItem('flappyUnlockedAbilities')) || {};
} catch (e) {
unlockedAbilities = {};
}
// Define the available abilities. Only two remain: Giant Mode and Bomb. Each has
// a price (coins required to unlock) and a description explaining its effect.
const abilities = [
{ id: 'giant', displayName: 'Giant Mode', description: 'Grow huge and destroy pipes briefly.', price: 50 },
{ id: 'bomb', displayName: 'Bomb', description: 'Destroy the next pipe instantly.', price: 30 }
];
// Iconography for each ability. Use simple emoji to convey the power at a glance.
const abilityIcons = {
giant: '🦅',
bomb: '💣'
};
// Initial shop tab (characters or abilities)
let currentShopTab = 'characters';
// Bird state
const bird = {
x: 80,
y: H / 2,
width: 34, // approximate width for collision box
height: 24, // approximate height for collision box
vy: 0,
frame: 0
};
// Base bird dimensions (for transformations)
const baseBirdW = bird.width;
const baseBirdH = bird.height;
// Ability state variables
// Ability state variables
// Number of giant frames remaining when Giant Mode is active
let giantTimer = 0;
// Bomb state: when >0, the next pipe will automatically be destroyed and skipped
let bombReady = 0;
// Timers for removed abilities (slow motion, freeze, gravity flip) are omitted but kept at zero for compatibility
let slowMotionTimer = 0;
let freezeTimer = 0;
let gravityFlipTimer = 0;
// Remove timers and flags for abilities that were dropped (tiny, wind, doubleflap, glide).
// These variables are intentionally omitted to keep state management simple.
// Cooldown timer for the ability button (frames)
let abilityCooldown = 0;
// Timer and identifier for active particle effect
let effectTimer = 0;
let effectAbility = null;
// Touch delay to prevent double triggers (0.2 seconds = 12 frames at 60fps)
let touchDelay = 0;
/**
* Reset all ability timers and flags. Called when starting a new round.
*/
function resetAbilityState() {
// Reset timers for current abilities
giantTimer = 0;
bombReady = 0;
slowMotionTimer = 0;
freezeTimer = 0;
gravityFlipTimer = 0;
// Reset cooldowns and effects
abilityCooldown = 0;
effectTimer = 0;
effectAbility = null;
// Reset touch delay
touchDelay = 0;
}
/**
* Activate the currently selected ability. This function applies the relevant
* gameplay modifiers, triggers the particle effect and plays the associated sound.
* After activation the ability enters a cooldown before it can be used again.
*/
function activateAbility() {
if (!selectedAbility || abilityCooldown > 0 || gameState !== 'play') return;
// Reset any existing ability effects before applying new one
resetAbilityState();
// Apply the ability effect
switch (selectedAbility) {
case 'giant':
// Grow huge for 5 seconds (~300 frames).
giantTimer = 300;
break;
case 'bomb':
// Destroy the next pipe instantly.
bombReady = 1;
break;
}
// Trigger particle effect and sound
effectAbility = selectedAbility;
effectTimer = 60; // show effect for one second (60 frames)
if (effectImg && effectContainer) {
effectImg.src = `assets/effects/${selectedAbility}.gif`;
// Adjust size based on ability: giant uses a larger effect than bomb
if (selectedAbility === 'giant') {
effectImg.style.width = '256px';
effectImg.style.height = '256px';
} else {
effectImg.style.width = '160px';
effectImg.style.height = '160px';
}
// Position the effect at the centre of the bird
effectImg.style.left = `${bird.x + bird.width / 2}px`;
effectImg.style.top = `${bird.y + bird.height / 2}px`;
effectContainer.style.display = 'block';
}
// Play ability sound if loaded
const snd = abilitySounds[selectedAbility];
if (snd) {
snd.currentTime = 0;
snd.play();
}
// Set cooldown (5 seconds ~ 300 frames)
abilityCooldown = 300;
updateAbilityButton();
}
// Constants
const gravity = 0.35;
const flapStrength = -7;
const pipeGap = 140; // vertical gap between pipes
const pipeWidth = 60;
const pipeSpeed = 2;
const spawnInterval = 90; // frames between pipe generation (~1.5s at ~60fps)
// Pipes array
const pipes = [];
// Assets
const images = {};
const assetsToLoad = [
{ name: 'bird0', src: 'assets/bird1.png' },
{ name: 'bird1', src: 'assets/bird2.png' },
{ name: 'pipe', src: 'assets/pipe.png' },
{ name: 'background', src: 'assets/background.jpg' }
];
// Add tinted bird variants for character selection
['blue_bird1','blue_bird2','red_bird1','red_bird2','green_bird1','green_bird2'].forEach(name => {
assetsToLoad.push({ name: name, src: `assets/${name}.png` });
});
// Audio assets. Wing flap sound and ability sounds.
const audio = {
wing: new Audio('assets/wing.wav'),
};
audio.wing.volume = 0.6;
// Load a unique sound for each ability. Only giant and bomb (turbo) remain. These audio
// variants were generated from the original wing sound to provide unique cues per power.
const abilitySounds = {};
['giant','bomb'].forEach(id => {
// For bomb we reuse the turbo sound file if a dedicated file is not present.
const file = id === 'bomb' ? 'turbo' : id;
const s = new Audio(`assets/${file}.wav`);
s.volume = 0.7;
abilitySounds[id] = s;
});
// Preload images
let assetsLoaded = 0;
assetsToLoad.forEach(asset => {
const img = new Image();
img.src = asset.src;
img.onload = () => {
images[asset.name] = img;
assetsLoaded++;
if (assetsLoaded === assetsToLoad.length) {
// Once assets are loaded, restore any saved game state or start a new game
if (localStorage.getItem('flappyGameState')) {
loadGameState();
} else {
resetGame();
}
requestAnimationFrame(loop);
}
};
});
/**
* Resets the game state for a new round. Also clears any persisted game save.
*/
function resetGame() {
gameState = 'start';
overlay.style.display = 'flex';
overlay.innerHTML = 'Click or press space to flap
Press again after game over to restart';
score = 0;
scoreElem.textContent = '';
bird.y = H / 2;
bird.vy = 0;
bird.frame = 0;
pipes.length = 0;
frameCount = 0;
// Remove saved game state since we are starting fresh
localStorage.removeItem('flappyGameState');
// Update persistent display elements
updateScoresDisplay();
updateCoinsDisplay();
updateAbilityButton();
// Reset ability state
resetAbilityState();
// Hide any active particle effect
if (effectContainer) effectContainer.style.display = 'none';
}
/**
* Handles the bird flap action.
*/
function flap() {
if (gameState === 'start') {
// Start the game on first flap. Do not automatically trigger any ability.
gameState = 'play';
overlay.style.display = 'none';
// Enable the ability button now that the game is active
updateAbilityButton();
} else if (gameState === 'play') {
// If touch delay is active, discard this input (treat as single input)
if (touchDelay > 0) return;
// Each click sets the bird's vertical velocity. No limit on the number of flaps.
bird.vy = flapStrength;
audio.wing.currentTime = 0;
audio.wing.play();
// Set touch delay (0.2 seconds = 12 frames at 60fps) to discard subsequent inputs
touchDelay = 12;
} else if (gameState === 'over') {
resetGame();
}
}
// Input handling
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
flap();
}
});
// Only trigger flap on clicks/touches outside of interactive UI such as the shop
window.addEventListener('mousedown', (e) => {
// Ignore clicks on shop button, shop modal or their children
if (e.target.closest && (e.target.closest('#shop-btn') || e.target.closest('#shop-modal') || e.target.closest('#close-shop'))) {
return;
}
flap();
});
window.addEventListener('touchstart', (e) => {
if (e.target.closest && (e.target.closest('#shop-btn') || e.target.closest('#shop-modal') || e.target.closest('#close-shop'))) {
return;
}
flap();
});
/**
* Displays the character shop when the game is not in play.
*/
function openShop() {
if (gameState === 'play') return;
// Hide overlay while browsing shop to avoid text behind
overlay.style.display = 'none';
shopModal.style.display = 'block';
currentShopTab = 'characters';
updateShopNav();
renderShop();
// Update header text based on current tab
const header = document.getElementById('shop-header');
if (header) header.textContent = 'Select Bird';
}
/**
* Rebuilds the character list inside the shop modal. Called when the shop opens or after purchases.
*/
function renderShop() {
// Clear content area
shopContentElem.innerHTML = '';
if (currentShopTab === 'characters') {
characters.forEach(char => {
const container = document.createElement('div');
container.style.width = '120px';
container.style.textAlign = 'center';
container.style.border = '1px solid rgba(255,255,255,0.3)';
container.style.borderRadius = '8px';
container.style.padding = '10px';
container.style.background = 'rgba(0,0,0,0.5)';
// Character preview image
const img = new Image();
const frameName = char.frames[0];
if (images[frameName]) {
img.src = images[frameName].src;
} else {
const asset = assetsToLoad.find(a => a.name === frameName);
img.src = asset ? asset.src : '';
}
img.style.width = '60px';
img.style.height = '60px';
img.style.imageRendering = 'pixelated';
container.appendChild(img);
// Name label
const name = document.createElement('div');
name.textContent = char.displayName;
name.style.marginTop = '4px';
container.appendChild(name);
// Action button
const btn = document.createElement('button');
btn.style.marginTop = '6px';
btn.style.padding = '4px 8px';
btn.style.border = 'none';
btn.style.borderRadius = '4px';
btn.style.cursor = 'pointer';
if (currentChar === char.id) {
btn.textContent = 'Selected';
btn.disabled = true;
btn.style.background = '#888';
btn.style.color = '#fff';
} else {
btn.textContent = 'Select';
btn.style.background = '#00C853';
btn.style.color = '#fff';
btn.addEventListener('click', () => {
currentChar = char.id;
localStorage.setItem('flappyCharacter', currentChar);
renderShop();
});
}
container.appendChild(btn);
shopContentElem.appendChild(container);
});
} else if (currentShopTab === 'powers') {
abilities.forEach(abl => {
const container = document.createElement('div');
// Grid layout: each ability occupies half width of the modal minus gap
container.style.width = 'calc(50% - 20px)';
container.style.boxSizing = 'border-box';
container.style.textAlign = 'center';
container.style.border = '1px solid rgba(255,255,255,0.3)';
container.style.borderRadius = '8px';
container.style.padding = '10px';
container.style.background = 'rgba(0,0,0,0.5)';
// Icon
const iconElem = document.createElement('div');
iconElem.textContent = abilityIcons[abl.id] || '';
iconElem.style.fontSize = '1.8rem';
iconElem.style.marginBottom = '4px';
container.appendChild(iconElem);
// Name label
const title = document.createElement('div');
title.textContent = abl.displayName;
title.style.fontWeight = 'bold';
title.style.fontSize = '0.9rem';
container.appendChild(title);
// Description
const desc = document.createElement('div');
desc.textContent = abl.description;
desc.style.fontSize = '0.7rem';
desc.style.marginTop = '4px';
desc.style.opacity = '0.8';
container.appendChild(desc);
// Price label
const price = document.createElement('div');
price.style.fontSize = '0.7rem';
price.style.marginTop = '4px';
price.style.fontStyle = 'italic';
price.textContent = `Price: ${abl.price} coins`;
container.appendChild(price);
// Action button (Buy/Select)
const btn = document.createElement('button');
btn.style.marginTop = '6px';
btn.style.padding = '4px 8px';
btn.style.border = 'none';
btn.style.borderRadius = '4px';
btn.style.cursor = 'pointer';
if (!unlockedAbilities[abl.id]) {
// Not unlocked: show Buy button
btn.textContent = `Buy (${abl.price})`;
btn.style.background = coins >= abl.price ? '#00C853' : '#757575';
btn.style.color = '#fff';
btn.disabled = coins < abl.price;
btn.addEventListener('click', () => {
if (coins >= abl.price) {
coins -= abl.price;
localStorage.setItem('flappyCoins', coins.toString());
unlockedAbilities[abl.id] = true;
localStorage.setItem('flappyUnlockedAbilities', JSON.stringify(unlockedAbilities));
updateCoinsDisplay();
renderShop();
}
});
} else {
// Already unlocked: allow select
if (selectedAbility === abl.id) {
btn.textContent = 'Selected';
btn.disabled = true;
btn.style.background = '#888';
btn.style.color = '#fff';
} else {
btn.textContent = 'Select';
btn.style.background = '#00C853';
btn.style.color = '#fff';
btn.addEventListener('click', () => {
selectedAbility = abl.id;
localStorage.setItem('flappySelectedAbility', selectedAbility);
updateAbilityButton();
renderShop();
});
}
}
container.appendChild(btn);
shopContentElem.appendChild(container);
});
}
}
/**
* Hides the character shop.
*/
function closeShop() {
shopModal.style.display = 'none';
// If game isn't in play state, restore overlay visibility for start or game over screens
if (gameState !== 'play') {
overlay.style.display = 'flex';
}
}
/**
* Updates the high score display in the UI.
*/
function updateScoresDisplay() {
highScore = parseInt(localStorage.getItem('flappyHighScore')) || highScore;
// Display high score with trophy icon
highscoreElem.innerHTML = `🏆 ${highScore}`;
}
/**
* Updates the coin counter display in the UI.
*/
function updateCoinsDisplay() {
if (typeof coins !== 'number') coins = 0;
// Display coin count with coin icon (U+1FA99), fallback to simple circle if unsupported
const coinIcon = '🪙';
coinsElem.innerHTML = `${coinIcon} ${coins}`;
}
/**
* Updates the ability activation button. The button shows the icon of the
* selected ability and is hidden when no ability is selected. It becomes
* disabled during cooldown and enabled otherwise.
*/
function updateAbilityButton() {
if (!abilityBtn) return;
if (selectedAbility && unlockedAbilities[selectedAbility]) {
abilityBtn.style.display = 'block';
abilityBtn.textContent = abilityIcons[selectedAbility] || '?';
// Disable button if the ability is cooling down or if the game is not active
if (abilityCooldown > 0 || gameState !== 'play') {
abilityBtn.disabled = true;
abilityBtn.style.opacity = '0.5';
} else {
abilityBtn.disabled = false;
abilityBtn.style.opacity = '1';
}
} else {
abilityBtn.style.display = 'none';
}
}
// Event handlers for shop buttons
shopBtn.addEventListener('click', openShop);
closeShopBtn.addEventListener('click', closeShop);
// Ability button triggers the currently selected ability when pressed
if (abilityBtn) {
abilityBtn.addEventListener('click', (e) => {
e.stopPropagation();
activateAbility();
});
}
// Update navigation button styles based on current tab
function updateShopNav() {
if (currentShopTab === 'characters') {
tabCharBtn.style.background = '#00C853';
tabPowerBtn.style.background = '#757575';
const header = document.getElementById('shop-header');
if (header) header.textContent = 'Select Bird';
} else {
tabCharBtn.style.background = '#757575';
tabPowerBtn.style.background = '#00C853';
const header = document.getElementById('shop-header');
if (header) header.textContent = 'Abilities';
}
}
// Tab navigation events
tabCharBtn.addEventListener('click', () => {
currentShopTab = 'characters';
updateShopNav();
renderShop();
});
/**
* Save the current game state to local storage so the user can continue later.
* We only save minimal necessary data: game state, score, bird position, velocity,
* pipe positions and heights, ability timers, cooldown, and selected ability.
*/
function saveGameState() {
const state = {
gameState,
score,
bird: { x: bird.x, y: bird.y, vy: bird.vy },
pipes: pipes.map(p => ({ x: p.x, topHeight: p.topHeight, passed: p.passed })),
giantTimer,
bombReady,
slowMotionTimer,
freezeTimer,
gravityFlipTimer,
abilityCooldown,
selectedAbility,
touchDelay
};
try {
localStorage.setItem('flappyGameState', JSON.stringify(state));
} catch (e) {
// local storage may be full or disabled; ignore errors
}
}
/**
* Load a previously saved game state from local storage. If no valid saved state exists,
* this function does nothing. It sets up the game variables and UI to reflect the
* saved state so the player can continue where they left off.
*/
function loadGameState() {
const dataStr = localStorage.getItem('flappyGameState');
if (!dataStr) return;
try {
const data = JSON.parse(dataStr);
// Restore basic state
gameState = data.gameState || 'start';
score = data.score || 0;
if (data.bird) {
bird.x = data.bird.x;
bird.y = data.bird.y;
bird.vy = data.bird.vy;
}
pipes.length = 0;
if (Array.isArray(data.pipes)) {
data.pipes.forEach(p => {
pipes.push({ x: p.x, topHeight: p.topHeight, passed: p.passed });
});
}
giantTimer = data.giantTimer || 0;
bombReady = data.bombReady || 0;
slowMotionTimer = data.slowMotionTimer || 0;
freezeTimer = data.freezeTimer || 0;
gravityFlipTimer = data.gravityFlipTimer || 0;
abilityCooldown = data.abilityCooldown || 0;
selectedAbility = data.selectedAbility || selectedAbility;
touchDelay = data.touchDelay || 0;
// Restore UI
overlay.style.display = gameState === 'play' ? 'none' : 'flex';
if (gameState === 'play') {
overlay.style.display = 'none';
} else if (gameState === 'start') {
overlay.innerHTML = 'Click or press space to flap
Press again after game over to restart';
} else if (gameState === 'over') {
overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`;
}
updateScoresDisplay();
updateCoinsDisplay();
updateAbilityButton();
} catch (e) {
// If parsing fails, clear the corrupted saved state and start fresh
localStorage.removeItem('flappyGameState');
resetGame();
}
}
tabPowerBtn.addEventListener('click', () => {
currentShopTab = 'powers';
updateShopNav();
renderShop();
});
/**
* Generates a new pipe at the right edge of the canvas with a random gap position.
*/
function spawnPipe() {
const minHeight = 40;
const maxTopHeight = H - groundHeight - pipeGap - minHeight;
const topHeight = Math.floor(Math.random() * (maxTopHeight - minHeight + 1)) + minHeight;
pipes.push({
x: W,
topHeight: topHeight,
passed: false
});
}
/**
* Draws the scrolling background. A simple parallax effect is achieved by repeating the background horizontally.
*/
function drawBackground() {
const bgImg = images.background;
const scrollX = (frameCount * pipeSpeed / 2) % bgImg.width;
// Draw two images side by side to fill the canvas width
ctx.drawImage(bgImg, -scrollX, 0, bgImg.width, H);
ctx.drawImage(bgImg, bgImg.width - scrollX, 0, bgImg.width, H);
}
/**
* Draws the ground as a simple green rectangle with a brown border.
*/
function drawGround() {
const groundY = H - groundHeight;
// Grass
ctx.fillStyle = '#78C850';
ctx.fillRect(0, groundY, W, groundHeight * 0.35);
// Dirt
ctx.fillStyle = '#8B4513';
ctx.fillRect(0, groundY + groundHeight * 0.35, W, groundHeight * 0.65);
// Draw some lighter stripes to simulate texture
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
for (let i = 0; i < W; i += 20) {
ctx.beginPath();
ctx.moveTo(i, groundY + groundHeight * 0.4);
ctx.lineTo(i, H);
ctx.stroke();
}
}
/**
* Main update and render loop.
*/
function loop() {
frameCount++;
ctx.clearRect(0, 0, W, H);
drawBackground();
// Adjust bird size for giant/tiny power‑ups
let scale = 1;
if (giantTimer > 0) {
scale = 1.6;
}
bird.width = baseBirdW * scale;
bird.height = baseBirdH * scale;
if (gameState === 'play') {
// Determine gravity direction. Gravity can be inverted when gravityFlipTimer > 0.
const gdir = gravityFlipTimer > 0 ? -1 : 1;
let effectiveGravity = gravity * gdir;
bird.vy += effectiveGravity;
bird.y += bird.vy;
// Bound bird to screen
if (gdir === 1) {
if (bird.y < 0) {
bird.y = 0;
bird.vy = 0;
}
} else {
// When gravity is flipped, top is ground; do not bound at bottom
// But keep bird within canvas
if (bird.y + bird.height > H - groundHeight) {
// allow falling off bottom but keep within visible area
bird.y = H - groundHeight - bird.height;
bird.vy = 0;
}
}
// Spawn pipes periodically
if (frameCount % spawnInterval === 0) {
spawnPipe();
}
// Determine current pipe speed with modifiers
let currentSpeed = pipeSpeed;
if (slowMotionTimer > 0) currentSpeed = pipeSpeed * 0.5;
if (freezeTimer > 0) currentSpeed = 0;
// Update pipe positions
for (let i = 0; i < pipes.length; i++) {
const p = pipes[i];
p.x -= currentSpeed;
}
// Remove off‑screen pipes
while (pipes.length && pipes[0].x + pipeWidth < 0) {
pipes.shift();
}
// If the bomb ability has been activated (bombReady > 0), automatically
// destroy the next upcoming pipe. This simulates an explosion that
// clears a single obstacle.
if (bombReady > 0 && pipes.length) {
const nextPipe = pipes[0];
if (!nextPipe.passed) {
nextPipe.passed = true;
score++;
// Award a coin for each pipe cleared via bomb
coins++;
localStorage.setItem('flappyCoins', coins.toString());
updateCoinsDisplay();
// Remove the skipped pipe to prevent collision detection
pipes.shift();
bombReady--;
}
}
// Award score when bird passes pipes
for (const p of pipes) {
if (!p.passed && p.x + pipeWidth < bird.x) {
p.passed = true;
score++;
// Award one coin per pipe passed
coins++;
localStorage.setItem('flappyCoins', coins.toString());
updateCoinsDisplay();
}
}
// Collision detection with pipes
for (let i = 0; i < pipes.length && gameState === 'play'; i++) {
const p = pipes[i];
const birdBox = { x: bird.x, y: bird.y, w: bird.width, h: bird.height };
const topBox = { x: p.x, y: 0, w: pipeWidth, h: p.topHeight };
const bottomBox = { x: p.x, y: p.topHeight + pipeGap, w: pipeWidth, h: H - groundHeight - (p.topHeight + pipeGap) };
if (rectIntersect(birdBox, topBox) || rectIntersect(birdBox, bottomBox)) {
if (giantTimer > 0) {
// Destroy pipe: award pass and remove
p.passed = true;
score++;
coins++;
localStorage.setItem('flappyCoins', coins.toString());
updateCoinsDisplay();
pipes.splice(i, 1);
i--;
continue;
} else {
// Game over
gameState = 'over';
touchDelay = 0; // Reset touch delay to allow immediate restart
updateAbilityButton();
if (score > highScore) {
highScore = score;
localStorage.setItem('flappyHighScore', highScore.toString());
}
overlay.style.display = 'flex';
overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`;
updateScoresDisplay();
break;
}
}
}
// Check collision with ground or top depending on gravity
if (gameState === 'play') {
if (gravityFlipTimer <= 0) {
// Normal gravity – check ground at bottom
if (bird.y + bird.height >= H - groundHeight) {
if (giantTimer > 0) {
// If giant mode is active, shrink back and allow one extra bounce off the ground
giantTimer = 0;
bird.y = H - groundHeight - bird.height - 1;
bird.vy = 0;
} else {
bird.y = H - groundHeight - bird.height;
gameState = 'over';
touchDelay = 0; // Reset touch delay to allow immediate restart
updateAbilityButton();
if (score > highScore) {
highScore = score;
localStorage.setItem('flappyHighScore', highScore.toString());
}
overlay.style.display = 'flex';
overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`;
updateScoresDisplay();
}
}
} else {
// Gravity flipped – check top as ground
if (bird.y <= 0) {
if (giantTimer > 0) {
// If giant mode is active when gravity is flipped, shrink back and allow a bounce
giantTimer = 0;
bird.y = 1;
bird.vy = 0;
} else {
bird.y = 0;
gameState = 'over';
touchDelay = 0; // Reset touch delay to allow immediate restart
updateAbilityButton();
if (score > highScore) {
highScore = score;
localStorage.setItem('flappyHighScore', highScore.toString());
}
overlay.style.display = 'flex';
overlay.innerHTML = `Game Over
Score: ${score}
High Score: ${highScore}
Click or press space to restart`;
updateScoresDisplay();
}
}
}
}
// Decrement timers each frame
if (slowMotionTimer > 0) slowMotionTimer--;
if (freezeTimer > 0) freezeTimer--;
if (giantTimer > 0) giantTimer--;
if (gravityFlipTimer > 0) gravityFlipTimer--;
// Update ability cooldown and effect timers
if (abilityCooldown > 0) {
abilityCooldown--;
if (abilityCooldown === 0) {
updateAbilityButton();
}
}
if (effectTimer > 0) {
effectTimer--;
if (effectTimer <= 0) {
// Hide the effect overlay when finished
effectContainer.style.display = 'none';
effectAbility = null;
}
}
// Decrement touch delay timer
if (touchDelay > 0) touchDelay--;
// Periodically save the game state so the player can resume later
if (!window._saveFrameCounter) window._saveFrameCounter = 0;
window._saveFrameCounter++;
if (window._saveFrameCounter >= 30) {
saveGameState();
window._saveFrameCounter = 0;
}
}
// Draw pipes
for (const p of pipes) {
// Top pipe
ctx.drawImage(images.pipe, p.x, p.topHeight - images.pipe.height, pipeWidth, images.pipe.height);
// Bottom pipe
ctx.save();
// Flip the pipe vertically for bottom
ctx.translate(p.x + pipeWidth, p.topHeight + pipeGap);
ctx.scale(-1, 1);
ctx.drawImage(images.pipe, 0, 0, pipeWidth, images.pipe.height);
ctx.restore();
}
// Animate bird (toggle between frames every few frames)
if (frameCount % 10 === 0) bird.frame = (bird.frame + 1) % 2;
// Select the appropriate frame for the current character. Default to classic frames if unavailable.
let birdImg;
const currentCharData = characters.find(c => c.id === currentChar) || characters[0];
const frameName = currentCharData.frames[bird.frame] || currentCharData.frames[0];
birdImg = images[frameName] || images.bird0;
// Slight rotation based on velocity for a dynamic feel
const angle = Math.min(Math.max(bird.vy / 10, -0.5), 0.7);
ctx.save();
ctx.translate(bird.x + bird.width / 2, bird.y + bird.height / 2);
ctx.rotate(angle);
ctx.drawImage(birdImg, -bird.width / 2, -bird.height / 2, bird.width, bird.height);
ctx.restore();
drawGround();
// Draw score
if (gameState !== 'start') {
scoreElem.textContent = score;
}
requestAnimationFrame(loop);
}
/**
* Simple rectangle collision detection.
* @param {Object} a Rectangle A with x, y, w, h.
* @param {Object} b Rectangle B with x, y, w, h.
* @returns {boolean} True if the rectangles intersect.
*/
function rectIntersect(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
// Initialize game
resetGame();
})();