Controller+BETADownload

node-host reference

Accept phones on your own Node 22+ HTTP(S) server. Import ControllerHost and serveControllerAssets from @controller-plus/sdk/node-host. No Controller+ service is required on this route.

Constructor

new ControllerHost({ server, origin, game = { id: 'custom-game', name: 'Your game', controls: {} }, path = '/ws', allowedOrigins = [], maxPlayers = 16, now = Date.now } = {})

Parameter Meaning
server Existing Node HTTP/HTTPS server. The host adds an upgrade handler; it does not call listen().
origin Phone-reachable HTTP(S) origin including port. Constructor throws if missing or not HTTP(S). localhost on a phone means the phone.
game { id, name, controls, privateView? }; used in roster/manifest/default view.
path WebSocket path handled by this host. Use a valid join-link path. The generated manifest currently still advertises /ws; use the default path for engine compatibility.
allowedOrigins Additional exact browser origins; the host's own origin is always allowed. Clients without an Origin header can connect with valid credentials.
maxPlayers Maximum allocated seats, including disconnected reservations. Default 16; this adapter does not clamp it.
now Millisecond clock function for deadlines; normally leave as Date.now.

One instance owns one room. Properties include room, origin, path, game, maxPlayers, expires (epoch milliseconds, six hours after creation), players (Map), defaultView, publicState, and server. Treat tokens, socket collections and mutable player records as host-only data.

Credentials and presentation

Full signature Return and behavior
credentials() { room, hostToken, joinURL, endpoint, manifest }. Host token grants game authority; show only the player joinURL to guests.
qrSVG(): Promise<string> Current invitation as 320-pixel SVG, margin 2, error correction M.
qrPNG(): Promise<Buffer> Same invitation as PNG bytes. QR library errors reject.
setView(playerId, view): boolean false if no seat. Choose viewFor fallback, JSON-copy and store/send the view; true even for a reserved offline seat. Throws if no string kind or over 14,000 bytes; serialization errors can also throw.
setDefaultView(view): void Clone the new default and apply it to current seats. New seats receive this default. Prefer a gamepad/waiting default and send custom surfaces from connected; new-seat defaults are not individually converted through viewFor.
publish(state): void Clone and broadcast JSON public state, at most 180,000 bytes. Throws for oversize or unserializable data. Do not use undefined.
state(): object { type: 'state', room: { id, name, players: [{ id, name, connected }] }, game: publicState, tier: 'developer' }. No v until transmitted.
broadcast(): void Send state() to all authenticated sockets.
remove(playerId): void Release input, invalidate resume, close/delete seat, rotate the join token and broadcast. Refresh the displayed QR after this call. Unknown IDs do nothing.
close(): Promise<void> Release input, terminate sockets, remove the upgrade handler and close WebSocketServer. Does not close your HTTP server.

Names are stripped of control/format characters and truncated to 24 characters after protocol validation. Seats have stable IDs such as p1. A reconnect rotates the resume ticket, resets input/sequence numbers and keeps the same seat. Offline reservations last five minutes.

Events

ControllerHost extends Node EventEmitter. on(event, callback) returns the host. Use off(event, callback) or once(event, callback) as normal.

Event Exact payload
connected { playerId, name, capabilities, app }, where app is { platform, version } or null. Only for a newly allocated seat, not resume.
input { playerId, input }, full state; also emitted when input is neutralized. Duplicate binary refreshes keep liveness but do not emit this callback.
event { playerId, event, payload }. Validate game-specific permissions and data.
disconnected { playerId }. Seat remains reserved.
command { command, payload } for an authenticated host command not handled by the adapter.

connected fires before the new seat's socket and app are assigned. setView there stores a view for delivery with welcome, but initial fallback selection sees no app. Defer app-specific view selection with setImmediate, or send a safe common view first. To observe resume, read roster state from a host Session or check the known seat on subsequent input. There is no reconnected event.

Feedback and phone output

Signature Return and behavior
registerFeedbackCatalog(entries): void Validate against origin, remember and send to all phones, including later joins.
playFeedback(playerId, cue, options = {}): void Cue options id?, volume = 1, pattern?.
rumble(playerId, options): void Required { low, high, durationMs }, optional id. No defaults.
stopFeedback(playerId, channel = 'all'): void Stop all, sound or vibration.
feedback(playerId, frame): void Validate FeedbackRequest and send to a connected target. Unknown/offline seats are ignored after validation.
output(playerId, type, payload = {}): void Only haptic or capability-request; otherwise throws. Sends to the target if present. This low-level API does not validate the payload or grant permissions. Prefer the bounded feedback methods.

Feedback validation throws before sending. Feedback is transient and is not replayed after reconnect.

Browser assets

await serveControllerAssets(req, res): Promise<boolean> serves /join and /sdk/ for GET/HEAD. Returns true if it handled the request (including a 400/404/405 response), false for other paths. Mount before your game router. It serves only SDK web/, src/, assets/, vendor/, and examples/games/ paths; it rejects hidden/traversal paths. It does not serve your game or sound catalog assets.

Low-level adapter hooks

These public methods support the adapter's implementation. Most games never call them. They use ws WebSocket instances and internal player records, not browser Session objects.

Signature Behavior
accept(ws): void Install handshake, input and close handlers on a new socket.
binary(ws, raw): void Process negotiated wire input/pings; invalid frames can throw a WireError.
hostFrame(frame): void Send a JSON frame to authenticated host sockets.
error(ws, code, message, close = false): void Send error frame; optionally close with 4003.
release(player): void Replace input with neutral and notify local and socket hosts.
sweep(): void Enforce liveness, reservations and expiry; automatic every 250 ms.

Limits and failures

Client messages: 16,384 bytes and 180 messages/second per socket. Authenticate within five seconds. A send buffer over 262,144 bytes closes as 4008. Held input releases after one second without input, sampled every 250 ms. Player application-message silence beyond five seconds terminates the connection; socket traffic silence beyond eight seconds also terminates it. Session pings keep quiet controllers alive.

JSON errors include the protocol error codes. Adapter errors include room_full, room_expired, unauthorized, invalid_resume, forbidden, rate_limited, invalid_binary and handler_error. A game callback exception during message handling becomes a generic handler_error on the phone. Do not throw from normal event handling; validate and ignore disallowed actions.

Complete server

import http from 'node:http';
import { ControllerHost, serveControllerAssets } from '@controller-plus/sdk/node-host';

const port = 47411;
export const server = http.createServer(async (req, res) => {
  if (await serveControllerAssets(req, res)) return;
  if (req.url === '/qr.svg') {
    res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
    return res.end(await host.qrSVG());
  }
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end('<h1>Join the game</h1><img src="/qr.svg" alt="Scan to join">');
});
export const host = new ControllerHost({
  server, origin: process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`,
  game: { id: 'direct-example', name: 'Direct game', controls: {} }, maxPlayers: 8,
});
host.on('connected', ({ playerId }) => {
  setImmediate(() => host.setView(playerId, { kind: 'waiting', title: 'Lobby', text: 'You joined' }));
});
host.on('event', ({ playerId, event }) => {
  if (event === 'ready') host.playFeedback(playerId, 'ui.confirm');
});
await new Promise(resolve => server.listen(port, '0.0.0.0', resolve));
export async function stop() { await host.close(); await new Promise(r => server.close(r)); }
process.once('SIGINT', stop);

For another device, set PUBLIC_ORIGIN to this computer's reachable LAN address with port 47411 before starting. Use HTTPS/WSS on the internet. See Quickstart for game state, player names, reconnects and QR refresh after removal.