Appearance
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:
| Value | Region |
|---|---|
'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.
| Option | Default and behavior |
|---|---|
slide | true; dragging into another zone changes the active zone |
smooth | 0; a positive value smooths axis toward raw by at most dt / smooth per update |
keys | Map 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'] })
| State | Meaning |
|---|---|
pressed, presses | A down event occurred; number of downs since the previous sample |
released, tapped | A release occurred; a completed tap occurred |
held, count | A pointer/key is still held; total downs since reset |
at | Last down position { x, y, nx, ny }; nx and ny are fractions of the whole view |
downAt | Last 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 }.
| Option | Default 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 |
range | view.w * 0.36; CSS-pixel movement corresponding to one normalized unit in relative/spring mode |
clamp | true; false allows relative/spring normalized values beyond [-1, 1] |
keyRate | 2.4 normalized units/s for relative/absolute keyboard steering; pixel mode uses keyRate * 160 px/s |
dot | true; show a marker under the pointer |
start | 0; 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.
| Option | Default and behavior |
|---|---|
area, origin | 'play', 'touch'; origin can instead be { x, y } in CSS pixels or a function of the view |
pull | true; aim opposite the drag for a slingshot; false aims toward the pointer |
dead, max | 12, 140 CSS px; power is zero within dead and reaches one at max; use max > dead |
minPower | 0.08; weaker releases set cancelled |
chargeS | 0.9 seconds for keyboard charge to reach full power |
turn | 2.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.
| Option | Default and behavior |
|---|---|
label | ''; short text drawn on the button |
primary | false; select primary sizing for the main action |
corner | 'br'; also 'bl' or 'bc' for bottom-left or bottom-center |
size | Default from SIZES.button or SIZES.primaryButton; CSS pixels |
rect | Optional view => ({ x, y, w, h }) in CSS pixels, replacing automatic placement |
keys, key | Array 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:
| Method | When 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.