- JavaScript 69.4%
- CSS 12.5%
- Python 11.1%
- HTML 4.8%
- Shell 2.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Stacked vertically, a landscape phone sized the playfield from its SHORT edge,
so the canvas ended up tiny while ~500px of width sat unused. Worse, the
stacked chrome no longer fitted: `--chrome` under-reserved the real HUD +
toolbar height, so `#wrap` overflowed its `100dvh` and `overflow: hidden`
clipped the top of the HUD and the bottom of the toolbar.
Under `(orientation: landscape) and (max-height: 620px)` the layout is now a
grid -- playfield on the left, HUD and toolbar stacked in a right-hand rail:
grid-template-areas: "stage hud"
"stage tools"
The HUD becomes vertical label/value rows (and gets back the Best and Combo
cells that the narrow-width breakpoint hides, since the rail has room), the
toolbar becomes a 3-column button pad, and the overlay panels are compacted so
they fit a short stage -- including restoring the key list that the
short-viewport rule drops. Rail padding respects safe-area insets, so the rail
is not tucked under a notch.
Stage width uses the same "min(what the width allows, what the height allows)"
formula as portrait, transposed. Measured playfield area, vs the old stacked
layout at the same viewport:
915x412 389x292 -> 531x398 1.86x
667x375 340x255 -> 481x361 2.00x
568x320 267x200 -> 406x304 2.32x
Also threads viewport changes through one handler driven by a ResizeObserver
on the canvas -- the resize event can report the old size before the
orientation reflow has settled -- and makes Renderer.resize() idempotent, since
it re-bakes every brick sprite and now has five overlapping triggers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
| css | ||
| js | ||
| tools | ||
| .gitignore | ||
| Caddyfile | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| index.html | ||
| install.sh | ||
| README.md | ||
Neon Breakout
A complete Breakout clone in plain HTML, CSS and JavaScript. No build step, no
dependencies, no bundler, no assets — every sound is synthesised at runtime and
every graphic is drawn on a <canvas>.
Running it
Just open the file:
xdg-open index.html # or double-click it
file:// works because the scripts are classic <script> tags rather than ES
modules. If you'd rather serve it:
python3 -m http.server 8000 # then browse to http://localhost:8000
Drop the directory into any webserver's document root and it will work as-is.
Controls
| Input | Action |
|---|---|
| Mouse / touch | Move the paddle |
← → or A D |
Move the paddle |
Space / click / tap |
Launch the ball, fire lasers, dismiss screens |
P or Esc |
Pause |
M |
Mute everything |
N |
Soundtrack on/off |
G |
Performance mode |
H |
Power-up and brick guide |
What's in it
Ten hand-built levels, then endless. After Last Stand the game generates mirrored layouts forever, with the hit points, solid blocks and explosive density all climbing with depth.
Four brick types. Standard bricks (1–5 hit points, colour-coded, with cracks and pips showing damage), indestructible solids, explosive bricks that chain- detonate, and mystery bricks that always drop a capsule.
Eleven power-ups, including two traps that also drop: wide paddle, multiball, laser cannons, slow motion, catch, piercing ball, force field, extra life, bonus points — and narrow paddle and overdrive, which you want to dodge.
Combo scoring. Every brick destroyed without touching the paddle raises the
multiplier, up to x10. Touch the paddle and it resets. Extra life every 25,000
points. The high score persists in localStorage.
Presentation. Parallax starfield and perspective grid, per-brick glow, particle shatter, shockwaves, screen shake, ball trails, CRT scanlines, and a full WebAudio sound board driven by oscillators and filtered noise.
An 80s synthwave soundtrack, generated live — see below.
A neon reticle replaces the arrow cursor over the playfield, tethered down to the paddle so you can always see which one the mouse is driving.
Two layouts. Portrait stacks the HUD above and the toolbar below the playfield; landscape on a phone moves both into a side rail so the playfield gets the whole height — roughly twice the play area of the stacked layout.
Performance mode for phones and weak GPUs — G, or the ⚡ button. It is
picked automatically on mobile, and the game will also drop into it on its own
if it measures a bad frame rate.
Layout
index.html markup, HUD and overlay screens
css/style.css all presentation, responsive down to phone widths
js/utils.js maths, easing, safe localStorage
js/audio.js WebAudio synthesiser — every sound effect
js/levels.js level data, endless generator, reachability guard
js/powerups.js capsule catalogue and effects
js/fx.js particles, popups, shockwaves, shake, background
js/game.js simulation: state machine, physics, collision, scoring
js/render.js all canvas drawing
js/main.js input, fixed-timestep loop, HUD and screens
Scripts load in that order; each is an IIFE that hangs one object off window.
The soundtrack
There is no audio file anywhere in this project. The music is a four-bar Am - F - C - G loop built out of oscillators at schedule time:
- Bass — two detuned oscillators (saw + square) through a resonant lowpass that plucks open and shut on every note.
- Drum machine — four-on-the-floor kick with a pitch drop, hats on the offbeats, and a gated-reverb snare: a noise crack followed by three tails that are cut off with a hard ramp to silence, which is the single most 80s sound ever committed to tape.
- Arpeggio — plucky sixteenths through the chord, each one echoed one and a half steps later at a third of the volume to fake a tape delay.
- Pad — a slow string-machine chord, three saws spread by a few cents.
- Lead — a detuned triple-saw riff that only shows up on the deeper levels.
Layers switch in as an intensity value rises, so the track is a moody pad on
the title screen, picks up drums and arpeggio once you're playing, and gets the
screaming lead by the later levels. Intensity is eased rather than snapped, so
nothing pops in mid-phrase.
Timing uses the standard WebAudio lookahead pattern — a coarse 30 ms
setTimeout queues notes a fraction of a second ahead against the audio
hardware clock — so the groove stays locked regardless of frame rate.
M mutes everything; N toggles just the music, since the soundtrack rides its
own gain bus. Both preferences persist.
Notes on the implementation
Mouse tracking is bound to the window, not the playfield. Bound to the
stage — the obvious place — the paddle froze the instant the pointer crossed
into the HUD or the toolbar, which happens constantly because the playfield is
short and you overshoot it vertically. Listening globally means only X matters
and leaving the field vertically changes nothing. The canvas rect is cached and
invalidated on relayout, since getBoundingClientRect() forces a layout flush
and pointermove fires 120+ times a second.
Leaving the browser window entirely still stops mouse events — the browser sends none — but the reticle disappears when that happens, so the state is visible rather than mysterious, and the keyboard still works.
The canvas is sized from the display, not the logical field. The backing
store is displayedCssWidth x devicePixelRatio, not 800 x dpr. Sizing it from
the logical units meant a phone with a ~390 px-wide stage rendered 2000x1500 and
downscaled it — roughly 4x more pixels than the screen could show, and the cause
of single-digit frame rates on Firefox Android. It also means large desktop
displays now get a properly sharp render instead of an upscaled 800x600 one.
Performance mode (js/quality.js) gates everything expensive behind a
preset: canvas shadowBlur, blend modes, backdrop-filter, the full-canvas
vignette, particle counts, the starfield and parallax grid, and the physics
rate. mix-blend-mode over an animating canvas was removed outright — on
Firefox Android it can drop the entire page to software compositing. On a
Pixel-class phone the rendered pixel count goes 3.0M → 728k (high) → 117k (low).
Ball speed. The ball starts at 400 px/s, gains 14 px/s per level and 2 px/s per brick destroyed, and caps at 780 px/s (947 with the Overdrive trap active). The paddle tops out at 1050 px/s so it can still keep up.
Fixed timestep. Physics runs at a fixed 120 Hz with an accumulator, capped at 8 steps per frame so a backgrounded tab can't spiral. Rendering runs at display rate. Behaviour is identical at 60 Hz and 144 Hz.
No tunnelling. Each tick the ball is advanced in sub-steps no longer than 0.55 × its radius, so it can never skip past a brick regardless of speed. At the worst case the game allows — 947 px/s — that is a 7.9 px move split into three 2.6 px sub-steps, against a 22 px brick. Per sub-step only the single deepest overlap is resolved, which keeps corner hits predictable instead of double-reflecting.
No horizontal orbits. After every brick bounce the vertical component is forced to at least 18% of the speed, so the ball can never settle into a near-flat path that grinds along a row forever.
Levels are guaranteed clearable. Solid bricks are permanent walls; every
other cell becomes passable once broken. So a breakable brick is reachable iff a
4-connected path of non-solid cells reaches it from below the field.
levels.js flood-fills to check exactly that, and the endless generator carves
escape routes until the layout passes — without it, roughly 8% of generated
levels contained bricks that could never be hit.
Anti-stall. If eight or fewer bricks remain and none is destroyed for 18 seconds, the game air-drops a helpful capsule (piercing, multiball, laser, wide or slow). Awkward last bricks resolve themselves instead of grinding.
Brick sprite cache. Brick glow uses shadowBlur, which is far too expensive
to pay ~130 times per frame. Each distinct brick appearance is painted once into
an offscreen canvas and blitted thereafter; the cache is keyed on type, hue,
and damage, and is rebuilt when the device pixel ratio changes.
Crisp on any display. The canvas backing store is scaled by
devicePixelRatio while the game draws in fixed 800×600 logical units, so the
same code is sharp on a phone and on a HiDPI monitor.