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 with vanilla JavaScript for maximum control and zero dependencies, or a framework like Phaser.js if you need advanced physics and want to save time on collisions and sprites. For a complex interface (cards, puzzles, quizzes), plain HTML/CSS is often enough, with no game engine 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 same approach already used elsewhere for personal projects with the same "one file, zero dependencies" style.

The minimum requirements to make it actually work as an installable PWA are three: a manifest.json with a name, icons at least 192px and 512px, and display: "standalone"; a service worker for offline caching and installability; and HTTPS, mandatory but already provided free by the hosting in use. Progress saving, at this stage, is deliberately simple: localStorage, no backend.

Why Breakout, among all the possible ideas

Among the options considered — a grid puzzle, a themed memory game, a timed quiz, an Italian 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 standalone arcade app.

The initial choices were deliberately minimal to get started quickly: touch/mouse drag controls for the paddle (works the same on desktop and mobile), levels with increasing difficulty, no power-ups in the first version, high score in localStorage, a retro/neon visual style. Everything else — fixed levels, special bricks, richer graphics, audio, power-ups — arrived in later iterations, each one grafted onto the previous work rather than rewritten from scratch.

IterazioneCosa aggiunge
1 — ScheletroCanvas, paletta, palla, mattoncini random, PWA installabile
2 — Livelli10 poi 15 livelli fissi, mattoncini multi-hit e indistruttibili, selettore livello
3 — Estetica e audioTrail, particelle, sfondo synthwave, suoni via Web Audio API
4 — Game feelScreen shake, 4 power-up, bilanciamento probabilità di drop

From random levels to 15 hand-drawn ones

The first version generated bricks with a semi-random grid that grew row by row: 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, not just more rows and a faster ball.

At that point two kinds of special brick came in: multi-hit (need two hits, change color after the first and award more points) and indestructible (bounce the ball but never break and don't count toward level completion — otherwise that level would never end). The "level complete" check had to explicitly exclude them when counting remaining bricks.

💡

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 in the menu that unlocks only levels already seen at least once. Later the progression was extended to 15 levels, keeping the same principle — denser patterns of armored bricks 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. The next step added: a glowing trail behind the ball, colored particles when a brick explodes (a different color for multi-hit and indestructible), 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 to get beeps, clicks, and percussive effects — 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.

web audio api · synthesized beep, no external file
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);
}

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 — there's no way around it, and 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. No physics library involved, just a temporary offset on the drawing itself.

For power-ups, the tricky part wasn't the idea (4 types: wide paddle, slow ball, 3-ball multi-ball, extra life) but restructuring the update logic. The main ball and any extra multi-ball balls had to share the same behavior — collisions, bounces, active effects — 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 simpler, more robust version applies the scaling once, on activation and deactivation of the effect, not every frame.

Every structural change of this kind was checked with an automatic JS syntax check and a headless browser simulating a few seconds of play, to catch runtime errors that a simple "looks fine by eye" wouldn't have revealed.

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. Balancing wasn't a theoretical calculation but a practical estimate: how often an event shows up during a real playthrough, at that brick density.

Power-upBeforeAfter
🟢 Wide paddle9%5%
🔵 Slow ball9%4%
🟡 Multi-ball7%3.5%
🔴 Extra life3%1.5%

The general principle: minor helpers (wide paddle, slow ball) can stay relatively frequent because their impact is limited and temporary, while strong ones (extra life) 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, to avoid over-rewarding bricks that are already harder to break.

When building a game in vanilla JS + Canvas makes sense (and when it doesn't)

When to use it

  • Simple 2D games (arcade, puzzle, breakout-like) where game logic and rendering don't require the complexity of a full physics engine.
  • Priority on weight and load speed: zero external dependencies means a tiny bundle, ideal for an installable, offline-playable PWA.
  • Full control over feel and polish (screen shake, synthesized audio, power-ups): building from scratch lets you fine-tune every detail without fighting an engine's abstractions.
  • Solo project or small team: without an engine, there's no learning overhead from a third-party framework.

When not to use it

  • 3D games or complex physics: an engine like Three.js, Babylon.js or a dedicated engine (Godot, Unity) avoids reinventing collision detection and 3D rendering from scratch.
  • Large team or long-lived project: a structured engine offers tooling, a visual editor, and conventions that help collaboration, which vanilla JS doesn't offer natively.
  • Need for a ready ecosystem of assets and plugins (advanced physics, multiplayer networking, skeletal animation): a mature engine already has these features built in.

Alternatives

For games with more complex state logic (cards, turns, persistent scores), see the approach used in Burraco online in vanilla JS, where the main challenge isn't rendering but managing game state and opponent AI.

Common mistakes

  • Underestimating the impact of game feel details (screen shake, audio, power-up timing): the difference between a prototype that "works" and a game that "feels alive" lies almost entirely in these details, often added only at the end.
  • Generating random levels when intentional design would work better: hand-drawn levels give a controlled difficulty curve that random procedural generation rarely matches for a short arcade game.
  • Balancing drop probabilities without real playtesting: numbers that look right on paper can turn out frustrating or too generous until tested with real players.

Where it landed, and what's still open

The game now lives in the games section as 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 — a deliberate choice for now, worth revisiting if the game keeps growing.

I later reused the same PWA approach — native canvas, zero dependencies, installable with no app store — for free online burraco, where the challenge wasn't graphics but game logic: automatic card dealing, hand sorting, and an AI opponent to play against the computer.

Frequently asked questions

Do you need a game engine for a simple 2D game like Breakout?

No. For a simple or medium 2D arcade game, native canvas with vanilla JavaScript gives maximum control with no dependencies and no build step, while a framework like Phaser.js only pays off if you need advanced physics or want to save weeks on collisions and sprites. Complex-UI games (cards, puzzles, quizzes) often just need plain HTML/CSS.

How do you generate sound effects without external audio files?

The Web Audio API lets you create oscillators directly in JavaScript, modulating frequency and volume over time to get beeps, clicks, and percussive effects. Zero files to download and virtually no added page weight; the limitation is that these stay synthetic sounds, fine for a retro arcade but not for complex music.

What do you need to make an HTML game installable as an app (PWA)?

Three minimum pieces: a manifest.json with a name, icons at least 192px and 512px, and standalone display; a service worker caching assets for offline use; and HTTPS, mandatory but often free with the hosting in use. With these three the browser shows the install prompt.

How do you implement screen shake on a canvas game?

By applying a random, decreasing translate() to the 2D context for a few frames before drawing the scene, then resetting it right after. You don't move the game elements: you shift the drawing's coordinate system for an instant, without touching collision logic.

How do you balance power-up drop probabilities in an arcade game?

Start with a generous percentage to verify the system works, then reduce it by observing how often the event shows up in a real playthrough. It helps to differentiate probabilities by effect: stronger helpers stay rarer than minor ones.