diff --git a/space-invaders/README.md b/space-invaders/README.md
new file mode 100644
index 00000000..d323ebc1
--- /dev/null
+++ b/space-invaders/README.md
@@ -0,0 +1,29 @@
+# Space Invaders — Nouveau
+
+A mobile-responsive Progressive Web App recreation of the 1978 arcade classic,
+built with vanilla HTML5 Canvas, CSS, and JavaScript (ES modules) — no build
+step, no dependencies.
+
+Faithful to the original's mechanics: the hive-mind alien swarm with its
+hardware-accurate speed-up curve, deterministic Rolling/Plunger/Squiggly
+firing patterns, the hidden shot-count-based mystery ship scoring table,
+pixel-level destructible bunkers, and the one-bullet-at-a-time cannon — all
+reskinned in a gilded, jewel-toned Art Nouveau visual style with procedurally
+synthesized (not sampled) sound effects.
+
+## Running locally
+
+Any static file server works, e.g.:
+
+```bash
+cd space-invaders
+python3 -m http.server 8080
+```
+
+Then open `http://localhost:8080`. Installable as a PWA (offline-capable via
+service worker) from a supporting browser.
+
+## Controls
+
+- **Move**: Arrow keys / A-D, or the on-screen D-pad on touch devices.
+- **Fire**: Space / Up / W, or the on-screen fire button.
diff --git a/space-invaders/css/style.css b/space-invaders/css/style.css
new file mode 100644
index 00000000..76540ac9
--- /dev/null
+++ b/space-invaders/css/style.css
@@ -0,0 +1,157 @@
+:root {
+ --gold: #d4af37;
+ --gold-bright: #f3d47a;
+ --gold-dim: #8a6a2a;
+ --ink: #120a24;
+ --ink-deep: #0a0616;
+ --plum: #1d1030;
+ --parchment: #e8d9a8;
+ --ruby: #c0384f;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ margin: 0;
+ background: radial-gradient(ellipse at center, var(--plum) 0%, var(--ink-deep) 75%);
+ color: var(--parchment);
+ font-family: Georgia, 'Palatino Linotype', 'Book Antiqua', serif;
+ overflow: hidden;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: none;
+}
+
+#stage {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.6rem;
+ padding: 0.6rem env(safe-area-inset-right) 0.6rem env(safe-area-inset-left);
+}
+
+#hud {
+ width: min(92vw, 460px);
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 0 0.4rem;
+}
+
+.hud-block {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 3.5rem;
+}
+
+.hud-label {
+ font-size: 0.55rem;
+ color: var(--gold-dim);
+ letter-spacing: 0.2em;
+}
+
+.hud-value {
+ font-size: 1.1rem;
+ color: var(--gold-bright);
+ text-shadow: 0 0 6px rgba(212, 175, 55, 0.55);
+ font-variant-numeric: tabular-nums;
+}
+
+#canvas-frame {
+ position: relative;
+ padding: 14px;
+ border-radius: 18px 18px 8px 8px;
+ background:
+ linear-gradient(180deg, rgba(212, 175, 55, 0.08), transparent 40%),
+ var(--ink);
+ border: 2px solid var(--gold);
+ box-shadow:
+ 0 0 0 1px rgba(212, 175, 55, 0.25),
+ 0 0 24px rgba(212, 175, 55, 0.18),
+ inset 0 0 30px rgba(0, 0, 0, 0.6);
+ max-height: 82vh;
+ display: flex;
+}
+
+#canvas-frame::before {
+ content: '';
+ position: absolute;
+ inset: 4px;
+ pointer-events: none;
+ border: 1px solid rgba(212, 175, 55, 0.35);
+ border-radius: 14px 14px 6px 6px;
+}
+
+#game-canvas {
+ display: block;
+ width: auto;
+ height: auto;
+ max-width: 88vw;
+ max-height: 78vh;
+ aspect-ratio: 224 / 256;
+ background: var(--ink-deep);
+ border-radius: 10px 10px 4px 4px;
+}
+
+#touch-controls {
+ display: none;
+ width: min(92vw, 460px);
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 0.5rem;
+}
+
+@media (pointer: coarse) {
+ #touch-controls {
+ display: flex;
+ }
+ #stage {
+ justify-content: space-between;
+ }
+ #game-canvas {
+ max-height: 62vh;
+ }
+}
+
+.dpad {
+ display: flex;
+ gap: 0.9rem;
+}
+
+.ctrl {
+ font-family: inherit;
+ width: 3.4rem;
+ height: 3.4rem;
+ border-radius: 50%;
+ border: 2px solid var(--gold);
+ background: radial-gradient(circle at 35% 30%, rgba(212, 175, 55, 0.35), rgba(18, 10, 36, 0.9));
+ color: var(--gold-bright);
+ font-size: 1.3rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 0 12px rgba(212, 175, 55, 0.3);
+ user-select: none;
+}
+
+.ctrl:active {
+ background: radial-gradient(circle at 35% 30%, rgba(243, 212, 122, 0.5), rgba(18, 10, 36, 0.9));
+}
+
+.ctrl--fire {
+ width: 4rem;
+ height: 4rem;
+ font-size: 1.6rem;
+}
+
+@media (max-height: 480px) {
+ .hud-value { font-size: 0.9rem; }
+ .hud-label { font-size: 0.5rem; }
+}
diff --git a/space-invaders/icons/icon-192.png b/space-invaders/icons/icon-192.png
new file mode 100644
index 00000000..cfc342f7
Binary files /dev/null and b/space-invaders/icons/icon-192.png differ
diff --git a/space-invaders/icons/icon-512.png b/space-invaders/icons/icon-512.png
new file mode 100644
index 00000000..c9d440cf
Binary files /dev/null and b/space-invaders/icons/icon-512.png differ
diff --git a/space-invaders/icons/icon-maskable-512.png b/space-invaders/icons/icon-maskable-512.png
new file mode 100644
index 00000000..ae75c75e
Binary files /dev/null and b/space-invaders/icons/icon-maskable-512.png differ
diff --git a/space-invaders/icons/icon.svg b/space-invaders/icons/icon.svg
new file mode 100644
index 00000000..bfcec82c
--- /dev/null
+++ b/space-invaders/icons/icon.svg
@@ -0,0 +1,26 @@
+
diff --git a/space-invaders/index.html b/space-invaders/index.html
new file mode 100644
index 00000000..ecb14aca
--- /dev/null
+++ b/space-invaders/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Space Invaders — Nouveau
+
+
+
+
+
+
+
+
+
+
+
+ Score
+ 0000
+
+
+ High Score
+ 0000
+
+
+ Lives
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/space-invaders/js/audio.js b/space-invaders/js/audio.js
new file mode 100644
index 00000000..60910100
--- /dev/null
+++ b/space-invaders/js/audio.js
@@ -0,0 +1,139 @@
+// AudioManager — all sound effects are synthesized procedurally at load time
+// (no external .wav assets), then played back as low-latency AudioBufferSourceNodes.
+export class AudioManager {
+ constructor() {
+ this.ctx = null;
+ this.buffers = new Map();
+ this.masterGain = null;
+ this.ufoSource = null;
+ this.ufoGain = null;
+ this.enabled = true;
+ }
+
+ // AudioContext must be created/resumed from a user gesture on most browsers.
+ unlock() {
+ if (this.ctx) {
+ if (this.ctx.state === 'suspended') this.ctx.resume();
+ return;
+ }
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ this.ctx = new Ctx();
+ this.masterGain = this.ctx.createGain();
+ this.masterGain.gain.value = 0.55;
+ this.masterGain.connect(this.ctx.destination);
+ this._generateAll();
+ }
+
+ _makeBuffer(duration, fn) {
+ const sr = this.ctx.sampleRate;
+ const length = Math.max(1, Math.floor(sr * duration));
+ const buffer = this.ctx.createBuffer(1, length, sr);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < length; i++) {
+ const t = i / sr;
+ data[i] = fn(t, i, sr);
+ }
+ return buffer;
+ }
+
+ _generateAll() {
+ const noise = (t, decay) => (Math.random() * 2 - 1) * Math.exp(-t * decay);
+
+ // Player shot: quick descending square-ish sweep, bright and thin.
+ this.buffers.set('shoot', this._makeBuffer(0.16, (t) => {
+ const freq = 900 - t * 3200;
+ const env = Math.exp(-t * 14);
+ return Math.sign(Math.sin(2 * Math.PI * freq * t)) * 0.35 * env;
+ }));
+
+ // Alien hit: a short "pop" — filtered noise burst with a falling thud.
+ this.buffers.set('alienHit', this._makeBuffer(0.14, (t) => {
+ const thud = Math.sin(2 * Math.PI * (140 - t * 400) * t) * 0.5;
+ return (noise(t, 24) * 0.6 + thud) * Math.exp(-t * 18);
+ }));
+
+ // Player explosion: bigger, longer, low rumbling noise burst.
+ this.buffers.set('playerExplosion', this._makeBuffer(0.5, (t) => {
+ const rumble = Math.sin(2 * Math.PI * (90 - t * 60) * t) * 0.4;
+ return (noise(t, 5) * 0.8 + rumble) * Math.exp(-t * 4.2);
+ }));
+
+ // UFO hit: rewarding bright descending chime/arpeggio.
+ this.buffers.set('ufoHit', this._makeBuffer(0.55, (t) => {
+ const notes = [1568, 1244, 987, 1568];
+ const idx = Math.min(notes.length - 1, Math.floor(t * 9));
+ const freq = notes[idx];
+ const env = Math.exp(-((t % 0.14)) * 10);
+ return Math.sin(2 * Math.PI * freq * t) * 0.4 * env;
+ }));
+
+ // UFO flying loop: two alternating warbling tones (76477-chip style siren).
+ this.buffers.set('ufoFlying', this._makeBuffer(0.6, (t) => {
+ const warble = Math.sin(2 * Math.PI * 6 * t);
+ const freq = 300 + warble * 90;
+ return Math.sin(2 * Math.PI * freq * t) * 0.25;
+ }));
+
+ // Four descending "march" notes forming the alien heartbeat.
+ const marchFreqs = [110, 98, 87, 82];
+ marchFreqs.forEach((freq, idx) => {
+ this.buffers.set(`march${idx + 1}`, this._makeBuffer(0.11, (t) => {
+ const env = Math.exp(-t * 16);
+ return (Math.sign(Math.sin(2 * Math.PI * freq * t)) * 0.5 + Math.sin(2 * Math.PI * freq * 2 * t) * 0.2) * env;
+ }));
+ });
+
+ // Extra life awarded: bright ascending arpeggio.
+ this.buffers.set('extraLife', this._makeBuffer(0.5, (t) => {
+ const notes = [523, 659, 784, 1046];
+ const idx = Math.min(notes.length - 1, Math.floor(t * 9));
+ const env = Math.exp(-((t % 0.12)) * 9);
+ return Math.sin(2 * Math.PI * notes[idx] * t) * 0.35 * env;
+ }));
+
+ // Wave clear fanfare.
+ this.buffers.set('waveClear', this._makeBuffer(0.7, (t) => {
+ const notes = [392, 523, 659, 784, 1046];
+ const idx = Math.min(notes.length - 1, Math.floor(t * 7.5));
+ const env = Math.exp(-((t % 0.13)) * 7);
+ return Math.sin(2 * Math.PI * notes[idx] * t) * 0.32 * env;
+ }));
+
+ // Game over: descending, mournful.
+ this.buffers.set('gameOver', this._makeBuffer(1.1, (t) => {
+ const freq = 220 - t * 120;
+ const env = Math.exp(-t * 1.6);
+ return Math.sin(2 * Math.PI * Math.max(40, freq) * t) * 0.3 * env;
+ }));
+ }
+
+ playSound(name, { loop = false, gain = 1 } = {}) {
+ if (!this.enabled || !this.ctx) return null;
+ const buffer = this.buffers.get(name);
+ if (!buffer) return null;
+ const source = this.ctx.createBufferSource();
+ source.buffer = buffer;
+ source.loop = loop;
+ const g = this.ctx.createGain();
+ g.gain.value = gain;
+ source.connect(g).connect(this.masterGain);
+ source.start(0);
+ return source;
+ }
+
+ startUfoLoop() {
+ if (this.ufoSource || !this.ctx) return;
+ this.ufoSource = this.playSound('ufoFlying', { loop: true, gain: 0.6 });
+ }
+
+ stopUfoLoop() {
+ if (this.ufoSource) {
+ try { this.ufoSource.stop(); } catch (e) { /* already stopped */ }
+ this.ufoSource = null;
+ }
+ }
+
+ playMarchStep(step) {
+ this.playSound(`march${(step % 4) + 1}`);
+ }
+}
diff --git a/space-invaders/js/collision.js b/space-invaders/js/collision.js
new file mode 100644
index 00000000..8a38d6d1
--- /dev/null
+++ b/space-invaders/js/collision.js
@@ -0,0 +1,104 @@
+import { Explosion } from './entities/explosion.js';
+import { PALETTE } from './constants.js';
+
+// Broad-phase: cheap Axis-Aligned Bounding Box overlap test used for every
+// entity-to-entity pair before any expensive pixel work is considered.
+export function isColliding(a, b) {
+ return a.x < b.x + b.width
+ && a.x + a.width > b.x
+ && a.y < b.y + b.height
+ && a.y + a.height > b.y;
+}
+
+function bunkerNarrowPhaseHit(bunker, bullet) {
+ // Sample a few points along the bullet's leading edge against the bunker's
+ // live pixel bitmap — the narrow-phase check, only reached after AABB hits.
+ const points = [
+ [bullet.x + bullet.width / 2, bullet.y],
+ [bullet.x + bullet.width / 2, bullet.y + bullet.height],
+ [bullet.x, bullet.y + bullet.height / 2],
+ [bullet.x + bullet.width, bullet.y + bullet.height / 2],
+ ];
+ for (const [px, py] of points) {
+ if (bunker.isSolidAt(px, py)) return { x: px, y: py };
+ }
+ return null;
+}
+
+export function checkCollisions(game) {
+ const { player, swarm, mysteryShip, bunkers, playerBullets, alienBullets, explosions, audio } = game;
+
+ // Player bullets vs aliens.
+ for (const bullet of playerBullets) {
+ if (!bullet.isAlive) continue;
+ for (const alien of swarm.aliens) {
+ if (!alien.isAlive) continue;
+ const pos = swarm.alienScreenPosition(alien);
+ const rect = { x: pos.x, y: pos.y, width: alien.width, height: alien.height };
+ if (isColliding(bullet, rect)) {
+ alien.isAlive = false;
+ bullet.isAlive = false;
+ game.score += alien.points;
+ game.onScoreChanged();
+ explosions.push(new Explosion(pos.x + alien.width / 2, pos.y + alien.height / 2, PALETTE.goldBright));
+ audio.playSound('alienHit');
+ break;
+ }
+ }
+ }
+
+ // Player bullets vs mystery ship.
+ if (mysteryShip.active) {
+ for (const bullet of playerBullets) {
+ if (!bullet.isAlive) continue;
+ if (isColliding(bullet, mysteryShip)) {
+ bullet.isAlive = false;
+ const points = mysteryShip.scoreValue();
+ game.score += points;
+ game.onScoreChanged();
+ explosions.push(new Explosion(
+ mysteryShip.x + mysteryShip.width / 2,
+ mysteryShip.y + mysteryShip.height / 2,
+ PALETTE.ufo,
+ 0.5,
+ ));
+ audio.playSound('ufoHit');
+ mysteryShip.despawn();
+ }
+ }
+ }
+
+ // Alien bullets vs player.
+ if (player.isAlive) {
+ for (const bullet of alienBullets) {
+ if (!bullet.isAlive) continue;
+ if (isColliding(bullet, player)) {
+ bullet.isAlive = false;
+ game.onPlayerHit();
+ }
+ }
+ }
+
+ // Bullets vs bunkers (AABB broad-phase, then pixel narrow-phase + damage).
+ const allBullets = [...playerBullets, ...alienBullets];
+ for (const bullet of allBullets) {
+ if (!bullet.isAlive) continue;
+ for (const bunker of bunkers) {
+ if (!isColliding(bullet, bunker.getBounds())) continue;
+ const hit = bunkerNarrowPhaseHit(bunker, bullet);
+ if (hit) {
+ bunker.applyDamage(hit.x, hit.y);
+ bullet.isAlive = false;
+ break;
+ }
+ }
+ }
+
+ // Reset the player's single-shot lock whenever its bullet has resolved.
+ for (const bullet of playerBullets) {
+ if (!bullet.isAlive) player.onBulletResolved();
+ }
+
+ game.playerBullets = playerBullets.filter((b) => b.isAlive);
+ game.alienBullets = alienBullets.filter((b) => b.isAlive);
+}
diff --git a/space-invaders/js/constants.js b/space-invaders/js/constants.js
new file mode 100644
index 00000000..52c498d2
--- /dev/null
+++ b/space-invaders/js/constants.js
@@ -0,0 +1,89 @@
+// Core game constants — the "magic numbers" centralized for easy tuning.
+export const CANVAS_WIDTH = 224;
+export const CANVAS_HEIGHT = 256;
+
+export const ALIEN_ROWS = 5;
+export const ALIEN_COLS = 11;
+export const ALIEN_H_SPACING = 16;
+export const ALIEN_V_SPACING = 16;
+export const ALIEN_START_X = 16;
+export const ALIEN_START_Y = 34;
+export const ALIEN_DROP_DISTANCE = 8;
+export const ALIEN_MAX_DEPTH_Y = 170;
+
+export const ALIEN_TYPES = {
+ SQUID: { name: 'squid', row: 0, width: 8, height: 8, points: 30 },
+ CRAB: { name: 'crab', rows: [1, 2], width: 11, height: 8, points: 20 },
+ OCTOPUS: { name: 'octopus', rows: [3, 4], width: 12, height: 8, points: 10 },
+};
+
+export function alienTypeForRow(row) {
+ if (row === 0) return 'squid';
+ if (row === 1 || row === 2) return 'crab';
+ return 'octopus';
+}
+
+export function pointsForType(type) {
+ if (type === 'squid') return 30;
+ if (type === 'crab') return 20;
+ return 10;
+}
+
+export const BASE_MOVE_INTERVAL = 900; // ms, slowest possible step (full swarm)
+export const MIN_MOVE_INTERVAL = 45; // ms, fastest possible step (last alien)
+
+export const PLAYER_WIDTH = 13;
+export const PLAYER_HEIGHT = 8;
+export const PLAYER_SPEED = 90; // px/sec
+export const PLAYER_START_LIVES = 3;
+export const EXTRA_LIFE_SCORE = 1500;
+
+export const PLAYER_BULLET_SPEED = 220; // px/sec upward
+export const ALIEN_BULLET_SPEED = 110; // px/sec downward
+
+export const MYSTERY_SHIP_WIDTH = 16;
+export const MYSTERY_SHIP_HEIGHT = 7;
+export const MYSTERY_SHIP_SPEED = 60; // px/sec
+export const MYSTERY_SHIP_MIN_DELAY = 12000;
+export const MYSTERY_SHIP_MAX_DELAY = 22000;
+
+export const UFO_SCORE_TABLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100];
+
+export const BUNKER_COUNT = 4;
+export const BUNKER_WIDTH = 22;
+export const BUNKER_HEIGHT = 16;
+export const BUNKER_Y = 200;
+
+export const ALIEN_FIRE_MIN_INTERVAL = 350;
+export const ALIEN_FIRE_MAX_INTERVAL = 1000;
+
+// Column target sequences for the three canonical shot "personalities".
+export const FIRE_PATTERNS = {
+ ROLLING: [4, 8, 2, 6, 10, 0, 9, 3, 7, 1, 5],
+ PLUNGER: [1, 7, 1, 1, 1, 4, 8, 2, 6, 10, 0, 9, 3, 7, 1],
+ SQUIGGLY: [5, 9, 3, 7, 1, 5, 10, 4, 8, 2, 6, 0, 9, 3, 7],
+};
+
+// Art Nouveau palette — jewel tones on deep indigo, gold linework throughout.
+export const PALETTE = {
+ bgTop: '#120a24',
+ bgBottom: '#1d1030',
+ gold: '#d4af37',
+ goldBright: '#f3d47a',
+ goldDim: '#8a6a2a',
+ squid: '#3ea88a',
+ squidDark: '#215e4c',
+ crab: '#a56cc1',
+ crabDark: '#5c3670',
+ octopus: '#d4763f',
+ octopusDark: '#7a4022',
+ player: '#4fb8c9',
+ playerDark: '#245a63',
+ ufo: '#c0384f',
+ ufoDark: '#6e1c2b',
+ bunker: '#7a6a9a',
+ bunkerDark: '#3a2f52',
+ bulletPlayer: '#f3d47a',
+ bulletAlien: '#e0668a',
+ text: '#e8d9a8',
+};
diff --git a/space-invaders/js/entities/alien.js b/space-invaders/js/entities/alien.js
new file mode 100644
index 00000000..4c7dc50f
--- /dev/null
+++ b/space-invaders/js/entities/alien.js
@@ -0,0 +1,16 @@
+import { alienTypeForRow, pointsForType } from '../constants.js';
+
+export class Alien {
+ constructor(row, col, offsetX, offsetY) {
+ this.row = row;
+ this.col = col;
+ this.offsetX = offsetX;
+ this.offsetY = offsetY;
+ this.type = alienTypeForRow(row);
+ this.points = pointsForType(this.type);
+ this.isAlive = true;
+ this.frame = 0;
+ this.width = this.type === 'squid' ? 8 : this.type === 'crab' ? 11 : 12;
+ this.height = 8;
+ }
+}
diff --git a/space-invaders/js/entities/alienFireController.js b/space-invaders/js/entities/alienFireController.js
new file mode 100644
index 00000000..10a8e08b
--- /dev/null
+++ b/space-invaders/js/entities/alienFireController.js
@@ -0,0 +1,49 @@
+import { FIRE_PATTERNS, ALIEN_FIRE_MIN_INTERVAL, ALIEN_FIRE_MAX_INTERVAL, ALIEN_COLS } from '../constants.js';
+import { AlienBullet } from './bullet.js';
+
+const PATTERN_NAMES = ['ROLLING', 'PLUNGER', 'SQUIGGLY'];
+
+// Models the original's deterministic, column-based alien firing rather than
+// pure randomness: a shot "personality" is picked, then a sequence of target
+// columns is walked, always hitting the lowest living alien in that column.
+export class AlienFireController {
+ constructor(swarm) {
+ this.swarm = swarm;
+ this.timer = 0;
+ this.nextInterval = this._randomInterval();
+ this.sequenceIndex = { ROLLING: 0, PLUNGER: 0, SQUIGGLY: 0 };
+ }
+
+ _randomInterval() {
+ return ALIEN_FIRE_MIN_INTERVAL + Math.random() * (ALIEN_FIRE_MAX_INTERVAL - ALIEN_FIRE_MIN_INTERVAL);
+ }
+
+ reset() {
+ this.timer = 0;
+ this.nextInterval = this._randomInterval();
+ }
+
+ update(dt, alienBullets) {
+ this.timer += dt * 1000;
+ if (this.timer < this.nextInterval) return;
+ this.timer = 0;
+ this.nextInterval = this._randomInterval();
+
+ const patternName = PATTERN_NAMES[Math.floor(Math.random() * PATTERN_NAMES.length)];
+ const sequence = FIRE_PATTERNS[patternName];
+ const idx = this.sequenceIndex[patternName];
+ const col = sequence[idx % sequence.length] % ALIEN_COLS;
+ this.sequenceIndex[patternName] = idx + 1;
+
+ const shooter = this.swarm.lowestAlienInColumn(col);
+ if (!shooter) return;
+
+ const pos = this.swarm.alienScreenPosition(shooter);
+ const bullet = new AlienBullet(
+ pos.x + shooter.width / 2 - 1.5,
+ pos.y + shooter.height,
+ patternName.toLowerCase(),
+ );
+ alienBullets.push(bullet);
+ }
+}
diff --git a/space-invaders/js/entities/bullet.js b/space-invaders/js/entities/bullet.js
new file mode 100644
index 00000000..32bae346
--- /dev/null
+++ b/space-invaders/js/entities/bullet.js
@@ -0,0 +1,56 @@
+import { PLAYER_BULLET_SPEED, ALIEN_BULLET_SPEED, CANVAS_HEIGHT, PALETTE } from '../constants.js';
+
+export class PlayerBullet {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ this.width = 1;
+ this.height = 4;
+ this.isAlive = true;
+ }
+
+ update(dt) {
+ this.y -= PLAYER_BULLET_SPEED * dt;
+ if (this.y + this.height < 0) this.isAlive = false;
+ }
+
+ render(ctx) {
+ ctx.fillStyle = PALETTE.bulletPlayer;
+ ctx.shadowColor = PALETTE.bulletPlayer;
+ ctx.shadowBlur = 3;
+ ctx.fillRect(this.x, this.y, this.width, this.height);
+ ctx.shadowBlur = 0;
+ }
+}
+
+const ALIEN_BULLET_WAVE = { amplitude: 1.5, frequency: 10 };
+
+export class AlienBullet {
+ constructor(x, y, style = 'rolling') {
+ this.x = x;
+ this.y = y;
+ this.width = 3;
+ this.height = 7;
+ this.isAlive = true;
+ this.style = style; // 'rolling' | 'plunger' | 'squiggly'
+ this.age = 0;
+ }
+
+ update(dt) {
+ this.age += dt;
+ this.y += ALIEN_BULLET_SPEED * dt;
+ if (this.style === 'squiggly') {
+ this.x += Math.sin(this.age * ALIEN_BULLET_WAVE.frequency) * ALIEN_BULLET_WAVE.amplitude * dt * 10;
+ }
+ if (this.y > CANVAS_HEIGHT) this.isAlive = false;
+ }
+
+ render(ctx) {
+ ctx.fillStyle = PALETTE.bulletAlien;
+ ctx.shadowColor = PALETTE.bulletAlien;
+ ctx.shadowBlur = 3;
+ const wobble = this.style === 'squiggly' ? Math.sin(this.age * 14) * 1.2 : 0;
+ ctx.fillRect(this.x + wobble, this.y, this.width, this.height);
+ ctx.shadowBlur = 0;
+ }
+}
diff --git a/space-invaders/js/entities/bunker.js b/space-invaders/js/entities/bunker.js
new file mode 100644
index 00000000..78dc9943
--- /dev/null
+++ b/space-invaders/js/entities/bunker.js
@@ -0,0 +1,94 @@
+import { BUNKER_WIDTH, BUNKER_HEIGHT, PALETTE } from '../constants.js';
+
+const DAMAGE_BLOCK = 3; // chunky erase granularity, matches the original's blocky look
+
+export class Bunker {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ this.width = BUNKER_WIDTH;
+ this.height = BUNKER_HEIGHT;
+ this.canvas = document.createElement('canvas');
+ this.canvas.width = BUNKER_WIDTH;
+ this.canvas.height = BUNKER_HEIGHT;
+ this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
+ this.repair();
+ }
+
+ // Draws the pristine ornamental bunker — an Art Nouveau arch with carved
+ // filigree — onto the off-screen bitmap that damage will later erase from.
+ repair() {
+ const c = this.ctx;
+ c.clearRect(0, 0, this.width, this.height);
+ c.fillStyle = PALETTE.bunker;
+ c.strokeStyle = PALETTE.bunkerDark;
+ c.lineWidth = 1;
+
+ c.beginPath();
+ c.moveTo(0, this.height);
+ c.lineTo(0, 6);
+ c.quadraticCurveTo(0, 0, 6, 0);
+ c.lineTo(this.width - 6, 0);
+ c.quadraticCurveTo(this.width, 0, this.width, 6);
+ c.lineTo(this.width, this.height);
+ // notch cut from underside like the classic bunker silhouette
+ c.lineTo(this.width * 0.68, this.height);
+ c.quadraticCurveTo(this.width * 0.55, this.height - 6, this.width * 0.5, this.height - 6);
+ c.quadraticCurveTo(this.width * 0.45, this.height - 6, this.width * 0.32, this.height);
+ c.closePath();
+ c.fill();
+ c.stroke();
+
+ // Ornamental filigree veins.
+ c.strokeStyle = PALETTE.goldDim;
+ c.lineWidth = 0.5;
+ c.beginPath();
+ c.moveTo(3, this.height - 3);
+ c.quadraticCurveTo(this.width / 2, 2, this.width - 3, this.height - 3);
+ c.stroke();
+
+ this.isDestroyed = false;
+ }
+
+ getBounds() {
+ return { x: this.x, y: this.y, width: this.width, height: this.height };
+ }
+
+ // Narrow-phase impact: erase a chunky radius of pixels around the impact
+ // point by zeroing their alpha, then write the modified bitmap back.
+ applyDamage(worldX, worldY, radius = 4) {
+ const localX = Math.round(worldX - this.x);
+ const localY = Math.round(worldY - this.y);
+ const imageData = this.ctx.getImageData(0, 0, this.width, this.height);
+ const data = imageData.data;
+
+ for (let by = -radius; by <= radius; by += DAMAGE_BLOCK) {
+ for (let bx = -radius; bx <= radius; bx += DAMAGE_BLOCK) {
+ if (bx * bx + by * by > radius * radius) continue;
+ for (let dy = 0; dy < DAMAGE_BLOCK; dy++) {
+ for (let dx = 0; dx < DAMAGE_BLOCK; dx++) {
+ const px = localX + bx + dx;
+ const py = localY + by + dy;
+ if (px < 0 || py < 0 || px >= this.width || py >= this.height) continue;
+ const idx = (py * this.width + px) * 4 + 3;
+ data[idx] = 0;
+ }
+ }
+ }
+ }
+ this.ctx.putImageData(imageData, 0, 0);
+ }
+
+ // Pixel-perfect hit test used as the narrow-phase check after an AABB hit.
+ isSolidAt(worldX, worldY) {
+ const localX = Math.round(worldX - this.x);
+ const localY = Math.round(worldY - this.y);
+ if (localX < 0 || localY < 0 || localX >= this.width || localY >= this.height) return false;
+ const pixel = this.ctx.getImageData(localX, localY, 1, 1).data;
+ return pixel[3] > 0;
+ }
+
+ render(ctx) {
+ ctx.drawImage(this.canvas, this.x, this.y);
+ }
+}
diff --git a/space-invaders/js/entities/explosion.js b/space-invaders/js/entities/explosion.js
new file mode 100644
index 00000000..f594c2cc
--- /dev/null
+++ b/space-invaders/js/entities/explosion.js
@@ -0,0 +1,21 @@
+import { drawExplosion } from '../render.js';
+
+export class Explosion {
+ constructor(x, y, color, duration = 0.35) {
+ this.x = x;
+ this.y = y;
+ this.color = color;
+ this.age = 0;
+ this.duration = duration;
+ this.isAlive = true;
+ }
+
+ update(dt) {
+ this.age += dt;
+ if (this.age >= this.duration) this.isAlive = false;
+ }
+
+ render(ctx) {
+ drawExplosion(ctx, this);
+ }
+}
diff --git a/space-invaders/js/entities/mysteryShip.js b/space-invaders/js/entities/mysteryShip.js
new file mode 100644
index 00000000..8c62f128
--- /dev/null
+++ b/space-invaders/js/entities/mysteryShip.js
@@ -0,0 +1,78 @@
+import {
+ MYSTERY_SHIP_WIDTH, MYSTERY_SHIP_HEIGHT, MYSTERY_SHIP_SPEED, CANVAS_WIDTH,
+ MYSTERY_SHIP_MIN_DELAY, MYSTERY_SHIP_MAX_DELAY, UFO_SCORE_TABLE, PALETTE,
+} from '../constants.js';
+
+export class MysteryShip {
+ constructor(game) {
+ this.game = game;
+ this.width = MYSTERY_SHIP_WIDTH;
+ this.height = MYSTERY_SHIP_HEIGHT;
+ this.x = 0;
+ this.y = 12;
+ this.direction = 1;
+ this.active = false;
+ this.spawnTimer = this._randomDelay();
+ }
+
+ _randomDelay() {
+ return MYSTERY_SHIP_MIN_DELAY + Math.random() * (MYSTERY_SHIP_MAX_DELAY - MYSTERY_SHIP_MIN_DELAY);
+ }
+
+ _spawn() {
+ this.direction = Math.random() < 0.5 ? 1 : -1;
+ this.x = this.direction === 1 ? -this.width : CANVAS_WIDTH + this.width;
+ this.active = true;
+ this.game.audio.startUfoLoop();
+ }
+
+ despawn() {
+ this.active = false;
+ this.game.audio.stopUfoLoop();
+ this.spawnTimer = this._randomDelay();
+ }
+
+ // Score awarded follows the original's hidden deterministic sequence based
+ // on total shots fired, not randomness.
+ scoreValue() {
+ return UFO_SCORE_TABLE[this.game.playerShotCount % UFO_SCORE_TABLE.length];
+ }
+
+ update(dt) {
+ if (!this.active) {
+ this.spawnTimer -= dt * 1000;
+ if (this.spawnTimer <= 0) this._spawn();
+ return;
+ }
+ this.x += this.direction * MYSTERY_SHIP_SPEED * dt;
+ if (this.direction === 1 && this.x > CANVAS_WIDTH) this.despawn();
+ if (this.direction === -1 && this.x + this.width < 0) this.despawn();
+ }
+
+ render(ctx) {
+ if (!this.active) return;
+ ctx.save();
+ ctx.translate(this.x, this.y);
+ ctx.shadowColor = PALETTE.ufo;
+ ctx.shadowBlur = 5;
+ ctx.fillStyle = PALETTE.ufo;
+ ctx.strokeStyle = PALETTE.gold;
+ ctx.lineWidth = 0.6;
+
+ ctx.beginPath();
+ ctx.moveTo(1, this.height);
+ ctx.bezierCurveTo(-2, this.height, -2, 2, this.width / 2, 1);
+ ctx.bezierCurveTo(this.width + 2, 2, this.width + 2, this.height, this.width - 1, this.height);
+ ctx.bezierCurveTo(this.width - 3, this.height + 2, 3, this.height + 2, 1, this.height);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // Gem-like canopy jewel, the classic Nouveau "peacock eye" motif.
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.beginPath();
+ ctx.ellipse(this.width / 2, this.height - 1, 2.2, 1.6, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+}
diff --git a/space-invaders/js/entities/player.js b/space-invaders/js/entities/player.js
new file mode 100644
index 00000000..b00ec452
--- /dev/null
+++ b/space-invaders/js/entities/player.js
@@ -0,0 +1,90 @@
+import {
+ PLAYER_WIDTH, PLAYER_HEIGHT, PLAYER_SPEED, CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE,
+} from '../constants.js';
+import { PlayerBullet } from './bullet.js';
+
+export class Player {
+ constructor(game) {
+ this.game = game;
+ this.width = PLAYER_WIDTH;
+ this.height = PLAYER_HEIGHT;
+ this.x = (CANVAS_WIDTH - this.width) / 2;
+ this.y = CANVAS_HEIGHT - 24;
+ this.canFire = true;
+ this.isAlive = true;
+ this.hitFlashTimer = 0;
+ }
+
+ update(dt, input) {
+ if (!this.isAlive) return;
+ let dx = 0;
+ if (input.left) dx -= 1;
+ if (input.right) dx += 1;
+ this.x += dx * PLAYER_SPEED * dt;
+ this.x = Math.max(2, Math.min(CANVAS_WIDTH - this.width - 2, this.x));
+
+ if (input.fire) this.fire();
+ if (this.hitFlashTimer > 0) this.hitFlashTimer -= dt;
+ }
+
+ fire() {
+ if (!this.canFire || !this.isAlive) return;
+ const bullet = new PlayerBullet(this.x + this.width / 2 - 0.5, this.y - 4);
+ this.game.playerBullets.push(bullet);
+ this.canFire = false;
+ this.game.playerShotCount += 1;
+ this.game.audio.playSound('shoot');
+ }
+
+ onBulletResolved() {
+ this.canFire = true;
+ }
+
+ render(ctx) {
+ if (!this.isAlive) return;
+ const cx = this.x + this.width / 2;
+ const cy = this.y + this.height;
+ ctx.save();
+ ctx.translate(cx, cy);
+
+ // Art Nouveau cannon: a stylised winged/finned dart with jewel core and
+ // whiplash tendrils sweeping back from the hull, rendered as vector paths.
+ const glow = this.hitFlashTimer > 0 ? PALETTE.goldBright : PALETTE.player;
+ ctx.strokeStyle = PALETTE.gold;
+ ctx.lineWidth = 0.6;
+ ctx.fillStyle = glow;
+ ctx.shadowColor = glow;
+ ctx.shadowBlur = 4;
+
+ ctx.beginPath();
+ ctx.moveTo(0, -this.height);
+ ctx.bezierCurveTo(-2, -this.height + 2, -3, -2, -this.width / 2, 0);
+ ctx.lineTo(-this.width / 2 + 1, -1);
+ ctx.bezierCurveTo(-3, -2.5, -1.5, -3.5, 0, -this.height + 1);
+ ctx.bezierCurveTo(1.5, -3.5, 3, -2.5, this.width / 2 - 1, -1);
+ ctx.lineTo(this.width / 2, 0);
+ ctx.bezierCurveTo(3, -2, 2, -this.height + 2, 0, -this.height);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // Tendril flourishes curling from either side of the hull.
+ ctx.strokeStyle = PALETTE.goldDim;
+ ctx.lineWidth = 0.5;
+ ctx.shadowBlur = 0;
+ ctx.beginPath();
+ ctx.moveTo(-this.width / 2, -1);
+ ctx.quadraticCurveTo(-this.width / 2 - 3, -3, -this.width / 2 - 1, -6);
+ ctx.moveTo(this.width / 2, -1);
+ ctx.quadraticCurveTo(this.width / 2 + 3, -3, this.width / 2 + 1, -6);
+ ctx.stroke();
+
+ // Central jewel core.
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.beginPath();
+ ctx.ellipse(0, -this.height + 3, 1.4, 1.8, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+ }
+}
diff --git a/space-invaders/js/entities/swarm.js b/space-invaders/js/entities/swarm.js
new file mode 100644
index 00000000..a65243df
--- /dev/null
+++ b/space-invaders/js/entities/swarm.js
@@ -0,0 +1,127 @@
+import {
+ ALIEN_ROWS, ALIEN_COLS, ALIEN_H_SPACING, ALIEN_V_SPACING, ALIEN_START_X, ALIEN_START_Y,
+ ALIEN_DROP_DISTANCE, CANVAS_WIDTH, BASE_MOVE_INTERVAL, MIN_MOVE_INTERVAL,
+} from '../constants.js';
+import { Alien } from './alien.js';
+import { AlienFireController } from './alienFireController.js';
+import { drawAlien } from '../render.js';
+
+const EDGE_MARGIN = 10;
+const STEP_SIZE = 3;
+
+// The "Hive Mind": a single controller owning the swarm's collective position,
+// direction and pace. Individual Aliens never store world coordinates — their
+// screen position is always swarm-origin + fixed grid offset.
+export class Swarm {
+ constructor(game) {
+ this.game = game;
+ this.x = 0;
+ this.y = 0;
+ this.direction = 1;
+ this.timer = 0;
+ this.animFrame = 0;
+ this.marchStep = 0;
+ this.aliens = [];
+ this.fireController = new AlienFireController(this);
+ this._buildGrid(ALIEN_START_Y);
+ }
+
+ _buildGrid(startY) {
+ this.aliens = [];
+ for (let row = 0; row < ALIEN_ROWS; row++) {
+ for (let col = 0; col < ALIEN_COLS; col++) {
+ const offsetX = ALIEN_START_X + col * ALIEN_H_SPACING;
+ const offsetY = startY + row * ALIEN_V_SPACING;
+ this.aliens.push(new Alien(row, col, offsetX, offsetY));
+ }
+ }
+ }
+
+ resetForWave(startY) {
+ this.x = 0;
+ this.y = 0;
+ this.direction = 1;
+ this.timer = 0;
+ this.marchStep = 0;
+ this._buildGrid(startY);
+ this.fireController.reset();
+ }
+
+ get aliveCount() {
+ return this.aliens.reduce((n, a) => n + (a.isAlive ? 1 : 0), 0);
+ }
+
+ get isDefeated() {
+ return this.aliveCount === 0;
+ }
+
+ alienScreenPosition(alien) {
+ return { x: this.x + alien.offsetX, y: this.y + alien.offsetY };
+ }
+
+ lowestAlienInColumn(col) {
+ let best = null;
+ for (const a of this.aliens) {
+ if (!a.isAlive || a.col !== col) continue;
+ if (!best || a.row > best.row) best = a;
+ }
+ return best;
+ }
+
+ bottomMostY() {
+ let maxY = -Infinity;
+ for (const a of this.aliens) {
+ if (!a.isAlive) continue;
+ const y = this.y + a.offsetY + a.height;
+ if (y > maxY) maxY = y;
+ }
+ return maxY === -Infinity ? 0 : maxY;
+ }
+
+ update(dt, alienBullets) {
+ const total = ALIEN_ROWS * ALIEN_COLS;
+ const alive = this.aliveCount;
+ if (alive === 0) return;
+
+ const moveInterval = Math.max(MIN_MOVE_INTERVAL, (alive / total) * BASE_MOVE_INTERVAL);
+ this.timer += dt * 1000;
+
+ if (this.timer >= moveInterval) {
+ this.timer = 0;
+ this._step();
+ this.game.audio.playMarchStep(this.marchStep);
+ this.marchStep += 1;
+ this.animFrame = 1 - this.animFrame;
+ }
+
+ this.fireController.update(dt, alienBullets);
+ }
+
+ _step() {
+ this.x += this.direction * STEP_SIZE;
+
+ let leftmost = Infinity;
+ let rightmost = -Infinity;
+ for (const a of this.aliens) {
+ if (!a.isAlive) continue;
+ const left = this.x + a.offsetX;
+ const right = left + a.width;
+ if (left < leftmost) leftmost = left;
+ if (right > rightmost) rightmost = right;
+ }
+ if (leftmost === Infinity) return;
+
+ if (rightmost >= CANVAS_WIDTH - EDGE_MARGIN || leftmost <= EDGE_MARGIN) {
+ this.y += ALIEN_DROP_DISTANCE;
+ this.direction *= -1;
+ }
+ }
+
+ render(ctx) {
+ for (const alien of this.aliens) {
+ if (!alien.isAlive) continue;
+ const pos = this.alienScreenPosition(alien);
+ drawAlien(ctx, alien, pos.x, pos.y, this.animFrame);
+ }
+ }
+}
diff --git a/space-invaders/js/game.js b/space-invaders/js/game.js
new file mode 100644
index 00000000..ea49cdcd
--- /dev/null
+++ b/space-invaders/js/game.js
@@ -0,0 +1,165 @@
+import {
+ CANVAS_WIDTH, CANVAS_HEIGHT, ALIEN_START_Y, ALIEN_DROP_DISTANCE, ALIEN_MAX_DEPTH_Y,
+ BUNKER_COUNT, BUNKER_WIDTH, BUNKER_Y, EXTRA_LIFE_SCORE, PLAYER_START_LIVES,
+} from './constants.js';
+import { InputHandler } from './input.js';
+import { AudioManager } from './audio.js';
+import { Player } from './entities/player.js';
+import { Swarm } from './entities/swarm.js';
+import { MysteryShip } from './entities/mysteryShip.js';
+import { Bunker } from './entities/bunker.js';
+import { Explosion } from './entities/explosion.js';
+import { StateMachine } from './state/stateMachine.js';
+import { createAttractState } from './state/attractState.js';
+import { createPlayState } from './state/playState.js';
+import { createGameOverState } from './state/gameOverState.js';
+
+const HIGH_SCORE_KEY = 'space-invaders-nouveau-high-score';
+
+class Game {
+ constructor(ctx) {
+ this.ctx = ctx;
+ this.input = new InputHandler();
+ this.audio = new AudioManager();
+
+ this.score = 0;
+ this.highScore = Number(localStorage.getItem(HIGH_SCORE_KEY)) || 0;
+ this.lives = PLAYER_START_LIVES;
+ this.wave = 1;
+ this.playerShotCount = 0;
+ this.extraLifeAwarded = false;
+
+ this.player = new Player(this);
+ this.swarm = new Swarm(this);
+ this.mysteryShip = new MysteryShip(this);
+ this.bunkers = this._createBunkers();
+ this.playerBullets = [];
+ this.alienBullets = [];
+ this.explosions = [];
+
+ this.hud = {
+ score: document.getElementById('score-value'),
+ lives: document.getElementById('lives-value'),
+ highScore: document.getElementById('highscore-value'),
+ };
+
+ this._updateHud();
+
+ const unlockOnce = () => { this.audio.unlock(); window.removeEventListener('keydown', unlockOnce); window.removeEventListener('touchstart', unlockOnce); };
+ window.addEventListener('keydown', unlockOnce);
+ window.addEventListener('touchstart', unlockOnce);
+ }
+
+ _createBunkers() {
+ const margin = (CANVAS_WIDTH - BUNKER_COUNT * BUNKER_WIDTH) / (BUNKER_COUNT + 1);
+ const bunkers = [];
+ for (let i = 0; i < BUNKER_COUNT; i++) {
+ const x = margin + i * (BUNKER_WIDTH + margin);
+ bunkers.push(new Bunker(Math.round(x), BUNKER_Y));
+ }
+ return bunkers;
+ }
+
+ _updateHud() {
+ this.hud.score.textContent = String(this.score).padStart(4, '0');
+ this.hud.lives.textContent = String(Math.max(0, this.lives));
+ this.hud.highScore.textContent = String(this.highScore).padStart(4, '0');
+ }
+
+ onScoreChanged() {
+ if (this.score > this.highScore) {
+ this.highScore = this.score;
+ localStorage.setItem(HIGH_SCORE_KEY, String(this.highScore));
+ }
+ if (!this.extraLifeAwarded && this.score >= EXTRA_LIFE_SCORE) {
+ this.extraLifeAwarded = true;
+ this.lives += 1;
+ this.audio.playSound('extraLife');
+ }
+ this._updateHud();
+ }
+
+ onPlayerHit() {
+ if (!this.player.isAlive) return;
+ this.player.isAlive = false;
+ this.lives -= 1;
+ this.player.hitFlashTimer = 0.3;
+ this.audio.playSound('playerExplosion');
+ this._updateHud();
+ this.explosions.push(new Explosion(
+ this.player.x + this.player.width / 2,
+ this.player.y + this.player.height / 2,
+ '#4fb8c9',
+ 0.6,
+ ));
+ }
+
+ startNewGame() {
+ this.score = 0;
+ this.lives = PLAYER_START_LIVES;
+ this.wave = 1;
+ this.playerShotCount = 0;
+ this.extraLifeAwarded = false;
+ this.playerBullets = [];
+ this.alienBullets = [];
+ this.explosions = [];
+ this.player = new Player(this);
+ this.mysteryShip = new MysteryShip(this);
+ this.swarm.resetForWave(ALIEN_START_Y);
+ for (const b of this.bunkers) b.repair();
+ this._updateHud();
+ }
+
+ nextWave() {
+ this.wave += 1;
+ const depth = Math.min(ALIEN_START_Y + (this.wave - 1) * ALIEN_DROP_DISTANCE, ALIEN_MAX_DEPTH_Y);
+ this.swarm.resetForWave(depth);
+ this.playerBullets = [];
+ this.alienBullets = [];
+ this.player.isAlive = true;
+ this.player.canFire = true;
+ this.player.x = (CANVAS_WIDTH - this.player.width) / 2;
+ for (const b of this.bunkers) b.repair();
+ }
+}
+
+function init() {
+ const canvas = document.getElementById('game-canvas');
+ canvas.width = CANVAS_WIDTH;
+ canvas.height = CANVAS_HEIGHT;
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = false;
+
+ const game = new Game(ctx);
+ window.__game = game; // debugging hook only
+
+ const machine = new StateMachine({});
+ // Build states after machine exists so they can call machine.transition().
+ machine.states.attract = createAttractState(game, machine);
+ machine.states.play = createPlayState(game, machine);
+ machine.states.gameOver = createGameOverState(game, machine);
+ machine.start('attract');
+
+ game.input.bindTouchControls(document.getElementById('touch-controls'));
+
+ let lastTime = performance.now();
+ function loop(now) {
+ let deltaTime = (now - lastTime) / 1000;
+ lastTime = now;
+ deltaTime = Math.min(deltaTime, 1 / 20); // clamp to avoid spiral-of-death on tab switch back
+
+ machine.update(deltaTime);
+ machine.render(ctx);
+
+ requestAnimationFrame(loop);
+ }
+ requestAnimationFrame(loop);
+
+ if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('./sw.js').catch(() => { /* offline support is best-effort */ });
+ });
+ }
+}
+
+window.addEventListener('load', init);
diff --git a/space-invaders/js/input.js b/space-invaders/js/input.js
new file mode 100644
index 00000000..3cabbe3c
--- /dev/null
+++ b/space-invaders/js/input.js
@@ -0,0 +1,66 @@
+// Polls keyboard + touch input and exposes a single, unified state object.
+export class InputHandler {
+ constructor() {
+ this.left = false;
+ this.right = false;
+ this.fire = false;
+ // Edge-triggered convenience for menu / restart actions. Latched by the
+ // raw press event itself (not just polled state) so a tap shorter than
+ // one animation frame is never dropped.
+ this.firePressed = false;
+ this._firePressLatch = false;
+
+ window.addEventListener('keydown', (e) => this._onKey(e, true));
+ window.addEventListener('keyup', (e) => this._onKey(e, false));
+ }
+
+ _onKey(e, isDown) {
+ switch (e.code) {
+ case 'ArrowLeft':
+ case 'KeyA':
+ this.left = isDown;
+ e.preventDefault();
+ break;
+ case 'ArrowRight':
+ case 'KeyD':
+ this.right = isDown;
+ e.preventDefault();
+ break;
+ case 'Space':
+ case 'ArrowUp':
+ case 'KeyW':
+ if (isDown && !this.fire) this._firePressLatch = true;
+ this.fire = isDown;
+ e.preventDefault();
+ break;
+ }
+ }
+
+ bindTouchControls(root) {
+ const bind = (selector, prop) => {
+ const el = root.querySelector(selector);
+ if (!el) return;
+ const start = (e) => {
+ e.preventDefault();
+ if (prop === 'fire' && !this.fire) this._firePressLatch = true;
+ this[prop] = true;
+ };
+ const end = (e) => { e.preventDefault(); this[prop] = false; };
+ el.addEventListener('touchstart', start, { passive: false });
+ el.addEventListener('touchend', end, { passive: false });
+ el.addEventListener('touchcancel', end, { passive: false });
+ el.addEventListener('mousedown', start);
+ el.addEventListener('mouseup', end);
+ el.addEventListener('mouseleave', end);
+ };
+ bind('[data-control="left"]', 'left');
+ bind('[data-control="right"]', 'right');
+ bind('[data-control="fire"]', 'fire');
+ }
+
+ // Call once per frame: drains the latch into a one-frame-wide pulse.
+ update() {
+ this.firePressed = this._firePressLatch;
+ this._firePressLatch = false;
+ }
+}
diff --git a/space-invaders/js/render.js b/space-invaders/js/render.js
new file mode 100644
index 00000000..939d23a5
--- /dev/null
+++ b/space-invaders/js/render.js
@@ -0,0 +1,203 @@
+import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from './constants.js';
+
+// --- Alien sprites -----------------------------------------------------
+// Each alien type is a small stained-glass/whiplash-curve creature drawn as
+// a mirrored vector path (draw the right half, reflect for the left) so the
+// silhouette stays perfectly symmetric like Art Nouveau glasswork.
+
+function halfPath(type, frame) {
+ switch (type) {
+ case 'squid':
+ return (c) => {
+ c.moveTo(0, 0);
+ c.quadraticCurveTo(2, -1, 3, -3);
+ c.quadraticCurveTo(4, -5, 2, -6);
+ c.quadraticCurveTo(1, -6.5, 0, -6);
+ c.lineTo(0, -1);
+ c.quadraticCurveTo(3, 0, 4, 2);
+ if (frame === 0) {
+ c.quadraticCurveTo(4.5, 4, 3, 4);
+ c.quadraticCurveTo(2.5, 3, 2, 2);
+ } else {
+ c.quadraticCurveTo(5, 3, 4, 4.5);
+ c.quadraticCurveTo(3, 3, 2, 2);
+ }
+ c.lineTo(0, 1);
+ };
+ case 'crab':
+ return (c) => {
+ c.moveTo(0, -6);
+ c.quadraticCurveTo(3, -6.5, 4, -4);
+ c.quadraticCurveTo(4.5, -2, 3, -1);
+ c.lineTo(5.5, -1);
+ c.quadraticCurveTo(6, 0, 5, 1);
+ c.lineTo(3, 1);
+ if (frame === 0) {
+ c.lineTo(4, 4);
+ c.quadraticCurveTo(3, 4.5, 2, 3);
+ } else {
+ c.lineTo(2, 4.5);
+ c.quadraticCurveTo(1.5, 4, 1, 3);
+ }
+ c.lineTo(1, 1);
+ c.lineTo(0, 1);
+ };
+ default: // octopus
+ return (c) => {
+ c.moveTo(0, -6);
+ c.quadraticCurveTo(4, -6.5, 5, -3);
+ c.quadraticCurveTo(5.5, -1, 4, 0);
+ c.lineTo(5.5, 0.5);
+ if (frame === 0) {
+ c.lineTo(5, 4);
+ c.lineTo(3.5, 2);
+ } else {
+ c.lineTo(3.5, 4.5);
+ c.lineTo(2.5, 2);
+ }
+ c.lineTo(2, 3);
+ c.lineTo(0, 1);
+ };
+ }
+}
+
+const ALIEN_COLORS = {
+ squid: [PALETTE.squid, PALETTE.squidDark],
+ crab: [PALETTE.crab, PALETTE.crabDark],
+ octopus: [PALETTE.octopus, PALETTE.octopusDark],
+};
+
+export function drawAlien(ctx, alien, x, y, animFrame) {
+ const [fill, dark] = ALIEN_COLORS[alien.type];
+ const scale = alien.type === 'squid' ? 1 : alien.type === 'crab' ? 1.3 : 1.4;
+ ctx.save();
+ ctx.translate(x + alien.width / 2, y + alien.height / 2 + 1);
+ ctx.scale(scale, scale);
+ ctx.fillStyle = fill;
+ ctx.strokeStyle = dark;
+ ctx.lineWidth = 0.4;
+ ctx.shadowColor = fill;
+ ctx.shadowBlur = 2.5;
+
+ const build = halfPath(alien.type, animFrame);
+ ctx.beginPath();
+ build(ctx);
+ ctx.scale(-1, 1);
+ build(ctx);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // Jewel eyes.
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.beginPath();
+ ctx.ellipse(-1, -2.5, 0.6, 0.6, 0, 0, Math.PI * 2);
+ ctx.ellipse(1, -2.5, 0.6, 0.6, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+}
+
+// --- Explosion particles ------------------------------------------------
+// A blooming-flower burst: petals of light radiating and curling outward.
+export function drawExplosion(ctx, explosion) {
+ const { x, y, age, duration, color } = explosion;
+ const progress = age / duration;
+ const petals = 8;
+ const radius = 2 + progress * 7;
+ const alpha = 1 - progress;
+
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = color;
+ ctx.shadowColor = color;
+ ctx.shadowBlur = 4;
+
+ for (let i = 0; i < petals; i++) {
+ const angle = (i / petals) * Math.PI * 2 + progress * 1.2;
+ const px = Math.cos(angle) * radius;
+ const py = Math.sin(angle) * radius;
+ ctx.beginPath();
+ ctx.ellipse(px, py, 1.6 * (1 - progress * 0.4), 0.8, angle, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.beginPath();
+ ctx.arc(0, 0, 1.4 * (1 - progress), 0, Math.PI * 2);
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.fill();
+ ctx.restore();
+}
+
+// --- Backdrop -------------------------------------------------------------
+// Deep nebula gradient with slow-drifting whiplash curves (the defining
+// Art Nouveau line — an elongated "S" swept across the field).
+export function drawBackground(ctx, time) {
+ const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_HEIGHT);
+ grad.addColorStop(0, PALETTE.bgTop);
+ grad.addColorStop(1, PALETTE.bgBottom);
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
+
+ ctx.save();
+ ctx.globalAlpha = 0.12;
+ ctx.strokeStyle = PALETTE.gold;
+ ctx.lineWidth = 1.2;
+ const drift = (time * 0.005) % (Math.PI * 2);
+ for (let i = 0; i < 3; i++) {
+ const yBase = 40 + i * 80 + Math.sin(drift + i) * 10;
+ ctx.beginPath();
+ ctx.moveTo(-20, yBase);
+ ctx.bezierCurveTo(
+ CANVAS_WIDTH * 0.25, yBase - 30 + Math.sin(drift + i) * 12,
+ CANVAS_WIDTH * 0.75, yBase + 30 - Math.sin(drift + i) * 12,
+ CANVAS_WIDTH + 20, yBase,
+ );
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ // Faint static starfield (deterministic pseudo-random, no per-frame alloc).
+ ctx.save();
+ ctx.fillStyle = PALETTE.gold;
+ for (let i = 0; i < 40; i++) {
+ const sx = (i * 53.7) % CANVAS_WIDTH;
+ const sy = (i * 97.3) % CANVAS_HEIGHT;
+ const tw = 0.3 + 0.3 * Math.sin(time * 0.002 + i);
+ ctx.globalAlpha = Math.max(0.05, tw);
+ ctx.fillRect(sx, sy, 1, 1);
+ }
+ ctx.restore();
+}
+
+// --- Ornamental frame -----------------------------------------------------
+// A gilded proscenium border wrapping the playfield, evoking a Mucha poster
+// frame: corner medallions joined by flowing vine linework.
+export function drawFrame(ctx) {
+ ctx.save();
+ ctx.strokeStyle = PALETTE.gold;
+ ctx.lineWidth = 2;
+ ctx.strokeRect(1, 1, CANVAS_WIDTH - 2, CANVAS_HEIGHT - 2);
+
+ ctx.lineWidth = 0.7;
+ ctx.globalAlpha = 0.8;
+ const corners = [
+ [0, 0, 1, 1],
+ [CANVAS_WIDTH, 0, -1, 1],
+ [0, CANVAS_HEIGHT, 1, -1],
+ [CANVAS_WIDTH, CANVAS_HEIGHT, -1, -1],
+ ];
+ for (const [cx, cy, sx, sy] of corners) {
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.scale(sx, sy);
+ ctx.beginPath();
+ ctx.moveTo(2, 10);
+ ctx.quadraticCurveTo(2, 2, 10, 2);
+ ctx.moveTo(4, 14);
+ ctx.quadraticCurveTo(14, 14, 14, 4);
+ ctx.stroke();
+ ctx.restore();
+ }
+ ctx.restore();
+}
diff --git a/space-invaders/js/state/attractState.js b/space-invaders/js/state/attractState.js
new file mode 100644
index 00000000..b2c6ff41
--- /dev/null
+++ b/space-invaders/js/state/attractState.js
@@ -0,0 +1,85 @@
+import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from '../constants.js';
+import { drawBackground, drawFrame, drawAlien } from '../render.js';
+import { Alien } from '../entities/alien.js';
+
+const LEGEND = [
+ { type: 'squid', label: '= 30 PTS' },
+ { type: 'crab', label: '= 20 PTS' },
+ { type: 'octopus', label: '= 10 PTS' },
+];
+
+export function createAttractState(game, machine) {
+ let time = 0;
+ let blink = 0;
+ const legendAliens = LEGEND.map((entry, i) => {
+ const a = new Alien(i === 0 ? 0 : i === 1 ? 1 : 3, 0, 0, 0);
+ return a;
+ });
+
+ return {
+ enter() {
+ time = 0;
+ game.audio.stopUfoLoop();
+ },
+ exit() {},
+ update(dt) {
+ time += dt;
+ blink += dt;
+ game.input.update();
+ if (game.input.firePressed) {
+ game.audio.unlock();
+ game.startNewGame();
+ machine.transition('play');
+ }
+ },
+ render(ctx) {
+ drawBackground(ctx, time * 1000);
+ drawFrame(ctx);
+
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.shadowColor = PALETTE.gold;
+ ctx.shadowBlur = 6;
+ ctx.font = '16px Georgia, serif';
+ ctx.fillText('SPACE', CANVAS_WIDTH / 2, 56);
+ ctx.fillText('INVADERS', CANVAS_WIDTH / 2, 74);
+ ctx.font = 'italic 6px Georgia, serif';
+ ctx.fillStyle = PALETTE.gold;
+ ctx.shadowBlur = 0;
+ ctx.fillText('— nouveau —', CANVAS_WIDTH / 2, 84);
+ ctx.restore();
+
+ ctx.save();
+ ctx.textAlign = 'left';
+ ctx.font = '6px Georgia, serif';
+ ctx.fillStyle = PALETTE.text;
+ let ly = 118;
+ ctx.fillText('* SCORE ADVANCE TABLE *', CANVAS_WIDTH / 2 - 52, ly - 12);
+ for (let i = 0; i < legendAliens.length; i++) {
+ const alien = legendAliens[i];
+ const x = CANVAS_WIDTH / 2 - 40;
+ drawAlien(ctx, alien, x, ly - 3, 0);
+ ctx.fillText(LEGEND[i].label, x + 14, ly);
+ ly += 16;
+ }
+ ctx.restore();
+
+ if (Math.floor(blink * 2) % 2 === 0) {
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.font = '8px Georgia, serif';
+ ctx.fillText('PRESS FIRE TO START', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 40);
+ ctx.restore();
+ }
+
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = PALETTE.text;
+ ctx.font = '6px Georgia, serif';
+ ctx.fillText('ARROWS / A-D MOVE SPACE FIRE', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 20);
+ ctx.restore();
+ },
+ };
+}
diff --git a/space-invaders/js/state/gameOverState.js b/space-invaders/js/state/gameOverState.js
new file mode 100644
index 00000000..8ceb1271
--- /dev/null
+++ b/space-invaders/js/state/gameOverState.js
@@ -0,0 +1,51 @@
+import { CANVAS_WIDTH, CANVAS_HEIGHT, PALETTE } from '../constants.js';
+import { drawBackground, drawFrame } from '../render.js';
+
+const MIN_DISPLAY_TIME = 1500;
+
+export function createGameOverState(game, machine) {
+ let time = 0;
+ let blink = 0;
+
+ return {
+ enter() {
+ time = 0;
+ blink = 0;
+ game.audio.playSound('gameOver');
+ game.audio.stopUfoLoop();
+ },
+ exit() {},
+ update(dt) {
+ time += dt;
+ blink += dt;
+ game.input.update();
+ if (time * 1000 > MIN_DISPLAY_TIME && game.input.firePressed) {
+ machine.transition('attract');
+ }
+ },
+ render(ctx) {
+ drawBackground(ctx, time * 1000);
+ drawFrame(ctx);
+
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = PALETTE.ufo;
+ ctx.shadowColor = PALETTE.gold;
+ ctx.shadowBlur = 6;
+ ctx.font = '18px Georgia, serif';
+ ctx.fillText('GAME OVER', CANVAS_WIDTH / 2, 110);
+
+ ctx.font = '8px Georgia, serif';
+ ctx.fillStyle = PALETTE.text;
+ ctx.shadowBlur = 0;
+ ctx.fillText(`SCORE ${game.score}`, CANVAS_WIDTH / 2, 135);
+ ctx.fillText(`HIGH SCORE ${game.highScore}`, CANVAS_WIDTH / 2, 150);
+
+ if (Math.floor(blink * 2) % 2 === 0) {
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.fillText('PRESS FIRE TO CONTINUE', CANVAS_WIDTH / 2, CANVAS_HEIGHT - 50);
+ }
+ ctx.restore();
+ },
+ };
+}
diff --git a/space-invaders/js/state/playState.js b/space-invaders/js/state/playState.js
new file mode 100644
index 00000000..e442e7e1
--- /dev/null
+++ b/space-invaders/js/state/playState.js
@@ -0,0 +1,128 @@
+import { CANVAS_WIDTH, PALETTE } from '../constants.js';
+import { drawBackground, drawFrame } from '../render.js';
+import { checkCollisions } from '../collision.js';
+import { Explosion } from '../entities/explosion.js';
+
+const RESPAWN_DELAY = 1200;
+const WAVE_CLEAR_DELAY = 1600;
+const GAME_OVER_DELAY = 900;
+
+export function createPlayState(game, machine) {
+ let phase = 'running'; // running | respawning | waveClear | dying
+ let timer = 0;
+ let bgTime = 0;
+
+ function beginRespawn() {
+ phase = 'respawning';
+ timer = RESPAWN_DELAY;
+ }
+
+ function beginGameOverTransition() {
+ phase = 'dying';
+ timer = GAME_OVER_DELAY;
+ }
+
+ function beginWaveClear() {
+ phase = 'waveClear';
+ timer = WAVE_CLEAR_DELAY;
+ game.audio.playSound('waveClear');
+ }
+
+ return {
+ enter() {
+ phase = 'running';
+ timer = 0;
+ },
+ exit() {
+ game.audio.stopUfoLoop();
+ },
+ update(dt) {
+ bgTime += dt;
+ game.input.update();
+
+ if (phase === 'respawning') {
+ timer -= dt * 1000;
+ if (timer <= 0) {
+ game.player.isAlive = true;
+ game.player.x = (CANVAS_WIDTH - game.player.width) / 2;
+ game.player.canFire = true;
+ phase = 'running';
+ }
+ return;
+ }
+
+ if (phase === 'waveClear') {
+ timer -= dt * 1000;
+ if (timer <= 0) {
+ game.nextWave();
+ phase = 'running';
+ }
+ return;
+ }
+
+ if (phase === 'dying') {
+ timer -= dt * 1000;
+ if (timer <= 0) machine.transition('gameOver');
+ return;
+ }
+
+ // --- normal gameplay update ---
+ game.player.update(dt, game.input);
+ game.swarm.update(dt, game.alienBullets);
+ game.mysteryShip.update(dt);
+
+ for (const b of game.playerBullets) b.update(dt);
+ for (const b of game.alienBullets) b.update(dt);
+ for (const e of game.explosions) e.update(dt);
+ game.explosions = game.explosions.filter((e) => e.isAlive);
+
+ checkCollisions(game);
+
+ // Aliens reaching the player's line is an instant loss condition.
+ if (game.swarm.aliveCount > 0 && game.swarm.bottomMostY() >= game.player.y) {
+ game.lives = 0;
+ game.player.isAlive = false;
+ game.explosions.push(new Explosion(
+ game.player.x + game.player.width / 2,
+ game.player.y + game.player.height / 2,
+ PALETTE.player,
+ 0.6,
+ ));
+ game.audio.playSound('playerExplosion');
+ }
+
+ if (!game.player.isAlive) {
+ if (game.lives <= 0) beginGameOverTransition();
+ else beginRespawn();
+ return;
+ }
+
+ if (game.swarm.isDefeated) {
+ beginWaveClear();
+ }
+ },
+ render(ctx) {
+ drawBackground(ctx, bgTime * 1000);
+ drawFrame(ctx);
+
+ for (const bunker of game.bunkers) bunker.render(ctx);
+ game.swarm.render(ctx);
+ game.mysteryShip.render(ctx);
+ game.player.render(ctx);
+ for (const b of game.playerBullets) b.render(ctx);
+ for (const b of game.alienBullets) b.render(ctx);
+ for (const e of game.explosions) e.render(ctx);
+
+ if (phase === 'waveClear') {
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = PALETTE.goldBright;
+ ctx.font = '10px Georgia, serif';
+ ctx.shadowColor = PALETTE.gold;
+ ctx.shadowBlur = 5;
+ ctx.fillText(`WAVE ${game.wave} CLEARED`, CANVAS_WIDTH / 2, 130);
+ ctx.restore();
+ }
+ },
+ };
+}
diff --git a/space-invaders/js/state/stateMachine.js b/space-invaders/js/state/stateMachine.js
new file mode 100644
index 00000000..74c6405d
--- /dev/null
+++ b/space-invaders/js/state/stateMachine.js
@@ -0,0 +1,24 @@
+export class StateMachine {
+ constructor(states) {
+ this.states = states;
+ this.current = null;
+ }
+
+ start(initial) {
+ this.transition(initial);
+ }
+
+ transition(name) {
+ if (this.current && this.states[this.current].exit) this.states[this.current].exit();
+ this.current = name;
+ if (this.states[this.current].enter) this.states[this.current].enter();
+ }
+
+ update(dt) {
+ this.states[this.current].update(dt);
+ }
+
+ render(ctx) {
+ this.states[this.current].render(ctx);
+ }
+}
diff --git a/space-invaders/manifest.webmanifest b/space-invaders/manifest.webmanifest
new file mode 100644
index 00000000..434067b2
--- /dev/null
+++ b/space-invaders/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Space Invaders — Nouveau",
+ "short_name": "SI Nouveau",
+ "description": "A modern Art Nouveau reimagining of the 1978 arcade classic, with synthesized sound and a gilded, jewel-toned swarm.",
+ "start_url": ".",
+ "scope": ".",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#120a24",
+ "theme_color": "#120a24",
+ "icons": [
+ { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/space-invaders/sw.js b/space-invaders/sw.js
new file mode 100644
index 00000000..7ac0ea4a
--- /dev/null
+++ b/space-invaders/sw.js
@@ -0,0 +1,55 @@
+const CACHE_NAME = 'space-invaders-nouveau-v1';
+
+const ASSET_LIST = [
+ './',
+ './index.html',
+ './manifest.webmanifest',
+ './css/style.css',
+ './js/game.js',
+ './js/constants.js',
+ './js/input.js',
+ './js/audio.js',
+ './js/render.js',
+ './js/collision.js',
+ './js/entities/alien.js',
+ './js/entities/alienFireController.js',
+ './js/entities/bullet.js',
+ './js/entities/bunker.js',
+ './js/entities/explosion.js',
+ './js/entities/mysteryShip.js',
+ './js/entities/player.js',
+ './js/entities/swarm.js',
+ './js/state/stateMachine.js',
+ './js/state/attractState.js',
+ './js/state/playState.js',
+ './js/state/gameOverState.js',
+ './icons/icon.svg',
+ './icons/icon-192.png',
+ './icons/icon-512.png',
+ './icons/icon-maskable-512.png',
+];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSET_LIST)),
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((names) => Promise.all(
+ names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)),
+ )),
+ );
+ self.clients.claim();
+});
+
+// Cache-first, falling back to network — ideal for a self-contained game
+// where every asset is required for the app to function at all.
+self.addEventListener('fetch', (event) => {
+ if (event.request.method !== 'GET') return;
+ event.respondWith(
+ caches.match(event.request).then((cached) => cached || fetch(event.request)),
+ );
+});