Getting Started with MelonJS

Published on Monday, December 15, 2025

LittleJS Example

And here is a simple example of how to use LittleJS to create a game.

LittleJS example
javascript
import * as littlejsengine from 'https://esm.run/littlejsengine';

const {
  setCameraScale,
  setCameraPos,
  Color,
  engineInit,
  vec2,
  tile,
  drawRect,
  drawTile,
  drawCircle,
  drawText,
  setCanvasFixedSize,
  keyDirection,
  keyWasPressed,
  mouseWasPressed,
  gamepadStick,
  Timer,
  rand,
  time,
  Sound,
  isTouchDevice,
  setTouchGamepadEnable,
  setTouchGamepadAnalog,
  setTouchGamepadSize,
  setTouchGamepadButtonCount,
} = littlejsengine;

// Enable touch gamepad for mobile devices
setTouchGamepadEnable(true);
setTouchGamepadAnalog(false); // Use 8-way dpad instead of analog
setTouchGamepadSize(80);
setTouchGamepadButtonCount(0); // No right-side buttons needed

// =============================================================================
// SOUND EFFECTS (using ZzFX via Sound class for better browser compatibility)
// Sounds are initialized in gameInit after engine starts
// Design sounds at: https://killedbyapixel.github.io/ZzFX/
// =============================================================================
const Sounds = {
  coin: null,
  combo: null,
  dash: null,
  bounce: null,
};

function initSounds() {
  // Initialize audio primer first
  initAudioPrimer();

  // Coin collect - brighter chime (about 3/4 pitch of red flag sound)
  // biome-ignore lint/suspicious/noSparseArray: ZzFX format
  Sounds.coin = new Sound([
    2.5,
    ,
    580,
    0.01,
    0.08,
    0.2,
    1,
    1.8,
    ,
    ,
    100,
    0.03,
    ,
    ,
    ,
    ,
    ,
    0.65,
    0.02,
  ]);

  // Combo collect - higher pitched, more exciting
  // biome-ignore lint/suspicious/noSparseArray: ZzFX format
  Sounds.combo = new Sound([
    2.5,
    ,
    698,
    0.02,
    0.1,
    0.3,
    1,
    2,
    ,
    ,
    200,
    0.05,
    ,
    ,
    ,
    ,
    ,
    0.7,
    0.02,
  ]);

  // Dash - whoosh sound
  // biome-ignore lint/suspicious/noSparseArray: ZzFX format
  Sounds.dash = new Sound([
    1,
    ,
    200,
    0.01,
    0.02,
    0.1,
    4,
    0.5,
    -10,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    0.3,
    0.01,
  ]);

  // Wall bounce - soft thud
  // biome-ignore lint/suspicious/noSparseArray: ZzFX format
  Sounds.bounce = new Sound([
    0.8,
    ,
    150,
    0.01,
    0.02,
    0.05,
    4,
    0.3,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    0.2,
    0.01,
  ]);
}

// Flag to track if audio has been unlocked
let audioPrimed = false;
let silentPrimer = null;

function initAudioPrimer() {
  // Create a very short, nearly silent sound just to unlock audio context
  // biome-ignore lint/suspicious/noSparseArray: ZzFX format
  silentPrimer = new Sound([0.01, , 1, , 0.001, 0.001]);
}

function primeAudio() {
  if (audioPrimed) return;
  audioPrimed = true;
  // Play nearly inaudible sound to unlock audio context
  silentPrimer?.play();
}

// =============================================================================
// LOCAL STORAGE HELPERS (safe for iframes)
// =============================================================================
function saveHighScore(score) {
  try {
    localStorage.setItem('slalomHighScore', score.toString());
  } catch (e) {
    // localStorage may be blocked in iframes
  }
}

function loadHighScore() {
  try {
    const saved = localStorage.getItem('slalomHighScore');
    return saved ? parseInt(saved, 10) : 0;
  } catch (e) {
    return 0;
  }
}

// =============================================================================
// GAME CONFIGURATION
// =============================================================================
const GameConfig = {
  center: 0,
  height: 28,
  size: 0,
  tiles: ['/images/tilemap.png'],
  tileSize: 16,
  tilePadding: 0.45,
  width: 20,
};

// =============================================================================
// GAME STATE
// =============================================================================
const GameState = {
  player: null,
  coins: [],
  particles: [],
  decorations: [],
  score: 0,
  highScore: 0,
  spawnTimer: null,
  comboTimer: null,
  combo: 0,
  screenShake: 0,
  gameTimer: null,
  gameTime: 30, // 30 second rounds
  gameOver: false,
  gameStarted: false, // Wait for input before starting
  flagsSpawned: 0, // Counter for deterministic red flag spawning
};

// =============================================================================
// PLAYER
// =============================================================================
function createPlayer() {
  return {
    pos: vec2(0, -8),
    vel: vec2(0, 0),
    size: vec2(2, 2),
    baseSpeed: 0.08,
    friction: 0.92,
    isMoving: false,
    squash: 1,
    stretch: 1,
    facingRight: true,
    trail: [],
  };
}

function updatePlayer(player) {
  // Don't update if game is over
  if (GameState.gameOver) return;

  // Get input from arrow keys, WASD, or touch gamepad
  const keyInput = keyDirection();
  const touchInput = gamepadStick(0);
  const input = keyInput.length() > 0 ? keyInput : touchInput;

  // Prime audio on first input
  if (!audioPrimed && (input.x !== 0 || input.y !== 0)) {
    primeAudio();
  }

  // Apply acceleration based on input
  player.vel.x += input.x * player.baseSpeed;
  player.vel.y += input.y * player.baseSpeed;

  // Apply friction
  player.vel = player.vel.scale(player.friction);

  // Update position
  player.pos = player.pos.add(player.vel);

  // Track facing direction
  if (Math.abs(player.vel.x) > 0.01) {
    player.facingRight = player.vel.x > 0;
  }

  // Add trail particles when moving fast
  if (player.vel.length() > 0.15) {
    player.trail.push({
      pos: player.pos.copy(),
      life: 1,
      size: 0.8,
    });
  }

  // Update trail
  player.trail = player.trail.filter((t) => {
    t.life -= 0.08;
    t.size *= 0.92;
    return t.life > 0;
  });

  // Bounce off walls with juice!
  const halfWidth = GameConfig.width / 2 - 1;
  const halfHeight = GameConfig.height / 2 - 2;

  if (player.pos.x < -halfWidth) {
    player.pos.x = -halfWidth;
    player.vel.x *= -0.7;
    player.squash = 0.6;
    GameState.screenShake = 0.4;
    spawnParticles(
      vec2(-halfWidth, player.pos.y),
      8,
      new Color(0.9, 0.95, 1, 1),
    );
    Sounds.bounce?.play();
    // Penalty for hitting wall
    GameState.score = Math.max(0, GameState.score - 1);
    spawnFloatingText(player.pos, '-1', new Color(1, 0.3, 0.3, 1));
  }
  if (player.pos.x > halfWidth) {
    player.pos.x = halfWidth;
    player.vel.x *= -0.7;
    player.squash = 0.6;
    GameState.screenShake = 0.4;
    spawnParticles(
      vec2(halfWidth, player.pos.y),
      8,
      new Color(0.9, 0.95, 1, 1),
    );
    Sounds.bounce?.play();
    // Penalty for hitting wall
    GameState.score = Math.max(0, GameState.score - 1);
    spawnFloatingText(player.pos, '-1', new Color(1, 0.3, 0.3, 1));
  }
  if (player.pos.y < -halfHeight) {
    player.pos.y = -halfHeight;
    player.vel.y *= -0.7;
    player.stretch = 0.6;
    GameState.screenShake = 0.3;
    spawnParticles(
      vec2(player.pos.x, -halfHeight),
      8,
      new Color(0.9, 0.95, 1, 1),
    );
    Sounds.bounce?.play();
    // Penalty for hitting wall
    GameState.score = Math.max(0, GameState.score - 1);
    spawnFloatingText(player.pos, '-1', new Color(1, 0.3, 0.3, 1));
  }
  if (player.pos.y > halfHeight) {
    player.pos.y = halfHeight;
    player.vel.y *= -0.7;
    player.stretch = 0.6;
    GameState.screenShake = 0.4;
    Sounds.bounce?.play();
    // Penalty for hitting wall
    GameState.score = Math.max(0, GameState.score - 1);
    spawnFloatingText(player.pos, '-1', new Color(1, 0.3, 0.3, 1));
  }

  // Animate squash and stretch back to normal
  player.squash += (1 - player.squash) * 0.15;
  player.stretch += (1 - player.stretch) * 0.15;

  // Track if player is moving (for animation state)
  player.isMoving = input.x !== 0 || input.y !== 0 || player.vel.length() > 0.1;
}

function renderPlayer(player) {
  // Render trail first (snowy/icy trail)
  for (const t of player.trail) {
    const alpha = t.life * 0.4;
    drawCircle(t.pos, t.size, new Color(0.8, 0.9, 1, alpha));
  }

  const drawPos = vec2(player.pos.x, player.pos.y);

  // Shadow on snow
  drawCircle(
    vec2(player.pos.x, player.pos.y - 0.7),
    1.4,
    new Color(0.4, 0.5, 0.6, 0.2),
  );

  // Skier character - tile 70 when idle, tile 71 when moving
  const playerTile = player.isMoving ? 71 : 70;
  const size = vec2(
    player.size.x * player.squash * (player.facingRight ? 1 : -1),
    player.size.y * player.stretch,
  );
  drawTile(
    drawPos,
    size,
    tile(playerTile, GameConfig.tileSize, 0, GameConfig.tilePadding),
  );
}

// =============================================================================
// FLAGS (Slalom gates to collect!)
// =============================================================================
function createFlag(pos) {
  const halfW = GameConfig.width / 2 - 2;
  const halfH = GameConfig.height / 2 - 3;
  // Every 7th flag is red (deterministic, fair for all players)
  GameState.flagsSpawned++;
  const isRed = GameState.flagsSpawned % 7 === 0;
  return {
    pos: pos || vec2(rand(-halfW, halfW), rand(-halfH + 2, halfH)),
    size: vec2(1.4, 1.4),
    spawnPhase: rand(0, Math.PI * 2),
    collected: false,
    spawnTime: time,
    scale: 0,
    isRed: isRed,
  };
}

function spawnFlags(count) {
  for (let i = 0; i < count; i++) {
    GameState.coins.push(createFlag());
  }
}

function updateFlags() {
  // Don't update if game is over
  if (GameState.gameOver) return;

  for (const flag of GameState.coins) {
    // Spawn animation only
    if (flag.scale < 1) {
      flag.scale += 0.1;
      if (flag.scale > 1) flag.scale = 1;
    }

    // Check collision with player
    const dist = flag.pos.distance(GameState.player.pos);
    if (dist < 1.3 && !flag.collected) {
      flag.collected = true;

      // Simple scoring: blue = 1, red = 3
      const points = flag.isRed ? 3 : 1;
      GameState.score += points;

      // Spawn floating score text
      const textColor = flag.isRed
        ? new Color(1, 0.4, 0.4, 1) // Red for red flags
        : new Color(0.4, 0.6, 1, 1); // Blue for blue flags
      spawnFloatingText(flag.pos.add(vec2(0, 0.5)), `+${points}`, textColor);

      // Spawn celebration particles (snow burst!)
      const particleCount = flag.isRed ? 15 : 10;
      const particleColor = flag.isRed
        ? new Color(1, 0.5, 0.5, 1)
        : new Color(0.5, 0.7, 1, 1);
      spawnParticles(flag.pos, particleCount, particleColor);

      // Red flags get the more exciting sound
      if (flag.isRed) {
        Sounds.combo?.play();
      } else {
        Sounds.coin?.play();
      }

      // Update high score
      if (GameState.score > GameState.highScore) {
        GameState.highScore = GameState.score;
        saveHighScore(GameState.highScore);
      }
    }
  }

  // Remove collected flags
  GameState.coins = GameState.coins.filter((c) => !c.collected);

  // Spawn new flags - keep between 1 and 6 on screen
  // Always maintain at least 1 flag so fast players always see one
  if (GameState.coins.length < 1) {
    spawnFlags(1);
  } else if (GameState.coins.length < 6) {
    // Spawn rate increases with score - fast players get more flags
    const baseRate = 0.015;
    const scoreBonus = Math.min(GameState.score * 0.001, 0.025);
    if (Math.random() < baseRate + scoreBonus) {
      spawnFlags(1);
    }
  }
}

function renderFlags() {
  for (const flag of GameState.coins) {
    const drawPos = vec2(flag.pos.x, flag.pos.y);

    // Flag shadow on snow
    drawCircle(
      vec2(flag.pos.x, flag.pos.y - 0.4),
      0.6 * flag.scale,
      new Color(0.4, 0.5, 0.6, 0.15),
    );

    // Subtle glow effect (red or blue based on flag type)
    const glowColor = flag.isRed
      ? new Color(1, 0.4, 0.4, 0.2)
      : new Color(0.4, 0.6, 1, 0.2);
    drawCircle(drawPos, 1.0 * flag.scale, glowColor);

    // Flag sprite (red=20, blue=21) - large slalom flags
    const flagTile = flag.isRed ? 20 : 21;
    const size = vec2(flag.size.x * flag.scale, flag.size.y * flag.scale);
    drawTile(
      drawPos,
      size,
      tile(flagTile, GameConfig.tileSize, 0, GameConfig.tilePadding),
    );
  }
}

// =============================================================================
// FLOATING TEXT (score popups)
// =============================================================================
function spawnFloatingText(pos, text, color) {
  GameState.floatingTexts.push({
    pos: pos.copy(),
    vel: vec2(rand(-0.02, 0.02), 0.08),
    text: text,
    color: color,
    life: 1,
    scale: 0.5,
  });
}

function updateFloatingTexts() {
  for (const ft of GameState.floatingTexts) {
    ft.pos = ft.pos.add(ft.vel);
    ft.vel = ft.vel.scale(0.98);
    ft.life -= 0.025;
    // Scale up quickly then stay
    if (ft.scale < 1) {
      ft.scale += 0.15;
      if (ft.scale > 1) ft.scale = 1;
    }
  }

  // Remove faded texts
  GameState.floatingTexts = GameState.floatingTexts.filter((ft) => ft.life > 0);
}

function renderFloatingTexts() {
  for (const ft of GameState.floatingTexts) {
    const alpha = ft.life * ft.life; // Ease out
    const color = new Color(ft.color.r, ft.color.g, ft.color.b, alpha);
    const shadowColor = new Color(0, 0, 0, alpha * 0.5);
    const size = 0.6 * ft.scale;

    // Shadow
    drawText(ft.text, ft.pos.add(vec2(0.05, -0.05)), size, shadowColor);
    // Main text
    drawText(ft.text, ft.pos, size, color);
  }
}

// =============================================================================
// PARTICLES
// =============================================================================
function spawnParticles(pos, count, color) {
  for (let i = 0; i < count; i++) {
    const angle = rand(0, Math.PI * 2);
    const speed = rand(0.08, 0.2);
    GameState.particles.push({
      pos: pos.copy(),
      vel: vec2(Math.cos(angle) * speed, Math.sin(angle) * speed + 0.1),
      size: rand(0.15, 0.4),
      color: new Color(
        color.r + rand(-0.1, 0.1),
        color.g + rand(-0.1, 0.1),
        color.b + rand(-0.1, 0.1),
        1,
      ),
      life: 1,
      decay: rand(0.015, 0.035),
      gravity: rand(0.002, 0.006),
    });
  }
}

function updateParticles() {
  for (const p of GameState.particles) {
    p.pos = p.pos.add(p.vel);
    p.vel = p.vel.scale(0.97);
    p.vel.y -= p.gravity;
    p.life -= p.decay;
    p.size *= 0.97;
  }

  // Remove dead particles
  GameState.particles = GameState.particles.filter((p) => p.life > 0);
}

function renderParticles() {
  for (const p of GameState.particles) {
    const alpha = p.life * p.life; // Ease out
    const color = new Color(p.color.r, p.color.g, p.color.b, alpha);
    drawCircle(p.pos, p.size, color);
  }
}

// =============================================================================
// DECORATIONS (snowy background stuff)
// =============================================================================
function createDecorations() {
  const decorations = [];
  const baseY = -13.5; // At the very bottom of the screen

  // Mix of snowy pines (6/18), bare trees (7/19) - 10 trees with organic spacing
  const treePositions = [-9.2, -6.5, -4.8, -2, 0.5, 2.8, 5.2, 6.8, 8.5, 9.8];
  for (let i = 0; i < treePositions.length; i++) {
    const xPos = treePositions[i];
    const treeX = xPos + rand(-0.8, 0.8); // Much more horizontal variation
    const isBare = rand() > 0.65; // 35% chance of bare tree
    const topTile = isBare ? 7 : 6;
    const trunkTile = isBare ? 19 : 18;
    const treeSize = rand(1.1, 1.6);

    // Trunk
    decorations.push({
      pos: vec2(treeX, baseY),
      tile: trunkTile,
      size: vec2(treeSize, treeSize),
      isTree: true,
    });
    // Top
    decorations.push({
      pos: vec2(treeX, baseY + treeSize * 0.85),
      tile: topTile,
      size: vec2(treeSize, treeSize),
      isTree: true,
    });
  }

  // Add lots of bushes scattered between and in front of trees (24 bushes)
  for (let i = 0; i < 24; i++) {
    const bushX = rand(-9, 9);
    const bushY = baseY + rand(0.1, 1.2);
    const isSnowy = rand() > 0.4; // 60% snowy, 40% bare
    decorations.push({
      pos: vec2(bushX, bushY),
      tile: isSnowy ? 30 : 31,
      size: vec2(rand(0.5, 0.9), rand(0.5, 0.9)),
      isBush: true, // Mark as bush for render ordering
    });
  }

  // Add a big snowman between trees
  decorations.push({
    pos: vec2(rand(-2, 2), baseY + rand(0.5, 1.0)),
    tile: 69,
    size: vec2(2.0, 2.0),
    isBush: true, // Render with bushes (behind trees)
  });

  // Add some ski tracks on the ground
  for (let i = 0; i < 8; i++) {
    decorations.push({
      pos: vec2(rand(-8, 8), rand(-11, -8)),
      tile: 41,
      size: vec2(1, 1),
      isTrack: true,
    });
  }
  // Add some snow clouds
  for (let i = 0; i < 5; i++) {
    decorations.push({
      pos: vec2(rand(-10, 10), rand(9, 12)),
      tile: -1, // Use circles for clouds
      size: vec2(rand(2, 4), rand(1, 2)),
      phase: rand(0, Math.PI * 2),
      speed: rand(0.008, 0.02),
      isCloud: true,
    });
  }
  // Add falling snowflakes
  for (let i = 0; i < 20; i++) {
    decorations.push({
      pos: vec2(rand(-10, 10), rand(-12, 14)),
      size: vec2(rand(0.1, 0.25)),
      phase: rand(0, Math.PI * 2),
      speed: rand(0.02, 0.05),
      isSnowflake: true,
    });
  }
  return decorations;
}

function updateDecorations() {
  for (const d of GameState.decorations) {
    if (d.isCloud) {
      d.pos.x += d.speed;
      if (d.pos.x > 14) d.pos.x = -14;
    }
    if (d.isSnowflake) {
      d.phase += 0.05;
      d.pos.y -= d.speed;
      d.pos.x += Math.sin(d.phase) * 0.02;
      // Reset snowflake to top when it falls off screen
      if (d.pos.y < -14) {
        d.pos.y = 14;
        d.pos.x = rand(-10, 10);
      }
    }
  }
}

function renderDecorations() {
  // Render clouds first (behind everything)
  for (const d of GameState.decorations) {
    if (d.isCloud) {
      const wobble = Math.sin(d.phase) * 0.1;
      drawCircle(d.pos, d.size.x, new Color(0.9, 0.93, 0.98, 0.8));
      drawCircle(
        d.pos.add(vec2(-0.8, 0.2)),
        d.size.x * 0.7,
        new Color(0.95, 0.97, 1, 0.8),
      );
      drawCircle(
        d.pos.add(vec2(0.8, 0.1 + wobble)),
        d.size.x * 0.6,
        new Color(0.92, 0.95, 1, 0.8),
      );
    }
    // Render falling snowflakes
    if (d.isSnowflake) {
      const twinkle = 0.6 + Math.sin(d.phase * 3) * 0.4;
      drawCircle(d.pos, d.size.x, new Color(1, 1, 1, twinkle));
    }
  }
}

function renderForegroundDecorations() {
  // Render bushes and snowman first (behind trees)
  for (const d of GameState.decorations) {
    if (d.isBush && d.tile) {
      drawTile(
        d.pos,
        d.size,
        tile(d.tile, GameConfig.tileSize, 0, GameConfig.tilePadding),
      );
    }
  }

  // Then render trees and tracks on top
  for (const d of GameState.decorations) {
    if ((d.isTree || d.isTrack) && d.tile) {
      drawTile(
        d.pos,
        d.size,
        tile(d.tile, GameConfig.tileSize, 0, GameConfig.tilePadding),
      );
    }
  }
}

// =============================================================================
// BACKGROUND & UI
// =============================================================================
function renderBackground() {
  // Winter sky gradient (pale blue to white)
  drawRect(
    vec2(0, 0),
    vec2(GameConfig.width, GameConfig.height),
    new Color(0.7, 0.82, 0.92, 1),
  );
  drawRect(
    vec2(0, -5),
    vec2(GameConfig.width, 12),
    new Color(0.82, 0.88, 0.95, 1),
  );

  // Snow/ice ground
  drawRect(
    vec2(0, -12),
    vec2(GameConfig.width, 5),
    new Color(0.95, 0.97, 1, 1),
  );
  // Ice rink surface
  drawRect(
    vec2(0, -2),
    vec2(GameConfig.width - 2, GameConfig.height - 6),
    new Color(0.85, 0.92, 0.98, 1),
  );
  // Snow bank edges
  drawRect(
    vec2(0, -13),
    vec2(GameConfig.width, 2),
    new Color(0.88, 0.91, 0.96, 1),
  );
}

function renderUI() {
  // Dark bar behind top UI for readability
  drawRect(
    vec2(0, 13),
    vec2(GameConfig.width, 3),
    new Color(0.1, 0.15, 0.25, 0.7),
  );

  // Score display
  const scoreText = `Flags: ${GameState.score}`;
  drawText(scoreText, vec2(-7, 12.5), 0.7, new Color(0, 0, 0, 0.5));
  drawText(scoreText, vec2(-7, 12.7), 0.7, new Color(1, 1, 1, 1));

  // High score
  const highText = `Best: ${GameState.highScore}`;
  drawText(highText, vec2(7, 12.5), 0.6, new Color(0, 0, 0, 0.5));
  drawText(highText, vec2(7, 12.7), 0.6, new Color(1, 1, 1, 1));

  // Timer display
  const timeLeft = Math.max(0, Math.ceil(-(GameState.gameTimer?.get() || 0)));
  const timerColor =
    timeLeft <= 5 ? new Color(1, 0.3, 0.3, 1) : new Color(1, 1, 1, 1);
  drawText(`Time: ${timeLeft}`, vec2(0, 12.7), 0.7, timerColor);

  // Start screen
  if (!GameState.gameStarted) {
    drawRect(
      vec2(0, 0),
      vec2(GameConfig.width, GameConfig.height),
      new Color(0, 0, 0, 0.5),
    );
    drawText('SLALOM', vec2(0, 4), 1.5, new Color(1, 1, 1, 1));
    drawText(
      'Ski through the flags!',
      vec2(0, 1.5),
      0.5,
      new Color(0.8, 0.9, 1, 0.9),
    );
    const controlText = isTouchDevice
      ? 'Use d-pad to move'
      : 'Arrow keys to move';
    drawText(controlText, vec2(0, 0.5), 0.45, new Color(0.8, 0.9, 1, 0.8));
    const pulse = 0.7 + Math.sin(time * 4) * 0.15;
    const startText = isTouchDevice ? 'Tap to start' : 'Press SPACE to start';
    drawText(startText, vec2(0, -2), 0.55 * pulse, new Color(0.7, 0.85, 1, 1));
    return;
  }

  // Game over display
  if (GameState.gameOver) {
    drawRect(
      vec2(0, 0),
      vec2(GameConfig.width, GameConfig.height),
      new Color(0, 0, 0, 0.5),
    );
    drawText('TIME UP!', vec2(0, 3), 1.2, new Color(1, 1, 1, 1));
    drawText(
      `Final Score: ${GameState.score}`,
      vec2(0, 0.5),
      0.8,
      new Color(0.7, 0.85, 1, 1),
    );
    const restartText = isTouchDevice
      ? 'Tap to play again'
      : 'Press SPACE to play again';
    drawText(restartText, vec2(0, -2), 0.5, new Color(0.8, 0.9, 1, 0.8));
  }

  // Instructions (fade out after game starts)
  if (!GameState.gameOver) {
    const timeSinceStart = -(GameState.gameTimer?.get() || 0);
    const instructionAlpha = Math.max(0, 1 - timeSinceStart * 0.2);
    if (instructionAlpha > 0) {
      drawText(
        'Avoid walls!',
        vec2(0, 0),
        0.45,
        new Color(0.3, 0.4, 0.6, instructionAlpha * 0.8),
      );
    }
  }
}

// =============================================================================
// MAIN GAME FUNCTIONS
// =============================================================================
function gameInit() {
  const gameSize = vec2(
    GameConfig.width * GameConfig.tileSize,
    GameConfig.height * GameConfig.tileSize,
  );
  setCanvasFixedSize(gameSize);
  GameConfig.size = vec2(GameConfig.width, GameConfig.height);
  GameConfig.center = vec2(0);
  setCameraScale(GameConfig.tileSize);

  // Initialize sounds (must be done after engine init)
  initSounds();

  // Load high score from localStorage
  GameState.highScore = loadHighScore();

  // Initialize game state
  GameState.player = createPlayer();
  GameState.coins = [];
  GameState.particles = [];
  GameState.floatingTexts = [];
  GameState.decorations = createDecorations();
  GameState.score = 0;
  GameState.spawnTimer = new Timer();
  GameState.spawnTimer.set(2); // Start spawn timer
  GameState.comboTimer = null;
  GameState.combo = 0;
  GameState.gameTimer = null; // Don't start timer until player presses SPACE
  GameState.gameOver = false;
  GameState.gameStarted = false;
  GameState.flagsSpawned = 0;

  // Spawn initial flags
  spawnFlags(5);
}

function gameUpdate() {
  // Check for game over
  // Wait for player to start the game
  if (!GameState.gameStarted) {
    if (keyWasPressed('Space') || mouseWasPressed(0)) {
      GameState.gameStarted = true;
      GameState.gameTimer = new Timer(GameState.gameTime);
      primeAudio();
    }
    updateDecorations(); // Keep snowflakes moving
    return;
  }

  if (GameState.gameTimer?.elapsed() && !GameState.gameOver) {
    GameState.gameOver = true;
    // Update high score at end of game and save to localStorage
    if (GameState.score > GameState.highScore) {
      GameState.highScore = GameState.score;
      saveHighScore(GameState.highScore);
    }
  }

  // Restart game on space or tap when game over
  if (GameState.gameOver && (keyWasPressed('Space') || mouseWasPressed(0))) {
    GameState.player = createPlayer();
    GameState.coins = [];
    GameState.score = 0;
    GameState.combo = 0;
    GameState.gameTimer = new Timer(GameState.gameTime);
    GameState.gameOver = false;
    GameState.flagsSpawned = 0;
    spawnFlags(5);
  }

  // Update screen shake
  GameState.screenShake *= 0.9;

  updatePlayer(GameState.player);
  updateFlags();
  updateParticles();
  updateFloatingTexts();
  updateDecorations();
}

function gameRender() {
  // Apply screen shake by offsetting camera
  const shakeX = rand(-1, 1) * GameState.screenShake;
  const shakeY = rand(-1, 1) * GameState.screenShake;
  setCameraPos(vec2(shakeX, shakeY));

  renderBackground();
  renderDecorations();
  renderFlags();
  renderParticles();
  renderPlayer(GameState.player);
  renderFloatingTexts();
  renderForegroundDecorations();
}

function gameUpdatePost() {}

function gameRenderPost() {
  renderUI();
}

// Fire up LittleJS!
engineInit(
  gameInit,
  gameUpdate,
  gameUpdatePost,
  gameRender,
  gameRenderPost,
  GameConfig.tiles,
  document.querySelector('#demo-one-example'),
);