Devlog

Notes on building Easter Hop — a browser game made entirely in vanilla HTML5 Canvas.

Performance Pass

performancemobile

After the AdSense rejection I spent some time cleaning up performance on low-end devices. The main issue was on older Android phones where the frame rate would dip noticeably on the death burst — 28 particles all spawning at once and the score animation triggering simultaneously. The fix was to stagger the particle spawning over two frames rather than all at once, which smoothed things out.

I also profiled the palette lerp calculation. It was running the full color interpolation for every drawable element every frame, including elements that hadn't changed. I added a dirty flag — the palette only recomputes when the score changes, not every frame. Tiny win on desktop, noticeable on mobile.

One other thing: I found that calling ctx.save() and ctx.restore() more often than necessary was adding up. I audited all the draw functions and consolidated the save/restore calls so we're only doing the minimum required. The game now runs at a solid 60fps on every device I've tested, including a 2018 mid-range Android.

AdSense Rejection & What I'm Doing About It

metacontent

Applied to Google AdSense last week and got rejected with the reason "low value content." Honest response: fair enough. The site had two content pages, a devlog with four entries all dated the same month, and a game. Google's crawlers have seen ten thousand sites like this and most of them are thin.

So I'm doing something about it. I've added a tips and strategy guide, an about page, a technical breakdown of how the game works, and a short essay about why I make browser games. I'm also filling in the devlog properly — there were real development stages that I didn't document at the time, so I'm writing them up now from notes and git history.

I'm not trying to game the system by padding content. These are things that are genuinely worth writing. If you found the technical page or the essay useful, that's the point. If Google approves the ads as a result, that's a nice bonus.

Submitting to Google AdSense

metamonetisation

The game has been live for about a month and getting a small but steady trickle of traffic — mostly from a Reddit post that got a few upvotes and a couple of links from browser-game directories. Nothing dramatic, but real people playing it. That felt like a reasonable point to try adding a small ad.

I added the AdSense publisher script to the index page and a leaderboard-format ad unit below the hero section. The ad slot shows a placeholder while the account is under review. The review process apparently takes up to two weeks. Submitted and waiting.

#4 — Mobile Polish & Global Leaderboard

featuremobilebackend

The local high score was fine for solo play but felt lonely. I added a global top-3 leaderboard backed by Supabase — a Postgres database with a simple REST API. No backend server required; the game posts scores directly from the browser using fetch().

When a score qualifies for the top 3, the game transitions to a name-entry screen. Getting the keyboard to pop up on iOS was the trickiest part: canvas elements can't receive focus, so I added an off-screen <input> element that gets programmatically focused. Android triggers the keyboard automatically on focus; iOS Safari requires the focus call to originate from a touch event, so I hooked it into the touchstart listener.

Also fixed a longstanding visual bug where Easter eggs and clouds appeared to jump up and down. The root cause was computing their vertical position from the current pixel offset, which changed every frame. Switching to a slot-index-based hash — where each cloud or egg derives its position from a stable integer ID — made them scroll smoothly.

Touch Controls & Mobile Testing

mobileinput

The game used spacebar and mouse click from the start, but touch was an afterthought. Adding a touchstart listener was one line, but making it actually feel good on mobile took more work. The main problem was that tapping a touch screen has a 300ms default delay on many mobile browsers — a legacy feature from the era when double-tap meant zoom. Solving it required adding touch-action: none to the canvas CSS and calling preventDefault() on touch events.

I tested on a mix of old and new devices. The game ran well on everything current. On older Android phones (2017–2019 era), frame rate was acceptable but the death burst particle effect caused occasional stutters. I reduced the particle count on small screens based on a window.devicePixelRatio check, which helped.

One surprising find: several testers said they preferred touch to keyboard. Something about the tactile tap felt more satisfying than pressing a key. I have no explanation for this.

#3 — Fireworks, Fences & Lawn Decorations

gameplayvisual

Three visual improvements this sprint. First, the brown wall-style obstacles got replaced with proper white picket fences — posts, horizontal rails, vertical planks, and pointed picket tips that extend into the gap from both sides. The fence body is off-white with a subtle shadow rail and a highlight strip on the leading edge of each post.

Second, Easter eggs now sit on the lawn and scroll past with the ground. They're tiny 8×10 pixel ovals in four color variants (pink, blue, yellow, lavender) with a white horizontal stripe. Combined with the scattered background flowers, the world feels genuinely inhabited.

Third: every 10 fences cleared triggers a small firework burst. 28 particles fan out from the bunny in all directions using Easter egg colors, with a slight upward bias and individual fade-out. It reuses the existing particle pool with no new data structures.

#2 — Easter Hop: The Rebrand

designart

With Easter approaching I decided to transform the game's entire visual identity. The copper robot mole became a cream-white Easter bunny, drawn in the same 17×14 pixel grid. The bunny has pink ear interiors, a rose nose, dark eye pupils, blush cheeks, and shadowed paws — all defined by a colour key lookup table so no image assets are needed.

The underground cyber-cave aesthetic gave way to a spring meadow that transitions to golden summer as your score climbs. Two palette objects (SPRING and SUMMER) define sky, ground, obstacle, and particle colours; every frame lerps between them based on score. At score 30 the transformation is complete — the sky deepens to summer blue, ground turns rich green, and flower petals shift from pink blossoms to sunflower yellow.

Obstacles changed from server racks to wooden garden fences. The background gained scrolling clouds (three overlapping rectangles per cloud, parallax at 0.18× game speed), scattered pixel flowers in a two-pass render (centers first, then petals, to minimise canvas state changes), and a simple sky gradient.

#1 — Building the Core: Molt the Mole

architecturegameplay

Easter Hop started life as "Molt the Mole" — a Flappy Bird-style runner with a copper robot mole navigating server racks in an underground tunnel. The entire game runs in a single HTML file with no dependencies, no build step, and no external assets. Everything — sprite, physics, particles, UI — is rendered on an HTML5 Canvas element using the 2D API.

The game loop uses a fixed-timestep accumulator pattern: wall-clock delta time is accumulated and consumed in 16.67 ms (60 Hz) chunks. This decouples update logic from render rate and prevents the simulation from running at different speeds on different devices.

The player character is a 17×14 pixel sprite defined as an array of character strings, each character mapping to an RGB colour. At render time the sprite is drawn as a grid of filled rectangles scaled by MOLE_SIZE = 3. Canvas save() / restore() handle the rotation and squash-stretch applied on flap. Physics are simple: constant gravity accumulates into a vertical velocity clamped at terminal velocity; a flap applies an upward impulse.

Obstacle gaps shrink and scroll speed increases with score, creating a smooth difficulty ramp without abrupt jumps. A particle pool of 80 pre-allocated objects handles both flap dust and the death burst — avoiding mid-game garbage collection pauses by never allocating new objects during play.

Score System & Local High Score

featureui

The original prototype had no score display — you just played until you died and restarted. Adding a score counter was straightforward (increment on each fence cleared), but deciding where and how to display it took longer. The canvas is already busy: bird, obstacles, background, particles. I didn't want a floating number competing with the gameplay.

I settled on a large, semi-transparent score in the upper center of the canvas — big enough to see at a glance, transparent enough that it doesn't obscure anything important. The font is drawn with the canvas text API using a bitmap-style font stack, keeping the pixel-art aesthetic without importing assets.

For the high score, localStorage was the obvious choice. On game over, check if currentScore > parseInt(localStorage.getItem('highScore') || 0) and update if so. Read it back on load. Six lines of code that work perfectly across sessions with no server. I always find it satisfying when the right answer is also the simple answer.

Adding Sound (and Removing It)

audioux

I spent about a day and a half trying to add sound effects. The Web Audio API is powerful but genuinely fiddly — generating a plausible "flap" sound programmatically, making it trigger reliably on both desktop and mobile, handling the autoplay restrictions that most browsers now enforce, getting the volume right so it's not annoying. I got it working on desktop.

On iOS Safari, it refused to produce any sound until a user gesture had occurred. This is by design — browsers block autoplay audio to prevent sites from blasting sound the moment you open them. The fix is to initialise the AudioContext inside a user event handler (tap, click, keypress). I did this, and it worked about 70% of the time. The other 30%, the context would be in a "suspended" state and calling resume() would work... eventually... on the next gesture.

I cut audio entirely. The game is better without it for now — it works perfectly in silent environments (offices, commutes, late night), and adding unreliable sound that fails a third of the time on iOS isn't worth the trade-off. If I ever revisit this, I'll use an audio sprite with a proper gesture-unlock flow from the start, not bolt it on afterwards.

Getting the Physics Right

gameplayphysics

The first playable version had gravity set too high and the jump force too weak. The bunny would fall like a stone and require constant rapid tapping to stay airborne. This isn't fun — it's stressful and imprecise, with no room to develop a rhythm. I spent several sessions just adjusting three constants and playtesting: GRAVITY, JUMP_FORCE, and TERMINAL_VELOCITY.

The goal was a feel where one deliberate tap produces a clear arc — up for about half a second, then a graceful curve back down. The player should be able to "hold" a height by tapping at the peak of each arc. Getting there required lower gravity (0.6, down from 0.9) and a stronger upward impulse (-10, up from -7). Terminal velocity stays at 12 to prevent runaway falls on long gaps.

I also added a brief squash-stretch animation on flap — the sprite compresses slightly on the upswing and elongates on the fall. This is purely cosmetic but it makes the physics feel heavier and more physical. Animation that reinforces the physics makes the physics feel more real, even when they're not.

#0 — First Prototype

prototypearchitecture

Started this project on a quiet Sunday in January. The idea: a Flappy Bird-style game in a single HTML file, no dependencies, no build step. I'd been wanting to build something small and completable for a while, and a one-button runner seemed like the right scope — core loop is simple enough to finish in a weekend, but interesting enough to polish over a few weeks.

The first prototype was a white rectangle falling down the screen, jumping when you pressed space. No sprites, no obstacles, no score — just the physics loop. Getting the game loop right first is something I've learned from past projects: if your update/render cycle isn't solid, everything built on top of it will be unreliable. I used a fixed-timestep accumulator from the start, burning a couple of hours to get it right rather than hacking in a simple setInterval I'd regret later.

By end of day one: a jumping rectangle, two moving obstacles with a gap between them, and collision detection that ended the run. Not pretty. But it was a game. You could play it, it had a goal, and you could fail. That's the foundation everything else is built on.