• 10 min read
Void Striker — Shoot to Unlock
Destroy enemies to unlock each concept of the game
Void Striker
Destroy enemies to unlock a walkthrough of how this game was built. Every system in Void Striker — the scrolling starfield, the scene switcher, the shooting, the ship selection, the boss AI, and the gravity physics — is a technique you will learn by playing through it.
Shoot 5 enemies to unlock the next lesson block. Keep going until all 4 sections are revealed.
Controls
| Key | Action |
|---|---|
| WASD | Move your ship |
| Arrow Keys | Fire (triple-shot spread) |
| Background button (top center) | Cycle between Nebula, Deep Space, and Supernova scenes |
| P | Pause and open ship selection menu |
Parallax BG
BG Change
Shooting
Boss & Gravity
Keep shooting — each 5 kills unlocks the next section.
🔒 Parallax Background (unlock: 5 kills)
The scrolling starfield is built from five independent particle layers, each moving at a different speed. Nearby things move fast; distant things move slow — your brain reads that speed difference as depth. This technique is called parallax.
Every particle tracks its own y position and a speed. Each frame the particle moves down by speed. When it falls off the bottom of the canvas, the modulo operator wraps it back to the top with no extra if-statements:
// H is the canvas height
particle.y = (particle.y + particle.speed) % H;
Here's what a full layer update looks like — move every particle down, then draw it:
function updateLayer(particles) {
for (const p of particles) {
p.y = (p.y + p.speed) % H; // scroll down, wrap at bottom
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
}
Void Striker stacks five layers from slowest (farthest) to fastest (nearest). Draw order matters — layer 1 is drawn first (behind everything), layer 5 is drawn last (closest to the player):
| Layer | What It Is | Speed |
|---|---|---|
| 1 — Deep Nebula | Pre-rendered canvas, seamless drift | 0.04 |
| 2 — Far Starfield | Tiny dim stars | 0.08–0.18 |
| 3 — Mid Starfield | Brighter stars with twinkle | 0.25–0.5 |
| 4 — Near Stars | Large stars with motion trails | 0.8–1.6 |
| 5 — Cosmic Dust | Glowing cyan particles | 1.5–3.2 |
Here's how a layer of stars is created at startup — each star gets a random position, speed, and brightness within a range:
function createStarLayer(count, speedMin, speedMax, radiusMin, radiusMax, alpha) {
const stars = [];
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
speed: speedMin + Math.random() * (speedMax - speedMin),
radius: radiusMin + Math.random() * (radiusMax - radiusMin),
color: `rgba(200,210,255,${alpha * Math.random()})`,
});
}
return stars;
}
// Far layer — lots of tiny, dim stars (speed 0.08–0.18)
const farStars = createStarLayer(120, 0.08, 0.18, 0.3, 0.7, 0.5);
// Near layer — fewer large, bright stars (speed 0.8–1.6)
const nearStars = createStarLayer(30, 0.8, 1.6, 1.2, 2.5, 0.9);
The depth illusion lives entirely in the speed difference. Set every layer to the same speed and parallax vanishes — the whole background becomes a flat sliding wall.
🔒 Background Change (unlock: 10 kills)
The button at the top of the game cycles through three scenes — Nebula, Deep Space, and Supernova. Each scene is a different color palette painted onto a hidden off-screen canvas. Swapping scenes at runtime means just repainting that canvas and the next frame picks it up automatically.
Why a Hidden Canvas?
The nebula is built from overlapping color gradients. Recalculating them from scratch every frame (60× per second) would be slow. The fix: paint the background once onto a hidden canvas at startup, then copy it to the screen each frame with drawImage. Copying a finished image is far cheaper than recalculating gradients.
Step 1 — Create and Paint the Hidden Canvas
const bgCanvas = document.createElement('canvas');
bgCanvas.width = W; bgCanvas.height = H;
const bgCtx = bgCanvas.getContext('2d');
function buildScene(palette) {
// Dark space base — linear gradient top to bottom
const base = bgCtx.createLinearGradient(0, 0, 0, H);
base.addColorStop(0, palette.top);
base.addColorStop(0.5, palette.mid);
bgCtx.fillStyle = base;
bgCtx.fillRect(0, 0, W, H);
// Glowing nebula cloud — radial gradient fades to transparent at edges
const cloud = bgCtx.createRadialGradient(W*0.4, H*0.3, 0, W*0.4, H*0.3, W*0.5);
cloud.addColorStop(0, palette.glow);
cloud.addColorStop(1, 'transparent');
bgCtx.fillStyle = cloud;
bgCtx.fillRect(0, 0, W, H);
}
Step 2 — Define the Three Scenes
const SCENES = [
{ name: 'Nebula', top: '#020010', mid: '#0a0025', glow: 'rgba(80,0,180,0.4)' },
{ name: 'Deep Space', top: '#000005', mid: '#000010', glow: 'rgba(0,30,80,0.3)' },
{ name: 'Supernova', top: '#100002', mid: '#200010', glow: 'rgba(180,20,0,0.45)' },
];
let currentScene = 0;
buildScene(SCENES[0]); // paint the first scene once at startup
Step 3 — Scroll It Every Frame
offsetY ticks up by 0.04 each frame. The canvas is drawn twice, stacked vertically, so there is never a gap as it scrolls. When offsetY reaches H, the modulo wraps it to 0 and the loop is seamless:
let offsetY = 0;
function drawBackground() {
offsetY = (offsetY + 0.04) % H;
ctx.drawImage(bgCanvas, 0, offsetY - H); // top copy slides in from above
ctx.drawImage(bgCanvas, 0, offsetY); // main copy drifts down
}
Step 4 — Swap on Button Click
When the player clicks the button, advance the scene index and repaint bgCanvas. The very next frame automatically uses the updated image — no restart, no flicker:
function cycleBackground() {
currentScene = (currentScene + 1) % SCENES.length;
buildScene(SCENES[currentScene]);
bgButton.textContent = SCENES[currentScene].name;
}
bgButton.addEventListener('click', cycleBackground);
This same pattern works for any runtime visual swap — day to night, calm to storm, level theme changes. Repaint the hidden canvas whenever you need a change and every future frame inherits it.
🔒 Shooting & Character Swap (unlock: 15 kills)
Triple-Shot Shooting
Pressing an arrow key fires three bullets in a spread pattern. Each bullet gets the same base direction as the key pressed, plus a small angle offset to create the spread. The offsets are measured in radians — about ±11°:
const SPREAD = [-0.2, 0, 0.2]; // radians: left-of-center, center, right-of-center
const BULLET_SPEED = 8;
function fireBullets(baseAngle) {
for (const offset of SPREAD) {
const angle = baseAngle + offset;
bullets.push({
x: ship.x,
y: ship.y,
vx: Math.cos(angle) * BULLET_SPEED,
vy: Math.sin(angle) * BULLET_SPEED,
});
}
}
// Each arrow key maps to an angle in radians
if (keys['ArrowUp']) fireBullets(-Math.PI / 2); // straight up
if (keys['ArrowRight']) fireBullets(0); // straight right
if (keys['ArrowDown']) fireBullets(Math.PI / 2); // straight down
if (keys['ArrowLeft']) fireBullets(Math.PI); // straight left
Each frame, every bullet moves along its velocity vector. Collision with an enemy is a circle-distance check — if the bullet center is within the enemy radius, both are removed and the kill counter increments:
bullets.forEach((b, bi) => {
b.x += b.vx;
b.y += b.vy;
enemies.forEach((e, ei) => {
const dist = Math.hypot(b.x - e.x, b.y - e.y);
if (dist < e.radius + BULLET_RADIUS) {
bullets.splice(bi, 1); // destroy bullet
enemies.splice(ei, 1); // destroy enemy
totalKills++;
window.dispatchEvent(new CustomEvent('vs-kills', { detail: { total: totalKills } }));
}
});
});
// Remove bullets that have left the canvas
bullets = bullets.filter(b => b.x > 0 && b.x < W && b.y > 0 && b.y < H);
Character Swap
Press P to pause and open the ship selection overlay. Each character is defined in a config array — hull color, cockpit color, thrust color, bullet color, and speed. Storing everything in one place means swapping ships only requires changing a single index:
const SHIP_CHARS = [
{ name: 'Striker', body: '#a0d8ff', cockpit: '#00eeff', thrustRgb: '0,200,255', bullet: '#00eeff', speed: 4.5 },
{ name: 'Shadow', body: '#bb99ee', cockpit: '#cc55ff', thrustRgb: '160,0,255', bullet: '#cc55ff', speed: 5.2 },
{ name: 'Inferno', body: '#ffbb77', cockpit: '#ff5500', thrustRgb: '255,100,0', bullet: '#ff6600', speed: 4.0 },
{ name: 'Nova', body: '#99ffcc', cockpit: '#00ff99', thrustRgb: '0,255,140', bullet: '#00ff99', speed: 4.8 },
];
let activeChar = 0;
Selecting a ship updates activeChar and patches ship.speed instantly. Because drawShip() and drawBullets() both read from SHIP_CHARS[activeChar], colors change the very next frame — no player object recreation, no restart:
function selectChar(idx) {
activeChar = idx;
ship.speed = SHIP_CHARS[idx].speed;
// close the pause overlay
paused = false;
}
function drawShip() {
const ch = SHIP_CHARS[activeChar];
ctx.fillStyle = ch.body;
// ... draw hull shape using ch.body for the hull color ...
ctx.fillStyle = ch.cockpit;
// ... draw cockpit glow using ch.cockpit ...
}
function drawBullets() {
const color = SHIP_CHARS[activeChar].bullet;
bullets.forEach(b => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(b.x, b.y, BULLET_RADIUS, 0, Math.PI * 2);
ctx.fill();
});
}
🔒 Enemy Boss, Chasing System & Gravity Asteroids (unlock: 20 kills)
Boss Alien
On wave 3 a tanky Boss Alien spawns at the top of the screen. Unlike regular enemies, it actively homes in on the player's ship each frame. The trick is Math.atan2, which converts an (x, y) displacement into an angle — then cos/sin turn that angle back into a unit direction vector:
function updateBoss(boss, ship) {
const dx = ship.x - boss.x;
const dy = ship.y - boss.y;
// atan2 gives the angle from boss to ship — dy goes first, easy to mix up!
const angle = Math.atan2(dy, dx);
// Step toward the ship along that angle
boss.x += Math.cos(angle) * boss.speed;
boss.y += Math.sin(angle) * boss.speed;
}
While the boss is alive, worldSpeed drops to 0.4, slowing every asteroid and regular enemy automatically — creating a dramatic slowdown effect without any extra conditional logic. Killing the boss restores full speed and counts as 5 kills. Each successive boss generation is faster and has a new color palette.
Chasing Enemies
Starting on wave 2, some regular enemies become chasers that lock onto the ship instead of drifting straight down. The core formula: divide the displacement vector by its length to isolate direction (a unit vector), then scale by speed:
function updateChaser(e, ship) {
const dx = ship.x - e.x;
const dy = ship.y - e.y;
const dist = Math.hypot(dx, dy) || 1; // avoid divide-by-zero
// Ramp up speed gradually — chasers accelerate as they close in
e.chaseAcc = Math.min(e.speed, e.chaseAcc + 0.0006);
const spd = (e.speed + e.chaseAcc) * worldSpeed;
e.x += (dx / dist) * spd; // move along the unit vector
e.y += (dy / dist) * spd;
// Rotate the enemy sprite to face the ship
e.angle = Math.atan2(dy, dx);
}
Chasers are drawn as arrow shapes and rotated via ctx.rotate(e.angle + Math.PI / 2) so they visually point at the ship. The proportion of chasers scales with wave number — later waves are far more aggressive.
Gravity-Driven Asteroids
Asteroids used to fall at a constant speed. A per-frame gravity loop makes them accelerate as they fall, exactly like real objects under gravity. Each asteroid tracks a verticalVelocity and a gravityAcceleration:
function updateAsteroid(a) {
// Subtract gravity each frame — velocity grows in magnitude (downward)
a.verticalVelocity -= a.gravityAcceleration;
// Cap at terminal velocity so asteroids stay readable at any wave count
if (-a.verticalVelocity > a.terminalVelocity) {
a.verticalVelocity = -a.terminalVelocity;
}
// Flip sign: canvas y increases downward, but velocity is stored as negative
a.y += -a.verticalVelocity * worldSpeed;
// Spin the asteroid as it falls
a.rotation += a.spinSpeed;
}
Why store velocity as negative? Canvas y grows downward, so a positive "fall" direction is negative in the velocity math — flipping the sign on the final move keeps the gravity formula intuitive (v -= g means pulling down).
The worldSpeed multiplier automatically slows asteroids during the boss fight without any extra code — the same variable that slows regular enemies slows gravity too.
🎉 Lesson complete! You've seen every core system in Void Striker — parallax layers, scene switching, triple-shot bullets, character config arrays, boss pursuit with atan2, normalized chase vectors, and gravity physics.
✨ Credits — Team Sorcerers for the Gravity System • the Ocean Team for the Boss Chasing Enemy • Lance Oberiano, Yiming Yin & Arjun Ganesh for the Chase Logic & Character Swap ✨