TECH DETAILS

Shadow of the Beast - Day 10

Advanced Level

Table of Contents

01 / Multi-Layer Parallax Scrolling

Parallax scrolling creates the illusion of depth by moving background layers at different speeds. Objects farther away move slower than those closer to the camera - just like looking out a car window.

// 7 parallax layers like the original Amiga game const layers = [ { speed: 0.05, elements: [] }, // Stars - almost static { speed: 0.1, elements: [] }, // Far mountains { speed: 0.2, elements: [] }, // Mid mountains { speed: 0.35, elements: [] }, // Hills { speed: 0.5, elements: [] }, // Far trees { speed: 0.7, elements: [] }, // Near trees { speed: 1.0, elements: [] } // Ground - moves with camera ]; // World position tracks camera offset let worldX = 0; // Each layer scrolls at its own rate function drawLayer(layerIndex) { const layer = layers[layerIndex]; const offset = worldX * layer.speed; layer.elements.forEach(element => { const screenX = element.x - offset; // Draw element at screenX position }); }

The Math Behind Parallax

The key formula is simple: screenX = worldX - (cameraX * speed). A speed of 1.0 moves with the camera (foreground), while a speed of 0 stays completely static (distant stars).

Amiga Legacy

Shadow of the Beast (1989) was revolutionary for its 12-layer parallax scrolling on Amiga. We recreate this effect with 7 distinct layers, each with procedurally generated content.

02 / Layer Architecture

Each layer contains different types of elements, from twinkling stars to detailed ground objects. This creates visual depth and atmosphere.

function initLayers() { // Layer 0: Twinkling stars for (let i = 0; i < 100; i++) { layers[0].elements.push({ x: Math.random() * canvas.width * 3, y: Math.random() * 150, size: Math.random() * 2 + 0.5, twinkle: Math.random() * Math.PI * 2 }); } // Layer 1-3: Mountains at varying depths for (let i = 0; i < 20; i++) { layers[1].elements.push({ x: i * 200 - 100, width: 150 + Math.random() * 100, height: 80 + Math.random() * 60 }); } // Layer 4-5: Trees with type variation for (let i = 0; i < 50; i++) { layers[5].elements.push({ x: i * 60 + Math.random() * 30, height: 80 + Math.random() * 50, type: Math.floor(Math.random() * 3) // Dead tree, pine, or bush }); } // Layer 6: Ground details (rocks, bones, skulls) for (let i = 0; i < 100; i++) { layers[6].elements.push({ x: i * 40, type: Math.floor(Math.random() * 4) }); } }

Rendering Order

Layers are drawn back-to-front: sky first, then distant mountains, closer hills, trees, and finally the ground with details. This ensures proper visual layering.

03 / Player Physics & Animation

The player (a beast-like warrior) uses simple physics for movement and jumping, with animated legs and attack poses.

const player = { x: 150, y: 300, velocityX: 0, velocityY: 0, speed: 4, jumpPower: -15, gravity: 0.6, onGround: false, attacking: false, facingRight: true, animTimer: 0 }; function update() { // Horizontal movement with friction if (keys.left) { player.velocityX = -player.speed; player.facingRight = false; } else if (keys.right) { player.velocityX = player.speed; player.facingRight = true; } else { player.velocityX *= 0.8; // Friction } // Jumping (only when grounded) if (keys.jump && player.onGround) { player.velocityY = player.jumpPower; player.onGround = false; } // Apply gravity player.velocityY += player.gravity; // Ground collision if (player.y > groundY - player.height) { player.y = groundY - player.height; player.velocityY = 0; player.onGround = true; } // Animate legs when moving if (Math.abs(player.velocityX) > 0.5) { player.animTimer++; } }

Camera Following

The camera smoothly follows the player, keeping them at 1/3 of the screen width. This gives more visibility in the direction of movement.

// Smooth camera follow const targetWorldX = player.x - canvas.width / 3; worldX += (targetWorldX - worldX) * 0.1; // Lerp for smoothness

04 / Enemy AI Patterns

Three enemy types with different behaviors: ground goblins, flying bats, and skeleton warriors. Each has unique movement patterns.

function spawnEnemy() { // Spawn from left or right edge const side = Math.random() < 0.5 ? -1 : 1; const x = side === 1 ? worldX + canvas.width + 50 : worldX - 50; enemies.push({ x: x, y: groundY - 50, health: 30, speed: 1 + Math.random() * 1.5, direction: side === 1 ? -1 : 1, type: Math.floor(Math.random() * 3) // 0=goblin, 1=bat, 2=skeleton }); } // Enemy behavior in update loop enemies.forEach(enemy => { // Chase player const dx = player.x - enemy.x; enemy.direction = dx > 0 ? 1 : -1; enemy.x += enemy.direction * enemy.speed; // Bats float up and down if (enemy.type === 1) { enemy.y = groundY - 80 + Math.sin(enemy.animTimer * 0.1) * 20; } });
Difficulty Scaling

Spawn rate increases with level: spawnChance = 0.02 + level * 0.005. This gradually introduces more enemies as the player progresses.

05 / Combat & Collision

The attack system uses a timed cooldown and hitbox detection. Enemies can damage the player on contact, but attacks deal knockback and damage.

// Attack initiation if (keys.attack && player.attackCooldown <= 0) { player.attacking = true; player.attackTimer = 10; // Attack lasts 10 frames player.attackCooldown = 20; // Can't attack again for 20 frames } // Attack hitbox check if (player.attacking && player.attackTimer > 5) { // Hitbox extends in front of player const attackX = player.facingRight ? player.x + 60 // Right attack : player.x - 20; // Left attack enemies.forEach((enemy, index) => { if (Math.abs(enemy.x - attackX) < 50 && Math.abs(enemy.y - player.y) < 50) { enemy.health -= 15; addParticles(enemy.x, enemy.y, '#9b59b6', 10); if (enemy.health <= 0) { enemies.splice(index, 1); score += 100; addParticles(enemy.x, enemy.y, '#ff6b6b', 20); } } }); } // Enemy contact damage if (Math.abs(enemy.x - player.x) < 30 && !player.attacking) { player.health -= 1; // Constant damage on contact }

06 / Particle Effects

Particles add visual feedback for hits and enemy deaths. Each particle has velocity, gravity, and a fading lifetime.

let particles = []; function addParticles(x, y, color, count) { for (let i = 0; i < count; i++) { particles.push({ x: x, y: y, vx: (Math.random() - 0.5) * 8, // Random X velocity vy: (Math.random() - 0.5) * 8, // Random Y velocity life: 30 + Math.random() * 20, // Frames until death color: color, size: 2 + Math.random() * 4 }); } } // Update particles particles.forEach((p, index) => { p.x += p.vx; p.y += p.vy; p.vy += 0.2; // Gravity p.life--; if (p.life <= 0) { particles.splice(index, 1); } }); // Draw with fade function drawParticles() { particles.forEach(p => { ctx.fillStyle = p.color; ctx.globalAlpha = p.life / 50; // Fade out ctx.beginPath(); ctx.arc(p.x - worldX, p.y, p.size, 0, Math.PI * 2); ctx.fill(); }); ctx.globalAlpha = 1; }

Visual Feedback

Purple particles for hits, red particles for kills. The world coordinates are subtracted when drawing so particles stay in world space, not screen space.

Performance Tip

Always remove dead particles with splice() and limit max particles to prevent memory issues. This game caps at around 100 active particles.