Space-Invaders-Luis / index.html
Luis-Filipe's picture
Update index.html
3e4d7e7 verified
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Space Invaders Arcade</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:root {
--bg-color: #050505;
--screen-bg: #111;
--neon-green: #39ff14;
--neon-red: #ff0033;
--neon-white: #eee;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--neon-white);
font-family: 'Press Start 2P', cursive;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
user-select: none;
-webkit-user-select: none;
}
#game-container {
position: relative;
width: 100%;
height: 100%;
max-width: 600px; /* Arcade aspect ratio constraint */
display: flex;
align-items: center;
justify-content: center;
background: #000;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.1);
}
canvas {
background-color: var(--screen-bg);
image-rendering: pixelated; /* Crucial for retro look */
max-width: 100%;
max-height: 100%;
}
/* Retro CRT Scanline Effect */
.scanlines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to bottom,
rgba(255,255,255,0),
rgba(255,255,255,0) 50%,
rgba(0,0,0,0.2) 50%,
rgba(0,0,0,0.2)
);
background-size: 100% 4px;
pointer-events: none;
z-index: 10;
}
.overlay-ui {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
box-sizing: border-box;
z-index: 20;
}
.hud {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 16px;
text-shadow: 2px 2px 0px #000;
}
#start-screen, #game-over-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 30;
text-align: center;
}
h1 {
color: var(--neon-green);
font-size: 40px;
margin-bottom: 20px;
line-height: 1.5;
text-shadow: 4px 4px 0px #003300;
}
.btn {
background: transparent;
border: 2px solid var(--neon-green);
color: var(--neon-green);
padding: 15px 30px;
font-family: 'Press Start 2P', cursive;
font-size: 16px;
cursor: pointer;
margin-top: 20px;
text-transform: uppercase;
transition: all 0.1s;
pointer-events: auto;
}
.btn:active {
background: var(--neon-green);
color: #000;
}
.hidden {
display: none !important;
}
/* Mobile Controls Hints */
.mobile-controls-hint {
position: absolute;
bottom: 20px;
width: 100%;
text-align: center;
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
pointer-events: none;
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="gameCanvas"></canvas>
<div class="scanlines"></div>
<div class="overlay-ui">
<div class="hud">
<div id="scoreDisplay">SCORE: 0000</div>
<div id="highScoreDisplay">HI: 0000</div>
</div>
</div>
<div id="start-screen">
<h1>SPACE<br>INVADERS</h1>
<p style="color: #ccc; font-size: 12px; margin-bottom: 30px;">ARROWS TO MOVE • SPACE TO SHOOT</p>
<p style="color: #888; font-size: 10px; margin-bottom: 30px;">(TOUCH SIDES TO MOVE • TAP TO SHOOT)</p>
<button class="btn" id="startBtn">INSERT COIN</button>
</div>
<div id="game-over-screen" class="hidden">
<h1 style="color: var(--neon-red);">GAME OVER</h1>
<p id="finalScore">SCORE: 0</p>
<button class="btn" id="restartBtn">TRY AGAIN</button>
</div>
</div>
<script>
/**
* SPACE INVADERS CLONE
* Single file implementation with synthesized audio and generated pixel art.
*/
// --- Audio System (Web Audio API) ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const soundEnabled = true;
const Sounds = {
shoot: () => playTone(880, 'square', 0.1, -10),
alienDeath: () => playTone(150, 'sawtooth', 0.2, -5),
playerDeath: () => noise(0.5),
ufo: () => playTone(300, 'sine', 0.5, -10, true), // Looping tone concept, implemented simply here
ufoHit: () => playTone(1000, 'square', 0.3, -5)
};
function playTone(freq, type, duration, vol = -10, slide = false) {
if (!soundEnabled || audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (slide) {
osc.frequency.linearRampToValueAtTime(freq - 100, audioCtx.currentTime + duration);
}
gain.gain.setValueAtTime(Math.pow(10, vol/20), audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function noise(duration) {
if (!soundEnabled || audioCtx.state === 'suspended') audioCtx.resume();
const bufferSize = audioCtx.sampleRate * duration;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noiseSrc = audioCtx.createBufferSource();
noiseSrc.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
noiseSrc.connect(gain);
gain.connect(audioCtx.destination);
noiseSrc.start();
}
// --- Graphics Constants (Pixel Art Bitmaps) ---
// 1 = pixel, 0 = empty
const SPRITES = {
player: [
[0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,1,1,1,0,0,0,0],
[0,0,0,0,1,1,1,0,0,0,0],
[0,1,1,1,1,1,1,1,1,1,0],
[1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1]
],
alien1: [ // Squid (Top row)
[0,0,0,1,1,0,0,0],
[0,0,1,1,1,1,0,0],
[0,1,1,1,1,1,1,0],
[1,1,0,1,1,0,1,1],
[1,1,1,1,1,1,1,1],
[0,1,0,1,1,0,1,0],
[1,0,0,0,0,0,0,1],
[0,1,0,0,0,0,1,0]
],
alien2: [ // Crab (Middle rows)
[0,0,1,0,0,0,0,0,1,0,0],
[0,0,0,1,0,0,0,1,0,0,0],
[0,0,1,1,1,1,1,1,1,0,0],
[0,1,1,0,1,1,1,0,1,1,0],
[1,1,1,1,1,1,1,1,1,1,1],
[1,0,1,1,1,1,1,1,1,0,1],
[1,0,1,0,0,0,0,0,1,0,1],
[0,0,0,1,1,0,1,1,0,0,0]
],
alien3: [ // Octopus (Bottom rows)
[0,0,0,0,1,1,1,1,0,0,0,0],
[0,1,1,1,1,1,1,1,1,1,1,0],
[1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,0,0,1,1,0,0,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1],
[0,0,0,1,1,0,0,1,1,0,0,0],
[0,0,1,1,0,1,1,0,1,1,0,0],
[1,1,0,0,0,0,0,0,0,0,1,1]
],
ufo: [
[0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0],
[0,0,0,1,1,1,1,1,1,1,1,1,1,0,0,0],
[0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0],
[0,1,1,0,1,1,0,1,1,0,1,1,0,1,1,0],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[0,0,1,1,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0]
]
};
// --- Game Engine ---
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Game Constants
const LOGICAL_WIDTH = 600;
const LOGICAL_HEIGHT = 800;
const PLAYER_SPEED = 4;
const BULLET_SPEED = 7;
const ALIEN_BULLET_SPEED = 4;
const PIXEL_SIZE = 3; // Size of one 'pixel' in the sprite grid
// Game State
let lastTime = 0;
let score = 0;
let highScore = localStorage.getItem('si_highscore') || 0;
let lives = 3;
let level = 1;
let gameState = 'START'; // START, PLAYING, GAMEOVER, LEVEL_TRANSITION
// Entities
let player = {};
let aliens = [];
let bullets = []; // {x, y, vY, type} type: 'player' | 'enemy'
let particles = []; // {x, y, vX, vY, life, color}
let bunkers = []; // {x, y, width, height, blocks: []}
let ufo = null;
let ufoTimer = 0;
// Input State
const keys = {
ArrowLeft: false,
ArrowRight: false,
Space: false
};
// Touch State
let touchX = null;
let touchActive = false;
function initCanvas() {
canvas.width = LOGICAL_WIDTH;
canvas.height = LOGICAL_HEIGHT;
ctx.imageSmoothingEnabled = false;
}
function resetGame() {
score = 0;
lives = 3;
level = 1;
updateScoreUI();
initLevel();
gameState = 'PLAYING';
document.getElementById('game-over-screen').classList.add('hidden');
document.getElementById('start-screen').classList.add('hidden');
}
function initLevel() {
player = {
x: LOGICAL_WIDTH / 2 - (11 * PIXEL_SIZE) / 2,
y: LOGICAL_HEIGHT - 50,
w: 11 * PIXEL_SIZE,
h: 8 * PIXEL_SIZE,
color: '#39ff14',
cooldown: 0
};
bullets = [];
particles = [];
ufo = null;
ufoTimer = Math.random() * 1000 + 500;
// Create Aliens
aliens = [];
const rows = 5;
const cols = 11;
const startX = 50;
const startY = 80;
for(let r=0; r<rows; r++) {
for(let c=0; c<cols; c++) {
let type = 'alien1';
if(r >= 1 && r <= 2) type = 'alien2';
if(r >= 3) type = 'alien3';
aliens.push({
x: startX + c * 40,
y: startY + r * 35,
w: SPRITES[type][0].length * PIXEL_SIZE,
h: SPRITES[type].length * PIXEL_SIZE,
type: type,
row: r,
col: c,
active: true
});
}
}
// Alien movement state
alienDir = 1; // 1 = right, -1 = left
alienMoveTimer = 0;
alienMoveInterval = Math.max(5, 60 - (level * 5)); // Get faster per level
alienSoundToggle = false; // For simple march beat if we added one
// Bunkers (only reset on full game restart usually, but here every level for simplicity)
if (level === 1 || bunkers.length === 0) {
bunkers = [];
for(let i=0; i<4; i++) {
createBunker(80 + i * 130, LOGICAL_HEIGHT - 150);
}
}
}
function createBunker(x, y) {
const w = 60;
const h = 40;
const blocks = [];
const blockSize = 4;
for(let by = 0; by < h; by += blockSize) {
for(let bx = 0; bx < w; bx += blockSize) {
// Create an arch shape
if (by > 25 && bx > 15 && bx < 45) continue;
// Corner rounding
if (by < 8 && (bx < 8 || bx > 52)) continue;
blocks.push({
x: x + bx,
y: y + by,
w: blockSize,
h: blockSize,
active: true
});
}
}
bunkers.push({ x, y, w, h, blocks });
}
// --- Input Handling ---
window.addEventListener('keydown', e => {
if(e.code === 'ArrowLeft') keys.ArrowLeft = true;
if(e.code === 'ArrowRight') keys.ArrowRight = true;
if(e.code === 'Space' || e.code === 'ArrowUp') keys.Space = true;
});
window.addEventListener('keyup', e => {
if(e.code === 'ArrowLeft') keys.ArrowLeft = false;
if(e.code === 'ArrowRight') keys.ArrowRight = false;
if(e.code === 'Space' || e.code === 'ArrowUp') keys.Space = false;
});
// Touch Controls
canvas.addEventListener('touchstart', e => {
e.preventDefault();
touchActive = true;
handleTouch(e.touches[0]);
// Tap logic for shooting: if tap is quick or if we want auto fire
// For arcade feel: touching sides moves, tapping center area shoots
// Simplified: Just fire if touching.
keys.Space = true;
}, {passive: false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
handleTouch(e.touches[0]);
}, {passive: false});
canvas.addEventListener('touchend', e => {
e.preventDefault();
touchActive = false;
keys.ArrowLeft = false;
keys.ArrowRight = false;
keys.Space = false;
touchX = null;
});
function handleTouch(touch) {
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const scaleX = canvas.width / rect.width;
const gameX = x * scaleX;
// Virtual joystick logic logic
// Screen divided in half. Left half = Left, Right half = Right.
if (gameX < LOGICAL_WIDTH / 2) {
keys.ArrowLeft = true;
keys.ArrowRight = false;
} else {
keys.ArrowLeft = false;
keys.ArrowRight = true;
}
}
// --- Logic ---
function update(dt) {
if (gameState !== 'PLAYING') return;
// Player Movement
if (keys.ArrowLeft) player.x = Math.max(10, player.x - PLAYER_SPEED);
if (keys.ArrowRight) player.x = Math.min(LOGICAL_WIDTH - player.w - 10, player.x + PLAYER_SPEED);
// Player Shoot
if (player.cooldown > 0) player.cooldown--;
if (keys.Space && player.cooldown <= 0) {
bullets.push({
x: player.x + player.w/2 - 2,
y: player.y,
w: 4,
h: 10,
vY: -BULLET_SPEED,
color: '#39ff14',
type: 'player'
});
player.cooldown = 25;
Sounds.shoot();
}
// Update Bullets
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.y += b.vY;
// Out of bounds
if (b.y < 0 || b.y > LOGICAL_HEIGHT) {
bullets.splice(i, 1);
continue;
}
// Bullet collisions with bunkers
let bulletHitBunker = false;
bunkers.forEach(bunker => {
if (b.x < bunker.x + bunker.w && b.x + b.w > bunker.x &&
b.y < bunker.y + bunker.h && b.y + b.h > bunker.y) {
// Check individual blocks
for (let k = bunker.blocks.length - 1; k >= 0; k--) {
const block = bunker.blocks[k];
if (block.active &&
b.x < block.x + block.w && b.x + b.w > block.x &&
b.y < block.y + block.h && b.y + b.h > block.y) {
block.active = false; // Destroy block
bulletHitBunker = true;
createExplosion(block.x, block.y, '#39ff14', 2);
// Destroy bullet
break; // Only destroy one block per frame per bullet usually, or bullet dies immediately
}
}
}
});
if (bulletHitBunker) {
bullets.splice(i, 1);
continue;
}
// Bullet Collisions (Player Bullet hitting Aliens)
if (b.type === 'player') {
// Check UFO
if (ufo && checkRectCollide(b, ufo)) {
createExplosion(ufo.x + ufo.w/2, ufo.y + ufo.h/2, '#ff0033', 20);
ufo = null;
score += Math.floor(Math.random()*3 + 1) * 100;
updateScoreUI();
Sounds.ufoHit();
bullets.splice(i, 1);
continue;
}
// Check Aliens
let hit = false;
for (let j = 0; j < aliens.length; j++) {
const a = aliens[j];
if (a.active && checkRectCollide(b, a)) {
a.active = false;
hit = true;
score += (4 - Math.floor(a.row/2)) * 10; // Top row worth more? Actually in SI top is 30, mid 20, bot 10
updateScoreUI();
createExplosion(a.x + a.w/2, a.y + a.h/2, '#fff', 8);
Sounds.alienDeath();
break;
}
}
if (hit) {
bullets.splice(i, 1);
// Increase speed slightly as aliens die
const activeAliens = aliens.filter(a => a.active).length;
if (activeAliens === 0) {
levelComplete();
} else {
// Speed up based on ratio remaining
const total = 55;
const ratio = activeAliens / total;
alienMoveInterval = Math.max(2, (60 - (level*5)) * ratio);
}
continue;
}
}
// Alien Bullet hitting Player
else if (b.type === 'enemy') {
if (checkRectCollide(b, player)) {
playerHit();
bullets.splice(i, 1);
continue;
}
}
// Bullet vs Bullet (rare but cool)
/* logic omitted for simplicity */
}
// Update Aliens
alienMoveTimer++;
if (alienMoveTimer > alienMoveInterval) {
alienMoveTimer = 0;
moveAliens();
}
// Alien Shooting
if (Math.random() < 0.02 + (level * 0.005)) {
const activeCols = [];
aliens.forEach(a => {
if(a.active) {
if(!activeCols[a.col] || a.y > activeCols[a.col].y) {
activeCols[a.col] = a;
}
}
});
const shootingAlien = activeCols[Object.keys(activeCols)[Math.floor(Math.random() * Object.keys(activeCols).length)]];
if(shootingAlien) {
bullets.push({
x: shootingAlien.x + shootingAlien.w/2,
y: shootingAlien.y + shootingAlien.h,
w: 4,
h: 10,
vY: ALIEN_BULLET_SPEED,
color: '#fff',
type: 'enemy'
});
}
}
// UFO Logic
if (!ufo) {
ufoTimer--;
if (ufoTimer <= 0) {
ufo = {
x: -50,
y: 40,
w: SPRITES.ufo[0].length * PIXEL_SIZE,
h: SPRITES.ufo.length * PIXEL_SIZE,
vX: 3
};
Sounds.ufo();
}
} else {
ufo.x += ufo.vX;
if (ufo.x > LOGICAL_WIDTH + 50) {
ufo = null;
ufoTimer = Math.random() * 1000 + 500;
}
}
// Particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vX;
p.y += p.vY;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
}
function moveAliens() {
let hitEdge = false;
let lowermost = 0;
aliens.forEach(a => {
if (!a.active) return;
if (alienDir === 1 && a.x + a.w > LOGICAL_WIDTH - 20) hitEdge = true;
if (alienDir === -1 && a.x < 20) hitEdge = true;
if (a.y > lowermost) lowermost = a.y;
});
if (hitEdge) {
alienDir *= -1;
aliens.forEach(a => a.y += 20);
// Check invasion
if (lowermost + 20 >= player.y) {
gameOver();
}
} else {
aliens.forEach(a => a.x += 10 * alienDir);
}
}
function checkRectCollide(r1, r2) {
return (r1.x < r2.x + r2.w &&
r1.x + r1.w > r2.x &&
r1.y < r2.y + r2.h &&
r1.y + r1.h > r2.y);
}
function playerHit() {
lives--;
createExplosion(player.x + player.w/2, player.y + player.h/2, '#39ff14', 50);
Sounds.playerDeath();
updateScoreUI();
if (lives <= 0) {
gameOver();
} else {
// Respawn delay or effect could go here
bullets = []; // Clear bullets for fairness
player.x = LOGICAL_WIDTH / 2; // Reset Pos
// Pause briefly?
}
}
function levelComplete() {
level++;
score += 1000;
initLevel();
// Brief pause logic could go here
}
function gameOver() {
gameState = 'GAMEOVER';
if (score > highScore) {
highScore = score;
localStorage.setItem('si_highscore', highScore);
}
document.getElementById('finalScore').innerText = "SCORE: " + score;
document.getElementById('game-over-screen').classList.remove('hidden');
}
function createExplosion(x, y, color, count) {
for(let i=0; i<count; i++) {
particles.push({
x: x,
y: y,
vX: (Math.random() - 0.5) * 8,
vY: (Math.random() - 0.5) * 8,
life: 20 + Math.random() * 10,
color: color
});
}
}
// --- Rendering ---
function drawSprite(spriteMap, x, y, color) {
ctx.fillStyle = color;
for(let r=0; r<spriteMap.length; r++) {
for(let c=0; c<spriteMap[r].length; c++) {
if(spriteMap[r][c] === 1) {
ctx.fillRect(x + c * PIXEL_SIZE, y + r * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
}
}
}
}
function draw() {
// Clear Screen
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
// Stars/Background
// (Static optimization: could draw once to offscreen canvas, but cheap enough here)
ctx.fillStyle = 'white';
// Simple starfield
/* Note: In a real complex app, don't generate stars every frame.
Here we just leave black for cleaner arcade look or draw static stars if precalc'd.
Let's skip stars for that stark 1978 look. */
// Draw Bunkers
ctx.fillStyle = '#39ff14';
bunkers.forEach(b => {
b.blocks.forEach(block => {
if(block.active) {
ctx.fillRect(block.x, block.y, block.w, block.h);
}
});
});
if (gameState === 'PLAYING' || gameState === 'GAMEOVER') {
// Draw Player
if (lives > 0 || Math.floor(Date.now() / 100) % 2 === 0) { // Flicker if hit? (not imp yet)
drawSprite(SPRITES.player, player.x, player.y, player.color);
}
// Draw Aliens
// Animation frame for aliens (arms up/down)
// Using global timer for sync animation
// Actually in SI, animation depends on position, but simple toggle is fine
const animFrame = Math.floor(Date.now() / 500) % 2;
aliens.forEach(a => {
if (a.active) {
// For pure authentic look, sprites change slightly.
// We use same sprite for simplicity, or could modify array reading.
// Let's just draw them.
drawSprite(SPRITES[a.type], a.x, a.y, '#fff');
}
});
// Draw UFO
if (ufo) {
drawSprite(SPRITES.ufo, ufo.x, ufo.y, '#ff0033');
}
// Draw Bullets
bullets.forEach(b => {
ctx.fillStyle = b.color;
// Simple rect bullet
ctx.fillRect(b.x, b.y, b.w, b.h);
// Or zig-zag for alien bullets
if(b.type === 'enemy') {
// Add visual flare to alien bullets
ctx.fillRect(b.x - 2, b.y + 2, 8, 2);
}
});
// Draw Particles
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, PIXEL_SIZE, PIXEL_SIZE);
});
// Draw Floor Line
ctx.fillStyle = '#39ff14';
ctx.fillRect(0, LOGICAL_HEIGHT - 1, LOGICAL_WIDTH, 1);
}
// Lives Display (Icons at bottom)
for(let i=0; i<Math.max(0, lives-1); i++) {
drawSprite(SPRITES.player, 20 + i * 40, LOGICAL_HEIGHT - 30, '#39ff14');
}
}
function updateScoreUI() {
document.getElementById('scoreDisplay').innerText = `SCORE: ${score.toString().padStart(4, '0')}`;
document.getElementById('highScoreDisplay').innerText = `HI: ${highScore.toString().padStart(4, '0')}`;
}
function loop() {
const now = Date.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
update(dt);
draw();
requestAnimationFrame(loop);
}
// --- Initialization ---
initCanvas();
updateScoreUI();
document.getElementById('startBtn').addEventListener('click', () => {
// Resume Audio Context on user interaction
if(audioCtx.state === 'suspended') audioCtx.resume();
resetGame();
});
document.getElementById('restartBtn').addEventListener('click', () => {
resetGame();
});
window.addEventListener('resize', initCanvas); // Ideally handle resize better, but canvas scales via CSS
// Start Loop
requestAnimationFrame(loop);
</script>
</body>
</html>