Star Collector - Day 31
Intermediate LevelThe spaceship uses an acceleration-based movement model with friction, creating a smooth, momentum-driven feel reminiscent of classic arcade games. Instead of instant position changes, we accumulate velocity through acceleration when keys are pressed and decelerate through friction when released.
// Ship physics configuration
const ship = {
x: W / 2, // Center of screen
y: H - 80, // Near bottom
w: 40, // Collision width
h: 50, // Collision height
speed: 0, // Current horizontal velocity
maxSpeed: 8, // Maximum velocity cap
accel: 0.5, // Acceleration per frame
friction: 0.92 // Velocity decay multiplier
};
// Movement update (called every frame)
function updateShip() {
// Apply acceleration based on input
if (keys.left) ship.speed -= ship.accel;
if (keys.right) ship.speed += ship.accel;
// Clamp to maximum speed
ship.speed = Math.max(-ship.maxSpeed,
Math.min(ship.maxSpeed, ship.speed));
// Apply friction (deceleration)
ship.speed *= ship.friction;
// Integrate position
ship.x += ship.speed;
// Keep ship within screen bounds
ship.x = Math.max(ship.w / 2,
Math.min(W - ship.w / 2, ship.x));
}
The friction value of 0.92 means the ship retains 92% of its velocity each frame. This creates a smooth "glide" effect when releasing the controls - the ship decelerates gradually rather than stopping instantly. Combined with acceleration of 0.5 and max speed of 8, the ship reaches full speed in about 16 frames (~266ms at 60fps) and stops in about 30 frames (~500ms). This momentum gives the game a physical, weighty feel while still being responsive enough for quick dodges.
Adjust friction closer to 1.0 for a "slippery" ice feel, or closer to 0.8 for tight, responsive controls. The sweet spot depends on your game's pacing - faster games need higher friction for quick course corrections.
Stars and asteroids spawn at intervals that decrease as difficulty increases, creating escalating challenge. Each object type has randomized attributes (size, speed, rotation) within defined ranges to create visual variety while maintaining balanced gameplay.
// Difficulty scales based on score
let difficulty = 1 + Math.floor(score / 500) * 0.2;
// Spawn stars at decreasing intervals
const starInterval = Math.max(30, 60 - difficulty * 5);
if (frameCount % starInterval === 0) spawnStar();
// Spawn asteroids less frequently
const asteroidInterval = Math.max(40, 80 - difficulty * 8);
if (frameCount % asteroidInterval === 0) spawnAsteroid();
function spawnStar() {
const size = 15 + Math.random() * 10; // 15-25px
stars.push({
x: Math.random() * (W - 40) + 20,
y: -size, // Start above screen
size,
speed: 2 + Math.random() * 2 + difficulty * 0.3,
rotation: Math.random() * Math.PI * 2,
rotSpeed: (Math.random() - 0.5) * 0.1,
glow: Math.random() * 0.3 + 0.7
});
}
function spawnAsteroid() {
const size = 20 + Math.random() * 25; // 20-45px
asteroids.push({
x: Math.random() * (W - 60) + 30,
y: -size,
size,
speed: 1.5 + Math.random() * 2 + difficulty * 0.4,
rotation: Math.random() * Math.PI * 2,
rotSpeed: (Math.random() - 0.5) * 0.05,
vertices: generateAsteroidVertices()
});
}
Each asteroid has a unique irregular shape generated by creating vertices at equal angles around a circle, with each vertex's radius randomly varied. This creates organic, rock-like silhouettes that feel natural while being computationally simple.
function generateAsteroidVertices() {
const verts = [];
const points = 8 + Math.floor(Math.random() * 4); // 8-11 vertices
for (let i = 0; i < points; i++) {
const angle = (Math.PI * 2 / points) * i;
const radius = 0.6 + Math.random() * 0.4; // 60-100% of base size
verts.push({ angle, radius });
}
return verts;
}
Star Collector uses simple circle-vs-circle collision detection, which is efficient and accurate enough for arcade-style gameplay. Two circles collide when the distance between their centers is less than the sum of their radii.
function circleCollision(x1, y1, r1, x2, y2, r2) {
const dx = x1 - x2;
const dy = y1 - y2;
const distSq = dx * dx + dy * dy; // Distance squared
const radiusSum = r1 + r2;
// Compare squared values to avoid sqrt
return distSq < radiusSum * radiusSum;
}
// Usage: Check star collection
if (circleCollision(
ship.x, ship.y, ship.w * 0.4, // Ship hitbox (40% of width)
star.x, star.y, star.size * 0.5 // Star hitbox
)) {
// Collision detected - collect star!
score += 10;
spawnParticles(star.x, star.y, GOLD, 8);
playStarSound();
}
// Check asteroid collision (with invincibility check)
if (invincible === 0 && circleCollision(
ship.x, ship.y, ship.w * 0.35,
asteroid.x, asteroid.y, asteroid.size * 0.4
)) {
lives--;
invincible = 120; // 2 seconds of immunity
spawnParticles(ship.x, ship.y, RED, 15);
playHitSound();
}
Notice the multipliers (0.4, 0.35, 0.5) applied to object sizes. This creates hitboxes smaller than the visual sprites, implementing "generous" collision that favors the player. Stars use larger hitboxes (50%) making them easier to collect, while the ship uses a smaller hitbox (35-40%) for dodging, creating a forgiving but challenging experience.
We compare distSq < radiusSum * radiusSum instead of sqrt(distSq) < radiusSum because square root is computationally expensive. Since we only need to know if the distance is less than a threshold (not the actual distance), squaring the right side avoids the sqrt entirely. This matters when checking dozens of objects every frame.
The game's visual style combines gradient backgrounds, glow effects using Canvas shadows, and procedurally drawn shapes. The spaceship is drawn entirely with Canvas paths, creating a crisp, scalable sprite.
function drawShip() {
ctx.save();
ctx.translate(ship.x, ship.y);
// Invincibility blink effect
if (invincible > 0 && Math.floor(invincible / 8) % 2 === 0) {
ctx.globalAlpha = 0.3;
}
// Engine glow (radial gradient)
const engineGlow = ctx.createRadialGradient(
0, ship.h * 0.4, 0,
0, ship.h * 0.4, 20
);
engineGlow.addColorStop(0, 'rgba(255, 107, 0, 0.8)');
engineGlow.addColorStop(0.5, 'rgba(255, 107, 0, 0.3)');
engineGlow.addColorStop(1, 'rgba(255, 107, 0, 0)');
ctx.fillStyle = engineGlow;
ctx.beginPath();
ctx.arc(0, ship.h * 0.35, 20 + Math.random() * 5, 0, Math.PI * 2);
ctx.fill();
// Ship body with shadow glow
ctx.shadowColor = '#00bcd4';
ctx.shadowBlur = 15;
// Main hull shape (polygon path)
ctx.fillStyle = '#2a4a6e';
ctx.beginPath();
ctx.moveTo(0, -ship.h * 0.5); // Nose
ctx.lineTo(-ship.w * 0.4, ship.h * 0.3); // Left wing
ctx.lineTo(-ship.w * 0.15, ship.h * 0.3);
ctx.lineTo(-ship.w * 0.15, ship.h * 0.4);
ctx.lineTo(ship.w * 0.15, ship.h * 0.4);
ctx.lineTo(ship.w * 0.15, ship.h * 0.3);
ctx.lineTo(ship.w * 0.4, ship.h * 0.3); // Right wing
ctx.closePath();
ctx.fill();
// Cockpit (cyan ellipse)
ctx.fillStyle = '#00bcd4';
ctx.beginPath();
ctx.ellipse(0, -ship.h * 0.1, ship.w * 0.15, ship.h * 0.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
Stars are rendered using a classic 5-pointed star algorithm that alternates between outer and inner radius points. Each point is calculated using trigonometry at 72-degree intervals (360/5).
function drawStar(s) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.rotation);
// Glow effect
ctx.shadowColor = '#ffd700';
ctx.shadowBlur = 15 * s.glow;
ctx.fillStyle = '#ffd700';
ctx.beginPath();
for (let i = 0; i < 5; i++) {
// Outer point
const angle = (Math.PI * 2 / 5) * i - Math.PI / 2;
const outerX = Math.cos(angle) * s.size * 0.5;
const outerY = Math.sin(angle) * s.size * 0.5;
// Inner point (rotated 36 degrees)
const innerAngle = angle + Math.PI / 5;
const innerX = Math.cos(innerAngle) * s.size * 0.2;
const innerY = Math.sin(innerAngle) * s.size * 0.2;
if (i === 0) ctx.moveTo(outerX, outerY);
else ctx.lineTo(outerX, outerY);
ctx.lineTo(innerX, innerY);
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
Particles are spawned on events (star collection, asteroid hit) and simulate outward explosion with gravity. Each particle has position, velocity, lifetime, color, and size. They're updated and removed when their life expires.
let particles = [];
function spawnParticles(x, y, color, count) {
count = count || 10;
for (let i = 0; i < count; i++) {
// Distribute particles in a circle
const angle = (Math.PI * 2 / count) * i + Math.random() * 0.5;
const speed = 2 + Math.random() * 4;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1, // 1.0 = full, 0 = dead
color,
r: 2 + Math.random() * 3 // Radius 2-5px
});
}
}
function updateParticles() {
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.life -= 0.025; // Fade over ~40 frames
p.vy += 0.1; // Gravity pulls down
});
// Remove dead particles
particles = particles.filter(p => p.life > 0);
}
function drawParticles() {
particles.forEach(p => {
ctx.globalAlpha = p.life; // Fade out
ctx.shadowColor = p.color;
ctx.shadowBlur = 6;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
}
Particles are rendered with globalAlpha and shadowBlur for glow effects. While beautiful, shadows are expensive. For games with hundreds of particles, consider pre-rendering glowing circles to an offscreen canvas and drawing those instead of computing shadows per-particle.
Sound effects use the Web Audio API to synthesize retro-style chiptune sounds. Each sound creates an oscillator with a specific waveform and frequency, connected through a gain node that exponentially decays to create percussive attack-decay envelopes.
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext ||
window.webkitAudioContext)();
}
}
function playSound(freq, dur, type, vol) {
if (!audioCtx) return;
vol = vol || 0.08;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type || 'square'; // square, sine, sawtooth, triangle
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
// Attack-decay envelope
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(
0.001,
audioCtx.currentTime + dur
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + dur);
}
// Star collection: ascending arpeggio
function playStarSound() {
playSound(880, 0.1, 'sine', 0.06);
setTimeout(() => playSound(1100, 0.08, 'sine', 0.04), 50);
}
// Asteroid hit: low rumble
function playHitSound() {
playSound(150, 0.3, 'sawtooth', 0.08);
setTimeout(() => playSound(100, 0.4, 'sawtooth', 0.06), 80);
}
// Game over: descending tones
function playGameOverSound() {
playSound(200, 0.2, 'square', 0.06);
setTimeout(() => playSound(150, 0.2, 'square', 0.06), 150);
setTimeout(() => playSound(100, 0.4, 'square', 0.06), 300);
}
Sine waves produce pure, clean tones - perfect for positive feedback like collecting stars. Square waves have that classic 8-bit NES sound, great for UI and game state changes. Sawtooth waves are harsh and buzzy, ideal for impacts and damage. Triangle waves are softer than square, good for ambient or mellow effects.
Modern browsers require audio contexts to be created after user interaction. That's why we call initAudio() on the first keypress or touch - attempting to create the context earlier results in suspended audio that never plays. This is a security feature to prevent unwanted autoplay.