TECH DETAILS

Star Collector - Day 31

Intermediate Level

Table of Contents

01 / Ship Movement & Physics

The 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)); }

Why Friction Creates Better Feel

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.

Pro Tip

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.

02 / Object Spawning System

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() }); }

Procedural Asteroid Shapes

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; }

03 / Circle Collision Detection

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(); }

Hitbox Scaling for Fair Gameplay

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.

Why Squared Distance?

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.

04 / Retro Visual Rendering

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(); }

Drawing Stars with 5-Point Algorithm

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(); }

05 / Particle Effects System

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; }
Performance Tip

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.

06 / Web Audio Sound Effects

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); }

Oscillator Types for Different Moods

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.

Audio Context Initialization

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.