DEV Community

Aryaman Godara
Aryaman Godara

Posted on

Your Phone Is the Joystick: I Turned a Browser Tab Into a Game Console

Cover Image

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.
demo GIF

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
Uploading image 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:

  • Firestoresessions/{code}: session metadata, score, one-shot actions (start/restart). Written only on state edges.
  • Realtime Databasecontrollers/{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 };
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 the switch attribute, then programmatically clicking the label to nudge the OS haptic engine. If even that fails, a red .loss-flash CSS 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(); }
Enter fullscreen mode Exit fullscreen mode
  • 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 / globalPrivacyControl auto-decline. No consent → no handle, no cookies, no gtag. One trackEvent() 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 localStorage lets a phone that refreshes mid-round recover its snake, and onDisconnect only nukes the host's _host child, 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)