Skip to main content

Controller Pairing

The paired-controller stack lets phone/web controllers connect to a running Hs2d game, claim a player slot, and feed normalized GameInput objects into the runtime. It is separate from the input model itself; see Game Input for normalized buttons, phases, and gameplay routing helpers.

For the pairing UI, controller-state overlay, character assignment, hosted controller presentation, motion, and custom controls, see Controller Pairing Scenes And Presentation.

Source References

SourceWhat to read there
../games/hosanna-ui/src/hosanna-game/control/Hs2dControlProtocol.tsProtocol message types, player state, pairing state, max players, source adapter name, and button-code mapping.
../games/hosanna-ui/src/hosanna-game/control/Hs2dControlInputManager.tsServer lifecycle, session token, pairing, reconnection, input conversion, draining, and diagnostics.
../games/hosanna-ui/src/hosanna-game/control/PairedControlsInputAdapter.tsAdapter that starts the manager, ticks it, and dispatches drained inputs to GameInputAdapterManager.
../hosanna-ui-game-samples-public/src/hosanna-game-examples/diagnostics/ControllerRigController.tsDiagnostic gameplay that routes paired players to separate squares.

Pieces

PieceResponsibility
Hs2dControlServerPlatform server abstraction. Starts HTTP/WebSocket endpoints, drains events, sends responses, closes connections.
Hs2dControlInputManagerOwns session token, player slots, controller state, event conversion, and queued GameInput objects.
PairedControlsInputAdapterBridges the manager into the normal GameInputAdapterManager.
Hs2dControlPairingSceneUser-facing pairing screen with URL, QR-style code, and player slots.
Hs2dControlInputManagerSceneCompact overlay showing controller slots and allowing slot removal.
Hs2dControlPlayerPickerSceneAssigns the host remote and paired controller slots to game-defined characters.
Hs2dControllerPresentationDescribes the controller UI and live values a game publishes to paired clients.

Controller pairing sequence from client hello and slot selection through player assignment, runtime player-index remapping, gameplay input, and presentation updatesController pairing sequence from client hello and slot selection through player assignment, runtime player-index remapping, gameplay input, and presentation updates

The hard player limit is H2D_CONTROL_MAX_PLAYERS = 4. Paired controller inputs use sourceAdapter: 'pairedControlsInput' and deviceType: 'touch'.

Manager Lifecycle

Create the manager with a server implementation:

const controlManager = new Hs2dControlInputManager({
server,
host: '0.0.0.0',
port: 49280,
controllerRoot: '/controller',
});
OptionUse
serverRequired platform server implementation.
hostOptional host passed to the server.
portOptional port. Defaults to 49280.
sessionTokenOptional explicit token. If omitted, the manager generates hs2d-....
controllerRootOptional server-specific controller asset root.
nowOptional clock for tests and deterministic state. Defaults to Date.now().

The core lifecycle methods are:

APIBehavior
start()Starts the server once, passes host/port/session/controller root, marks enabled, and logs pairing/WebSocket URLs.
stop()Stops the server, clears players and queued inputs, and marks disabled.
resetPairings()Closes current player connections, clears slots/input, generates a new session token, and restarts if enabled.
_tick(deltaMs)Ticks the server when supported, drains server events, and updates manager state.
drainInputs()Returns and clears queued GameInput objects.

getPairingUrl() returns:

<server-http-origin>/controller?session=<sessionToken>

getWebSocketUrl() delegates to the server with the current session token. The manager logs both URLs on start, and the pairing scene logs the controller URL periodically so it is visible in device logs.

Because the URL contains a session token, treat it as a local-session credential. Use resetPairings() when a session should no longer be joinable.

Pairing Protocol

Controller clients send one of these message types:

MessageMeaning
helloPair or reconnect a device. Includes deviceId, optional label, and optional requestedPlayerIndex.
inputSend a button, stick, or axis event.
heartbeatRefresh last-seen time.
leaveRemove the connection.

On hello, the manager first checks for an existing player with the same deviceId. If found, it updates the connection ID, marks the player connected, and preserves the existing slot. Otherwise it assigns the requested slot if valid and open; if not, it assigns the first open slot. If all four slots are full, it sends a rejected response with reason full.

Disconnect events mark a player disconnected but keep the slot. A later hello from the same deviceId reconnects to the same slot. leave removes the connection entirely.

Input Conversion

Paired input messages become normal GameInput objects:

this.inputQueue.push(new GameInput({
buttonCode,
button: this.resolveButton(message.control, isAxis),
press: message.phase === 'press',
held: message.phase === 'held' || message.phase === 'axis',
release: message.phase === 'release',
x: message.x,
y: message.y,
sourceAdapter: PAIRED_CONTROLS_SOURCE_ADAPTER,
deviceType: 'touch',
deviceId: player.deviceId,
playerIndex: player.playerIndex,
timestamp: message.timestamp ?? this.now(),
control: String(message.control),
motion: message.motion,
axis: isAxis ? {
name: String(message.control),
value: message.value ?? 0,
x: message.x,
y: message.y,
} : undefined,
}));

Button aliases are applied at conversion time:

Controller controlGameInput.button
aok
attack, bomb, or confirmok
startplay
leftStickleftStick with axis
rightStickrightStick with axis
Other known button namesSame button name
Unknown namesunknown

hs2dControlButtonCode maps controller names to numeric codes. It mirrors Roku remote codes for shared controls and uses synthetic values for gamepad-style controls.

Runtime Adapter

Register PairedControlsInputAdapter with the runtime input manager when paired controllers should feed gameplay:

const inputManager = new GameInputAdapterManager();

inputManager.registerAdapter(
new PairedControlsInputAdapter(controlManager),
);

The adapter:

  1. Calls controlManager.start() on attach.
  2. Calls controlManager._tick(deltaMs) on each enabled adapter tick.
  3. Drains paired inputs.
  4. During player selection, dispatches the controller's raw slot index to the picker.
  5. During gameplay, remaps a claimed controller slot to its assigned game-player index.
  6. Drops input from an unclaimed paired controller once assignments exist.
  7. Dispatches accepted input to GameInputAdapterManager.
  8. Calls controlManager.stop() on detach.

Gameplay scenes should not talk to the control server directly. They receive normal GameInput objects and route by playerIndex.

Pairing State

Use getPairingState() for debugger panels and scene snapshots:

const state = controlManager.getPairingState();
FieldMeaning
isEnabledWhether the server has been started.
sessionTokenCurrent pairing session token.
pairingUrlHTTP controller URL.
websocketUrlWebSocket URL for the same session.
maxPlayersCurrent maximum, 4.
playersPlayer records with slot, device ID, label, color, connection state, and last-seen time.
connectedPlayerCountNumber of connected players.
pairedSlotLabelsFour-element slot label list. Empty string means open slot.
lastInputLast input summary from the most recently active player.
lastErrorLast server error message, when present.

Expose this state through getHs2dDebugSnapshot() so the game debugger can show pairing health without scraping scene text.

Do And Don't

DoDon't
Register PairedControlsInputAdapter when paired controllers should feed runtime input.Poll Hs2dControlInputManager directly from gameplay scenes.
Route gameplay by playerIndex.Assume every paired controller controls player 0.
Use resetPairings() to rotate the session token.Reuse a leaked or stale pairing URL indefinitely.
Keep disconnected players in their slots for reconnection.Remove a slot on every transient disconnect unless that is an explicit user action.
Use getPairingState() for diagnostics.Parse logs or rendered pairing text to infer controller state.
Talk to us