I wanted a small arcade game, installable as an app, with no dependencies and no build step. Here's how a complete Breakout came together — from a minimal skeleton to 15 hand-drawn levels, sound effects generated with the Web Audio API without downloading a single audio file, particles, screen shake, and power-ups balanced so the playfield doesn't get overcrowded.
Stack first, game second
The first question wasn't "which game" but "with what". For a simple or medium 2D game the practical options are few:
- Native canvas + vanilla JavaScript — maximum control, zero dependencies
- Phaser.js — a full framework with physics included, useful if you want to save time on collisions and sprites
- Plain HTML/CSS — for games with complex UI (cards, puzzles, quizzes), often with no game engine needed at all
Since the rest of the site is already built as single-file HTML pages with no build step, the natural choice was to stay consistent: native canvas, one self-contained file, easy to understand and deploy.
The minimum requirements to make it actually work as an installable PWA are three:
- A
manifest.jsonwith a name, icons at least 192px and 512px, anddisplay: "standalone" - A service worker for offline caching and installability
- HTTPS, mandatory but already provided free by many hosting providers
Progress saving, at this stage, is deliberately simple: localStorage, no backend.
Why Breakout
Among the options considered — a grid puzzle, a themed memory game, a timed quiz, a Wordle-like, a light idle-management game — Breakout won on the ratio between development time and immediate payoff: simple collision logic, native canvas with no complex sprites, and a genre that lends itself well to becoming an installable arcade app.
The initial choices were deliberately minimal to get started quickly:
| Iteration | What it adds |
|---|---|
| 1 — Skeleton | Canvas, paddle, ball, random bricks, installable PWA |
| 2 — Levels | 10 then 15 fixed levels, multi-hit and indestructible bricks, level selector |
| 3 — Visuals and audio | Trail, particles, synthwave background, sounds via Web Audio API |
| 4 — Game feel | Screen shake, 4 power-ups, drop-probability balancing |
From random levels to 15 hand-drawn ones
The first version generated bricks with a semi-random grid: functional, but with no personality. The next step was replacing it with hand-drawn patterns — pyramids, checkerboards, diamonds, corridors, a "fortress" — each with a deliberate difficulty curve.
At that point two kinds of special brick came in:
- multi-hit: need two hits, change color after the first and award more points
- indestructible: bounce the ball but never break and don't count toward level completion
💡 An easy detail to forget: once you add indestructible bricks, every place in the code that checks "how many bricks are left to finish the level" needs to explicitly exclude them, or the level becomes impossible to complete.
With 10 fixed levels came the rest of the progression too: high score and highest level reached saved together in localStorage, and a level selector that unlocks only levels already seen at least once. Later the progression was extended to 15 levels, with denser patterns toward the end, up to a final level that's almost entirely indestructible except for one opening row.
Neon visuals and sound without a single audio file
The base version worked but looked visually bare. Next came a glowing trail behind the ball, colored particles when a brick explodes, a synthwave-style grid-and-scanline background, a pulsing glow on the paddle, and a flash on contact with the ball.
For audio, the goal was zero files to download: the Web Audio API lets you generate oscillators directly in JavaScript, modulating frequency and volume over time:
function beep(freq, duration, type = 'square', volume = 0.15) {
if (!audioCtx || muted) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.value = freq;
gain.gain.value = volume;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
A different sound for a wall bounce, a paddle bounce, a normal brick, a multi-hit brick, and an indestructible one, plus short melodies for losing a life, game over, and level complete.
One non-negotiable technical detail: browsers require a user interaction to start the audio context, so the first sound only plays on the first tap or click — it should be handled as expected behavior, not a bug.
Screen shake and power-ups: from "it works" to "it feels alive"
Screen shake, when you lose a life, is simpler than it sounds: apply a random, decreasing translate() to the canvas context for a few frames before drawing the scene, without touching the real position of the ball, paddle, or bricks.
For power-ups (wide paddle, slow ball, 3-ball multi-ball, extra life), the tricky part wasn't the idea but restructuring the update logic: the main ball and any extra multi-ball balls had to share the same behavior instead of duplicating code. The more robust fix was extracting a reusable updateSingleBall() function, called in a loop over every ball on screen.
⚠️ The first attempt at "slow ball" scaled dx/dy every frame with a multiplicative factor, risking divide-by-zero when restoring speed. The more robust version applies the scaling once, on activation and deactivation of the effect, not every frame.
Balancing drop probabilities
The first power-up version used a 28% total drop chance per normal brick destroyed — with hundreds of bricks per playthrough, the field got crowded far too fast.
| Power-up | Before | After |
|---|---|---|
| 🟢 Wide paddle | 9% | 5% |
| 🔵 Slow ball | 9% | 4% |
| 🟡 Multi-ball | 7% | 3.5% |
| 🔴 Extra life | 3% | 1.5% |
The general principle: minor helpers can stay relatively frequent because their impact is limited and temporary, while strong ones need to stay rare — otherwise the game loses the tension that makes it interesting. Power-ups only drop from normal bricks, never from multi-hit or indestructible ones.
Where it landed
The game is now a fully installable PWA: 15 fixed levels with multi-hit and indestructible bricks, a level selector, visual and audio effects generated without external assets, screen shake, and 4 balanced power-ups. The service worker caches all assets for offline use, and the cache version gets bumped on every deploy to avoid mismatches between the served and cached versions.
Openly postponed for now: more advanced power-ups and syncing scores across devices via a backend, instead of the current localStorage-only approach.
Original article with the bilingual IT/EN version, FAQ, and a live demo of the game: roversia.it
Top comments (1)
This is awesome! I'm curious how