Controller Input
A phone or tablet's browser can act as a twin-stick controller for your channel - no app install, no SceneGraph, no second computer. The Roku itself hosts a small web page; a player opens it in their browser over the local network.
Enabling it
game.enableControllerInput() ' starts the server on port 8888Show the connection URL somewhere in your UI so a player knows what to open:
label.setText(game.getControllerConnectionInfo())Mapping input
game.controls (a BGE.Controller.ControlMap) is the only concept you need: bind a logical action/axis name once, then read it every frame - your game code never has to know whether the remote or a connected controller produced the input.
game.controls.bindAction("jump", "OK", "ok") ' name, remoteButton, controllerButton
game.controls.bindAxis("move") ' defaults to the controller's stick "1"controllerButton/the stick name are whatever the browser page sends - the built-in page's three on-screen buttons send "ok"/"b"/"back" (not their on-screen "A"/"B"/ "Back" labels) and its two sticks send "1"/"2", but a custom page can send any name it likes (e.g. "reload") and bind to it with no engine change. When a real gamepad is connected, the built-in page also sends its standard-mapping face/menu buttons under these same names (alongside their raw numeric index, e.g. "0"), so a binding to "ok"/"b"/"back" works for a touch tap, a remote press, and a real controller button with no extra binding needed - see "Simulator/physical gamepad buttons" below. The gamepad's X/Y also send "x"/"y" (and L1/R1 send "l1"/"r1"). Controller input reaches gameUi widgets' onInput() too (not just entities and the focus system), so a custom widget can handle a gamepad press the same as a remote one.
The recommended way to read bound state each frame is onControls(), a GameEntity lifecycle hook called once per frame with the game's ControlMap (the same object as m.game.controls):
override sub onControls(controls as BGE.Controller.ControlMap)
if controls.isActionPressed("jump") then ...
move = controls.getAxis("move") ' a BGE.Math.Vector
m.velocity.x = move.x * speed
m.velocity.y = move.y * speed
end subonControls() is only called on a frame where the game has bound at least one action/axis (ControlMap.hasBindings()) - a game that never calls bindAction/bindAxis never gets this callback at all, keeping the zero-cost-when-unused guarantee. isActionPressed/isActionReleased read true only on the frame the bound button was pressed/released, and isActionHeld every frame in between.
You can still read m.game.controls directly from onUpdate() (or anywhere else) instead, if you want different per-frame ordering or would rather keep controller reads alongside your other update logic - the two approaches read the exact same state, onControls() just saves the m.game.controls boilerplate and guarantees the read happens before onUpdate() runs:
sub onUpdate(deltaTime as float)
if m.game.controls.isActionPressed("jump") then ...
end subbindAxis's axis falls back to the remote d-pad whenever the bound controller stick reads neutral, so binding once supports both input sources automatically.
Multiple controllers
playerIndex (0, 1, 2, ...) is shared across every input source - a physical Roku remote, a brs-engine simulator gamepad, and a connected browser all draw from the same pool. By default (shareFirstControllerWithRemote, see enableControllerInput()), the very first browser to connect shares index 0 with the first physical remote/gamepad, whichever of the two shows up first - so a single-player game can ignore playerIndex entirely (it defaults to 0) and its bindings respond to the remote and a connected phone interchangeably, with no code caring which one the player actually used. Only a second browser (or second remote/gamepad) gets its own index, starting from 1, in the order each one connects.
For a genuinely multiplayer game where every input source should always get its own distinct index - including the very first browser, which would otherwise share player 0 with the remote - pass false:
game.enableControllerInput(8888, false) ' port, shareFirstControllerWithRemoteFor a single-player game where either the remote/keyboard or a gamepad should drive the same player - regardless of which one connects or sends input first - pass true instead:
game.setCombineRemoteInputs(true)This is independent of enableControllerInput()/browser controllers, and safe to call whether or not that's been used. Without it, whichever physical device sends input first permanently claims index 0 and any other device gets its own separate index - fine for genuinely distinct players, but surprising for a single-player game where a player might pick up either device at any time.
Pass playerIndex to bindAction/bindAxis to say which player's input a binding listens to:
game.controls.bindAction("p2fire", invalid, "b", 1) ' player 1's button "b"
game.controls.bindAxis("p2move", "1", 1) ' player 1's stick "1"
if game.controls.isActionPressed("p2fire") then ...Reading an action or axis never takes a playerIndex - each name is bound to one player at bind time, so isActionPressed("p2fire")/getAxis("p2move") already know which controller they refer to. Give each player's actions their own names.
Simulator/physical gamepad buttons
A brs-engine simulator gamepad, or a real controller paired to a Roku that supports multi_controllers, arrives through the same remote-event path as the Roku remote (not the browser-controller path above) - its face/shoulder buttons report as "a", "b", "x", "y", "l1", "r1", "l2", "r2" (see BGE.buttonNameFromCode). "a"/"b" are aliased to "ok"/"back" (the conventional gamepad meaning - A confirms, B cancels), so no extra code is needed for a gamepad's A/B to drive menus (BGE.UI.FocusManager's click handling) or any isButton("back") check a scene already has - and a bindAction("jump", "ok", ...) binding fires for a gamepad's A press too, with no changes.
bindAction's remoteButton/controllerButton also accept an array of names instead of one, if you want a gamepad button that has no built-in alias (e.g. "x") to trigger the same action as a remote button:
game.controls.bindAction("jump", ["ok", "x"], "a")A real gamepad plugged into the browser controller page is a separate, unrelated case - see "Mapping input" above: its standard-mapping face/menu buttons send the same "ok"/"b"/"back" names the on-screen buttons do, so a bindAction already bound to those needs no changes at all.
Simulator analog sticks
The brs-engine simulator (not real Roku hardware) can report a connected game controller's analog stick, if the consuming app's own manifest sets multi_controllers=1:
#Channel Options
multi_controllers=1With that flag set, bindAxis's getAxis() picks up a bound player's analog stick automatically - no code change needed beyond the manifest flag - falling back to the remote d-pad exactly as before when no analog data is available (real hardware, or the flag left unset). This is an experimental, simulator-only capability with no real-Roku equivalent; a real device always uses the digital d-pad path.
A connected gamepad's second (right) stick works the same way - bind it with stick name "2":
game.controls.bindAxis("move", "1") ' left stick - strafe/drive
game.controls.bindAxis("look", "2") ' right stick - yaw/pitchgetRawAxis() (unlike getAxis()) treats this real per-stick analog data the same way it treats a connected browser controller's stick - as genuine independent-axis data, never falling back to the d-pad - so it's the right call to use when deciding between a dual-stick scheme and a single-source fallback scheme (see BGE.FreeFlyCameraController for a worked example).
Labels and the raw custom payload
bindAction/bindAxis take an optional trailing label - once a browser connects, the server sends it {playerIndex, labels} (its assigned player number plus a name -> label map for every labeled binding), so a custom controller page can render meaningful text instead of raw names:
game.controls.bindAction("jump", "OK", "ok", 0, "Jump")A custom on-screen control that isn't button/stick shaped (a slider, a color picker, etc.) can send an arbitrary custom payload in its message; read the latest one with getCustomPayload():
payload = game.controls.getCustomPayload() ' the raw object, {} if none sent yetAdvanced: raw controller input
Every controller button press also flows through the normal onInput callback as a BGE.GameInput, with playerIndex set and button equal to the raw name the browser sent. Most games won't need this - ControlMap above is the intended way to consume controller input.
Limitations
-
The controller page loads a small library from a public CDN - the player's phone needs internet access for the on-screen sticks to render (the Roku itself needs none).
-
Discovery is a plain LAN URL - draw it as a QR code with
BGE.QrCodeinstead of/alongside text so a player can scan rather than type it:
See examples/controller for a full runnable demo.