Building a LittleJS Game with Claude
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.
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.