Open a Snake game on your laptop. Scan the QR code with your phone. Your phone is now the joystick — push the stick further and the snake turns and speeds up. No app install, no Bluetooth, no pairing menu.

Play it now: open go-console-84748.web.app on a laptop and scan the QR with your phone. (Repo link at the bottom.)
TL;DR — A cross-device Snake game. The desktop browser hosts and renders the game; the phone is a wireless analog joystick. They link with a 6-digit code or QR. Stack: vanilla JS, no bundler, no build step, plus two Firebase databases (Firestore for state, Realtime DB for the joystick
stream). Last snake standing, up to 3 players today — the engine and rules are already wired for 6.
Why I built it
I wanted "couch co-op" energy without making anyone download an app. Everyone already has a phone with a touchscreen and a browser. Why is connecting it to the big screen still a chore? So I made the phone the gamepad and the laptop the console — and the only thing standing between you and playing is a glance at a QR code.
How the two devices talk
One index.html holds both roles. Which one you get is decided at load: wide screen → host, or a ?session= URL param (what the QR encodes) → controller. Same file, two completely different apps.
The data flow splits cleanly by update frequency:
- Desktop (host): owns the game loop, physics, collision, rendering.
- Phone (controller): captures the joystick, streams input.
- Pairing: 6-digit code / QR.
And the databases split the same way:
-
Firestore —
sessions/{code}: session metadata, score, one-shot actions (start/restart). Written only on state edges. -
Realtime Database —
controllers/{code}: the high-frequency (~30Hz, throttled) joystick stream.
That hybrid is the one architecture decision I'd defend hardest. Firestore is great for sparse, structured writes; it is not where you want to fire 30 joystick updates a second per player. RTDB is. Splitting the control plane from the data plane is what keeps a multiplayer match sane.
Deep dive 1: the joystick is analog, not 4-way
Classic Snake snaps to four directions. This one steers at any angle. The joystick maps to a heading (radians) and a speed boost scaled by how far you push:
function joystickToControl(input, baseSpeed, config) {
const magnitude = Math.hypot(input.x, input.y);
if (magnitude > 0.1) {
return {
active: true,
targetDirection: Math.atan2(input.y, input.x),
speed: baseSpeed + Math.min(magnitude, 1) * config.maxSpeedBoost
};
}
return { active: false, targetDirection: null, speed: baseSpeed };
}
atan2 gives the heading; magnitude doubles as the throttle. Push gently to ease around a corner, slam the stick to sprint. It's a tiny pure function (lives in logic.js, fully unit-tested), but it changes the entire feel of the game.
One more trick: I don't re-send the joystick vector if it hasn't really moved. An epsilon change-gate (shouldSendJoystick) means a steady-held stick stops re-sending entirely — noticeably cutting joystick writes without dropping a single real input.
Deep dive 2: a combo system where the visual is the truth
Eat again within the combo window (4.5s) and your multiplier climbs (capped at 6×). Score per food = 10 × multiplier. The yellow combo badge drains via a CSS animation that runs for exactly comboWindowMs — so the bar you watch emptying is the same duration as the actual expiry logic, which is checked each play frame in the game loop:
if (gameState.combo > 0 &&
Date.now() - gameState.lastFoodTime > gameConfig.comboWindowMs) {
gameState.combo = 0;
updateComboDisplay();
}
The same comboJuice() curve drives particles, screen shake, and food-sound pitch, so a 3× combo looks and sounds identical in solo and multiplayer. Speaking of sound — every SFX is synthesized at runtime with the Web Audio API (no asset files), and the food pitch rises with the combo:
function playFoodSound(step = 1) {
const freq = 660 * Math.pow(1.06, Math.max(0, step - 1));
playTone(freq, 110, 'square', 0.28);
}
A 6× streak literally sounds triumphant.
Deep dive 3: no bundler, no modules — and a CI guard to keep me honest
There's no bundler and no ES modules. 19 plain <script> files load in a fixed order into one global scope:
<script src="js/utils.js"></script>
<script src="js/logic.js"></script>
<script src="js/config.js"></script>
<!-- … strict order … -->
<script src="js/main.js"></script>
The deployed code is exactly what I wrote, just concatenated by the browser — no tree-shaking surprises, no source-map archaeology. The catch: rename a function and you can silently break a consumer in another file. So no-undef is on, every cross-file global is declared in .eslintrc.json, and a CI script (check:globals) fails the build if that list drifts in either direction from the actual top-level declarations. The failure is the feature.
Gotchas (the stuff that actually bit me)
-
iOS Safari has no
navigator.vibrate. It never has. So haptics fall back to an old Safari trick: a hidden<label>wrapping an<input type="checkbox">with theswitchattribute, then programmatically clicking the label to nudge the OS haptic engine. If even that fails, a red.loss-flashCSS cue plays everywhere instead. Feature-detect, degrade, never throw into gameplay:
if (typeof navigator.vibrate === 'function') { navigator.vibrate(pattern); return; }
// iOS shim: a hidden <label> + checkbox-with-`switch` can poke the haptic engine
const label = document.createElement('label');
const sw = document.createElement('input');
sw.type = 'checkbox';
sw.setAttribute('switch', '');
label.appendChild(sw);
document.body.appendChild(label);
try { label.click(); } finally { label.remove(); }
-
The service worker stale-cache trap. An earlier "network-first HTML + stale assets" strategy let fresh markup load against a cached
controller.js, breaking event handlers until a second reload. Going network-first for everything same-origin closed the gap.const CACHE = 'snake-shell-v31'— that v31 is 31 times I changed shell assets and had to bump the cache. -
Analytics off by default. GA4 only boots after an explicit opt-in, and
navigator.doNotTrack/globalPrivacyControlauto-decline. No consent → no handle, no cookies, no gtag. OnetrackEvent()helper no-ops when analytics is unavailable, so a privacy setting can never crash the game. -
Multiplayer races. Two phones grabbing the last slot at once → a Firestore transaction serializes them; nobody double-claims. A rejoin token in
localStoragelets a phone that refreshes mid-round recover its snake, andonDisconnectonly nukes the host's_hostchild, never live players.
Try it / what's next
Grab a friend (or two). Open it on a laptop, scan with your phone, and go for the high score — or start a last-snake-standing match. The live cap is 3 players right now; the simulation and security rules already accept p1–p6, so flipping one maxPlayers knob is the only change between here and a six-snake arena.
▶ Play: go-console-84748.web.app
Code: github.com/aryamangodara/snake_controller
The phone-as-gamepad pattern feels underused. What other browser-only hardware tricks have you pulled off without shipping a native app? Drop them in the comments — I'll be replying all day.

Top comments (0)