Virtual Controls
@yagejs-addons/virtual-controls puts touch controls on screen — a virtual
joystick and clustered action buttons — and feeds them into the input
system, not into your gameplay code. Your game keeps reading ordinary
actions (isPressed("jump"), getVector(…), getHoldDuration("jump")) and
getStick("left"); whether they came from a keyboard, a gamepad, or a thumb
on glass is invisible to it.
Install
Section titled “Install”npm install @yagejs-addons/virtual-controlsThe addon declares the engine packages as peer dependencies, so it reuses your single engine install:
npm install @yagejs/core @yagejs/input @yagejs/renderer@yagejs/coreand@yagejs/inputare required peers.@yagejs/rendereris the optional peer — only the./presenterssubpath needs it (and it bringspixi.jstransitively). The headless model and component work without it.
Quick start
Section titled “Quick start”The overlay drives actions that already exist — define them in your
InputPlugin action map first (an unknown name warns and is skipped until it
exists, so a typo can’t crash a gesture):
engine.use( new InputPlugin({ actions: { left: ["KeyA", "ArrowLeft"], right: ["KeyD", "ArrowRight"], up: ["KeyW", "ArrowUp"], down: ["KeyS", "ArrowDown"], jump: ["Space"], dash: ["ShiftLeft"], }, }),);Then spawn the controls in the scenes that want them:
class GameScene extends Scene { onEnter() { this.spawn("touch-controls").add( new VirtualControls({ stick: { actions: ["left", "right", "up", "down"] }, // L/R/U/D order buttons: [ { id: "a", action: "jump" }, { id: "b", action: "dash" }, ], presenter: createControlsPresenter(), }), ); }}The tuple binds the four directions in left/right/up/down order (null
skips one); the object form { left: "moveLeft", … } works too when the
names differ.
That’s a floating joystick on the left half of the screen and two buttons in the bottom-right corner. Every default is derived, not hardcoded:
- Sizes scale with the viewport (stick radius 11%, buttons 6.5% of the smaller side), so one config works for a 320×180 pixel-art game and a 1920×1080 one.
- Button clusters auto-arrange by count: 1 sits in the corner, 2 form a
diagonal pair, 3 a corner-hugging arc, 4 an A/B/X/Y diamond, and other
counts fan out in a ring. A left-handed layout is one keyword each:
cluster: "bottom-left"moves the cluster (the size-derived inset stays),stick: { side: "right" }flips the stick — or give any button an explicitplacementto pin it. - The layer (
"virtual-controls", screen-space, order 1080) is auto-provisioned — noScene.layersdeclaration needed. - The presenter is zero-asset Graphics + canvas labels; pass a partial
theme (
createControlsPresenter({ buttonPressedColor: 0xf472b6 })) to restyle it.
Two entry points
Section titled “Two entry points”// Headless — model, component, events, presenter contracts. No pixi.import { VirtualControls, prefersTouchControls } from "@yagejs-addons/virtual-controls";
// Pixi presentation — the built-in Graphics look + theme.import { createControlsPresenter } from "@yagejs-addons/virtual-controls/presenters";Showing it only on mobile
Section titled “Showing it only on mobile”The overlay defaults to visible: "auto": shown when the device’s primary
pointer is coarse (a finger), hidden otherwise — so phones and tablets get
controls and desktops don’t, including touch-screen laptops whose primary
pointer is still the mouse.
You stay in charge:
new VirtualControls({ visible: true, … }); // always (e.g. this demo page)new VirtualControls({ visible: settings.touch ?? "auto", … });
controls.setVisible(false); // runtime lever (settings toggle, cutscene)prefersTouchControls(); // the raw detection, for your own policyHiding releases every engaged control — mirrored actions get a real release edge and the synthetic stick axes reset — so nothing sticks when the overlay disappears mid-hold.
Deactivating the host entity (entity.setActive(false)) does the same, and so
does controls.enabled = false: the controls leave the screen and stop
claiming touches. Whatever you passed to setVisible is remembered, so turning
the entity back on restores the overlay you asked for, not a forced-on one.
Change one button at runtime
Section titled “Change one button at runtime”Show or hide a configured button without rebuilding the overlay:
controls.setButtonVisible("restart", gameOver);controls.setButtonEnabled("jump", canJump);controls.enabled controls the whole overlay. setButtonEnabled affects only
the named button.
A hidden button does not draw or claim touches. It keeps its layout slot, so the other buttons do not move. A disabled button remains visible but does not claim touches; the built-in presenter dims it. Hiding or disabling a held button releases its pointer and action before the setter returns. Both methods throw if the id is not in the configured button set.
Touches never leak into gameplay
Section titled “Touches never leak into gameplay”A classic virtual-joystick bug: the thumb that drives the stick also counts
as a click, firing tap-to-move or MouseLeft-bound actions underneath. The
addon closes this at the input-system level: every pointer a control claims
is consumed before that frame’s action edges apply, so a
claimed touch never reaches gameplay actions — while unclaimed touches pass
through untouched. Touches that land on @yagejs/ui surfaces are left alone
entirely (explicit UI wins over the stick’s engagement zone).
Real action semantics
Section titled “Real action semantics”Buttons mirror through the synthetic action API (setActionHeld), so they
behave exactly like held keys: press/release edges, onAction /
onActionReleased listeners, group enable/disable, and getHoldDuration for
charge mechanics all work unchanged.
The stick mirrors three ways at once:
- Digital — deflection past
threshold(default 0.5, with hysteresis) holds the four bound actions, sogetVector("left", "right", "up", "down")works. - Analog via
getStick()— the raw deflection feeds the synthetic gamepad axes, soinput.getStick("left")returns the virtual stick. A physical pad deflected past its deadzone wins; a controller merely sitting plugged in doesn’t mask the stick. A second stick defaults to the right side — twin-stick works out of the box.getStick()applies the input system’s own stick deadzone here, exactly as it does for physical pads — the stick’sdeadZoneoption shapes only the digital mirror and itsvalue. - Direct —
controls.stick().valueis the dead-zoned vector,.rawValuethe unfiltered one.
Stick modes
Section titled “Stick modes”"floating"(default) — touching anywhere in the engagement zone (the bottom 70% of the stick’s half of the screen) re-centers the base under your finger; it returns to its anchor on release."fixed"— the base never moves; grabbing within 1.5× its radius deflects the knob immediately."follow"— floating, plus the base gets dragged along when the finger travels past full deflection, so deflection holds while the hand drifts.
Observing the controls
Section titled “Observing the controls”The component emits entity events (they bubble to the scene):
VirtualButtonPressEvent / VirtualButtonReleaseEvent ({ id, action }) and
VirtualStickEngageEvent / VirtualStickReleaseEvent ({ id }). They’re the
hook for haptics, UI sounds, tutorials — or buttons that deliberately have no
action and exist only as events.
For a tutorial highlight or a custom HUD hint, read a control’s resolved geometry
by id: controls.button("a")?.layout or controls.stick("left")?.layout.
controls.model.buttons and controls.model.sticks are readonly arrays of the
VirtualButton / VirtualStick instances, each carrying its own .id and
.layout — there is no id-keyed layout lookup object.
Custom presenters
Section titled “Custom presenters”The model owns hit-testing, routing, and layout; a presenter only draws. Two pixi-free interfaces from the root entry:
interface ControlsPresenter { mount(scene: Scene): void; createStickView(stick: VirtualStick): ControlView; createButtonView(button: VirtualButton): ControlView; dispose(): void;}interface ControlView { update(dt: number): void; // poll stick state or button.pressed / visible / enabled / layout setVisible(visible: boolean): void; dispose(): void;}Pass presenter: null and the controls still function invisibly — useful
when a DOM overlay or a fully custom render path draws them. (Omitting the
option entirely warns once: an active-but-invisible overlay is usually a
forgotten import, not a choice.)
The control set and bindings are fixed at construction. Individual button visibility and enabled state can change through the methods above. To add or remove a button or change a binding, destroy the host entity and spawn a fresh component; teardown releases all mirrored input state cleanly.
Try it
Section titled “Try it”The repo’s examples/virtual-controls.html is a playable side-view demo:
keyboard and overlay drive the same actions, a tap-ripple backdrop proves
claimed touches never leak, and buttons switch between 1/2/4-button layouts
live.