SPACE INVADERS - INTERACTIVE ARCADE GAME SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="theme-color" content="#04010a">
<title>Space Invaders</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;width:100%;overflow:hidden;background:#04010a; transition: background 0.3s;}
body.light-mode { background:#e1e6ed; }
body{
touch-action:none;overscroll-behavior:none;-webkit-user-select:none;user-select:none;
-webkit-tap-highlight-color:transparent;
font-family:ui-monospace,"SF Mono",Menlo,Consolas,"Courier New",monospace;
}
#game{position:fixed;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated}
/* CRT layers — GPU-composited, so they cost nothing per frame */
#scan{
position:fixed;inset:0;z-index:5;pointer-events:none;opacity:.5;
background:repeating-linear-gradient(0deg,rgba(0,0,0,.42) 0 1px,transparent 1px 3px);
}
body.light-mode #scan {
background:repeating-linear-gradient(0deg,rgba(255,255,255,.3) 0 1px,transparent 1px 3px);
}
#vig{
position:fixed;inset:0;z-index:6;pointer-events:none;
background:radial-gradient(ellipse 75% 75% at 50% 50%,transparent 40%,rgba(2,0,8,.82) 100%);
}
body.light-mode #vig {
background:radial-gradient(ellipse 75% 75% at 50% 50%,transparent 40%,rgba(255,255,255,.5) 100%);
}
.fx-off{display:none!important}
#chrome{
position:fixed;z-index:8;display:flex;gap:6px;
top:max(185px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));
}
body.in-fullscreen #chrome {
top:max(10px,env(safe-area-inset-top));
}
.btn{
width:34px;height:34px;border-radius:9px;cursor:pointer;
background:rgba(10,4,26,.55);border:1px solid rgba(120,240,255,.28);
color:#7fe9ff;font:600 13px/1 ui-monospace,monospace;
display:flex;align-items:center;justify-content:center;
backdrop-filter:blur(6px);transition:border-color .15s,color .15s,transform .08s;
}
body.light-mode .btn {
background:rgba(255,255,255,.55);border:1px solid rgba(0,85,255,.28);
color:#0055ff;
}
.btn:hover{border-color:rgba(120,240,255,.7);color:#d8fbff}
body.light-mode .btn:hover{border-color:rgba(0,85,255,.7);color:#0033cc}
.btn:active{transform:scale(.93)}
.btn.dim{color:#4a5f6b;border-color:rgba(120,240,255,.12)}
body.light-mode .btn.dim{color:#8899a6;border-color:rgba(0,85,255,.12)}
@media (max-width:600px){.btn{width:38px;height:38px}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="scan"></div>
<div id="vig"></div>
<div id="chrome">
<div class="btn" id="bSound" title="Sound (M)">♪</div>
<div class="btn" id="bFx" title="CRT filter (G)">▦</div>
<div class="btn" id="bFull" title="Fullscreen (F)">⛶</div>
</div>
<script>
"use strict";
/* ==========================================================================
SIGNAL LOST — an invaders game
Virtual world is 1000 units tall; width adapts to the viewport aspect ratio
so the playfield genuinely fills the screen instead of letterboxing.
========================================================================== */
const cv = document.getElementById('game');
cv.tabIndex = 0;
cv.style.outline = 'none';
const ctx = cv.getContext('2d', { alpha:false });
const clamp = (v,a,b) => v<a?a:v>b?b:v;
const lerp = (a,b,t) => a+(b-a)*t;
const rand = (a,b) => a+Math.random()*(b-a);
const randi = (a,b) => Math.floor(rand(a,b));
const pick = a => a[randi(0,a.length)];
/* ---------- palette -------------------------------------------------- */
const C_DARK = {
bg0:'#0a0320', bg1:'#02010a',
ink:'#8ef6ff', hot:'#ff2e88', acid:'#a8ff36', amber:'#ffb020',
violet:'#9d5cff', white:'#ffffff', dim:'#4a5f6b'
};
const C_LIGHT = {
bg0:'#f4f7fa', bg1:'#e6ebf0',
ink:'#0066cc', hot:'#cc0044', acid:'#228800', amber:'#cc6600',
violet:'#6a0dad', white:'#000000', dim:'#8b9bb4'
};
let C = C_DARK;
let SURGE = ['#ff2e88','#a8ff36','#8ef6ff','#ffb020','#9d5cff'];
function isDarkMode() {
try { return window.parent.document.documentElement.classList.contains('dark'); }
catch(e) { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; }
}
let lastDark = null;
function applyTheme() {
const dark = isDarkMode();
if (dark === lastDark) return;
lastDark = dark;
C = dark ? C_DARK : C_LIGHT;
SURGE = dark
? ['#ff2e88','#a8ff36','#8ef6ff','#ffb020','#9d5cff']
: ['#cc0044','#228800','#0066cc','#cc6600','#6a0dad'];
document.body.classList.toggle('light-mode', !dark);
bunkers.forEach(b => b.dirty = true);
}
setInterval(applyTheme, 300);
/* ---------- pixel sprites -------------------------------------------- */
let _sid = 0;
function sprite(rows){
const h = rows.length, w = rows[0].length;
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d'); g.fillStyle = '#fff';
for (let y=0;y<h;y++) for (let x=0;x<w;x++) if (rows[y][x] !== '.') g.fillRect(x,y,1,1);
c._id = ++_sid;
return c;
}
const tintCache = new Map();
function tint(s, color){
if (!s._id) s._id = ++_sid; // never let an untagged canvas collide on "undefined"
const k = s._id + '|' + color;
let c = tintCache.get(k);
if (!c){
c = document.createElement('canvas'); c.width = s.width; c.height = s.height;
const g = c.getContext('2d');
g.drawImage(s,0,0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color; g.fillRect(0,0,c.width,c.height);
tintCache.set(k,c);
}
return c;
}
/* draw sprite with a cheap additive bloom instead of shadowBlur */
function blit(s, color, x, y, w, h, glow){
const t = tint(s,color);
if (glow){
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = glow;
ctx.drawImage(t, x-w*0.14, y-h*0.14, w*1.28, h*1.28);
ctx.restore();
}
ctx.drawImage(t, x, y, w, h);
}
const SP = {
squid: [sprite([
"...XX...","..XXXX..",".XXXXXX.","XX.XX.XX",
"XXXXXXXX","..X..X..",".X.XX.X.","X.X..X.X"]),
sprite([
"...XX...","..XXXX..",".XXXXXX.","XX.XX.XX",
"XXXXXXXX",".X.XX.X.","X......X",".X....X."])],
crab: [sprite([
"..X.....X..","...X...X...","..XXXXXXX..",".XX.XXX.XX.",
"XXXXXXXXXXX","X.XXXXXXX.X","X.X.....X.X","...XX.XX..."]),
sprite([
"..X.....X..","X..X...X..X","X.XXXXXXX.X","XXX.XXX.XXX",
"XXXXXXXXXXX",".XXXXXXXXX.","..X.....X..",".X.......X."])],
octo: [sprite([
"....XXXX....",".XXXXXXXXXX.","XXXXXXXXXXXX","XXX..XX..XXX",
"XXXXXXXXXXXX","...XX..XX...","..XX.XX.XX..","XX........XX"]),
sprite([
"....XXXX....",".XXXXXXXXXX.","XXXXXXXXXXXX","XXX..XX..XXX",
"XXXXXXXXXXXX","..XXX..XXX..",".XX..XX..XX.","..XX....XX.."])],
ship: sprite([
".......X.......","......XXX......","......XXX......",".XXXXXXXXXXXXX.",
"XXXXXXXXXXXXXXX","XXXXXXXXXXXXXXX","XXX.XXXXXXX.XXX","XX...XXXXX...XX"]),
ufo: sprite([
".....XXXXXX.....","...XXXXXXXXXX...","..XXXXXXXXXXXX..",".XX.XX.XX.XX.XX.",
"XXXXXXXXXXXXXXXX","..XXX..XX..XXX..","...X........X..."]),
bomb: [
sprite([".X.","XXX",".X.",".X.","XXX",".X."]),
sprite([".X.","X..",".X.","..X",".X.","X.."]),
sprite(["X.X",".X.","X.X",".X.","X.X",".X."])],
bombAlt: [
sprite(["XXX",".X.",".X.","XXX",".X.",".X."]),
sprite([".X.","..X",".X.","X..",".X.","..X"]),
sprite([".X.","X.X",".X.","X.X",".X.","X.X"])],
bunker: sprite([
"....XXXXXXXXXXXXXX....","..XXXXXXXXXXXXXXXXXX..",".XXXXXXXXXXXXXXXXXXXX.",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXX....XXXXXXXXX","XXXXXXX........XXXXXXX",
"XXXXXX..........XXXXXX","XXXXX............XXXXX","XXXXX............XXXXX",
"XXXXX............XXXXX"])
};
const BUNKER_MASK = [
"....XXXXXXXXXXXXXX....","..XXXXXXXXXXXXXXXXXX..",".XXXXXXXXXXXXXXXXXXXX.",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXXXXXXXXXXXXXXX",
"XXXXXXXXXXXXXXXXXXXXXX","XXXXXXXXX....XXXXXXXXX","XXXXXXX........XXXXXXX",
"XXXXXX..........XXXXXX","XXXXX............XXXXX","XXXXX............XXXXX",
"XXXXX............XXXXX"];
/* ---------- audio (procedural, no assets) ---------------------------- */
const Snd = {
ac:null, master:null, on:true, noise:null, stepIx:0, ufoNode:null, lastStep:0,
boot(){
if (this.ac) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { this.on = false; return; }
this.ac = new AC();
this.master = this.ac.createGain();
this.master.gain.value = 0.35;
this.master.connect(this.ac.destination);
const len = this.ac.sampleRate * 1.2;
const buf = this.ac.createBuffer(1, len, this.ac.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<len;i++) d[i] = Math.random()*2-1;
this.noise = buf;
},
resume(){ if (this.ac && this.ac.state === 'suspended') this.ac.resume(); },
tone(f0,f1,dur,type,vol,delay=0){
if (!this.on || !this.ac) return;
const t = this.ac.currentTime + delay;
const o = this.ac.createOscillator(), g = this.ac.createGain();
o.type = type; o.frequency.setValueAtTime(f0,t);
o.frequency.exponentialRampToValueAtTime(Math.max(1,f1), t+dur);
g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(vol, t+0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.02);
},
burst(dur,f0,f1,vol){
if (!this.on || !this.ac) return;
const t = this.ac.currentTime;
const s = this.ac.createBufferSource(); s.buffer = this.noise;
const f = this.ac.createBiquadFilter(); f.type = 'lowpass';
f.frequency.setValueAtTime(f0,t);
f.frequency.exponentialRampToValueAtTime(f1, t+dur);
const g = this.ac.createGain();
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
s.connect(f); f.connect(g); g.connect(this.master);
s.start(t); s.stop(t+dur);
},
shoot(){ this.tone(900,180,0.10,'square',0.07); },
step(){
if (!this.ac) return;
if (this.ac.currentTime - this.lastStep < 0.075) return; // stays a pulse, never a buzz
this.lastStep = this.ac.currentTime;
const notes = [110,98,87,78];
this.tone(notes[this.stepIx%4], notes[this.stepIx%4]*0.94, 0.09,'triangle',0.13);
this.stepIx++;
},
kill(){ this.burst(0.22,2600,220,0.24); this.tone(420,120,0.14,'square',0.05); },
hurt(){ this.burst(0.5,900,80,0.34); this.tone(200,40,0.5,'sawtooth',0.16); },
drop(){ this.tone(70,52,0.14,'square',0.10); },
power(){ [523,659,880].forEach((f,i)=>this.tone(f,f,0.10,'square',0.09,i*0.055)); },
wave(){ [330,440,554,660,880].forEach((f,i)=>this.tone(f,f,0.13,'triangle',0.10,i*0.075)); },
over(){ [440,392,330,262,196].forEach((f,i)=>this.tone(f,f*0.7,0.32,'sawtooth',0.10,i*0.16)); },
ufoStart(){
if (!this.on || !this.ac || this.ufoNode) return;
const t = this.ac.currentTime;
const o = this.ac.createOscillator(), g = this.ac.createGain();
const lfo = this.ac.createOscillator(), la = this.ac.createGain();
o.type='sawtooth'; o.frequency.value=520;
lfo.type='sine'; lfo.frequency.value=11; la.gain.value=140;
lfo.connect(la); la.connect(o.frequency);
g.gain.value=0.055;
o.connect(g); g.connect(this.master);
o.start(t); lfo.start(t);
this.ufoNode = {o,lfo,g};
},
ufoStop(){
if (!this.ufoNode) return;
const {o,lfo,g} = this.ufoNode;
const t = this.ac.currentTime;
g.gain.setValueAtTime(g.gain.value,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+0.15);
o.stop(t+0.2); lfo.stop(t+0.2);
this.ufoNode = null;
}
};
/* ---------- viewport / world ----------------------------------------- */
const V = { w:1600, h:1000 };
const view = { scale:1, ox:0, oy:0, dpr:1 };
let shake = 0, glitch = 0, fxOn = true;
function layout(){
const cw = Math.max(1, window.innerWidth), ch = Math.max(1, window.innerHeight);
view.dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.floor(cw * view.dpr);
cv.height = Math.floor(ch * view.dpr);
cv.style.width = cw + 'px';
cv.style.height = ch + 'px';
V.h = 1000;
V.w = clamp(1000 * (cw/ch), 660, 2400);
view.scale = Math.min(cv.width / V.w, cv.height / V.h);
view.ox = (cv.width - V.w * view.scale) / 2;
view.oy = (cv.height - V.h * view.scale) / 2;
ctx.imageSmoothingEnabled = false;
reflow();
}
window.addEventListener('resize', layout);
window.addEventListener('orientationchange', () => setTimeout(layout, 120));
/* ---------- game state ------------------------------------------------ */
const G = {
state:'menu', // menu | play | wave | dead | over
t:0, timer:0, score:0, best:0, level:1, lives:3,
surge:0, msg:'', anim:0
};
const player = { x:0, y:0, w:64, h:34, vx:0, inv:0, cool:0, weapon:0, wt:0 };
const shots = [], bombs = [], parts = [], drops = [];
let enemies = [], form = { x:0, y:140, dir:1, step:0, frame:0 };
let gapX = 90, gapY = 62, alienW = 54;
let bunkers = [];
let ufo = null, ufoTimer = 12;
const stars = [];
for (let i=0;i<140;i++) stars.push({ x:Math.random(), y:Math.random(), z:rand(0.25,1), s:rand(0.6,2.1) });
const TYPES = [
{ sp:SP.squid, pts:30, col:C.hot },
{ sp:SP.crab, pts:20, col:C.ink },
{ sp:SP.octo, pts:10, col:C.acid }
];
const rowType = r => r === 0 ? 0 : r <= 2 ? 1 : 2;
/* reposition everything that depends on world size */
function reflow(){
gapX = Math.min(102, (V.w * 0.80) / 11);
gapY = 62;
alienW = gapX * 0.60;
player.w = clamp(V.w * 0.042, 52, 76);
player.h = player.w * (8/15);
player.y = V.h - 96;
player.x = clamp(player.x || (V.w/2 - player.w/2), 0, V.w - player.w);
const totalW = 10*gapX + alienW;
form.x = clamp(form.x || (V.w - totalW)/2, 40, V.w - totalW - 40);
bunkers.forEach(b => { b.x = V.w * b.frac - b.w/2; b.y = V.h - 250; });
}
function alienSize(t){
const s = TYPES[t].sp[0];
const w = alienW;
return { w, h: w * (s.height / s.width) };
}
function alienPos(e){
const sz = alienSize(e.type);
return { x: form.x + e.col*gapX + (alienW - sz.w)/2, y: form.y + e.row*gapY, w: sz.w, h: sz.h };
}
function buildWave(){
enemies = [];
for (let r=0;r<5;r++)
for (let c=0;c<11;c++)
enemies.push({ col:c, row:r, type:rowType(r), alive:true });
form.dir = 1;
form.y = 130 + Math.min(150, (G.level-1) * 20);
const totalW = 10*gapX + alienW;
form.x = (V.w - totalW)/2;
form.step = 0;
}
function buildBunkers(){
bunkers = [];
const cols = BUNKER_MASK[0].length, rows = BUNKER_MASK.length;
const cell = 4.6, w = cols*cell, h = rows*cell;
[0.2,0.4,0.6,0.8].forEach(f => {
const cells = new Uint8Array(cols*rows);
for (let y=0;y<rows;y++) for (let x=0;x<cols;x++)
cells[y*cols+x] = BUNKER_MASK[y][x] === 'X' ? 1 : 0;
const cn = document.createElement('canvas'); cn.width = cols; cn.height = rows;
const b = { frac:f, x:V.w*f - w/2, y:V.h-250, w, h, cols, rows, cell, cells, cn,
dirty:true, col:null };
bunkers.push(b);
});
}
/* Repainted only when the barrier is damaged or its colour changes, so this
costs nothing on a normal frame. Deliberately NOT routed through tint():
that cache is for immutable sprites, and a barrier changes every hit. */
function paintBunker(b, col){
const g = b.cn.getContext('2d');
g.clearRect(0,0,b.cols,b.rows);
g.fillStyle = col;
for (let y=0;y<b.rows;y++) for (let x=0;x<b.cols;x++)
if (b.cells[y*b.cols+x]) g.fillRect(x,y,1,1);
b.dirty = false; b.col = col;
}
/* Blast shapes, in barrier cells. The grid is 22x16 — the same resolution as
the arcade original — so a player shot nibbles a small notch and takes many
hits to tunnel through, while an alien bomb splashes a bit wider. */
const BLOB_SHOT = [
[0,-1],
[-1, 0],[0, 0],[1, 0],
[-1, 1],[0, 1],[1, 1]
];
const BLOB_BOMB = [
[-1,-1],[0,-1],[1,-1],
[-2, 0],[-1, 0],[0, 0],[1, 0],[2, 0],
[-2, 1],[-1, 1],[0, 1],[1, 1],[2, 1],
[-1, 2],[0, 2],[1, 2]
];
function carve(b, cx, cy, blob){
for (const o of blob){
const x = cx+o[0], y = cy+o[1];
if (x<0||y<0||x>=b.cols||y>=b.rows) continue;
b.cells[y*b.cols+x] = 0;
}
/* a few random nibbles so edges crumble instead of staying geometric */
for (let i=0;i<5;i++){
const x = cx+randi(-2,3), y = cy+randi(-2,3);
if (x<0||y<0||x>=b.cols||y>=b.rows) continue;
if (Math.random() < 0.4) b.cells[y*b.cols+x] = 0;
}
b.dirty = true;
}
/* Swept test: walks the segment the projectile covered this frame in half-cell
steps, so fast shots can skips over a barrier. Returns the impact point, or
null if the path was clear — shots pass straight through carved-out holes. */
function bunkerHit(x0,y0,x1,y1,blob){
const dx = x1-x0, dy = y1-y0;
const steps = Math.max(1, Math.ceil(Math.hypot(dx,dy) / 2.2));
for (let i=0;i<=steps;i++){
const t = i/steps, px = x0+dx*t, py = y0+dy*t;
for (const b of bunkers){
if (px < b.x || px > b.x+b.w || py < b.y || py > b.y+b.h) continue;
const cx = Math.floor((px-b.x)/b.cell), cy = Math.floor((py-b.y)/b.cell);
if (cx<0||cy<0||cx>=b.cols||cy>=b.rows) continue;
if (!b.cells[cy*b.cols+cx]) continue;
carve(b, cx, cy, blob);
return { x:px, y:py };
}
}
return null;
}
function razeBunkersAt(y){
for (const b of bunkers){
if (y < b.y) continue;
let any = false;
const upto = Math.min(b.rows, Math.ceil((y - b.y)/b.cell));
for (let r=0;r<upto;r++) for (let x=0;x<b.cols;x++)
if (b.cells[r*b.cols+x]) { b.cells[r*b.cols+x]=0; any=true; }
if (any) b.dirty = true;
}
}
function burst(x,y,n,color,power=1){
for (let i=0;i<n;i++){
const a = Math.random()*Math.PI*2, s = rand(40,300)*power;
parts.push({ x, y, vx:Math.cos(a)*s, vy:Math.sin(a)*s,
life:rand(0.28,0.75), max:0.75, col:color || pick(SURGE), sz:rand(1.6,4.2) });
}
}
function reset(){
G.score = 0; G.level = 1; G.lives = 3; G.surge = 0;
player.x = V.w/2 - player.w/2; player.inv = 2; player.weapon = 0; player.wt = 0;
shots.length = 0; bombs.length = 0; drops.length = 0; parts.length = 0;
ufo = null; ufoTimer = 14; Snd.ufoStop();
buildBunkers(); buildWave();
G.state = 'play';
}
/* ---------- input ----------------------------------------------------- */
const keys = Object.create(null);
const GAME_KEYS = new Set(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Spacebar']);
let touchX = null, touchFire = false, isTouch = false;
function press(){
Snd.boot(); Snd.resume();
if (G.state === 'menu' || G.state === 'over') reset();
}
addEventListener('keydown', e => {
if (e.repeat) { if (GAME_KEYS.has(e.key)) e.preventDefault(); return; }
const k = e.key;
keys[k.length===1 ? k.toLowerCase() : k] = true;
if (GAME_KEYS.has(k)) e.preventDefault();
if (k === ' ' || k === 'Enter') press();
if (k === 'p' || k === 'P' || k === 'Escape'){
if (G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
else if (G.state === 'pause'){ G.state = 'play'; if (ufo) Snd.ufoStart(); }
}
if (k === 'm' || k === 'M') toggleSound();
if (k === 'f' || k === 'F') toggleFull();
if (k === 'g' || k === 'G') toggleFx();
});
addEventListener('keyup', e => {
const k = e.key;
keys[k.length===1 ? k.toLowerCase() : k] = false;
if (GAME_KEYS.has(k)) e.preventDefault();
});
addEventListener('blur', () => {
for (const k in keys) keys[k] = false;
if (G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && G.state === 'play') { G.state = 'pause'; Snd.ufoStop(); }
});
/* pointer: drag anywhere to steer, holding fires */
function ptrX(e){
const r = cv.getBoundingClientRect();
const px = (e.clientX - r.left) * (cv.width / r.width);
return (px - view.ox) / view.scale;
}
cv.addEventListener('pointerdown', e => {
/* A mouse click only claims keyboard focus and starts the round. Steering by
pointer is touch-only, so clicking never yanks the ship away from the keys. */
try { window.focus(); } catch(_) {}
cv.focus();
if (e.pointerType === 'touch'){
isTouch = true;
cv.setPointerCapture(e.pointerId);
touchX = ptrX(e); touchFire = true;
}
press(); e.preventDefault();
});
cv.addEventListener('pointermove', e => {
if (isTouch && touchFire && e.pointerType === 'touch') touchX = ptrX(e);
});
const endPtr = () => { touchFire = false; touchX = null; };
cv.addEventListener('pointerup', endPtr);
cv.addEventListener('pointercancel', endPtr);
cv.addEventListener('pointerleave', endPtr);
cv.addEventListener('contextmenu', e => e.preventDefault());
/* ---------- chrome buttons -------------------------------------------- */
const bSound = document.getElementById('bSound'),
bFx = document.getElementById('bFx'),
bFull = document.getElementById('bFull');
function toggleSound(){
Snd.boot(); Snd.on = !Snd.on;
if (!Snd.on) Snd.ufoStop();
if (Snd.master) Snd.master.gain.value = Snd.on ? 0.35 : 0;
bSound.classList.toggle('dim', !Snd.on);
}
function toggleFx(){
fxOn = !fxOn;
document.getElementById('scan').classList.toggle('fx-off', !fxOn);
document.getElementById('vig').classList.toggle('fx-off', !fxOn);
bFx.classList.toggle('dim', !fxOn);
}
async function toggleFull(){
try {
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
else await document.exitFullscreen();
} catch (_) { /* sandboxed frames may refuse; the layout already fills the viewport */ }
}
bSound.addEventListener('click', e => { e.stopPropagation(); toggleSound(); });
bFx.addEventListener('click', e => { e.stopPropagation(); toggleFx(); });
bFull.addEventListener('click', e => { e.stopPropagation(); toggleFull(); });
if (!document.fullscreenEnabled) bFull.style.display = 'none';
function updateFullscreenState(){
const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight >= window.screen.height - 10);
document.body.classList.toggle("in-fullscreen", isFull);
}
document.addEventListener("fullscreenchange", updateFullscreenState);
document.addEventListener("webkitfullscreenchange", updateFullscreenState);
window.addEventListener("resize", updateFullscreenState);
/* ---------- update ---------------------------------------------------- */
function fire(){
const cx = player.x + player.w/2;
const mk = (x,vx) => shots.push({ x, y:player.y-6, w:4, h:16, vx, vy:-1150 });
if (player.weapon === 2){ mk(cx-2,-190); mk(cx-2,0); mk(cx-2,190); }
else mk(cx-2,0);
player.cool = player.weapon === 1 ? 0.11 : 0.26;
Snd.shoot();
}
function killPlayer(){
G.lives--;
player.inv = 2.6; player.weapon = 0; player.wt = 0;
burst(player.x+player.w/2, player.y+player.h/2, 46, C.hot, 1.5);
shake = 22; glitch = 0.5; Snd.hurt();
bombs.length = 0;
if (G.lives <= 0){
G.state = 'over'; G.timer = 0; Snd.ufoStop(); Snd.over();
G.best = Math.max(G.best, G.score);
} else {
G.state = 'dead'; G.timer = 1.1;
}
}
function update(dt){
G.t += dt; G.anim += dt;
shake = Math.max(0, shake - dt*60);
glitch = Math.max(0, glitch - dt);
G.surge = Math.max(0, G.surge - dt);
/* stars drift always, even on menus */
for (const s of stars){
s.y += dt * (0.012 + s.z*0.05) * (G.state==='play'?1:0.4);
if (s.y > 1) { s.y -= 1; s.x = Math.random(); }
}
for (let i=parts.length-1;i>=0;i--){
const p = parts[i];
p.x += p.vx*dt; p.y += p.vy*dt;
p.vx *= 1 - 1.6*dt; p.vy = p.vy*(1-1.6*dt) + 240*dt;
p.life -= dt;
if (p.life <= 0) parts.splice(i,1);
}
if (G.state === 'dead'){
G.timer -= dt;
if (G.timer <= 0){ player.x = V.w/2 - player.w/2; G.state = 'play'; }
return;
}
if (G.state === 'wave'){
G.timer -= dt;
if (G.timer <= 0){ buildWave(); G.state = 'play'; }
return;
}
if (G.state !== 'play') return;
/* --- player --- */
const acc = 5200, maxV = 640;
let want = 0;
if (keys['ArrowLeft'] || keys['a']) want -= 1;
if (keys['ArrowRight'] || keys['d']) want += 1;
if (touchX !== null){
const target = clamp(touchX - player.w/2, 0, V.w - player.w);
const d = target - player.x;
want = clamp(d / 40, -1, 1);
}
player.vx += want * acc * dt;
player.vx *= Math.pow(0.0009, dt); // frame-rate independent drag
player.vx = clamp(player.vx, -maxV, maxV);
player.x = clamp(player.x + player.vx*dt, 0, V.w - player.w);
if (player.x <= 0 || player.x >= V.w - player.w) player.vx = 0;
if (player.inv > 0) player.inv -= dt;
if (player.wt > 0){ player.wt -= dt; if (player.wt <= 0) player.weapon = 0; }
player.cool -= dt;
if ((keys[' '] || touchFire) && player.cool <= 0) fire();
/* --- player shots --- */
for (let i=shots.length-1;i>=0;i--){
const s = shots[i];
s.x += s.vx*dt; s.y += s.vy*dt;
if (s.y < -20 || s.x < -20 || s.x > V.w+20){ shots.splice(i,1); continue; }
const hitB = bunkerHit(s.x + s.w/2 - s.vx*dt, s.y - s.vy*dt,
s.x + s.w/2, s.y, BLOB_SHOT);
if (hitB){
burst(hitB.x, hitB.y, 5, C.violet, 0.45);
shots.splice(i,1); continue;
}
let gone = false;
if (ufo && s.y < ufo.y + ufo.h && s.y > ufo.y &&
s.x > ufo.x && s.x < ufo.x + ufo.w){
G.score += ufo.pts; burst(ufo.x+ufo.w/2, ufo.y+ufo.h/2, 34, C.amber, 1.3);
shake = 14; glitch = 0.35; G.surge = 3.2;
Snd.kill(); Snd.ufoStop(); ufo = null; shots.splice(i,1); continue;
}
for (const e of enemies){
if (!e.alive) continue;
const p = alienPos(e);
if (s.x < p.x+p.w && s.x+s.w > p.x && s.y < p.y+p.h && s.y+s.h > p.y){
e.alive = false;
G.score += TYPES[e.type].pts * (G.surge>0?2:1);
burst(p.x+p.w/2, p.y+p.h/2, 14, TYPES[e.type].col);
shake = Math.max(shake, 5); Snd.kill();
if (Math.random() < 0.065) drops.push({
x:p.x+p.w/2-13, y:p.y+p.h/2, w:26, h:26, vy:150,
kind: Math.random()<0.08 ? 'life' : pick(['rapid','triple','shield'])
});
shots.splice(i,1); gone = true; break;
}
}
if (gone) continue;
}
/* --- formation marching --- */
const alive = enemies.filter(e => e.alive);
if (alive.length === 0){
G.level++; G.score += 100 * G.level;
G.state = 'wave'; G.timer = 1.6; G.surge = 2.5;
Snd.wave(); Snd.ufoStop(); ufo = null;
if (G.level % 3 === 0 && bunkers.some(b => b.cells.some(c=>!c))) buildBunkers();
return;
}
const ratio = alive.length / 55;
const interval = clamp(lerp(0.045, 0.50, ratio) * Math.pow(0.9, G.level-1), 0.03, 0.5)
* (G.surge>0 ? 0.75 : 1);
form.step += dt;
if (form.step >= interval){
form.step -= interval;
form.frame ^= 1;
let minC = 99, maxC = -1;
for (const e of alive){ if (e.col<minC) minC=e.col; if (e.col>maxC) maxC=e.col; }
const stepX = 20;
const left = form.x + minC*gapX;
const right = form.x + maxC*gapX + alienW;
if ((form.dir > 0 && right + stepX > V.w - 36) ||
(form.dir < 0 && left - stepX < 36)){
form.dir *= -1;
form.y += 28;
Snd.drop();
razeBunkersAt(form.y + 4*gapY + alienSize(2).h);
} else {
form.x += form.dir * stepX;
Snd.step();
}
/* invasion check */
let lowest = 0;
for (const e of alive){ const p = alienPos(e); lowest = Math.max(lowest, p.y+p.h); }
if (lowest >= player.y){
G.lives = 0; killPlayer(); return;
}
}
/* --- alien fire (bottom of a random column, classic rule) --- */
const rate = (0.62 + G.level*0.12) * clamp(1.4 - ratio*0.6, 0.7, 1.4);
if (Math.random() < rate * dt){
const cols = [...new Set(alive.map(e=>e.col))];
const col = pick(cols);
let low = null;
for (const e of alive) if (e.col === col && (!low || e.row > low.row)) low = e;
if (low){
const p = alienPos(low);
bombs.push({ x:p.x+p.w/2-4, y:p.y+p.h, w:8, h:20,
vy: 300 + G.level*22, kind: randi(0,3) });
}
}
for (let i=bombs.length-1;i>=0;i--){
const b = bombs[i];
b.y += b.vy*dt;
if (b.y > V.h+20){ bombs.splice(i,1); continue; }
const hitB = bunkerHit(b.x + b.w/2, b.y + b.h - b.vy*dt,
b.x + b.w/2, b.y + b.h, BLOB_BOMB);
if (hitB){
burst(hitB.x, hitB.y, 7, C.hot, 0.55);
bombs.splice(i,1); continue;
}
if (player.inv <= 0 &&
b.x < player.x+player.w && b.x+b.w > player.x &&
b.y < player.y+player.h && b.y+b.h > player.y){
bombs.splice(i,1); killPlayer(); return;
}
}
/* --- UFO --- */
ufoTimer -= dt;
if (!ufo && ufoTimer <= 0 && alive.length > 4){
const dir = Math.random()<0.5 ? 1 : -1;
const w = clamp(V.w*0.048, 58, 84);
ufo = { x: dir>0 ? -w : V.w, y:78, w, h:w*(7/16), vx:dir*230,
pts: pick([50,100,150,200,300]) };
ufoTimer = rand(18,32);
Snd.ufoStart();
}
if (ufo){
ufo.x += ufo.vx*dt;
if (ufo.x < -ufo.w-10 || ufo.x > V.w+10){ ufo = null; Snd.ufoStop(); }
}
/* --- powerup drops --- */
for (let i=drops.length-1;i>=0;i--){
const d = drops[i];
d.y += d.vy*dt;
if (d.y > V.h+30){ drops.splice(i,1); continue; }
if (d.x < player.x+player.w && d.x+d.w > player.x &&
d.y < player.y+player.h && d.y+d.h > player.y){
if (d.kind === 'rapid') { player.weapon = 1; player.wt = 11; }
if (d.kind === 'triple') { player.weapon = 2; player.wt = 11; }
if (d.kind === 'shield') { player.inv = Math.max(player.inv, 7); }
if (d.kind === 'life') { G.lives = Math.min(6, G.lives+1); }
burst(d.x+13, d.y+13, 16, C.amber, 0.8);
Snd.power(); drops.splice(i,1);
}
}
G.best = Math.max(G.best, G.score);
}
/* ---------- render ---------------------------------------------------- */
function text(str,x,y,size,color,align='left',weight=700){
ctx.font = `${weight} ${size}px ui-monospace, Menlo, Consolas, "Courier New", monospace`;
ctx.textAlign = align; ctx.textBaseline = 'alphabetic';
ctx.shadowColor = color; ctx.shadowBlur = size*0.55;
ctx.fillStyle = color; ctx.fillText(str,x,y);
ctx.shadowBlur = 0;
}
function surgeCol(base, seed){
return G.surge > 0 ? SURGE[Math.floor(G.t*11 + seed) % SURGE.length] : base;
}
function render(){
/* backdrop fills the entire canvas — no bars, whatever the aspect ratio */
const g = ctx.createRadialGradient(cv.width/2, cv.height*0.42, 0,
cv.width/2, cv.height*0.42, cv.height*0.95);
g.addColorStop(0, C.bg0); g.addColorStop(1, C.bg1);
ctx.fillStyle = g; ctx.fillRect(0,0,cv.width,cv.height);
const sx = shake ? rand(-shake,shake) : 0;
const sy = shake ? rand(-shake,shake) : 0;
ctx.setTransform(view.scale,0,0,view.scale, view.ox + sx*view.scale, view.oy + sy*view.scale);
/* stars */
for (const s of stars){
ctx.globalAlpha = 0.25 + s.z*0.7;
ctx.fillStyle = s.z > 0.75 ? C.ink : '#ffffff';
ctx.fillRect(s.x*V.w, s.y*V.h, s.s, s.s);
}
ctx.globalAlpha = 1;
/* horizon line — the one piece of set dressing */
ctx.strokeStyle = 'rgba(157,92,255,.28)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, player.y+player.h+26); ctx.lineTo(V.w, player.y+player.h+26); ctx.stroke();
if (G.state !== 'menu'){
/* bunkers */
for (const b of bunkers){
const col = surgeCol(C.violet, 3);
if (b.dirty || b.col !== col) paintBunker(b, col);
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.28;
ctx.drawImage(b.cn, b.x-2, b.y-2, b.w+4, b.h+4); ctx.restore();
ctx.drawImage(b.cn, b.x, b.y, b.w, b.h);
}
/* aliens */
for (const e of enemies){
if (!e.alive) continue;
const p = alienPos(e);
const T = TYPES[e.type];
blit(T.sp[form.frame], surgeCol(T.col, e.col+e.row*3), p.x, p.y, p.w, p.h, 0.3);
}
/* ufo */
if (ufo){
const c = SURGE[Math.floor(G.t*14)%SURGE.length];
blit(SP.ufo, c, ufo.x, ufo.y, ufo.w, ufo.h, 0.55);
}
/* bombs */
const bf = Math.floor(G.anim*14)%2;
for (const b of bombs){
const s = (bf ? SP.bombAlt : SP.bomb)[b.kind];
blit(s, C.hot, b.x, b.y, b.w, b.h, 0.35);
}
/* player shots */
for (const s of shots){
ctx.save(); ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = C.ink; ctx.globalAlpha = 0.45;
ctx.fillRect(s.x-3, s.y-4, s.w+6, s.h+10);
ctx.restore();
ctx.fillStyle = C.white; ctx.fillRect(s.x, s.y, s.w, s.h);
}
/* drops */
for (const d of drops){
const col = d.kind==='rapid' ? C.ink : d.kind==='triple' ? C.hot
: d.kind==='shield' ? C.amber : C.acid;
const pulse = 0.6 + 0.4*Math.sin(G.t*9);
ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.globalAlpha = 0.35*pulse;
ctx.fillStyle = col; ctx.fillRect(d.x-5, d.y-5, d.w+10, d.h+10); ctx.restore();
ctx.strokeStyle = col; ctx.lineWidth = 2.5;
ctx.strokeRect(d.x, d.y, d.w, d.h);
const ch = d.kind==='rapid'?'R':d.kind==='triple'?'W':d.kind==='shield'?'S':'+';
text(ch, d.x+d.w/2, d.y+d.h*0.74, 17, col, 'center');
}
/* player */
if (G.state !== 'dead' && G.state !== 'over'){
const blink = player.inv > 0 && player.inv < 1.2 && Math.floor(G.t*14)%2 === 0;
if (!blink){
blit(SP.ship, player.inv>0 ? C.amber : C.ink, player.x, player.y, player.w, player.h, 0.4);
/* thruster */
const th = 6 + Math.abs(player.vx)/70 + rand(0,5);
ctx.save(); ctx.globalCompositeOperation='lighter'; ctx.globalAlpha=0.8;
ctx.fillStyle = C.hot;
ctx.fillRect(player.x+player.w*0.38, player.y+player.h, player.w*0.24, th);
ctx.restore();
}
if (player.inv > 0){
ctx.strokeStyle = `rgba(255,176,32,${0.15+0.15*Math.sin(G.t*11)})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(player.x+player.w/2, player.y+player.h/2, player.w*0.85, player.h*1.5, 0, 0, 6.29);
ctx.stroke();
}
}
}
/* particles */
ctx.save(); ctx.globalCompositeOperation = 'lighter';
for (const p of parts){
ctx.globalAlpha = clamp(p.life/p.max,0,1);
ctx.fillStyle = p.col;
ctx.fillRect(p.x, p.y, p.sz, p.sz);
}
ctx.restore(); ctx.globalAlpha = 1;
drawHud();
ctx.setTransform(1,0,0,1,0,0);
if (fxOn && glitch > 0) glitchPass();
}
function drawHud(){
const pad = 28;
if (G.state === 'menu'){
const cx = V.w/2;
ctx.save();
ctx.globalAlpha = 0.9;
text('SIGNAL', cx, V.h*0.36, 92, C.hot, 'center', 800);
text('LOST', cx, V.h*0.36+86, 92, C.ink, 'center', 800);
ctx.restore();
text('they came down through the carrier wave', cx, V.h*0.36+140, 20, C.dim, 'center', 500);
const on = Math.floor(G.t*2)%2 === 0;
const focused = document.hasFocus();
text(isTouch ? 'TAP TO BEGIN'
: (focused ? 'PRESS SPACE TO BEGIN' : 'CLICK HERE, THEN PRESS SPACE'),
cx, V.h*0.62, 26, on ? C.acid : 'rgba(168,255,54,.28)', 'center');
text(isTouch ? 'DRAG TO STEER · HOLD TO FIRE'
: '← → / A D MOVE SPACE FIRE P PAUSE F FULLSCREEN',
cx, V.h*0.62+52, 17, C.dim, 'center', 500);
if (G.best) text('BEST ' + G.best, cx, V.h*0.62+92, 17, C.amber, 'center');
return;
}
text('SCORE', pad, 44, 15, C.dim, 'left', 500);
text(String(G.score).padStart(5,'0'), pad, 78, 32, C.ink);
text('WAVE', V.w/2, 44, 15, C.dim, 'center', 500);
text(String(G.level).padStart(2,'0'), V.w/2, 78, 32, surgeCol(C.violet,1), 'center');
text('BEST', V.w-pad, 44, 15, C.dim, 'right', 500);
text(String(G.best).padStart(5,'0'), V.w-pad, 78, 32, C.amber, 'right');
/* lives as ship icons */
for (let i=0;i<G.lives;i++){
blit(SP.ship, C.ink, pad + i*40, V.h-46, 30, 16, 0.25);
}
/* weapon timer */
if (player.wt > 0){
const w = 180, x = V.w-pad-w, y = V.h-42;
ctx.fillStyle = 'rgba(142,246,255,.15)'; ctx.fillRect(x,y,w,7);
ctx.fillStyle = player.weapon===1 ? C.ink : C.hot;
ctx.fillRect(x,y,w*(player.wt/11),7);
text(player.weapon===1?'RAPID':'SPREAD', V.w-pad, y-10, 14,
player.weapon===1?C.ink:C.hot, 'right', 600);
}
if (G.surge > 0){
text('SURGE ×2', V.w/2, 116, 20, SURGE[Math.floor(G.t*13)%SURGE.length], 'center');
}
if (G.state === 'wave'){
text('WAVE ' + G.level, V.w/2, V.h/2, 64, C.acid, 'center', 800);
}
if (G.state === 'pause'){
ctx.fillStyle = 'rgba(4,1,10,.72)'; ctx.fillRect(0,0,V.w,V.h);
text('PAUSED', V.w/2, V.h/2, 58, C.ink, 'center', 800);
text('press P to resume', V.w/2, V.h/2+46, 19, C.dim, 'center', 500);
}
if (G.state === 'over'){
ctx.fillStyle = 'rgba(4,1,10,.7)'; ctx.fillRect(0,0,V.w,V.h);
text('SIGNAL LOST', V.w/2, V.h/2-34, 68,
SURGE[Math.floor(G.t*6)%SURGE.length], 'center', 800);
text('SCORE ' + G.score, V.w/2, V.h/2+26, 28, C.white, 'center');
text('WAVE ' + G.level + ' · BEST ' + G.best, V.w/2, V.h/2+64, 19, C.dim, 'center', 500);
const on = Math.floor(G.t*2)%2 === 0;
text(isTouch ? 'TAP TO RETRY'
: (document.hasFocus() ? 'PRESS SPACE TO RETRY' : 'CLICK HERE, THEN PRESS SPACE'),
V.w/2, V.h/2+124, 24, on ? C.acid : 'rgba(168,255,54,.25)', 'center');
}
}
/* short-lived RGB/slice tear — only fires on real events, never every frame */
function glitchPass(){
const a = clamp(glitch/0.5, 0, 1);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i=0;i<5;i++){
const y = randi(0, cv.height-4);
const h = randi(4, Math.max(8, cv.height*0.09));
const dx = (Math.random()-0.5) * 90 * a * view.dpr;
ctx.globalAlpha = 0.4*a;
ctx.drawImage(cv, 0, y, cv.width, h, dx, y, cv.width, h);
}
ctx.globalAlpha = 0.16*a;
ctx.fillStyle = C.hot;
ctx.fillRect(0, randi(0,cv.height), cv.width, randi(2,10));
ctx.restore();
}
/* ---------- loop ------------------------------------------------------ */
let last = performance.now();
function frame(now){
const dt = Math.min(0.05, (now - last)/1000);
last = now;
update(dt);
render();
requestAnimationFrame(frame);
}
layout();
buildBunkers();
buildWave();
requestAnimationFrame(frame);
</script>
</body>
</html>