Your paddles are portals. A Breakout/Arkanoid-style arcade game with a twist: the ball never bounces off your paddle — it teleports out of the other paddle at the opposite edge of the field, keeping its momentum. A falling ball re-enters falling from the top; a rising ball re-enters rising from the bottom. Guard both edges, steer with portal english, and break every brick.
▶ Play it: https://lp177.github.io/PortalBreakout/
- 🌀 Portal paddles — one orange (bottom), one blue (top). Where the ball enters the paddle decides how its exit curves: edge hits add spin, and every teleport speeds the ball up.
- 🧱 150 handcrafted levels inspired by the most famous Breakout/Arkanoid layouts, plus Portal-flavored pixel art (the cake is real here).
- 🎮 Solo — control both paddles at once (fully rebindable keys, mouse, or split-screen touch), with an optional assist that mirrors the top paddle.
- 👥 Play with a friend — peer-to-peer via WebRTC: host a room, share a code or link, each player takes one portal. Pick your map from a lobby that previews every layout, and when a game ends "Change map" keeps you both in the room — choose another arena and start again, no re-invite. Disconnections pause the game and let you wait or continue solo.
- ⚔️ Versus mode — duel a friend online or the built-in AI (4 difficulties, and you pick the arena from a previewed list). One portal each, 3 lives each: miss a ball on your side, lose a life — last one standing wins. The ball speeds up over time, so no camping; power-ups belong to whoever catches them; and when an arena is cleared, the player who broke the last brick banks a bonus life and serves next — so the final brick is worth fighting for.
- 💥 Game juice everywhere — particles, screen shake, portal flashes, combos, slow-mo finishes, and seven power-ups (multiball, lasers, fireball, sticky, expand, slow, extra life).
- 🔊 Fully procedural audio — every sound effect and every music track is synthesized live with WebAudio. No assets, no downloads. The soundtrack picks one of six generative songs per game — each with its own scale, groove, swing and subdivision, from a sparse half-time dub to a relentless 16th-note driver — and stays tied to what's happening: it speeds up and brightens as the ball accelerates or as more balls crowd the field, and eases back down when things calm. Changing gear — a fireball, a multiball, a new arena, a rematch — rolls a different song, and a power-up's track hands back to the one it interrupted when it wears off.
- 📴 Installable and offline-capable — a service worker precaches the whole game (254 KB), so it launches instantly and plays with no connection at all; only multiplayer needs the network. New builds are detected in the background and offered as a non-modal "Reload", never mid-rally.
- ♿ Accessible — fully keyboard-driven: arrow keys navigate every menu, dialog and the level grid (as a grid), Enter activates, and in-game arrows stay on the paddles. Visible focus,
prefers-reduced-motionsupport, and a reduced-effects mode.
| Action | Player 1 (orange) | Player 2 (blue) |
|---|---|---|
| Move ← / → | Arrow Left / Arrow Right | A / D (ZQSD on AZERTY) |
| Launch ball | Enter | Space |
| Fire lasers | Arrow Up | W |
| Pause | Esc | Esc |
Binds are physical key positions, so the left-hand cluster is WASD on QWERTY and ZQSD on AZERTY with no configuration. They are mapped to match where the keys physically sit: the letters are higher up the board than the arrows, so the letters drive the top portal and the arrows the bottom one.
Solo: one player drives both portals using both key sets. Two players, one computer: split them — P1 arrows, P2 left-hand keys — or plug in two gamepads (pad 1 → orange, pad 2 → blue; analog stick or d-pad, A to serve, Start to pause). Versus → "2 players, same computer" starts a local duel.
Mouse moves the orange paddle, and a left click serves — the same as Space/Enter, so a mouse game never has to reach for the keyboard to release the ball after a life loss or out of a sticky catch. On touch screens, drag on the bottom or top half of the field to move that paddle; a tap serves.
Options are reachable from the pause menu (Esc → Options), so keys, volume and effects can be changed mid-game without abandoning the level or the room.
Controllers work everywhere — d-pad or stick to move through menus, A to select, B to go back — so a couch session never needs the keyboard.
Vanilla ES modules, no framework. Source lives in /src; Vite bundles it into /docs, the committed production build that GitHub Pages serves. Never edit /docs by hand.
npm install
npm run dev # dev server with hot reload
npm run build # rebuild /docs before committingArchitecture and module contracts are documented in CONTRACT.md.
| Module | Role |
|---|---|
js/engine.js |
Simulation, portal physics, canvas rendering, HUD |
js/levels.js |
150 level definitions + brick types |
js/particles.js |
Particles, screen shake, portal FX |
js/audio.js |
Procedural WebAudio SFX + generative music |
js/input.js |
Keyboard/mouse/touch + rebinding |
js/net.js |
PeerJS multiplayer session + heartbeat |
js/ui.js + css/style.css |
Material-dark menus, level select, options |
js/main.js |
Bootstrap and wiring |
Multiplayer uses PeerJS (vendored) with its free public broker for signaling; gameplay traffic is peer-to-peer.
"Could not connect / connection timed out" when joining almost always means WebRTC couldn't traverse one player's NAT (mobile networks, CGNAT, VPNs, strict firewalls). Direct and STUN-assisted connections work for most pairs, but the hard cases need a TURN relay, and the free public relays bundled as a fallback are best-effort at most.
-
Self-test: open the game with
?relay=1on both sides and try to join — if it fails, no working TURN relay is reachable and hard-NAT pairs won't connect either. -
Reliable fix — self-hosted relay:
server/turn/ships a ready-to-run coturn + credentials-endpoint stack (podman-first, docker-compatible, plain.envconfiguration):cd server/turn ./start.sh # creates .env, generates the secret, starts the stack
The TURN shared secret never reaches players' browsers: the game fetches short-lived HMAC credentials (coturn REST-auth mode) from the
/iceendpoint. Point the game at your deployment by settingICE_ENDPOINTinsrc/js/constants.jsto your https/iceURL and rebuilding (npm run build) — or test first without a rebuild vialocalStorage.setItem('pb.iceurl.v1', 'https://turn.example.com/ice').Verify the relay actually authenticates (a common failure is coturn silently ignoring an unreadable config and rejecting every allocation) — mint a credential and use it:
curl -s https://turn.example.com/ice # returns {username, credential, urls} # then, on the TURN host: turnutils_uclient -y -u <username> -w <credential> -p 3478 <public-ip> # a clean run reports "tot_send_msgs" climbing with 0 lost packets
Quadlet-based deploys additionally ship in
server/turn/quadlet/(rootlesspodmanuser). Two gotchas that cause "connection timed out" for hard-NAT peers even when STUN works: coturn's config must be world-readable (chmod 644— the image drops privileges and can't read a600file), and setrelay-ip/listening-ipto the public IP so it doesn't advertise private/VPN relay candidates..envkeys (all optional —start.shfills the blanks):Key Default Purpose TURN_SECRETauto-generated Shared secret between coturn and the credentials endpoint TURN_HOSTauto-detected public IP Public IP or DNS name players reach the relay on TURN_PORT3478TURN listening port (open udp+tcp in the firewall) TURN_MIN_PORT/TURN_MAX_PORT49160/49200UDP relay range (open udp in the firewall) TURN_REALMportalbreakoutTURN auth realm label CREDS_BIND/CREDS_PORT127.0.0.1/8788Credentials endpoint bind (reverse-proxy it over https) CREDS_TTL_S7200Lifetime of minted credentials, seconds CORS_ORIGINhttps://lp177.github.ioAllowed browser origins for /ice(comma list or*)TURN_EXTERNAL_IP/TURN_INTERNAL_IPunset Only for 1:1-NAT hosts (see compose file) -
Alternative without a server: a free metered.ca account gives personal TURN credentials; both players register them in the browser console — no rebuild needed:
localStorage.setItem('pb.ice.v1', JSON.stringify([ { urls: 'turn:turn.example.com:443', username: 'user', credential: 'pass' }, ]));
Custom servers are tried before the public defaults.