Building a LittleJS Game with Claude

Published on Monday, December 15, 2025
A code editor on the left with LittleJS game code, and a running early attempt at slalom game on the right
A code editor on the left with LittleJS game code, and a running early attempt at slalom game on the right

I wanted a JavaScript game engine that was simple, quick to pick up, and could run demos directly in the browser. Tried a few — MelonJS, Phaser3, PixiJS, and LittleJS — before landing on LittleJS for a small slalom game.

LittleJS won out mainly because it was lightweight enough to embed cleanly in a blog post without a lot of boilerplate getting in the way. Nothing against the others — just the best fit for what I was building.

What I was more curious about was how Claude would handle the whole thing — not just writing code, but the back-and-forth of building something that actually feels good to play. Straight from the horse’s mouth:

The following is written by Claude — the AI that helped build this game.

The goal was a small browser game: a 30-second slalom, collect flags, avoid walls, beat your high score. The engine was LittleJS. I was the coding partner throughout. Here’s an honest account of what that collaboration looked like.

Why LittleJS pairs well with an AI assistant

LittleJS has a small, well-defined API. The entire surface area you interact with is maybe two dozen functions: engineInit, vec2, drawRect, drawTile, keyDirection, Sound, Timer. That’s it. The documentation is compact and the patterns are consistent.

That’s exactly the kind of context an LLM handles well. Bounded, specific, learnable within a single conversation. Compare this to vibe-coding a React app with 50 dependencies, a build system, a state management library, and a component library — all of which have their own conventions, versioning quirks, and edge cases. The surface area is enormous and the failure modes are subtle. LittleJS doesn’t have that problem. When the scope is tight, an AI pair programmer can hold the whole thing in context and stay useful.

What Claude handled well

The structural scaffolding. The game loop setup came together quickly. LittleJS asks you to implement five functions — gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost — and pass them to engineInit. Generating that skeleton, wiring up the tilemap, initializing state, and getting something on screen is exactly where an AI produces confident, correct output:

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

The same goes for the GameState and GameConfig objects, the createPlayer factory, the spawnFlags / updateFlags / renderFlags trio. Describing what each subsystem needed to do and getting back working, organized code is where this kind of collaboration has a real return.

The particle system. This is a good example of describing an effect in words and getting working code back. “When the player hits a wall, spawn a burst of icy particles that fan outward, slow down, and fade.” The spawnParticles function does exactly that:

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),
    });
  }
}

This is maybe 20 lines, but getting the random spread, the slight upward velocity bias, the color variation, and the decay rate feeling right across a few different call sites — that usually takes manual iteration. Describing the feel and having the code generated in roughly the right shape saved real time.

ZzFX sound design. Synthesized sounds in LittleJS are defined as parameter arrays — frequency, attack, sustain, release, waveform, and about fifteen other knobs. They look like this:

Sounds.coin = new Sound([
  // volume, randomness, frequency, attack, sustain, release, shape, ...
  2.5, , 580, 0.01, 0.08, 0.2, 1, 1.8,
  , , 100, 0.03, , , , , , 0.65, 0.02,
]);

Designing sounds by hand from parameter arrays is not intuitive. The ZzFX tool helps, but iterating on feel — “the coin sound should be brighter, the bounce should be a softer thud, the red flag should feel more exciting than the blue” — is a back-and-forth process that moves faster when you can describe the target in words and get adjusted parameters back to try.

The demo

Here’s where all of that landed. Arrow keys to move (touch controls on mobile). Blue flags are worth 1 point, red flags are worth 3. Hit a wall and you lose a point. You have 30 seconds.

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'),
);

The full source is in the Code tab — about 975 lines including comments, configuration, and all game logic.

Where iteration happened

The parts that required the most back-and-forth were the parts where feedback means running the game, not reading the code.

Mobile touch controls needed several passes. LittleJS has a built-in touch gamepad (setTouchGamepadEnable, setTouchGamepadAnalog, setTouchGamepadSize) and unifying the input handling so that keyDirection() and gamepadStick(0) both fed the same movement code was straightforward. But getting the d-pad size, position, and responsiveness to feel right on an actual phone — not in a desktop browser with touch simulation — took iteration that no amount of code generation could shortcut.

Physics tuning is similar. The player’s friction value is 0.92, the wall bounce multiplier is -0.7, the baseSpeed is 0.08. Those numbers are correct now, but they weren’t the first numbers tried. “The player feels too slippery” or “the wall bounce is too bouncy” are feedback that requires playing the game, not reading the code. An AI can generate the structure; it can’t play-test.

Visual polish — the squash and stretch on wall collisions, the screen shake magnitude, the floating score text that scales up quickly and then fades — these all involved running the game and adjusting constants. The code shape was right early, but the specific values came from watching the thing run.

Takeaway

The structural parts of this game — the loop setup, input unification, particle math, sound iteration — came together faster with a coding partner than they would have alone. The physics constants and the feel of the controls required playing the thing, adjusting, and playing again. No amount of generated code shortcuts that loop.

For a small, bounded project like this slalom game, the division of labor was clear enough that it mostly worked. The engine’s tight API helped: when the scope is well-defined, there’s less room for the collaboration to go sideways.