Zelda Adventure - Day 25
AdvancedThe game follows a classic action-adventure architecture with separate systems for rendering, physics, and game logic. The core loop runs at 60 FPS using requestAnimationFrame.
function gameLoop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
if (gameState === 'playing') {
update(deltaTime);
}
render();
animationFrame++;
requestAnimationFrame(gameLoop);
}
Four game states: start, playing, gameover, victory
Player, enemies, items, projectiles as separate arrays
Multiple rooms with their own maps and entities
Visual feedback for hits, pickups, and effects
The world is built using a 2D array where each number represents a tile type. This approach is memory-efficient and allows for easy level design.
// Tile type definitions
const TILES = {
GRASS: 0,
WALL: 1,
WATER: 2,
TREE: 3,
DOOR: 4,
FLOOR: 5,
DUNGEON_WALL: 6,
CHEST: 7,
STAIRS: 8,
TRIFORCE: 9
};
// Room definition with map and entities
const rooms = {
overworld: {
map: [
[3,3,3,3,3,1,1,1,...], // Trees, walls
[3,0,0,0,3,1,0,0,...], // Grass walkable
...
],
enemies: [...],
items: [...],
doors: [...]
}
};
Each tile is 16x16 pixels. The map renders top-to-bottom, left-to-right, allowing proper layering. Tile details like grass tufts use modulo operations for visual variety without extra data.
// Adding visual variety to grass tiles
if ((x + y) % 3 === 0) {
ctx.fillStyle = COLORS.grassDark;
ctx.fillRect(px + 4, py + 4, 2, 2);
ctx.fillRect(px + 10, py + 10, 2, 2);
}
The game uses corner-based tile collision. Before moving, we check if all four corners of the entity's hitbox would be in walkable tiles.
function canMove(x, y, width, height) {
const corners = [
{ x: x + 1, y: y + 1 }, // Top-left
{ x: x + width - 1, y: y + 1 }, // Top-right
{ x: x + 1, y: y + height - 1 }, // Bottom-left
{ x: x + width - 1, y: y + height - 1 }
];
for (const corner of corners) {
const tileX = Math.floor(corner.x / TILE_SIZE);
const tileY = Math.floor(corner.y / TILE_SIZE);
const tile = currentMap[tileY][tileX];
if (tile === TILES.WALL ||
tile === TILES.TREE ||
tile === TILES.WATER) {
return false;
}
}
return true;
}
Doors are special tiles - they block movement unless the player has a key. When touched with a key, the door tile is replaced with a floor tile dynamically.
Each enemy type has unique behavior patterns, creating varied gameplay. The AI is state-based with timers for actions.
Random patrol + projectile shooting every 90-150 frames
Chases player, moves toward player position
Patrol, changes direction on wall collision
Aggressive chase, higher HP, deals double damage
// Moblin AI - Chase player
case 'moblin':
const dx = player.x - enemy.x;
const dy = player.y - enemy.y;
// Choose dominant axis for movement
if (Math.abs(dx) > Math.abs(dy)) {
enemy.direction = dx > 0 ? 'right' : 'left';
} else {
enemy.direction = dy > 0 ? 'down' : 'up';
}
// Move every 3 frames (slower than player)
if (enemy.moveTimer % 3 === 0) {
moveEnemy(enemy, 1);
}
break;
// Octorok projectile shooting
function shootProjectile(enemy) {
const angle = Math.atan2(
player.y - enemy.y,
player.x - enemy.x
);
projectiles.push({
x: enemy.x + enemy.width / 2,
y: enemy.y + enemy.height / 2,
vx: Math.cos(angle) * 3,
vy: Math.sin(angle) * 3,
type: 'rock'
});
}
Combat uses a sword attack with directional hitbox. The system includes knockback, invincibility frames, and visual feedback through particles.
function checkSwordHit() {
const swordRange = 20;
let swordX = player.x + player.width / 2;
let swordY = player.y + player.height / 2;
// Extend sword position based on direction
switch (player.direction) {
case 'up': swordY -= swordRange; break;
case 'down': swordY += swordRange; break;
case 'left': swordX -= swordRange; break;
case 'right': swordX += swordRange; break;
}
enemies.forEach(enemy => {
const dist = Math.hypot(
swordX - (enemy.x + enemy.width / 2),
swordY - (enemy.y + enemy.height / 2)
);
if (dist < 18) {
enemy.health--;
enemy.stunned = true;
enemy.stunnedTimer = 30;
// Apply knockback
const angle = Math.atan2(
enemy.y - player.y,
enemy.x - player.x
);
enemy.x += Math.cos(angle) * 10;
enemy.y += Math.sin(angle) * 10;
}
});
}
Every hit spawns particles at the impact point. Enemies flash white when stunned using frame-based visibility toggle. The player flashes when invincible.
The game supports multiple rooms (overworld and dungeon). Each room has its own map, enemies, items, and door connections.
function loadRoom(roomName) {
const room = rooms[roomName];
currentMap = room.map;
// Create fresh enemy instances
enemies = room.enemies.map(e => ({
x: e.x * TILE_SIZE,
y: e.y * TILE_SIZE,
type: e.type,
health: e.type === 'darknut' ? 4 : 2,
direction: 'down',
moveTimer: 0,
shootTimer: Math.random() * 120,
stunned: false
}));
// Copy items to avoid mutation
items = room.items.map(i => ({
x: i.x * TILE_SIZE + 4,
y: i.y * TILE_SIZE + 4,
type: i.type,
collected: false
}));
// Reset projectiles
projectiles = [];
}
// Door/stairs transition logic
const tile = currentMap[playerTileY][playerTileX];
if (tile === TILES.STAIRS) {
const door = room.doors.find(d =>
d.x === playerTileX &&
d.y === playerTileY
);
if (door) {
currentRoom = door.targetRoom;
loadRoom(currentRoom);
player.x = door.targetX * TILE_SIZE;
player.y = door.targetY * TILE_SIZE;
}
}
Chests remember their opened state in the room definition. When re-entering a room, opened chests stay open while enemies respawn - classic Zelda behavior!