Shadow of the Beast - Day 10
Advanced LevelParallax 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 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).
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.
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)
});
}
}
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.
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++;
}
}
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
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;
}
});
Spawn rate increases with level: spawnChance = 0.02 + level * 0.005. This gradually introduces more enemies as the player progresses.
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
}
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;
}
Purple particles for hits, red particles for kills. The world coordinates are subtracted when drawing so particles stay in world space, not screen space.
Always remove dead particles with splice() and limit max particles to prevent memory issues. This game caps at around 100 active particles.