A pure-GDScript addon for Godot 4

Turn a Godot 4 game into the host for a room full of phone controllers.

Players scan a QR code on the big screen; their phone's browser loads a controller page served by the game itself. No app install, no Node, no relay server, no native binaries. Jackbox-style, with the game as the authority.

  • Godot 4.3+ (tested 4.7.1)
  • Pure GDScript
  • MIT
Buzzer Party demo host screen: lobby QR code, join URL, and a roster of connected players Phone controller in the browser: big BUZZ button, secret symbol, live scores
The bundled Buzzer Party demo — the TV shows the lobby, each phone is a controller.

What joining looks like

LOBBY 192.168.1.87:8080
QR code linking to the GitHub repository

Waiting for players…

A room filling up, simulated. The QR is real — generated by the addon's own GDScript encoder, and it points at this repository if you want to scan it.

How it works

1

Enable the addon

Copy addons/phone_mass_controllers/ into your project, enable the plugin, and add a PMCHost node pointing at a folder of plain HTML/JS — your controller page.

2

Show the QR on screen

host.qr_texture() renders a join QR in pure GDScript. Put it on a TextureRect next to host.join_url() for the rare manual typist.

3

Phones join via browser

The camera app scans the code, the game serves the page, and a WebSocket opens on the same port. From then on they're players — named, rejoinable, kickable — not raw sockets.

What's in the box

One port, HTTP + WebSocket

Its own RFC 6455 server on TCPServer — masking, fragmentation, ping/pong, binary frames — non-blocking with a per-frame I/O budget, so a slow phone can't stall the game. The same port serves your page, the SDK, the QR PNG and the socket, so a single tunnel hostname works. No dependencies.

Players, not sockets

Random rejoin tokens, a grace period for drops, and tombstones that restore a returning player's id and meta. Join codes, max players, kick/ban, "replaced by another tab" detection, and PIN-gated admin — the lobby bookkeeping a party game actually needs.

Private by default

host.send(player, data) targets one phone; broadcast(data, filter) reaches the rest. JSON for convenience, or raw binary frames passed through untouched — FlatBuffers-friendly for big payloads.

QR encoder built in

A pure-GDScript QR generator covering all 40 versions and every error-correction level, checked against ZXing, jsQR and the qrcode npm package. The join QR for your game is one call away — as an Image or an ImageTexture.

One click beyond the LAN

host.start_tunnel() fetches a checksum-verified cloudflared, opens a Cloudflare Quick Tunnel (no account), waits for DNS, then swaps the QR to an https://…trycloudflare.com URL and turns on a join code. Friends on other networks can play too.

pmc.js controller SDK

A zero-dependency ES module the host serves at /pmc/pmc.js. Reconnect with jittered backoff, clock sync for fair timing, vibrate() and screen-wake-lock helpers, and a saved token so a refresh keeps your seat. No build step.

Lobby helpers

PMCQueue, PMCVote (majority, vetoes, timeout) and PMCRotation (winner-stays / loser-stays / strict) — pure-logic pieces lifted out of a real party game, fully unit-tested.

Proven under load

Benchmarked on loopback at 200 phones × 60 msg/s — ~9,500 messages a second, zero lost, 60 fps host. A normal party game sits far below the first busy row. Editor dock included for host status and a test tunnel.

Quickstart

Copy addons/phone_mass_controllers/ into your project and enable Phone Mass Controllers under Project → Project Settings → Plugins. Then this is the whole game side:

game.gd — the host
extends Node

@onready var host := PMCHost.new()

func _ready() -> void:
	host.controller_dir = "res://controller"  # your index.html — served at "/"
	host.admin_pin = "2468"
	add_child(host)
	host.player_joined.connect(func(p): print(p.name, " joined"))
	host.player_rejoined.connect(func(p): print(p.name, " is back (id ", p.id, ")"))
	host.message_received.connect(_on_message)
	host.start()
	$QR.texture = host.qr_texture()            # show this on the big screen
	$URL.text = host.join_url()

func _on_message(player: PMCPlayer, data) -> void:
	if data is Dictionary and data.get("type") == "buzz":
		player.meta["score"] = player.meta.get("score", 0) + 1
		host.send(player, {"type": "you_scored", "score": player.meta.score})  # private
		host.broadcast({"type": "scores"})                                     # everyone
res://controller/index.html — the phone
<button id="buzz">BUZZ</button>
<script type="module">
  import { connect, vibrate } from '/pmc/pmc.js';

  const pmc = connect({ name: prompt('Your name?') });
  pmc.on('message', (d) => console.log(d));

  buzz.onpointerdown = () => {
    pmc.send({ type: 'buzz' });
    vibrate(20);
  };
</script>

Players outside your LAN? One more line — host.start_tunnel() — and the QR swaps itself to a public https URL with a join code. The README covers exports, the tunnel, and the full API; SPEC.md is the wire protocol.

Honest caveats

This is a party-room host, not a service. It earns its keep on a couch, not at scale.

  • Quick-tunnel URLs are ephemeral. Every start_tunnel() gets a new *.trycloudflare.com address, free and account-less but strictly best-effort — no uptime guarantee. Great for a game night; not a hosted endpoint. Named tunnels, Tailscale Funnel or a real relay are tracked as open issues.
  • Plain LAN http isn't a secure context. Screen wake lock and a few other web APIs need https, so on http://192.168.x.x a phone can still doze off mid-game. The tunnel URL is https and gets wake lock back.
  • iOS can't vibrate. Safari on iPhone has no navigator.vibrate; vibrate() feature-detects and quietly returns false there.
  • Backgrounded phones go quiet. Mobile browsers suspend WebSockets in background tabs and on lock screens. The SDK reconnects when the page returns, and grace_seconds keeps the player's seat (and score) warm meanwhile.
  • Your Wi-Fi has to allow it. LAN play needs a network without client isolation — many "guest" and campus APs block device-to-device traffic, which is exactly what this is.