9 min read
0%

Building Warrior of the Gods

Back to Blog
Building Warrior of the Gods

Building Warrior of the Gods

Every personal site needs a secret. Ours is a full Norse mythology platformer hidden inside a SvelteKit blog — discoverable via the Konami code, a valknut runestone plaque in the footer, or the direct URL. No external libraries. No sprite sheets. Pure canvas.

Play Warrior of the Gods →

Here’s how it was built.

Architecture

The game follows the same pattern as the site’s 404 Storm minigame: a thin SvelteKit route renders a Svelte component, which mounts a <canvas> and loads a self-contained vanilla JS engine from static/scripts/.

src/routes/warrior-of-the-gods/+page.svelte   ← route shell
src/lib/components/WarriorOfTheGods.svelte     ← canvas mount + HUD + touch controls
static/scripts/warrior-game.js                ← IIFE game engine (~2000 lines)
src/lib/utils/konamiCode.js                   ← Konami code detector

The game engine is a single IIFE — no bundler touches it, no imports, no dependencies. Everything lives on window.initWarriorGame, which the Svelte component calls on mount and tears down on destroy.

// warrior-game.js (simplified shell)
(function () {
  window.initWarriorGame = function (canvas, callbacks) {
    const ctx = canvas.getContext("2d");
    // ... engine setup ...
    return function destroy() {
      cancelAnimationFrame(rafId);
      window.removeEventListener("keydown", onKeyDown);
    };
  };
})();

The Svelte component passes callbacks for reactive HUD updates — onScoreChange, onLivesChange, onStateChange — so the score and lives render in HTML overlay rather than on canvas. This keeps font rendering crisp and lets us use the site’s Skranji Norse font.

The Game Loop

Fixed logical resolution of 800×450, scaled to viewport via CSS transform. Delta-time is capped at 33ms to prevent physics explosions on tab-restore.

function gameLoop(timestamp) {
  const dt = Math.min((timestamp - lastTimestamp) / 1000, 0.033);
  lastTimestamp = timestamp;
  update(dt);
  render();
  rafId = requestAnimationFrame(gameLoop);
}

Physics uses acceleration-based movement with friction — snappier than direct velocity assignment, avoids that floaty Mario feel:

const GRAVITY     = 1800; // px/s²
const JUMP_VY     = -600; // px/s
const WALK_ACCEL  = 2400;
const FRICTION    = 0.82; // applied each frame to vx

player.vx += input.dir * WALK_ACCEL * dt;
player.vx *= FRICTION;
player.vy += GRAVITY * dt;

Camera uses lerp follow with hard clamping to level bounds — smooth but never showing outside the world.

All Sprites Are Canvas Primitives

Zero image files. Every character, platform, and particle is drawn with fillRect, arc, bezierCurveTo, and a bit of ctx.save/restore. This keeps the repo clean and the game crisp at any scale.

Ragnar (the player) is built from layered rectangles and arcs:

function drawPlayer(ctx, player, camX) {
  const x = player.x - camX;
  ctx.save();
  ctx.translate(x + player.width / 2, player.y + player.height / 2);
  if (player.isRagnar) ctx.scale(1.8, 1.8);

  // Body
  ctx.fillStyle = player.isRagnar ? "#8b1a1a" : "#c8843a";
  ctx.fillRect(-8, -12, 16, 20);

  // Horned helmet
  ctx.fillStyle = player.isRagnar ? "#555" : "#8b7355";
  ctx.fillRect(-9, -22, 18, 10);
  // ... horns, beard, shield, eyes ...

  ctx.restore();
}

Enemies follow the same approach — all geometry, no textures:

  • Draugr (undead patrol): blue glowing eye sockets, skeletal silhouette in pale grey
  • Fenrir Wolf: low-slung quadruped in dark charcoal, bounding patrol cycle
  • Jörmungandr Serpent: chained arc segments that rise from pits using a sin-wave offset
  • Fire Giant: hulking figure in ember tones with a magma glow aura
  • Shade: translucent wisp with a flickering alpha pulse

Power-Up Progression

The player state follows a simple chain:

normal  →[mushroom]→  enlarged  →[mushroom]→  Ragnar  →[hit]→  enlarged  →[hit]→  normal

Enlarged doubles the hitbox and increases jump height. Ragnar is a full transformation — 1.8× scale, iron armour, red beard, orange glowing eyes, and the ability to throw axes. Ragnar mode persists until you take damage, at which point you revert to enlarged.

function hurtPlayer() {
  if (player.isRagnar) {
    player.isRagnar = false;
    player.enlarged = true;
    player.hurtTimer = HURT_TIME;
    spawnParticles(cx, cy, 8, "#ff6600");
    return;
  }
  if (player.enlarged) {
    player.enlarged = false;
    player.hurtTimer = HURT_TIME;
    return;
  }
  // Normal hit — lose a life
  setLives(livesCount - 1);
}

Mjölnir grants temporary invincibility — the player flashes gold and stomping enemies triggers a shockwave particle burst instead of a normal defeat.

Mead awards +1 life, spawning from mystery boxes like the mushrooms.

Axe Throwing

As Ragnar, pressing Z / X / F (or the AXE touch button) launches an axe projectile:

if (isAttack() && player.axeCooldown <= 0) {
  player.axeCooldown = 0.55; // seconds between throws
  axes.push({
    x: player.x + (player.facing > 0 ? player.width + 4 : -4),
    y: player.y + player.height * 0.35,
    vx: player.facing * 500,
    rotation: 0,
  });
}

// Each frame
for (const ax of axes) {
  ax.x += ax.vx * dt;
  ax.rotation += (ax.vx > 0 ? 1 : -1) * 12 * dt;
}

Axes are drawn with a wooden handle rect and a steel head polygon, spinning mid-air. They despawn on enemy contact or when they leave the camera viewport + 200px.

Custom Sounds via Web Audio API

No audio files for sound effects. Every sound effect is synthesised at runtime using the Web Audio API — oscillators, gain envelopes, and noise buffers.

function playJump() {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.connect(gain); gain.connect(audioCtx.destination);
  osc.frequency.setValueAtTime(220, audioCtx.currentTime);
  osc.frequency.exponentialRampToValueAtTime(440, audioCtx.currentTime + 0.12);
  gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
  osc.start(); osc.stop(audioCtx.currentTime + 0.15);
}

The sound palette covers: jump, land, rune collect, enemy stomp, hurt/injured, power-up transform, axe throw, Valhalla victory fanfare, and an ambient procedural drone for background music. All mutable via the site’s existing sound toggle.

The Nine Realms

The level is one continuous ~50,000px world divided into nine thematic realms — each with unique platform types, enemies, parallax backgrounds, ambient hazards, and a dedicated music track. Hit ▶ to sample any track.

#RealmPlatformsEnemiesHazardTrack
Intro / Scoreboard Theme retro-ragnarok
1Midgard ForestStone groundDraugrPits song-of-midgard-forest
2Bifröst BridgeRainbow bridgeFenrir WolvesStarfield void bifrost-bridge
3Jötunheim IceSlippery iceJörmungandr SerpentsSnowfall echoes-of-jotunheim
4NiflheimFrost platformsDraugr + SerpentsFrozen pits niflheim-of-fog
5MuspelheimVolcanic rockFire GiantsLava gaps tale-of-muspelheim
6ÁlfheimCrystal floatingFenrir WolvesCrystal voids hymn-of-alfheim
7SvartálfaheimrCave stoneDraugr + Fire GiantsDark caverns svartalfheims-forge
8HelheimBone platformsShades + DraugrSoul pits helheims-harp
9Gates of ValhallaGold platformsAll types (gauntlet)No mercy the-gates-of-valhalla

Each realm transitions seamlessly — the camera never cuts, the world scrolls continuously from Midgard to the Gates of Valhalla.

Per-Realm Music System

Music is managed entirely in the Svelte component, outside the game engine. A SECTION_TRACKS array maps realm index to audio file. The engine calls an onSectionChange(idx) callback whenever the player crosses a realm boundary; the component then crossfades between tracks using requestAnimationFrame.

const SECTION_TRACKS = [
  "/sounds/music/song-of-midgard-forest.mp3", // 0 Midgard
  "/sounds/music/bifrost-bridge.mp3",          // 1 Bifröst
  "/sounds/music/echoes-of-jotunheim.mp3",     // 2 Jötunheim
  "/sounds/music/niflheim-of-fog.mp3",         // 3 Niflheim
  "/sounds/music/tale-of-muspelheim.mp3",      // 4 Muspelheim
  "/sounds/music/hymn-of-alfheim.mp3",         // 5 Álfheim
  "/sounds/music/svartalfheims-forge.mp3",     // 6 Svartálfaheimr
  "/sounds/music/helheims-harp.mp3",           // 7 Helheim
  "/sounds/music/the-gates-of-valhalla.mp3",   // 8 Valhalla
  "/sounds/retro-ragnarok.mp3",                // 9 Menu / intro / death screen
];
const MENU_IDX = 9;

function crossfadeTo(idx) {
  // fade out active track, fade in new track over ~2s
  // tracks are lazy-loaded on first entry, prefetched one realm ahead
}

Tracks are lazy-loaded on first entry and prefetched one realm ahead to avoid buffering stalls. Each track starts 6 seconds in to skip any silence at the head of the file. The crossfade uses requestAnimationFrame to step volume rather than the Web Audio API’s AudioParam.linearRampToValueAtTime — simpler to interrupt mid-fade when the player backtracks across a boundary.

I also produced a full custom Warrior of the Gods soundtrack album for the game so each realm, menu state, and final ascent into Valhalla had its own musical identity. Instead of treating music like filler, the goal was to make the score feel like part of the worldbuilding — Norse-themed, cinematic, and still punchy enough to work inside an arcade platformer loop.

Parallax Backgrounds

Each realm has a three-layer parallax drawn before platforms each frame. Midgard gets pine silhouettes and mountain ridges; Bifröst gets a star field with a rainbow arc; Jötunheim adds falling snowflakes; Valhalla renders golden cloud layers and distant spires.

function drawBackground(ctx, camX, level) {
  const section = getCurrentSection(camX + W / 2, level);
  const parallax = [0.1, 0.25, 0.4]; // scroll speeds per layer
  for (let i = 0; i < 3; i++) {
    const offset = camX * parallax[i];
    drawBackgroundLayer(ctx, section, i, offset);
  }
}

Mobile Touch Controls

Touch controls appear only on coarse-pointer devices via a CSS media query, avoiding the cluttered desktop view:

@media (hover: none) and (pointer: coarse) {
  .warrior-game__controls { display: flex; }
}

The d-pad (left/right) and action buttons (jump, axe) set flags on a shared input object by reading data-warrior-* attributes on pointerdown / pointerup. The game loop reads the same input flags regardless of whether they came from keyboard or touch — no special-casing in the physics code.

Halls of Valhalla — Scoreboard

High scores persist in localStorage and survive page refreshes. On death or victory a name-entry prompt appears before the leaderboard. The intro screen shows the top 10; the death screen shows the top 3.

Each entry tracks whether the run reached Valhalla:

function saveScore(name, score, completed) {
  const entry = { name, score, date: Date.now(), completed };
  const scores = getScores();
  scores.push(entry);
  scores.sort((a, b) => b.score - a.score);
  localStorage.setItem("warrior-scores", JSON.stringify(scores.slice(0, 10)));
}

The leaderboard renders a ✅ for completed runs and 💀 for fallen warriors. Completing all nine realms also awards a life bonus — 420 points for every remaining life at the gates of Valhalla.

Easter Egg Discovery

Three ways to find the game:

  1. Konami code on any page: ↑ ↑ ↓ ↓ ← → ← → B A — detected with a 2-second timeout between presses, navigates via SvelteKit’s goto().
  2. Valknut runestone in the footer — a small dark stone plaque (arch-shaped, like a carved runestone tablet) sits in the footer at half opacity. Hover it and the valknut glows amber. Click it and the whole page dissolves into a swirling whirlpool animation — a canvas overlay that spins and contracts to a drain, with the valknut symbol flickering in the vortex before the page transitions to the game.
  3. Direct URL: /warrior-of-the-gods — prerendered, fully shareable.

The runestone is shaped with CSS alone — border-radius: 50% 50% 3px 3px / 28% 28% 3px 3px gives it the rounded-top, flat-bottom silhouette of a standing stone. The valknut inside is an inline SVG, coloured with currentColor so the amber hover tint applies with a single color change.

.footer-rune {
  border-radius: 50% 50% 3px 3px / 28% 28% 3px 3px; /* runestone arch */
  background: light-dark(
    linear-gradient(160deg, #2a2a2a, #0d0d0d),
    linear-gradient(160deg, #e8e0d0, #c8bfaa)
  );
  opacity: 0.5;
  transition: opacity 0.35s ease, transform 0.35s ease;
}

.footer-rune:hover {
  opacity: 1;
  transform: scale(1.08);
  color: #ffab00; /* valknut glows amber */
}

The whirlpool transition is a full-viewport <canvas> drawn over the page — particles spiral inward, the drain contracts, and the valknut fades in at the centre before goto('/warrior-of-the-gods') fires.

The Konami listener lives in +layout.svelte so it’s active on every page without any per-route setup.

Particles

A lightweight flat array handles all visual effects — collection sparkles, enemy defeat bursts, axe impacts, landing dust. Each particle is { x, y, vx, vy, life, maxLife, color, size }. No object pooling — the array stays small enough (50–100 entries peak) that GC pressure is negligible.

function spawnParticles(x, y, count, color) {
  for (let i = 0; i < count; i++) {
    particles.push({
      x, y,
      vx: (Math.random() - 0.5) * 200,
      vy: Math.random() * -200 - 50,
      life: 0.6 + Math.random() * 0.4,
      maxLife: 1,
      color,
      size: 3 + Math.random() * 3,
    });
  }
}

Particles are disabled entirely when prefers-reduced-motion: reduce is set — the game remains fully playable, just without the sparkle layer.

Keeping it Self-Contained

The whole engine avoids import and export deliberately. It drops one global (window.initWarriorGame) and cleans it up on destroy. This makes it trivially portable — drop the script tag anywhere and it works. The Svelte component handles all lifecycle, reactive state, and cleanup so the engine stays pure and testable in isolation.


Browser support snapshot

Live support matrix for canvas from Can I Use.

Show static fallback image Data on support for canvas across major browsers from caniuse.com

Source: caniuse.com

Canvas is not supported in your browser