My own take on SK-111 'Star Killer', a 1995 freeware Windows 95 shooter by W. Mike Hare. Play it at https://games.503mail.com/starkiller/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.
What's in it
Your ship sits at the bottom while waves of asteroids (big ones split in two), green fighters, orange dive-bombers and purple saucers come down. The ship has two power bars. GUNS: shooting uses it, and it slowly recharges. SHIELD: hits use it, and when it's empty the next hit costs a ship. You can move power between them. Green pods add power to both, and every wave cleared gives a bonus. Sprites are shaded with gradients to suggest the original's ray-traced look, and the sound is synthesized with Web Audio. Auto fire (on by default on touch screens) fires slowly but never runs the guns dry. Easy makes everything slower and gentler. The high score is saved in the browser.
Controls
Keyboard: ◀ ▶ fly, Space fire, hold ▲ to move power to the shield, hold ▼ to move it to the guns, P pause, M sound. Touch: a button pad (SHIELD, ◀, FIRE, ▶, GUNS, pause), and dragging a finger on the play area steers the ship and fires.
Rebuild, step by step
Star Killer is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Star Killer tile.
1. Make the game's folder
Proxmox host shell (Proxmox web UI → pve → Shell):
mkdir -p /root/nibbles/starkiller
2. Create the game file
Paste this into the Proxmox host shell. It writes /root/nibbles/starkiller/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):
cat > /root/nibbles/starkiller/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Star Killer</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="Star Killer">
<meta property="og:image" content="https://games.503mail.com/starkiller/icon-512.png">
<style>
html,body{margin:0;height:100%;background:#000;color:#aaa;font-family:"Lucida Console","Courier New",monospace}
body{display:flex;align-items:center;justify-content:center;overflow:hidden}
:root{--ar:.75;--padh:30vh}
#wrap{position:relative;width:min(100vw,calc(100vh * var(--ar)));aspect-ratio:var(--ar)}
canvas{display:block;width:100%;height:100%;touch-action:none}
#setup{position:absolute;inset:0;background:radial-gradient(#101040,#000);color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2.4vmin;font-size:clamp(12px,2.6vmin,22px);text-align:center;padding:16px;box-sizing:border-box}
#setup.hidden{display:none}
#setup h1{margin:0;color:#FFFF55;letter-spacing:.3em;font-size:1.8em}
#setup .sub{color:#55FFFF;letter-spacing:.3em;margin-top:-1.5vmin}
#setup .row{display:flex;gap:1em;align-items:center;flex-wrap:wrap;justify-content:center}
#setup button{font:inherit;cursor:pointer;border:0}
#setup #go{background:#FF5555;color:#fff;font-size:1.2em;padding:.4em 2em}
#setup .help{color:#AAAAAA;font-size:.85em;line-height:1.6}
#setup kbd{color:#FFFF55}
#setup a{color:#55FFFF}
/* on-screen buttons, touch screens only */
body.touch{flex-direction:column}
body.touch #wrap{width:min(100vw,calc((100vh - var(--padh)) * var(--ar)))}
#pad{display:none;--b:min(18vw,calc(var(--padh) / 2.3));gap:calc(var(--b) * .12);margin-top:1.5vh;
grid-template-columns:repeat(5,var(--b));grid-template-rows:repeat(2,var(--b))}
@media (orientation:landscape){
body.touch{flex-direction:row;gap:2vw}
body.touch #wrap{width:min(60vw,calc(96vh * var(--ar)))}
#pad{--b:min(11vw,22vh);margin-top:0;grid-template-columns:repeat(3,var(--b));grid-template-rows:repeat(3,var(--b))}
#pad .l{grid-area:3/1} #pad .r{grid-area:3/3} #pad .f{grid-area:2/2} #pad .ts{grid-area:1/1} #pad .tw{grid-area:1/3} #pad .p{grid-area:3/2}
}
body.touch #pad{display:grid}
#pad button{background:#0000AA;color:#FFFF55;border:3px solid #55FFFF;border-radius:18%;
font:bold calc(var(--b) * .4)/1 "Lucida Console","Courier New",monospace;padding:0;
touch-action:none;user-select:none;-webkit-user-select:none}
#pad button.hit,#pad button.held{background:#5555FF}
#pad .f{background:#AA0000;border-color:#FF5555;color:#fff;font-size:calc(var(--b) * .22)}
#pad .ts,#pad .tw{font-size:calc(var(--b) * .17);line-height:1.3}
#pad .ts{color:#55FFFF} #pad .tw{color:#FF5555;border-color:#FF5555}
#pad .p{color:#fff;border-color:#AAAAAA;font-size:calc(var(--b) * .28)}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="900" height="1200"></canvas>
<div id="setup">
<h1>STAR KILLER</h1>
<div class="sub">SK-111</div>
<div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
<label><input type="checkbox" id="auto"> Auto fire</label>
<label><input type="checkbox" id="easy"> Easy</label></div>
<button id="go">START</button>
<div id="hi"></div>
<div class="help">
<kbd>← →</kbd> fly · <kbd>Space</kbd> fire · <kbd>P</kbd> pause · <kbd>M</kbd> sound<br>
Hold <kbd>↑</kbd> to move power to the <span style="color:#55FFFF">shield</span>,
hold <kbd>↓</kbd> to move it to the <span style="color:#FF5555">guns</span>.<br>
Shooting uses gun power. Getting hit uses shield power.<br>
Grab the green pods for more power. Don't let the shield run out!
</div>
<a href="/">Other games</a>
</div>
</div>
<div id="pad" aria-label="Game buttons">
<button class="ts" data-a="toShield" aria-label="Power to shield">▲<br>SHIELD</button>
<button class="l" data-a="left" aria-label="Left">◀</button>
<button class="f" data-a="fire" aria-label="Fire">FIRE</button>
<button class="r" data-a="right" aria-label="Right">▶</button>
<button class="tw" data-a="toGuns" aria-label="Power to guns">▼<br>GUNS</button>
<span></span><span></span><button class="p" data-a="pause" aria-label="Pause or continue">II</button><span></span><span></span>
</div>
<script>
(() => {
const LW = 300, LH = 400; // logical canvas size (drawn at 3x)
const cv = document.getElementById('c'), g = cv.getContext('2d');
g.scale(3, 3);
const setupEl = document.getElementById('setup');
const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
// ---- settings + high score (per-viewer convenience only) ----
let cfg = { sound:true, auto:isTouch, easy:false, high:0 };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('starkiller') || '{}')); } catch (e) {}
const saveCfg = () => { try { localStorage.setItem('starkiller', JSON.stringify(cfg)); } catch (e) {} };
// ---- sound: synthesized zaps and booms ----
let actx = null, noiseBuf = null;
function audio() {
if (!cfg.sound) return null;
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
if (actx.state === 'suspended') actx.resume();
return actx;
}
function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
const a = audio(); if (!a) return;
const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
}
function boom(dur = 0.4, vol = 0.25, cutoff = 900) {
const a = audio(); if (!a) return;
if (!noiseBuf) {
noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate);
const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const t = a.currentTime, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(60, t + dur);
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.001, t + dur);
s.connect(f).connect(v).connect(a.destination); s.start(t); s.stop(t + dur);
}
const SFX = {
shot: () => sweep(1400, 300, 0.09, 'square', 0.025),
eshot: () => sweep(500, 200, 0.12, 'sawtooth', 0.02),
hit: () => boom(0.15, 0.12, 2500),
kill: () => boom(0.35, 0.22, 1200),
big: () => boom(0.8, 0.35, 700),
shield: () => sweep(200, 90, 0.25, 'triangle', 0.15),
pod: () => { sweep(500, 1500, 0.15, 'triangle', 0.08); setTimeout(() => sweep(800, 2000, 0.15, 'triangle', 0.08), 90); },
empty: () => sweep(120, 100, 0.05, 'square', 0.02),
wave: () => [0, 120, 240].forEach((d, i) => setTimeout(() => sweep(400 * (i + 2), 400 * (i + 2), 0.12, 'square', 0.03), d)),
};
let xferTick = 0;
// ---- starfield (three layers of parallax) ----
const stars = Array.from({ length: 90 }, () => ({ x: Math.random() * LW, y: Math.random() * LH, z: 1 + Math.floor(Math.random() * 3) }));
// ---- pre-rendered "ray-traced" sprites (shaded with gradients once, then reused) ----
function sprite(w, h, fn) { const c = document.createElement('canvas'); c.width = w * 3; c.height = h * 3; const x = c.getContext('2d'); x.scale(3, 3); fn(x); return c; }
function ball(x, cx, cy, r, light, mid, dark) {
const gr = x.createRadialGradient(cx - r * .35, cy - r * .4, r * .1, cx, cy, r);
gr.addColorStop(0, light); gr.addColorStop(.45, mid); gr.addColorStop(1, dark);
x.fillStyle = gr; x.beginPath(); x.arc(cx, cy, r, 0, 7); x.fill();
}
const SPR = {
ship: sprite(24, 26, x => {
const body = x.createLinearGradient(0, 0, 24, 0);
body.addColorStop(0, '#334'); body.addColorStop(.45, '#eef'); body.addColorStop(1, '#445');
x.fillStyle = '#c33'; x.beginPath(); x.moveTo(12, 6); x.lineTo(24, 22); x.lineTo(16, 20); x.lineTo(8, 20); x.lineTo(0, 22); x.closePath(); x.fill();
x.fillStyle = body; x.beginPath(); x.moveTo(12, 0); x.lineTo(17, 20); x.lineTo(12, 24); x.lineTo(7, 20); x.closePath(); x.fill();
ball(x, 12, 11, 3, '#fff', '#5cf', '#036');
}),
fighter: sprite(20, 16, x => {
x.fillStyle = '#383'; x.beginPath(); x.moveTo(0, 2); x.lineTo(10, 10); x.lineTo(20, 2); x.lineTo(16, 12); x.lineTo(4, 12); x.closePath(); x.fill();
ball(x, 10, 8, 6, '#efe', '#5c5', '#030');
ball(x, 10, 10, 2.2, '#fff', '#f55', '#600');
}),
saucer: sprite(26, 14, x => {
const gr = x.createLinearGradient(0, 4, 0, 13); gr.addColorStop(0, '#fdf'); gr.addColorStop(.5, '#a3a'); gr.addColorStop(1, '#303');
x.fillStyle = gr; x.beginPath(); x.ellipse(13, 9, 13, 4.5, 0, 0, 7); x.fill();
ball(x, 13, 6, 5, '#fff', '#8ef', '#035');
x.fillStyle = '#ff5'; [5, 13, 21].forEach(px => { x.beginPath(); x.arc(px, 10, 1.2, 0, 7); x.fill(); });
}),
diver: sprite(18, 18, x => {
x.fillStyle = '#a60'; x.beginPath(); x.moveTo(9, 18); x.lineTo(18, 0); x.lineTo(9, 6); x.lineTo(0, 0); x.closePath(); x.fill();
ball(x, 9, 8, 5, '#ffe', '#fa3', '#520');
}),
pod: sprite(12, 12, x => ball(x, 6, 6, 5.5, '#fff', '#5f5', '#050')),
};
function rockSprite(r) {
const n = 11, pts = Array.from({ length: n }, (_, i) => { const a = i / n * Math.PI * 2, rr = r * (0.75 + Math.random() * 0.25); return [Math.cos(a) * rr, Math.sin(a) * rr]; });
return sprite(r * 2 + 2, r * 2 + 2, x => {
x.translate(r + 1, r + 1);
const gr = x.createRadialGradient(-r * .35, -r * .4, r * .1, 0, 0, r);
gr.addColorStop(0, '#ddc'); gr.addColorStop(.5, '#875'); gr.addColorStop(1, '#221');
x.fillStyle = gr; x.beginPath(); pts.forEach(([px, py], i) => i ? x.lineTo(px, py) : x.moveTo(px, py)); x.closePath(); x.fill();
x.fillStyle = 'rgba(0,0,0,.25)';
for (let i = 0; i < 3; i++) { x.beginPath(); x.arc((Math.random() - .5) * r, (Math.random() - .5) * r, r * (.12 + Math.random() * .12), 0, 7); x.fill(); }
});
}
const ROCKS = { 14: [0, 1, 2].map(() => rockSprite(14)), 8: [0, 1, 2].map(() => rockSprite(8)) };
// ---- game state ----
let mode = 'setup', ship, shots = [], foes = [], ebullets = [], booms = [], pods = [], popups = [], score, lives, wave, waveLeft, spawnT, fireT, flashT, msgT, newHigh;
const held = { left:false, right:false, fire:false, toShield:false, toGuns:false };
function newGame() {
ship = { x: LW / 2, y: LH - 34, guns: 100, shield: 100, inv: 0, dead: 0 };
shots = []; foes = []; ebullets = []; booms = []; pods = []; popups = [];
score = 0; lives = 3; wave = 0; fireT = 0; flashT = 0; newHigh = false;
nextWave(); mode = 'play';
}
function nextWave() {
wave++; waveLeft = 10 + wave * 4; spawnT = 1.5; msgT = 2;
if (wave > 1) SFX.wave();
}
const diff = () => (cfg.easy ? 0.6 : 1) * (1 + (wave - 1) * 0.12);
function spawnFoe() {
const r = Math.random(), x = 20 + Math.random() * (LW - 40);
const kinds = wave < 2 ? ['rock', 'rock', 'fighter'] : wave < 4 ? ['rock', 'fighter', 'fighter', 'diver'] : ['rock', 'fighter', 'diver', 'diver', 'saucer'];
const k = kinds[Math.floor(r * kinds.length)], d = diff();
if (k === 'rock') {
const big = Math.random() < .6;
foes.push({ k, x, y: -16, r: big ? 14 : 8, hp: big ? 3 : 1, vx: (Math.random() - .5) * 30, vy: (25 + Math.random() * 30) * d,
spr: ROCKS[big ? 14 : 8][Math.floor(Math.random() * 3)], rot: 0, vr: (Math.random() - .5) * 3, pts: big ? 50 : 20 });
} else if (k === 'fighter') {
foes.push({ k, x, y: -10, r: 9, hp: 2, x0: x, t: Math.random() * 6, vy: 30 * d, amp: 30 + Math.random() * 40, fire: 1 + Math.random() * 2, pts: 100 });
} else if (k === 'diver') {
foes.push({ k, x, y: -10, r: 8, hp: 1, vx: 0, vy: 40 * d, dive: false, pts: 150 });
} else {
foes.push({ k, x: Math.random() < .5 ? -14 : LW + 14, y: 40 + Math.random() * 60, r: 12, hp: 5, dir: 0, fire: 1, pts: 300 });
const s = foes[foes.length - 1]; s.dir = s.x < 0 ? 1 : -1;
}
}
function explode(x, y, size, color) {
booms.push({ x, y, t: 0, size, color });
for (let i = 0; i < size * 1.2; i++) {
const a = Math.random() * 7, v = 20 + Math.random() * size * 6;
booms.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, t: 0, life: .4 + Math.random() * .5, spark: true, color });
}
}
function damage(n) {
if (ship.inv > 0 || ship.dead > 0) return;
if (cfg.easy) n *= 0.6;
flashT = 0.15;
if (ship.shield >= n) { ship.shield -= n; SFX.shield(); ship.inv = 0.3; return; }
// shield is gone: lose a ship
ship.shield = 0; SFX.big(); explode(ship.x, ship.y, 20, '#fa5');
lives--; ship.dead = 2;
if (lives <= 0) setTimeout(gameOver, 1600);
}
function hitFoe(f, n) {
f.hp -= n;
if (f.hp > 0) { SFX.hit(); f.flash = 0.08; return; }
f.gone = true; score += f.pts; SFX.kill();
popups.push({ x: f.x, y: f.y, t: 0, s: '' + f.pts });
explode(f.x, f.y, f.r, f.k === 'rock' ? '#ca8' : f.k === 'saucer' ? '#f8f' : '#ff5');
if (f.k === 'rock' && f.r === 14) {
for (const s of [-1, 1]) foes.push({ k:'rock', x: f.x, y: f.y, r: 8, hp: 1, vx: s * (30 + Math.random() * 30), vy: f.vy * 1.1,
spr: ROCKS[8][Math.floor(Math.random() * 3)], rot: 0, vr: (Math.random() - .5) * 4, pts: 20, child: true });
}
if (Math.random() < (f.k === 'saucer' ? 1 : cfg.easy ? .2 : .12)) pods.push({ x: f.x, y: f.y, vy: 45 });
}
function gameOver() {
mode = 'over';
newHigh = score > cfg.high;
if (newHigh) { cfg.high = score; saveCfg(); }
}
// ---- update ----
function update(dt) {
for (const s of stars) { s.y += s.z * s.z * 8 * dt; if (s.y > LH) { s.y = 0; s.x = Math.random() * LW; } }
booms.forEach(b => { b.t += dt; if (b.spark) { b.x += b.vx * dt; b.y += b.vy * dt; } });
booms = booms.filter(b => b.t < (b.spark ? b.life : .5));
popups.forEach(p => { p.t += dt; p.y -= 20 * dt; }); popups = popups.filter(p => p.t < .8);
if (mode !== 'play') return;
// ship
if (ship.dead > 0) {
ship.dead -= dt;
if (ship.dead <= 0 && lives > 0) { ship.x = LW / 2; ship.guns = 100; ship.shield = 100; ship.inv = 2; ebullets = []; }
} else {
const dir = (held.right ? 1 : 0) - (held.left ? 1 : 0);
ship.x = Math.max(12, Math.min(LW - 12, ship.x + dir * 170 * dt));
if (ship.drag != null) ship.x += (ship.drag - ship.x) * Math.min(1, dt * 14);
ship.inv = Math.max(0, ship.inv - dt);
ship.guns = Math.min(100, ship.guns + 10 * dt); // guns recharge slowly by themselves
// power transfer, 40 units a second
const amt = 40 * dt;
if (held.toShield && ship.guns > 0 && ship.shield < 100) { const m = Math.min(amt, ship.guns, 100 - ship.shield); ship.guns -= m; ship.shield += m; xfer(); }
if (held.toGuns && ship.shield > 0 && ship.guns < 100) { const m = Math.min(amt, ship.shield, 100 - ship.guns); ship.shield -= m; ship.guns += m; xfer(); }
fireT -= dt;
// Holding fire shoots fast and drains the guns; auto fire is slower and never runs them dry.
if ((held.fire || cfg.auto) && fireT <= 0) {
const cost = held.fire ? (cfg.easy ? 1.5 : 3) : 1.5;
if (ship.guns >= cost) {
ship.guns -= cost; fireT = held.fire ? 0.15 : 0.3; SFX.shot();
shots.push({ x: ship.x - 6, y: ship.y - 4 }, { x: ship.x + 6, y: ship.y - 4 });
} else if (held.fire) { fireT = 0.25; SFX.empty(); }
}
}
shots.forEach(s => s.y -= 340 * dt); shots = shots.filter(s => s.y > -10);
// spawning
spawnT -= dt;
if (waveLeft > 0 && spawnT <= 0) { spawnFoe(); waveLeft--; spawnT = Math.max(.35, (1.4 - wave * .08)) / (cfg.easy ? .7 : 1); }
if (waveLeft <= 0 && !foes.length) { score += 500 * wave; popups.push({ x: LW / 2, y: LH / 2 + 20, t: 0, s: 'BONUS ' + 500 * wave }); nextWave(); }
// foes
const d = diff();
for (const f of foes) {
f.flash = Math.max(0, (f.flash || 0) - dt);
if (f.k === 'rock') { f.x += f.vx * dt; f.y += f.vy * dt; f.rot += f.vr * dt; if (f.x < f.r || f.x > LW - f.r) f.vx *= -1; }
else if (f.k === 'fighter') { f.t += dt; f.y += f.vy * dt; f.x = Math.max(10, Math.min(LW - 10, f.x0 + Math.sin(f.t * 1.6) * f.amp)); }
else if (f.k === 'diver') {
if (!f.dive && f.y > 70 + Math.random() * 40) { f.dive = true; f.vx = Math.sign(ship.x - f.x) * 60; f.vy = 150 * d; SFX.eshot(); }
f.x += f.vx * dt; f.y += f.vy * dt;
} else { f.x += f.dir * 50 * dt; if (f.x < -20 || f.x > LW + 20) f.gone = true, waveLeft++; }
if ((f.k === 'fighter' || f.k === 'saucer') && f.y > 0 && ship.dead <= 0) {
f.fire -= dt;
if (f.fire <= 0) {
f.fire = (f.k === 'saucer' ? 1 : 2.2 + Math.random() * 2) / d;
const dx = ship.x - f.x, dy = ship.y - f.y, l = Math.hypot(dx, dy), sp = 110 * Math.min(d, 1.8);
ebullets.push({ x: f.x, y: f.y + 6, vx: f.k === 'saucer' ? dx / l * sp : 0, vy: f.k === 'saucer' ? dy / l * sp : sp });
SFX.eshot();
}
}
if (f.y > LH + 20) f.gone = true;
}
for (const b of ebullets) { b.x += b.vx * dt; b.y += b.vy * dt; }
ebullets = ebullets.filter(b => b.y < LH + 5 && b.x > -5 && b.x < LW + 5 && !b.gone);
for (const p of pods) p.y += p.vy * dt;
// collisions
for (const s of shots) for (const f of foes) {
if (!f.gone && !s.gone && Math.abs(s.x - f.x) < f.r + 1 && Math.abs(s.y - f.y) < f.r + 4) { s.gone = true; hitFoe(f, 1); }
}
if (ship.dead <= 0) {
for (const f of foes) if (!f.gone && Math.hypot(f.x - ship.x, f.y - ship.y) < f.r + 9) {
damage(f.k === 'rock' ? f.r * 2 : 25); f.hp = 1; hitFoe(f, 1);
}
for (const b of ebullets) if (Math.hypot(b.x - ship.x, b.y - ship.y) < 9) { b.gone = true; damage(12); }
for (const p of pods) if (Math.hypot(p.x - ship.x, p.y - ship.y) < 14) {
p.gone = true; ship.guns = Math.min(100, ship.guns + 40); ship.shield = Math.min(100, ship.shield + 40);
score += 50; SFX.pod(); popups.push({ x: p.x, y: p.y, t: 0, s: 'POWER!' });
}
}
shots = shots.filter(s => !s.gone); foes = foes.filter(f => !f.gone);
ebullets = ebullets.filter(b => !b.gone); pods = pods.filter(p => !p.gone && p.y < LH + 10);
flashT = Math.max(0, flashT - dt); msgT = Math.max(0, msgT - dt);
}
function xfer() { if ((xferTick = (xferTick + 1) % 6) === 0) sweep(300 + Math.random() * 200, 600, .05, 'sine', .03); }
// ---- drawing ----
function text(s, x, y, color, size = 10, align = 'left') {
g.fillStyle = color; g.font = `bold ${size}px "Lucida Console","Courier New",monospace`;
g.textAlign = align; g.textBaseline = 'middle'; g.fillText(s, x, y);
}
function put(spr, x, y, rot) {
const w = spr.width / 3, h = spr.height / 3;
if (rot) { g.save(); g.translate(x, y); g.rotate(rot); g.drawImage(spr, -w / 2, -h / 2, w, h); g.restore(); }
else g.drawImage(spr, x - w / 2, y - h / 2, w, h);
}
function bar(x, y, w, v, color, label) {
text(label, x, y - 7, color, 8);
g.fillStyle = '#222'; g.fillRect(x, y, w, 7);
g.fillStyle = v < 25 && Math.floor(performance.now() / 200) % 2 ? '#fff' : color; g.fillRect(x, y, w * v / 100, 7);
g.strokeStyle = '#666'; g.lineWidth = 1; g.strokeRect(x + .5, y + .5, w - 1, 6);
}
function dialog(rows) {
const w = 200, h = rows.length * 15 + 22, x = (LW - w) / 2, y = (LH - h) / 2;
g.fillStyle = 'rgba(0,0,120,.9)'; g.fillRect(x, y, w, h);
g.strokeStyle = '#55FFFF'; g.lineWidth = 1; g.strokeRect(x + 4, y + 4, w - 8, h - 8);
rows.forEach((s, i) => text(s, LW / 2, y + 18 + i * 15, i === 0 ? '#FFFF55' : '#fff', i === 0 ? 13 : 10, 'center'));
}
function draw() {
g.fillStyle = flashT > 0 ? '#300' : '#000'; g.fillRect(0, 0, LW, LH);
for (const s of stars) { g.fillStyle = ['', '#446', '#88a', '#fff'][s.z]; g.fillRect(s.x, s.y, s.z === 3 ? 1.5 : 1, s.z === 3 ? 1.5 : 1); }
if (!ship) { text('S T A R K I L L E R', LW / 2, LH / 2, '#FFFF55', 14, 'center'); return; }
for (const p of pods) { put(SPR.pod, p.x, p.y); }
for (const f of foes) {
if (f.flash) g.filter = 'brightness(3)';
if (f.k === 'rock') put(f.spr, f.x, f.y, f.rot); else put(SPR[f.k], f.x, f.y);
g.filter = 'none';
}
g.fillStyle = '#ff5'; for (const s of shots) { g.fillRect(s.x - .75, s.y - 5, 1.5, 7); }
for (const b of ebullets) { g.fillStyle = '#f55'; g.beginPath(); g.arc(b.x, b.y, 2.4, 0, 7); g.fill(); g.fillStyle = '#fff'; g.fillRect(b.x - .7, b.y - .7, 1.4, 1.4); }
if (ship.dead <= 0 && !(ship.inv > 0.3 && Math.floor(ship.inv * 10) % 2)) {
// engine flame + shield bubble
g.fillStyle = Math.random() < .5 ? '#fa3' : '#ff5';
g.beginPath(); g.moveTo(ship.x - 3, ship.y + 11); g.lineTo(ship.x, ship.y + 15 + Math.random() * 5); g.lineTo(ship.x + 3, ship.y + 11); g.fill();
put(SPR.ship, ship.x, ship.y);
if (ship.shield > 0) {
g.strokeStyle = `rgba(85,255,255,${.12 + ship.shield / 100 * .35 + (ship.inv > 0 && ship.inv <= .3 ? .4 : 0)})`;
g.lineWidth = 1.2; g.beginPath(); g.arc(ship.x, ship.y + 1, 16, 0, 7); g.stroke();
}
}
for (const b of booms) {
if (b.spark) { g.globalAlpha = 1 - b.t / b.life; g.fillStyle = b.color; g.fillRect(b.x - 1, b.y - 1, 2, 2); }
else {
const k = b.t / .5, r = b.size * (.5 + k * 1.3);
const gr = g.createRadialGradient(b.x, b.y, 0, b.x, b.y, r);
gr.addColorStop(0, `rgba(255,255,220,${1 - k})`); gr.addColorStop(.4, `rgba(255,160,40,${.8 * (1 - k)})`); gr.addColorStop(1, 'rgba(120,20,0,0)');
g.globalAlpha = 1; g.fillStyle = gr; g.beginPath(); g.arc(b.x, b.y, r, 0, 7); g.fill();
}
g.globalAlpha = 1;
}
for (const p of popups) { g.globalAlpha = 1 - p.t / .8; text(p.s, p.x, p.y, '#fff', 8, 'center'); g.globalAlpha = 1; }
// HUD
text('SCORE ' + score.toLocaleString('en-US'), 6, 10, '#fff', 10);
text('HI ' + Math.max(cfg.high, score).toLocaleString('en-US'), LW - 6, 10, '#AAAAAA', 9, 'right');
text('WAVE ' + wave, LW / 2, 10, '#55FFFF', 9, 'center');
for (let i = 0; i < lives - (ship.dead > 0 ? 0 : 1); i++) g.drawImage(SPR.ship, 6 + i * 13, 18, 10, 11);
bar(6, LH - 9, 120, ship.guns, '#FF5555', 'GUNS');
bar(LW - 126, LH - 9, 120, ship.shield, '#55FFFF', 'SHIELD');
if (held.toShield || held.toGuns) text(held.toShield ? '>>>' : '<<<', LW / 2, LH - 6, '#FFFF55', 9, 'center');
if (msgT > 0 && mode === 'play') text('WAVE ' + wave, LW / 2, LH / 2 - 30, '#FFFF55', 18, 'center');
if (mode === 'paused') dialog(['PAUSED', '', isTouch ? 'Tap II to go on' : 'Push Space']);
if (mode === 'over') dialog(['GAME OVER', '', `Score ${score.toLocaleString('en-US')}`, `Wave ${wave}`].concat(newHigh ? ['NEW HIGH SCORE!'] : [],
['', isTouch ? 'Tap to continue' : 'Push Space']));
}
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (mode !== 'paused') update(dt);
draw();
requestAnimationFrame(frame);
}
// ---- control ----
function pause() { if (mode === 'play') mode = 'paused'; }
function proceed() {
if (mode === 'paused') { mode = 'play'; last = performance.now(); }
else if (mode === 'over') openSetup();
}
function openSetup() {
mode = 'setup';
document.getElementById('snd').checked = cfg.sound;
document.getElementById('auto').checked = cfg.auto;
document.getElementById('easy').checked = cfg.easy;
document.getElementById('hi').textContent = cfg.high ? `High score: ${cfg.high.toLocaleString('en-US')}` : '';
setupEl.classList.remove('hidden');
}
function startFromSetup() { saveCfg(); setupEl.classList.add('hidden'); audio(); SFX.wave(); newGame(); }
const KEYS = { ArrowLeft:'left', a:'left', ArrowRight:'right', d:'right', ' ':'fire', ArrowUp:'toShield', w:'toShield', ArrowDown:'toGuns', s:'toGuns' };
addEventListener('keydown', e => {
if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (KEYS[k]) { e.preventDefault(); if (mode === 'play') held[KEYS[k]] = true; }
if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); return; }
if (mode === 'play') { if (k === 'p' || k === 'Escape') pause(); return; }
if (!e.repeat && (k === ' ' || k === 'Enter' || k === 'p')) proceed();
});
addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (KEYS[k]) held[KEYS[k]] = false; });
addEventListener('blur', () => { for (const k in held) held[k] = false; });
// Dragging a finger on the play area steers the ship straight to it.
const toLogicalX = e => (e.clientX - cv.getBoundingClientRect().left) / cv.getBoundingClientRect().width * LW;
cv.addEventListener('pointerdown', e => {
e.preventDefault();
if (mode === 'play') { cv.setPointerCapture(e.pointerId); ship.drag = toLogicalX(e); held.fire = true; }
else proceed();
});
cv.addEventListener('pointermove', e => { if (mode === 'play' && ship.drag != null) ship.drag = toLogicalX(e); });
['pointerup', 'pointercancel'].forEach(t => cv.addEventListener(t, () => { if (ship) ship.drag = null; held.fire = false; }));
// On-screen buttons act while held.
if (isTouch) document.body.classList.add('touch');
document.querySelectorAll('#pad button').forEach(b => {
const a = b.dataset.a;
const up = () => { if (a !== 'pause') held[a] = false; b.classList.remove('held'); };
b.addEventListener('pointerdown', e => {
e.preventDefault(); b.setPointerCapture(e.pointerId);
if (mode !== 'play') { if (mode === 'paused' || mode === 'over') proceed(); return; }
if (a === 'pause') { pause(); b.classList.add('hit'); setTimeout(() => b.classList.remove('hit'), 120); return; }
held[a] = true; b.classList.add('held');
});
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, up));
});
// Pause automatically if the tablet switches away from the game.
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });
// ---- setup screen ----
document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; if (cfg.sound) SFX.shot(); });
document.getElementById('auto').addEventListener('change', e => { cfg.auto = e.target.checked; });
document.getElementById('easy').addEventListener('change', e => { cfg.easy = e.target.checked; });
document.getElementById('go').addEventListener('click', startFromSetup);
openSetup();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
EOF
3. Copy the icons
make-icons.ps1 (from the games site post) draws this game's icons into a starkiller subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):
scp -o BatchMode=yes starkiller\icon-512.png starkiller\icon-192.png starkiller\apple-touch-icon.png starkiller\favicon-32.png proxmox:/root/nibbles/starkiller/
4. Push it into CT 125
Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a starkiller folder:
bash /root/nibbles/05-push-site.sh
5. Check it
Proxmox host shell. Should print 200:
pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/starkiller/
Then open https://games.503mail.com/starkiller/ in a browser.
Updating it later
Edit index.html, paste the new version over /root/nibbles/starkiller/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.
Troubleshooting
The game runs entirely in the browser, so the server's only job is handing out the page.
- The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print
200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy). - The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for
/favicon.icois harmless (browsers ask for it on their own, and the games use PNG icons instead). - Settings or best scores look wrong: they're kept in the browser's localStorage under 'starkiller'. Clearing the site's data in the browser resets them; nothing is stored on the server.
Notes
- Why a remake: the original is on archive.org, but it needs Windows 95 with WinG and a keyboard. Running it in a browser would mean a full Windows emulator: laggy, and useless on a touch tablet.
- Auto fire was tuned for kids: at first auto fire drained the gun power so fast it was no fun, so auto fire is now slower and cheaper than holding FIRE.