Last updated: 2026-04-01 Source of truth:
server.js(single-file architecture)
Void Drift is a Canvas 2D space shooter with LAN multiplayer. Up to 4 players connect via Socket.io and fight cooperatively against alien enemies, asteroids, and a carrier boss -- while also being able to shoot each other. The background is a living space scene: a WebGL plasma nebula, a Canvas 2D starfield, and a full Canvas 2D game engine with mouse-following player ships, alien enemies, asteroids, bullets, lasers, and particles.
| Layer | Technology |
|---|---|
| Server | Node.js (ESM), Express 5, Socket.io |
| Frontend | Vanilla JS, inline in a server.js template literal |
| Background L0 | WebGL (off-DOM canvas) -- plasma nebula |
| Background L1 | Canvas 2D (#starfield) -- stars + plasma composited via drawImage |
| Game L2 | Canvas 2D (#game-canvas) -- ships, enemies, asteroids, particles |
Everything is a single file: server.js. The Express server serves one HTML page via a template literal. All CSS, HTML, and JS for the frontend are inline inside that template literal. There is no build step and no bundler. The only npm dependencies are express and socket.io.
- Port 3800 (port 3737 is reserved for Project Home; all other projects use 3000-3736 or 3738+)
The first browser to connect becomes the host. The host runs the authoritative game loop -- all game state updates, physics, collision detection, spawning, and AI happen on the host. Subsequent browsers are guests that send input to the host and receive serialized state for rendering.
The server itself is a relay: it assigns player IDs, tracks which client is the host, relays messages between clients, and caches the latest game state for host migration.
The host serializes the full game state each frame and broadcasts it to all guests via Socket.io. Guests deserialize and render directly -- they do not run their own game loop.
If the host disconnects, the server promotes the next connected client to host. The cached game state is sent to the new host so the game continues without interruption.
Guests receive the serialized game state and render it locally. Each guest runs its own rendering pipeline (WebGL plasma, Canvas starfield, Canvas game) but does not execute game logic. Mouse input is sent to the host for movement and aiming.
- WebSocket only (no HTTP long-polling fallback) -- LAN latency assumed at 1-5ms
- Up to 4 simultaneous players
The game tracks a players[] array instead of a single player object. Each player has a unique ID assigned by the server via nextId().
| Player | Color |
|---|---|
| Player 1 | Cyan (#00FFFF) |
| Player 2 | Magenta (#FF00FF) |
| Player 3 | Yellow (#FFFF00) |
| Player 4 | Green (#00FF00) |
Each player has independent death and respawn timers. When one player dies, the others continue playing. Respawn and initial join use safe random spawn: random positions 150px+ from enemies, carriers, asteroids, and other players. Falls back to pure random if no safe spot found in 20 attempts.
All game entities (players, enemies, bullets, asteroids, etc.) use a nextId() system for unique identification. This is essential for multiplayer state sync and ownership tracking (e.g., which player fired which bullet).
- Arrow/chevron shape: tip at (12, 0), wings at (-10, -8) and (-10, 8), notch at (-6, 0)
- Fill: player color, Stroke: lighter variant (1px)
- Engine glow: white circle at (-7, 0), radius 2.5px
- Shadow glow: 12px blur in player color
- Lerp factor scales with player level:
- Level 1:
0.01(nimble) - Level 2:
0.008(slightly heavier) - Level 3+:
0.007(floaty)
- Level 1:
- Minimum speed floor: 1.8 px/frame. When the ship is >2px from mouse and lerp would produce movement slower than 1.8, it scales up to that floor. Prevents crawling near screen edges.
- Ship is clamped to screen bounds (margin = player radius)
- Idle detection: 5000ms since last mouse move
- When idle: drifts toward canvas center at lerp 0.01, target angle set to
-PI/2(pointing up) - When mouse leaves window: ship glides to a stop using stored momentum (
glideVx/glideVy) with 0.995 friction per frame. Max glide speed capped at 4 px/frame. During glide, targetAngle is locked to current angle (ship holds heading). On mouse re-enter, normal tracking resumes. - Glide velocity is captured as the ship's actual per-frame movement delta (
p.x - prevX), no smoothing - Mouse velocity spikes >50px/frame are ignored (prevents teleporting on re-entry)
- Target angle:
atan2(mouseY - playerY, mouseX - playerX)-- always points toward mouse during drift; during normal movement, only when speed > 0.5 and not idle - Smoothing:
angle += angleDiff * 0.12per frame (angleDiff is wrapped to [-PI, PI]) - Stores
lastValidAngleto hold orientation when mouse stops moving
- Rate: every 700ms (
fireRate) fireTimerstarts at 500 (first shot within 200ms of spawn)- Quick-draw: when a target newly enters range (
hadTargetwas false, now true),fireTimerjumps to at leastfireRate - 200 - Target search range: 400px
- Target selection:
findTarget()returns sorted candidates array (enemies priority 0, other players priority 0, asteroids priority 1, sorted by distance within priority).autoFire()iterates candidates and fires at first one passing cone check. - Targeting cone: 20 degrees (
maxArc = 20 * PI / 180). Cone check tests against bothp.angle(current) ANDp.targetAngle(intended) -- fires if either passes. - Bullet spawns 14px ahead of player in aim direction
- Spawn shields: 1 (base shield)
- Max shields: 5 (additional shields come from shield pickups only)
- Only the base shield (first slot) recharges at 10000ms (10 seconds); shields above 1 are pickup-only
- Drift modifier: while the player is drifting, base shield recharge rate doubles to 5000ms (see Section 4, Drift Mechanic)
- Visual: orbiting dots at 30px radius, spaced
2*PI / maxShieldsapart (72 degrees for 5 max), rotating at 2 rad/s - Active shields: filled circles in player color (3px radius, alpha 0.9)
- Recharging indicator: only shown when shields < 1 (slot 0 only) -- empty circle (alpha 0.3) with pie-fill showing progress (alpha 0.6)
- No dim outlines drawn for empty pickup-sourced slots
- If shields > 0: lose 1 shield,
sparkShieldparticles at impact point, 300ms invulnerability - If shields = 0: death
- During invulnerability:
blinkTimerincrements
explodePlayer()particle explosion (50-80 particles in player color/white/blue)- 4-6 debris pieces
- 3-4 ring waves (player color 120px, white 90px, blue 60px, optional 50px)
addExplosionZone(x, y, 150)-- largest explosion zonescreenFlashAlpha = 0.25(white flash overlay)screenShake.intensity = 18- 80px AoE damage: kills nearby enemies (via
killEnemy-> dying state), breaks asteroids, deals 5 damage to carrier
- 5000ms delay after death
- Safe random spawn location (avoids enemies, other players, and hazards)
- 3-phase warp-in animation over 1500ms total:
- Phase 1 (0-500ms,
warpPhase0-0.33): Horizontal line grows from 0 to 100px, white with 15px shadow glow - Phase 2 (500-1000ms,
warpPhase0.33-0.66): Ellipse portal, X-radius shrinks 50->30, Y-radius grows 2->25. 3 nested inner ellipses in alternating purple/white - Phase 3 (1000-1500ms,
warpPhase0.66-1.0): Ship materializes. Portal circle shrinks from 30px. Ship fades in (opacity 0->1), scales from 2x to 1x
- Phase 1 (0-500ms,
- Post-respawn: 2000ms invulnerability, shields restored to 1 (base shield only)
- 1-2 particles per call (
budgetCount(1, 2)) behind ship - Multi-ship levels: iterates once per sub-ship; levels > 1 randomly skip 50% of sub-ship trails (
Math.random() > 0.5) - Sub-ship trails offset perpendicular to ship angle (8px spacing)
- Spread: +/-0.4 radians from opposite of ship angle
- Speed: 1-3 px/frame
- First 2 particles white, rest in player color
- Life: 10-20 frames, radius 1.5-3, friction 0.95
- Reduced when idle (30% chance of spawning)
The drift mechanic decouples movement from aiming, creating a rear-wheel-drive slide feel.
- Hold left mouse button to enter drift
- Ship must be moving > 0.5 speed to activate
- Not available during idle state
- Cancelled on player hit (shields or death)
- On enter: captures current lerp velocity as
driftVx/driftVy - During drift: ship coasts on captured velocity; rotation tracks mouse independently (aim decoupled from travel direction)
- Trajectory curve: 3% per-frame steering pull toward mouse direction (rear-wheel drift feel)
- Friction:
0.998per frame -- drift slowly decays - Wall bounce: drift velocity reverses at 30% on screen edge hits
- On release: normal lerp-to-mouse movement resumes immediately
- While drifting, base shield recharge rate is doubled (5000ms instead of 10000ms)
- This gives drifting a tactical purpose beyond movement: trade aiming precision for faster shield recovery
- The bonus applies only while actively drifting; recharge timer reverts to normal rate on drift end
- Engine glow dims (radius 2.5 -> 1.5, alpha 0.3, cyan tint)
- Drift trail replaces engine trail: 1-2 dim particles emitted opposite to drift direction
- Occasional perpendicular "tire" sparks
Player auto-fire targets other players at the same priority as enemies (priority 0). When another player is in range and within the targeting cone, bullets will fire at them just as they would at an enemy. Targets are sorted by distance within the same priority tier.
All player bullets carry an ownerId matching the player who fired them. Bullet-player collision checks skip the owner -- a player's own bullets pass through them. Bullets from other players deal damage normally.
Shields absorb PvP hits identically to enemy hits: each hit consumes 1 shield, triggers sparkShield particles, and grants 300ms invulnerability. If no shields remain, the hit kills the player.
- Shape: ellipse, 4px x 1.5px
- Color: owner's player color with 15px shadow glow
- White hot center: 2px x 0.8px white ellipse
- Trail: up to 4 positions, drawn as fading ellipses (alphas: 0.1, 0.2, 0.4, 0.6)
- Speed: 8 px/frame
- Lifetime: 90 frames
- Radius (collision): 4px
- Impact:
bulletImpact()-- 8-12 spark particles in player color/white, speed 1-4, life 10-15 - Owner tracking: each bullet stores
ownerIdfor PvP collision filtering
Two visual types:
- Saucer: horizontal ellipse (halfWidth = size/2, halfHeight = size/4), with dome (upper half-ellipse at 0.5x/0.8x scale), and small wing extensions (6x4px rectangles)
- Fighter: diamond body (0.7x halfWidth), with two swept-back wing triangles extending to 0.7-0.8x halfWidth
Size: 28-36px (random). Collision radius = size * 0.45.
5 colors and weapon types:
| Color | Hex | Weapon |
|---|---|---|
| Red | #FF3366 |
Pulse laser |
| Magenta | #FF33FF |
Pulse laser |
| Yellow | #FFFF33 |
Pulse laser |
| Green | #33FF66 |
Photon torpedoes |
| Orange | #FF9933 |
Photon torpedoes |
Spawn:
- Rate: 2500-5000ms (random interval, re-rolled each spawn)
- Max: 12 enemies simultaneously (doubles to 24 at player level 4+). Up to 2 carriers spawn simultaneously at level 4. Carriers launch elites at double rate when enemy count is below 75% of max capacity. On player death at L4, cap reduces back to 12 but existing enemies beyond the cap persist naturally.
- Spawn location: random edge of screen (20-40px outside)
- Player exclusion: 100px minimum distance from any player (up to 10 attempts to find valid position)
- Fade-in: 500ms
AI:
- Initial velocity: 0.5-1.5 px/frame, angled toward center with +/-0.5 rad jitter
- Direction changes: every 3000-6000ms, new random angle and speed 0.5-1.5
- Lazy pursuit: 30% chance (
pursuitflag) -- applies 0.3 acceleration toward nearest player per second - Hover oscillation:
sin(time/333 + offset) * 0.5on Y axis - Despawn: when 100px+ off any screen edge (300px for elite enemies)
- Behavior system:
behaviorproperty defaults to'normal'. On first non-lethal hit, 33% chance each of:'retreat'(0.6 px/s acceleration away from player),'aggressive'(0.8 px/s acceleration toward player), or no change.
Laser charge-up:
- Laser-type enemies (red, magenta, yellow) glow white in the 1 second before firing
chargingproperty ramps from 0 to 1 over the last 1000ms beforefireTimerreachesfireInterval- Draw color: lerps each RGB channel from base color toward 255 (white) by
chargingfactor - Shadow glow: increases from base 10px to
10 + charging * 20(max 30px) during charge-up - Resets to 0 when the laser fires or when player is out of range
HP: 2. Damage flash: white color for 150ms. Enemies have a dying property (see Dying State below). On destruction: explodeEnemy() + addExplosionZone(x, y, 120) + 100 score. explodeEnemy also sets screenFlashAlpha = 0.08 and screenShake.intensity = 6.
Dying State:
- On lethal hit, enemies enter a dying state instead of instant death (via
killEnemyfunction) dyingDuration: random 500-2000ms- During dying: drift forward at half velocity, slowly rotate (0.03 rad/frame), flicker orange/white, not collidable or targetable
- Fire/smoke particles every 40-100ms (colors:
#FF4400,#FF8800,#FFCC00,#FFFFFF) - Occasional mini screen shakes (2% chance per frame, intensity 2)
- After timer:
finalExplodeEnemy-- fullexplodeEnemyparticles + ring waves + explosion zone + shield drop - Final explosion AoE: radius =
enemy.radius * 3, damages players, other enemies (viakillEnemy-- chain reactions possible), asteroids, carrier (2 damage)
Shields:
- Enemies have a
shieldsproperty (0-5,maxShields: 5) - Shields absorb hits before HP damage (each hit consumes 1 shield)
- Spawn shield chance based on player level: 0% at L1, 25% at L2, 50% at L3, 75% at L4+
- Carrier-launched enemies use the same shield chance table
- Enemies steer toward nearby shield pickups within 200px (1.2 acceleration toward pickup)
- Enemies can pick up dropped shields on contact (
pickupRadius + enemy radius), gaining +1 shield - Visual: colored orbiting dots around enemy (same style as player shields) -- enemy color circles at
radius + 8px orbit, 2.5px dot radius, rotating at 2 rad/s, alpha 0.8 (faded byfadeIn) - Shield angle tracked per enemy (
shieldAngle), dots spaced2*PI / maxShieldsapart
Asteroid avoidance:
- Weak avoidance force: 0.02 strength, 30px buffer beyond collision radii (
e.radius + ast.radius + 30) - Much weaker than carrier avoidance (carrier uses 0.06 strength, 80px buffer)
- Enemies still collide with asteroids regularly
Weapons -- fire interval: 2000-4000ms (re-rolled each shot), only fires when a player is within 500px.
Fired by green (#33FF66) and orange (#FF9933) enemies.
Burst firing:
- 2-5 shots per burst (
randInt(2, 5)) - 500ms between shots in a burst
burstTimerstarts at 500 (fires first shot immediately when burst starts)
Owner tracking: Each torpedo stores an owner reference to the enemy that fired it, used to prevent self-hits in friendly fire collisions.
Visual (2-pass draw):
- Trail: up to 5 positions, circles with increasing size and opacity (alpha:
index/length * 0.4, radius:baseRadius * (0.3 + 0.5 * index/length)). Drawn without shadowBlur. - Glowing core: colored circle at full radius with single shared 20px shadowBlur, pulsing (
0.8 + sin(pulsePhase) * 0.2), pulsePhase increments by 0.15/frame - White hot center: white circle at
radius * 0.4 * pulse(drawn in same pass as core)
Rendered with globalCompositeOperation = 'lighter' (additive blending).
Speed: 3 px/frame, aimed at nearest player position at time of firing.
Radius (collision): 5px.
Range limit: 300px from start position. On range expiration:
bulletImpact()particles- Ring wave: 40px maxRadius, expandSpeed 3, alpha 0.7
- AoE damage check: if any player within 40px + playerRadius, hits player
Cap: max 20 enemy bullets on screen (raised to 30 during carrier barrages).
Fired by red (#FF3366), magenta (#FF33FF), and yellow (#FFFF33) enemies.
Behavior:
- Instant appearance -- line from enemy position toward nearest player
- Max range: 250px (or distance to player if closer)
- Hit detection: on creation, checks
pointToSegDist(player, laserStart, laserEnd)againstplayerRadius + laserWidth - If hit: records hit point on the laser for flash effect, calls
hitPlayer()
Visual:
- Line from (x1,y1) to (x2,y2) in enemy color
- Shadow glow:
12 * alphablur - Composite mode:
lighter(additive) - On hit: white flash circle at hit point, radius 8-20px, fading over 200ms
Fade:
- Total life: 375ms
- Alpha: starts at 0.6 (
clamp(life/maxLife, 0, 1) * 0.6), fades to 0
Width animation:
- First 100ms: expands from 3 to 6 (
3 + (elapsed/100) * 3) - After 100ms: shrinks from 6 toward 1 (
1 + 5 * alpha)
Cap: max 8 lasers on screen.
3 sizes:
| Size | Base Radius | Color |
|---|---|---|
| Large | 50-60px | #666666 |
| Medium | 30-35px | #777777 |
| Small | 15-20px | #888888 |
Shape: irregular polygon with 8-12 vertices. Each vertex at evenly-spaced angles, radius jittered by +/-30% (rand(0.7, 1.3)). Collision radius = baseRadius * 0.9. Stroke: #999999, 1px.
Craters: 3-4 per asteroid. Random position within 40% of baseRadius. Radius: 8-15% of baseRadius. Fill: #444444 at 0.5 alpha.
Spawn:
- 1 large asteroid every 3000-6000ms
- Max 12 large asteroids
- Max 50 total asteroids
- Spawn outside screen edges (baseRadius + 10px off-screen)
- Drift toward center with +/-0.6 rad jitter, speed 0.5-1.0
Breakup chain:
- Large -> 2-3 medium (velocity = parent + random [-1.5, 1.5])
- Medium -> 2-3 small
- Small -> destroyed (no children)
- Large/medium destruction creates explosion zones (large: 100px, medium: 60px)
- Large destruction:
screenShake.intensity = 6
Rotation: 0.008-0.026 rad/frame, random direction.
Despawn: when baseRadius + 80px off any screen edge.
Limits: max 500 particles. When at cap, oldest particle (index 0) is removed via shift().
Particle properties:
x, y-- positionvx, vy-- velocitylife, maxLife-- countdown in framesradius-- size (shrinks by*0.97per frame ifshrinkis true)color-- hex stringalpha-- computed aslife / maxLifeglow-- boolean, determines composite mode and shadowBlurshrink-- booleanfriction-- velocity multiplier per frame (default 0.98)gravity-- added to vy per frame (default 0)type--'circle','spark', or'debris'angle, angleVel-- rotation (for debris)vertices-- array of {x,y} for debris polygon (optional; falls back to rectangle)
Particle types:
- circle: simple filled arc
- spark: motion-blurred line. Length =
sqrt(vx^2+vy^2) * 3(min 2px). Line width =max(0.5, radius * 0.5). Oriented along velocity direction. - debris: rotating polygon. If
verticesprovided, draws polygon; otherwise draws rectangle2*radius x radius.
Rendering:
- First pass: non-glow particles with
globalCompositeOperation = 'source-over' - Second pass: glow particles with
globalCompositeOperation = 'lighter'(additive blending) - Performance LOD: when particle count > 300, glow pass skips
shadowBlurentirely
Preset functions:
| Function | Particle Count | Colors | Types | Usage |
|---|---|---|---|---|
engineTrail(x, y, angle) |
1-2 | 2 white + rest player color | circle | Behind player ship each frame (per sub-ship) |
bulletImpact(x, y) |
8-12 | 50% player color, 50% #FFFFFF |
spark | When bullet hits something |
sparkShield(x, y) |
15-25 | 60% player color, 40% #FFFFFF |
spark | Shield absorbs hit |
explodeEnemy(x, y, color) |
30-50 circles/sparks + 3-5 debris | 60% white + enemy color | circle/spark/debris | Enemy destroyed. Also spawns 2-3 ring waves. |
explodeAsteroid(x, y, size) |
10-30 (varies by size) | 50% bright (#CCBBAA, #FFDDBB, #FFFFFF with glow:true), 50% muted (#AA9977, #998866, #BB9955) |
50% debris, 25% spark, 25% circle | Asteroid destroyed. |
explodePlayer(x, y) |
50-80 circles/sparks + 4-6 debris | #FFFFFF, player color, #0088FF, #00CCFF |
circle/spark/debris | Player death. Also spawns 3-4 ring waves. |
warpEffect(x, y, phase) |
10-30 | #FFFFFF, #CC88FF, #8844FF, player color |
circle | Respawn warp animation. |
- Max: 12. When at cap, oldest is removed via
shift(). - Properties:
x, y, radius, maxRadius, expandSpeed, lineWidth, color, alpha - Behavior: radius grows by
expandSpeedper frame. Alpha =1 - radius/maxRadius. LineWidth lerps from 2 to 0.5 as radius approaches maxRadius. - Rendering:
globalCompositeOperation = 'lighter', 4px shadowBlur in ring color - Removed when
radius >= maxRadius
Created by addExplosionZone(x, y, maxRadius):
- Default maxRadius: 120px
expandSpeed: 6 px/framecurrentRadiusstarts atmaxRadius * 0.3(30% of max)- Expands until reaching
maxRadius, then starts fading fadeDuration: 2500msstrength: starts at 1.0, decays to 0 linearly over fade duration- Passed to shader as
radius = (currentRadius * strength) / max(screenW, screenH)(normalized) - Max 8 simultaneous zones (enforced by shader uniform array size)
- Removed when
strength <= 0
All collisions use circle-circle intersection: gameDist(x1, y1, x2, y2) < radius1 + radius2.
Collision radii:
| Entity | Radius |
|---|---|
| Player | 10px |
| Player bullet | 4px |
| Enemy | size * 0.45 (typically 12.6-16.2px) |
| Carrier | 55px |
| Asteroid | baseRadius * 0.9 (large: 45-54, medium: 27-31.5, small: 13.5-18) |
| Enemy bullet | 5px |
| Enemy bullet AoE | 40px (on range expiration) |
| Laser | point-to-segment distance check against playerRadius + laserWidth |
| Shield pickup | 20px (pickup radius for player collection) |
| Shield pickup AoE | 50px (mine explosion on expiration) |
Collision matrix:
| A | B | Outcome |
|---|---|---|
| Player bullet | Enemy | Bullet destroyed, enemy takes 1 damage. If HP=0: enemy destroyed, +100 score, explosion zone. Bullet ownerId used for score attribution. |
| Player bullet | Other player | Bullet destroyed (skipped if ownerId matches target). Shield absorbs or kill. |
| Player bullet | Asteroid | Bullet destroyed, asteroid destroyed and breaks into children. Large/medium: explosion zone. Large: screen shake 6. +50 score. |
| Player | Enemy | Enemy destroyed (explosion zone), player hit. |
| Player | Asteroid | Asteroid destroyed and breaks, player hit. |
| Enemy | Asteroid | Both destroyed (enemy explosion zone, asteroid breaks). No score. |
| Enemy torpedo | Asteroid | Torpedo destroyed, asteroid destroyed and breaks. |
| Enemy torpedo | Other enemy | Friendly fire: torpedo destroyed, enemy takes 1 damage (2-HP system). Torpedoes skip their owner enemy. |
| Enemy torpedo | Player (direct) | Torpedo destroyed, player hit. |
| Enemy torpedo | Player (AoE) | On range expiration, if any player within 40 + playerRadius: player hit. |
| Laser | Player | Checked on creation only. If within range: player hit, flash at contact point. |
| Laser | Asteroid | Checked within 50ms of laser creation. Uses point-to-segment distance. If within a.radius + l.width: asteroid destroyed and breaks. |
| Player bullet | Carrier | Bullet destroyed; if carrier has shields, lose 1 shield + sparkShield; else carrierTakeDamage(1). |
| Player | Carrier | Player hit (same as enemy body collision). |
| Asteroid | Carrier | Asteroid destroyed and breaks; carrierTakeDamage(5). |
| Asteroid | Asteroid | Elastic bounce: push apart to resolve overlap, swap velocity along collision normal with 0.8 coefficient. |
| Shield pickup AoE | Player | On expiration (10s), 50px AoE: if player within 50 + playerRadius, player hit. |
| Shield pickup AoE | Enemy | On expiration, 50px AoE: enemies within range take 2 HP damage. Chain reactions possible. |
| Shield pickup AoE | Asteroid | On expiration, 50px AoE: asteroids within range destroyed and break into children. |
| Enemy explosion AoE | Player | If within enemy.radius * 3: player hit. |
| Enemy explosion AoE | Other enemy | If within range: killEnemy (dying state, chain reactions). |
| Enemy explosion AoE | Asteroid | If within range: asteroid destroyed and breaks. |
| Enemy explosion AoE | Carrier | If within range: 2 HP damage. |
| Player explosion AoE | Enemy | 80px radius: killEnemy. |
| Player explosion AoE | Asteroid | 80px radius: destroyed and breaks. |
| Player explosion AoE | Carrier | 80px radius: 5 HP damage. |
| Carrier chunk AoE | Player/Enemy/Asteroid | 60px radius each chunk. |
| Carrier final AoE | Player/Enemy/Asteroid | 150px radius. |
Shield pickups drop from destroyed enemies and can be collected for extra shields or left to expire as mines.
Drop mechanics:
- 30% chance to drop from any enemy death (called via
tryDropShield(x, y)after everyexplodeEnemy) - Tracked in
gameState.shieldPickups[]
Visual:
- Glowing cyan pulsing diamond shape, rotating slowly (
pulsePhase * 0.3rad) - Outer glow:
#00FFFFcircle atradius * 1.5 * pulsewith 15px shadowBlur, alpha0.4 * pulse - Diamond body: 4-point shape, width =
radius * 0.6, height =radius(12px),#00FFFF, alpha0.9 * pulse - White center: 3px white circle, alpha
0.8 * pulse - Pulse:
0.7 + sin(pulsePhase) * 0.3(pulsePhase increments by 0.08/frame) - Rendered with
globalCompositeOperation = 'lighter'(additive blending)
Pickup behavior:
- Pickup radius: 20px (collision =
pickupRadius + playerRadius) - Visual radius: 12px
- Fly over to collect: +1 shield, up to max 5
- Triggers
sparkShieldeffect on collection
Expiration (mine behavior):
- Lifetime: 10000ms (10 seconds)
- Blinks in last 3 seconds (
sp.life < 3000): visibility toggled bysin(pulsePhase * 3) > -0.3 - On expiration:
bulletImpactparticles + ring wave (50px maxRadius, expandSpeed 4, cyan) - 50px AoE explosion damages players, enemies, and asteroids
A large enemy mothership that spawns smaller enemies and fires torpedo barrages.
Stats:
- Radius: 55px, HP: 60, Color:
#FF0000 - Shields: 8 (max 8), recharge rate: 8000ms
- Fade-in: 1000ms
- Rotation speed: 0.005 rad/frame
Spawn:
- Tracked in
gameState.carriers[]array (converted from singlecarrierobject for multi-carrier support) - Up to 1 carrier at player levels 1-3; up to 2 carriers simultaneously at level 4+
- Spawns from random screen edge (70px outside), aimed toward center
- Drift speed: 0.3-0.5 px/frame
AI:
- Orbits the nearest player at sweet spot range (200-350px): steers away if closer than 200px, drifts toward if farther than 350px
- Flanking AI: when 2 carriers are active, they intelligently approach the player from opposite sides
- Carrier-carrier repulsion: strong repulsion prevents overlap (120px + combined radii separation)
- Asteroid avoidance: 0.06 strength, 80px buffer beyond collision radii
- Random drift direction changes every 5000-8000ms, speed 0.3-0.5
Enemy launches:
- Every 3000ms, launches a new enemy from a random point around the carrier perimeter
- Double launch rate when enemy count is below 75% of max capacity
- Launched enemies get a speed boost outward from carrier
- Launched enemies are elites (see Elite Enemies below)
Elite Enemies (carrier-spawned):
- 5 shields, 3 HP (vs normal 0-5 shields, 2 HP)
- Visual: white body with pulsing glow ring and halo effect
- Slow launch velocity (0.5 px/frame) but strong pursuit AI (1.5x normal pursuit strength)
- Barrage-aware pathfinding: detects and flees the carrier torpedo cone
- Wider despawn boundary (300px vs normal 100px)
- Stronger asteroid and torpedo avoidance than normal enemies
Torpedo barrage (bullet-hell style):
- Only activates when a player is alive and at level 4+, and nearest player is within 500px
- Alternating left/right cannons (25px perpendicular offset from center), not simultaneous
- 45-degree spread arc toward nearest player (random angle within arc per shot)
- 200ms between shots
- 6-12 shots per burst (
randInt(6, 12)) - 2-3.5s between bursts (
rand(2000, 3500)) - Variable speed: 3.5-5 px/frame
- Torpedo color:
#FF4400, radius 5px - Enemy bullet cap raised to 30 for carrier barrages (vs 20 normal)
Damage:
- Shields absorb hits first (1 shield per hit, triggers
sparkShield) - Then HP damage, with 150ms damage flash
Death (multi-stage destruction):
- On HP reaching 0, carrier enters dying state (
dying=true,alive=false,dyingDuration=4000ms) - Enemies retreat immediately
- Initial flash 0.3, shake 15
- Carrier is not targetable or collidable during dying state
- Phase 1 (0-2s): Internal explosions at accelerating intervals (200-400ms). Each spawns 8-15 particles, ring wave, explosion zone, shake, flash. Hull drifts at 30% velocity, spins at 3x
rotationSpeed(accelerating). - Phase 2 (at 50%): Big flash 0.4, shake 25, explosion zone 180px, ring waves. Hull breaks into 5-7 debris chunks flying outward. Shield pickups drop. Score awarded (+500).
- Phase 3 (50-100%): Chunks drift with trailing fire particles. Mini-explosions every 200-500ms. Each chunk detonates individually (15-25 particles, ring wave, explosion zone, AoE damage).
- Finale: When last chunk explodes -- flash 0.6, shake 35, 200px ring wave, 250px explosion zone, 150px AoE damage.
- Carrier AoE damages: players, enemies (chain reactions), asteroids.
Shield recharge:
- Carrier steers toward nearby shield pickups within 200px
- Shields recharge over time at 8000ms interval
Despawn: removed if 300px+ off any screen edge.
Known fix (carrier freeze): A var ci shadowing bug previously caused game freezes during dual carrier battles. The loop variable was renamed to eliminate the shadowing conflict.
| Layer | z-index | Technology | Canvas | In DOM? | Purpose |
|---|---|---|---|---|---|
| L0: Plasma | n/a | WebGL | _plasma.canvas |
No (off-DOM) | Nebula effect, drawn onto starfield via drawImage |
| L1: Starfield | 1 | Canvas 2D | #starfield |
Yes | Stars + plasma background composite |
| L2: Game | 2 | Canvas 2D | #game-canvas |
Yes | Ships, enemies, asteroids, bullets, particles |
Compositing flow: Each frame, the starfield's requestAnimationFrame callback:
- Clears the starfield canvas
- Calls
updatePlasma()which renders the WebGL plasma to its off-DOM canvas - Calls
sfCtx.drawImage(_plasma.canvas, 0, 0, sfW, sfH)to paint the plasma as background - Draws stars on top
The game canvas (#game-canvas) has its own RAF loop via masterLoop which calls updateGame() then renderGame().
The plasma canvas is created via document.createElement('canvas') and never appended to the DOM. It is rendered by WebGL each frame and drawn onto the starfield canvas via drawImage. Context options: { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true }.
Prefers WebGL2, falls back to WebGL1.
Half native resolution: Math.floor(window.innerWidth * 0.5) x Math.floor(window.innerHeight * 0.5) (0.5x). Upscaled to full viewport by the drawImage call.
Vertex shader: Simple passthrough; positions a full-screen quad from the attribute a_position.
Fragment shader structure:
-
Simplex noise (
snoise): 3D simplex noise implementation (Ashima Arts style). Returns values in approximately [-1, 1]. -
fBM (
fbm): 4 octaves of simplex noise:- Octave 1:
snoise(p) * 0.5 - Octave 2:
snoise(p * 2.0) * 0.25 - Octave 3:
snoise(p * 4.0) * 0.125 - Octave 4:
snoise(p * 8.0) * 0.0625
- Octave 1:
-
HSL to RGB (
hsl2rgb): Standard conversion function. -
Main function:
- Computes UV coordinates, applies aspect ratio correction
- Ship repulsion: computed in unrotated screen space, scaled by
u_ship_alpha. Ship position with drift offset computed in shader. - Explosion cloud displacement: up to 8 zones, each with noise-warped boundary, cutout via smoothstep
- Rotation: applied to noise sampling coordinates only. Rotation speed:
0.00873rad/sec (~0.5 deg/sec). Rotates around center of screen. - Color computation: Two fBM samples combined. Hue rotates fully every 30 seconds, spatially varied. Full saturation. Lightness varies with cloud density. Alpha modulated by cloud density, ship hole, and explosion holes.
| Uniform | Type | Purpose |
|---|---|---|
u_time |
float |
performance.now() / 1000 |
u_resolution |
vec2 |
Drawing buffer dimensions (half-res) |
u_mouse |
vec2 |
Ship normalized position (X: [0,1], Y: [0,1] bottom-up) |
u_explosions[8] |
vec3[8] |
Each: (normalizedX, 1-normalizedY, radius) |
u_explosion_count |
int |
Number of active explosion zones (max 8) |
u_ship_alpha |
float |
Ship presence factor (0-1). Scales repulsion radius, strength, and hole cutout. |
- Count: 150
- Properties per star:
x,y,r(1.0-2.5px),opacity(0.2-0.8),speed(0.1-0.4),depth(0-1),twinkle(random phase)
Uses sin(time * 0.001 + star.twinkle). When sine > 0.6 (~20% of the time), opacity is modulated for occasional brightening.
Actual vertical speed: star.speed * (0.5 + star.depth * 0.5). Stars wrap around vertically.
Stars offset by mouse position: px = star.x + mouseX * 20 * star.depth. Maximum parallax offset is 20px for depth=1 stars.
Master loop: masterLoop(timestamp) called via requestAnimationFrame.
updateGame(timestamp, mouseX, mouseY):
- Compute delta time; cap at 16ms if > 100ms
- If paused, return early
- Update mouse state (prevX/prevY, lastMoveTime)
updateParticleBudget()updatePlayer(dt)(for each player)updateBullets(dt)updateCarrier(dt)(includes spawn, AI, enemy launches, torpedo barrage)updateEnemies(dt)(includes spawn logic and firing)updateAsteroids(dt)(includes spawn logic)updateEnemyBullets(dt)updateLasers(dt)updateShieldPickups(dt)checkCollisions()updateParticles(dt)updateExplosionZones(dt)updateRingWaves(dt)renderGame()
renderGame() draw order:
- Apply screen shake translation
drawAsteroids()drawCarrier()(if alive)drawEnemies()drawBullets()drawEnemyBullets()drawLasers()drawShieldPickups()drawPlayer()(for each player)drawParticles()drawRingWaves()drawHUD()drawScreenFlash()- Restore from shake
- Draw performance overlay (outside shake transform)
Pause: gameState.paused is set by P key or visibilitychange (tab hidden).
- Score: bottom-right corner,
10px monospace,#00FFFF, alpha 0.3. Text:"SCORE " + gameState.score. - Screen flash: white full-screen rect, alpha decays by 0.05 per frame. Ticks even when player is dead.
Keyboard controls:
| Key | Action |
|---|---|
P |
Pause / unpause game |
Shift+P |
Toggle performance panel (expanded/collapsed) |
` (backtick) |
Toggle debug mode (shown as "DEBUG" in perf overlay) |
1-4 |
Set player level instantly (debug mode only) |
5 |
Toggle god mode -- infinite health (shown as "DEBUG [GOD]" in perf overlay, debug mode only) |
All key handlers ignore keypresses when focused on INPUT, SELECT, or TEXTAREA elements.
Debug mode:
- Toggled with the backtick key
- When active, "DEBUG" is displayed in the performance overlay
- Keys 1-4 instantly set the player's level to the corresponding value
- Key 5 toggles god mode (infinite health); perf overlay shows "DEBUG [GOD]" when god mode is active
Performance audit overlay (press Shift+P to toggle):
- Collapsed (default): single line showing FPS, particle budget percentage, and
[P]hint. Shows "PAUSED" suffix when game is paused. - Expanded: full breakdown showing:
- FPS + budget percentage
- Per-subsystem update timings (ms/frame)
- Per-draw-function render timings (ms/frame)
- Entity counts: enemies, asteroids, bullets, eBullets, particles, lasers, rings, expZones, pickups, carrier
- Color coding for timing values: green (
#00FF88) < 0.5ms, orange (#FF9933) 0.5-2ms, red (#FF3366) > 2ms - FPS color: green > 50, orange 30-50, red < 30
A single mousemove listener on document updates:
sharedMouseX,sharedMouseY-- raw pixel coords (fed to game engine)sharedMouseNX,sharedMouseNY-- normalized [0,1] coords, Y-flipped (fed to plasma shader)starfieldMouseX,starfieldMouseY-- [-1, 1] range (fed to star parallax)
sharedMouseX/Y --[masterLoop]--> updateGame(timestamp, mouseX, mouseY)
--> gameState.mouse.x/y (with prevX/prevY tracking)
--> updatePlayer reads gameState.mouse
In multiplayer, guest mouse input is sent to the host via Socket.io. The host applies it to the corresponding player in the players[] array.
mouseleave: setsmouseInWindow = false. Ship enters glide-to-stop mode.mouseenter: setsmouseInWindow = true. Normal mouse-tracking resumes.
Player is considered idle when gameState.time - mouse.lastMoveTime > 5000. When idle:
- Ship drifts toward canvas center
- Target angle resets to
-PI/2(pointing up) - Engine trail reduced (70% chance of skipping)
- Auto-fire still works if targets are in range and cone
Pre-decoded AudioBuffer pool via Web Audio API. All sound files are decoded at load time into buffers. Playback creates a new AudioBufferSourceNode per instance -- unlimited polyphony, no channel contention.
sfxAt(buffer, x, y) plays a sound with distance-based volume and stereo panning:
- Volume scales inversely with distance from the listener (local player position)
- Stereo pan computed from horizontal offset relative to the player
| Sound | Trigger |
|---|---|
| Blaster | Player bullet fired |
| Laser | Enemy laser fired |
| Photon torpedo | Enemy torpedo fired |
| Asteroid explosion | Asteroid destroyed |
| Ship explosion (6 variants) | Player or enemy death (randomized selection) |
| Shield pickup | Shield collected |
- Auto-discovers Nebula
*.mp3tracks from the/sounds/directory at startup - Shuffled playlist per session -- no repeat until all tracks played
- Tracks play back-to-back with no gap, loops indefinitely
- Playback starts on splash screen Play button click (satisfies browser autoplay policy)
Liquid glass splash screen displayed before game starts:
- "Void Drift" title with rotating color gradient text
- Frosted glass Play button
- Click starts the game loop and initiates music playback
All effects are pure CSS applied to the game canvas layers -- zero JavaScript performance cost.
| Effect | Implementation |
|---|---|
| Scanlines | CSS overlay with repeating horizontal lines |
| Vignette | Radial gradient darkening screen edges |
| Neon color pop | CSS filter: saturate() contrast() brightness() |
| Phosphor glow | CSS glow effect on canvas elements |
Game field, starfield canvas, and plasma WebGL canvas all resize correctly on window.resize. Canvas dimensions and viewport uniforms are recalculated to match the new window size.
| Optimization | Detail |
|---|---|
| Glow sprite system | Pre-rendered radial gradient sprites replace Canvas 2D shadowBlur (CPU Gaussian blur). Sprites are drawn via drawImage instead. FPS improved from ~38 to 120+. |
| dt-normalized particles | Particle life, friction, shrink, and ring wave expansion all normalized to a 60fps baseline (dt60 = dt / 16.667). Frame-rate independent at any FPS. |
| Batched draw passes | Enemy drawing split into 3 passes (bodies, charging indicators, shields). Shield drawing split into 2 passes (active, recharging). Lasers split into 2 passes. Minimizes GPU state changes from globalCompositeOperation and shadowBlur toggling. |
| Eliminated JSON deep clone | Guest rendering loop replaced JSON.parse(JSON.stringify(gameState)) with a direct reference swap. Removes per-frame serialization overhead. |
| Plasma half-resolution | Rendered at 0.5x native, upscaled via drawImage |
| Off-screen culling | All draw functions skip entities more than 50px off-screen |
| Particle glow LOD | When particle count > 300, glow particles rendered without shadowBlur |
| Torpedo range limit | 300px max travel caps on-screen count; AoE detonation cleans up |
| Trail length limits | Player bullets: 4 positions, torpedoes: 5 positions |
| Entity count caps | Particles: 500, ring waves: 12, enemies: 12/24 (level-dependent), large asteroids: 12, total asteroids: 50, enemy bullets: 20 (30 during carrier barrages), lasers: 8, explosion zones: 8 |
| Delta time capping | If dt > 100ms (tab was hidden), cap to 16ms to prevent physics explosion |
| Visibility API pause | Game state frozen when tab is hidden |
| Oldest-recycle for particles/rings | shift() removes oldest when at cap |
| Dynamic particle budget | _particleBudget (0.03-1.0) scales all particle emission counts based on count pressure and FPS pressure |
~/Projects/VoidDrift/
├── server.js -- Express + Socket.io server + entire frontend (inline HTML/CSS/JS)
├── package.json -- ESM, express + socket.io, scripts: start
├── package-lock.json -- Lockfile
├── project.json -- Project Home registration
├── CLAUDE.md -- Claude Code instructions
├── SPEC.md -- This file
├── README.md -- Quick start guide
├── sounds/ -- Nebula *.mp3 background music tracks (auto-discovered)
└── node_modules/ -- Dependencies (express, socket.io)
- Two npm dependencies. Express and Socket.io only.
- No build step. No bundler, transpiler, or preprocessor.
- Port 3800. Port 3737 is reserved for Project Home.
- Template literal escaping: No backticks in inline JS. No unintended
${}. Use[].join('\\n')for multi-line strings (e.g., GLSL shaders). Use string concatenation for dynamic HTML. - Code style in game code:
var(notlet/const), regular functions (not arrow functions),gameCtxfor game canvas 2D context,sfCtxfor starfield context. - Utility functions:
lerp,gameDist,rand,randInt,randChoice,clamp,angleWrap,pointToSegDist-- all global, used throughout game code. - Entity IDs: All entities use
nextId()for unique identification across the multiplayer state.