Skip to content

Input ​

Use ctx.input to turn touch, mouse and keyboard input into state your game reads in update. Create schemes once in load; keep their returned state objects for the lifetime of the game. createGame samples input before each update and handles release, restart and disposal. Do not call ctx.input.frame() yourself or recreate schemes on every run.

For example, add these callbacks to your game options and use steer.axis to move your player:

js
let steer;

const controls = {
  load(ctx) {
    steer = ctx.input.zones({ left: 'left', right: 'right' });
  },
  update(dt, ctx) {
    // Use steer.axis * speed * dt for horizontal movement.
    ctx.hud.label(steer.axis < 0 ? 'LEFT' : steer.axis > 0 ? 'RIGHT' : 'STEER');
  },
};

Merge these callbacks into the playable template, or run the complete input and feedback example. The input requirements define event handling and control accessibility rules.

Coordinates and sampling ​

The view is { w, h, insets: { top, right, bottom, left } }, in CSS pixels, with (0, 0) at the top-left of the game surface. Positive x points right and positive y points down. Do not pass drawing-buffer pixels or multiply pointer coordinates by device pixel ratio.

An area (or a stick's zone) accepts:

ValueRegion
'play', 'any'Full view excluding the bottom inset; 'play' is the default
'left', 'right'Left or right half of the play rectangle
'upper', 'lower'Above or below 40% of the view height, clipped to the play rectangle
'lower-left', 'lower-right'Left or right half of the lower region
{ x, y, w, h }Fractions of the whole view, not the safe rectangle
view => ({ x, y, w, h })Custom rectangle returned in CSS pixels

Use safeRect(view) for visible controls and HUD; playRect(view) describes where gameplay touches can begin. These are different rectangles. The host insets remain authoritative even when an area is custom.

Handlers record actions; frame(dt) exposes the next sample, with dt in seconds. Read action flags in update, not render: rendering may run without an update or after several updates. A short press between frames is retained. tap.presses counts all presses in that sample; swipe.dir drains one queued direction per sample. Boolean flags do not count multiple actions.

A button takes a pointer exclusively. Otherwise all schemes covering its initial position can receive it; avoid overlapping schemes unless that behavior is intentional. Keyboard input also reaches every scheme using that key. Register unique button/control IDs and non-conflicting key mappings.

Choose a scheme ​

Zones: hold to steer ​

input.zones(map = { left: 'left', right: 'right' }, options = {})

map associates action names with areas. Each name becomes a boolean on the returned state. The names left/right drive raw and axis (-1 to 1); up/down drive axisY. If opposite directions are held, the latest press wins. Custom names provide booleans without contributing to these axes.

OptionDefault and behavior
slidetrue; dragging into another zone changes the active zone
smooth0; a positive value smooths axis toward raw by at most dt / smooth per update
keysMap of action names to arrays of KeyboardEvent.code strings; defaults are arrows and WASD for the four directions

A quick press keeps its zone active for one sample. Drawn zone glyphs are placed automatically inside the safe area. The pause/release path clears smoothing as well as held state.

Tap: immediate action or completed gesture ​

input.tap({ area = 'play', keys = ['Space', 'Enter'] })

StateMeaning
pressed, pressesA down event occurred; number of downs since the previous sample
released, tappedA release occurred; a completed tap occurred
held, countA pointer/key is still held; total downs since reset
atLast down position { x, y, nx, ny }; nx and ny are fractions of the whole view
downAtLast down timestamp in milliseconds

Use pressed for an immediate action. For pointers, tapped requires release in under 320 ms with no movement beyond 12 CSS px. Keyboard release counts as a tap regardless of hold duration. at and downAt record the latest down immediately; consume them alongside the sampled action in update. For multiple taps in one sample, the position is the last tap's, not a list of all positions.

keys can instead be a map such as { Space: { x: 0.25, y: 0.5 } }. These positions are fractions of the selected area; an array of key codes uses its center.

Drag: continuous position ​

input.drag(options) returns { x, y, dx, dy, vx, vy, active, samples, set }.

OptionDefault and behavior
axis'x'; also 'y' or 'both'
mode'relative' accumulates movement; 'absolute' follows the position in the area; 'spring' measures displacement from touchdown and returns to zero on release
area'play'
units'norm' for normalized values; 'px' for CSS-pixel values
rangeview.w * 0.36; CSS-pixel movement corresponding to one normalized unit in relative/spring mode
clamptrue; false allows relative/spring normalized values beyond [-1, 1]
keyRate2.4 normalized units/s for relative/absolute keyboard steering; pixel mode uses keyRate * 160 px/s
dottrue; show a marker under the pointer
start0; horizontal value applied by input.reset()

x/y use the selected units. In absolute normalized mode the area's edges map to -1 and 1; absolute pixel mode returns view coordinates. dx/dy are CSS-pixel displacement since the last sample, including keyboard steering. vx/vy are filtered pointer velocity in CSS px/s; samples is a cumulative count of movement samples, including coalesced events. Arrows and WASD steer the selected axes.

set(x, y = 0) changes the stored value; it appears in the public state on the next sample. Only one pointer owns a drag at a time. Relative/absolute positions persist after release; initialize your desired position in start, for example with drag.set(0).

Swipe: discrete direction ​

input.swipe({ area = 'play', min = 24, axis = 'both' })

Returns { dir, tap, count, active }. dir is 'left', 'right', 'up', 'down' or null. min is the required displacement in CSS pixels; axis can restrict detection to 'x' or 'y'. With both axes enabled, the dominant axis must be at least 1.2 times the other.

A continuing stroke emits once per direction; reversing can enqueue a new direction. The queue drains one direction per sample and increments count. tap marks a short gesture that did not swipe. Arrows and WASD enqueue directions through the same scheme.

Aim: charge and release ​

input.aim(options) returns { aiming, vector, power, released, cancelled, origin, point }. Apply a shot only when released is true. vector is its unit direction; power is in [0, 1]. origin and point describe the active pointer aim in CSS pixels, not keyboard targeting.

OptionDefault and behavior
area, origin'play', 'touch'; origin can instead be { x, y } in CSS pixels or a function of the view
pulltrue; aim opposite the drag for a slingshot; false aims toward the pointer
dead, max12, 140 CSS px; power is zero within dead and reaches one at max; use max > dead
minPower0.08; weaker releases set cancelled
chargeS0.9 seconds for keyboard charge to reach full power
turn2.2 radians/s for keyboard rotation

Hold Space/Enter to charge; Left/Right or A/D rotate; release to fire. Only one pointer aims at a time. The returned state describes one release per sample; it is not a queue of multiple shots.

Stick: two-axis movement ​

input.stick({ zone = 'play', deadzone = 0.14, travel = 56, follow = false, id = 'stick' })

Returns { x, y, mag, active }. x and y are signed deflections and mag is in [0, 1]. The radial deadzone is a fraction of travel; use 0 <= deadzone < 1 and a positive travel in CSS px. Touches must begin inside the zone and in the lower 60% of the view. The base starts under the thumb, so touchdown alone does not deflect it. follow: true lets the base follow a finger beyond its travel. Release returns the output to zero. Arrows/WASD give unit magnitude, including normalized diagonals.

Button: an additional action ​

input.button(options) returns { down, pressed, released, count }. id is required and must be unique. down stays true for at least one sample, even for a quick press; count counts downs since reset.

OptionDefault and behavior
label''; short text drawn on the button
primaryfalse; select primary sizing for the main action
corner'br'; also 'bl' or 'bc' for bottom-left or bottom-center
sizeDefault from SIZES.button or SIZES.primaryButton; CSS pixels
rectOptional view => ({ x, y, w, h }) in CSS pixels, replacing automatic placement
keys, keyArray of key codes, or one code; default buttons use X, C and V in registration order

Automatic placement stacks buttons above existing controls. Custom rectangles still undergo layout checks.

Use input with your own loop ​

Import createInput from portal-kit/input when your engine owns scheduling. Pass { root, getView, test?, capture?, colors?, onFx? }: root is the game surface element and getView returns its current CSS-pixel view. Keep the view size aligned with the surface's CSS size so drawn controls and hit regions align. colors.accent styles pressed controls; capture: true hides control/gesture visuals without disabling input. onFx(type, data) observes visual feedback and is not a gameplay callback.

The DOM adapter records pointer/keyboard events, converts client coordinates, draws feedback and releases input on blur, hidden and pagehide. It ignores modified shortcuts and typing in editable elements. Your engine still owns the host lifecycle:

MethodWhen to call or read it
frame(dt)Once immediately before each simulation update; seconds
setEnabled(false) / setEnabled(true)Disable and release input on pause/end; enable for play
releaseAll()Clear holds and pending actions without disabling future input
reset()Release input and reset scheme state for a new run; does not enable input or remove schemes
relayout()Recalculate controls after layout changes; frame also detects view size/inset changes
held()Current pointer-plus-key count
consumed()Whether the last sample consumed a down in a registered scheme
applied(), frames()Lifetime counters of sampled downs and samples; reset() does not zero these
controls(), violations()Current layout descriptors and diagnostics
dispose()Release input, remove listeners/layer and restore surface styles

test: true (or ?pptest=1 when omitted) makes illegal layouts throw with control IDs and reasons. Without test mode, inspect violations() yourself. checkLayout(controls, view) accepts [{ id, kind, primary, rect: { x, y, w, h } }] and returns { id, rule, detail }[]. It checks size, insets, lower placement, overlaps, duplicate IDs and a maximum of three buttons. RULES and SIZES expose the checker settings and default dimensions; the touch requirements define the required player experience.

Starting-touch handover ​

createGame already handles the host's starting gesture. With your own adapter, convert the protocol's normalized coordinates to view CSS pixels before calling inject({ x, y, type? }) for a completed tap, or hold({ x, y, id, type? }) for a still-held touch. Forward its continuation with pointer({ type: 'move' | 'up' | 'cancel', x, y }). id is the original host pointer ID so the DOM adapter can recognize that same pointer. Do not inject it twice. See the host commands and same-origin input handover.

Test without a DOM ​

createInputCore({ getView, now?, test?, fx? }) supplies the same schemes without listeners or visuals. now() and event timestamps use milliseconds; fx(type, data) receives visual feedback requests. Feed it with down(id, x, y, t?, type?), move(id, x, y, t?, points?), up(id, x, y, t?), cancel(id, t?) and key(code, down, t?). Coalesced points are { x, y, t }, oldest first. Its script({ type: 'down' | 'move' | 'up', x, y, id? }) also accepts downs while disabled for stepped tests; ordinary event methods respect enabled state. The DOM adapter's script takes client/window CSS coordinates.

After installing the packages, save this as input-check.mjs and run node input-check.mjs. Both short presses occur between samples; the expected output is 2 2 then false.

js
import { createInputCore } from 'portal-kit/input';

let now = 0;
const input = createInputCore({
  getView: () => ({ w: 440, h: 956, insets: { bottom: 90 } }),
  now: () => now,
  test: true,
});
const tap = input.tap();
for (const id of [1, 2]) {
  input.down(id, 220, 400, now);
  now += 10;
  input.up(id, 220, 400, now);
}
input.frame(1 / 60);
console.log(tap.presses, tap.count);
input.frame(1 / 60);
console.log(tap.pressed);
input.dispose();

For custom controls, the module also exports pure geometry helpers: areaRect(area, view), playRect(view), safeRect(view), stickOutput(dx, dy, travel = 56, dead = 0.14) returning { x, y, mag }, swipeDir(dx, dy, min = 24, axis = 'both', ratio = 1.2) returning a direction or null, and aimFrom(ox, oy, x, y, { max = 140, dead = 12, pull = true }) returning { x, y, power, len }. All geometric distances are CSS pixels.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1