How It Works
A look under the hood — for curious developers and anyone who wonders what's happening behind the spring meadow.
Easter Hop is built entirely in vanilla JavaScript and HTML5 Canvas with no external libraries. This page explains how the current system works: the game loop, physics, sprite rendering, the live palette system, particles, collision, and score storage. None of this is novel — it's all standard game programming — but I find it useful to read how other people have solved these problems, so here's how I solved them.
The game loop
The loop uses a fixed-timestep accumulator. requestAnimationFrame fires a callback with the current timestamp; the delta from the last frame is accumulated, then consumed in 16.67 ms (60 Hz) chunks. This decouples physics from render rate — the simulation runs at exactly 60 steps per second regardless of whether the display is running at 60, 90, or 120 Hz, and regardless of whether a frame was dropped.
let accumulator = 0;
const STEP = 1000 / 60;
function loop(timestamp) {
const delta = Math.min(timestamp - lastTime, 100);
accumulator += delta;
while (accumulator >= STEP) {
update();
accumulator -= STEP;
}
render();
lastTime = timestamp;
requestAnimationFrame(loop);
}
The Math.min(delta, 100) cap prevents the spiral of death — if the tab loses focus for several seconds and then regains it, the loop would otherwise try to simulate hundreds of missed steps at once, freezing the browser.
Physics
The physics are intentionally minimal. Gravity is a constant added to vertical velocity every step. A jump applies a fixed upward impulse. Velocity is clamped at a terminal value to prevent the bunny from accelerating to absurd speeds during a long fall.
const GRAVITY = 0.6;
const JUMP_FORCE = -10;
const TERMINAL_VEL = 12;
bird.vy += GRAVITY;
bird.vy = Math.min(bird.vy, TERMINAL_VEL);
bird.y += bird.vy;
These three constants are the tuning knobs for the entire feel of the game. Increasing gravity makes runs shorter and more punishing. Decreasing jump force makes it harder to recover from a deep drop. Finding values that feel fair — challenging but not arbitrary — took more iteration than I expected.
The sprite
The Easter bunny is drawn without any image assets. The sprite is defined as an array of strings, where each character maps to an RGB colour. At render time, the code iterates over the array and fills a small rectangle for each non-transparent character.
const SPRITE = [
'00000WW000WW00000',
'0000WWPW0WWPW0000',
// ... 14 rows total
];
const COLORS = { W: '#f0ebe1', P: '#ffa0b4', /* ... */ };
for (let r = 0; r < SPRITE.length; r++) {
for (let c = 0; c < SPRITE[r].length; c++) {
const ch = SPRITE[r][c];
if (ch === '0') continue;
ctx.fillStyle = COLORS[ch];
ctx.fillRect(c * SCALE, r * SCALE, SCALE, SCALE);
}
}
The canvas context is saved and restored around this draw call so the rotation and squash-stretch transforms applied during a flap don't bleed into the rest of the scene. The bunny is 17×14 pixels at scale 3, so 51×42 canvas pixels.
The palette system
The visual transition from spring to summer is driven by two palette objects — SPRING and SUMMER — each defining named colour values for sky, ground, obstacles, particles, and UI elements. Every frame, a t value (0 to 1) is computed from the current score, and all colours are linearly interpolated between the two palettes using a simple lerp on each RGB channel.
const t = Math.min(score / 30, 1);
function lerpColor(a, b, t) {
const ra = parseInt(a.slice(1,3),16), rb = parseInt(b.slice(1,3),16);
const ga = parseInt(a.slice(3,5),16), gb = parseInt(b.slice(3,5),16);
const ba = parseInt(a.slice(5,7),16), bb = parseInt(b.slice(5,7),16);
const r = Math.round(ra + (rb - ra) * t);
const g = Math.round(ga + (gb - ga) * t);
const bl = Math.round(ba + (bb - ba) * t);
return `rgb(${r},${g},${bl})`;
}
Every element that changes colour — sky gradient stops, ground fill, fence tints, particle colours — calls this function with its pair of spring and summer hex values. The result is a smooth, continuous world transition that doesn't require any per-element animation code.
The particle system
Both flap bursts and the death explosion use the same pool of 80 pre-allocated particle objects. When a particle effect is triggered, the code activates particles from the pool and sets their position, velocity, lifetime, and colour. Active particles update their position and fade out each step. Dead particles go back to an active: false state.
The reason for pre-allocation is garbage collection. If new objects were created on every flap (roughly once per second during play), the JavaScript engine would periodically pause to collect them. On most modern devices this pause is imperceptible, but on older phones it produces a visible stutter. Pre-allocating 80 objects and reusing them eliminates GC pressure during a run entirely.
Collision detection
Collision uses AABB (axis-aligned bounding box) — check if two rectangles overlap on both axes. It's the simplest possible approach and entirely sufficient here because nothing rotates during collision checks. The bunny sprite's visual rotation is cosmetic only; the hitbox stays axis-aligned.
The hitbox is deliberately slightly smaller than the sprite, giving the player a small amount of invisible forgiveness on close passes. This is standard practice in games — pixel-perfect collision looks fair but often feels arbitrary and punishing.
Score persistence
The high score is stored in localStorage under a simple key. There's no server involved. When a run ends, the code checks if the score exceeds the stored value and updates it if so. When the page loads, the stored value is read and displayed. That's the entire persistence system — about six lines of code.
The global leaderboard (top 3 all-time) uses a small Supabase Postgres database with a public REST endpoint. Scores are only submitted if they qualify for the top 3, which limits write traffic to effectively nothing.