Appearance
HUD and visual feedback
Use ctx.hud for readable game status and ctx.juice for shake, hit-stop and flash. The SDK keeps controls and HUD separate from the world you render. Call ctx.score(value) to update both the visible score and the host; hud.score(value) only changes the display.
Use these objects from load onward. createGame handles their clocks, layout, visibility and disposal. Configure them with createGame({ score, hud, colors }):
| Option | Meaning |
|---|---|
score.format | 'int' (default), 'time', 'distance', 'percent', or a function returning a string |
hud.position | Default top-left; 'top-centre' or 'top-center' centers the score column |
hud.unit | Optional text suffix for integer scores; true uses score.unit |
hud.toastY | Toast position as a fraction of safe height; default 0.24, clamped to 0.1–0.8 |
colors | { text, accent }; a can supply the accent when using manifest colors |
Run an interactive example
This small Canvas 2D demo exercises input, score display and feedback together. The square moves with simulation time. Each press increments the score, briefly stops movement, shakes the square and shows a toast. The score and controls stay still. This is a module demonstration; use the playable template for a complete game with audio, metadata and host integration.
In the complete SDK checkout, after npm ci --ignore-scripts, create artifacts/feedback-demo/ and save these two files there. Public package imports require that complete checkout or installed SDK tarballs.
index.html:
html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input and feedback demo</title>
<body><script type="module" src="./bundle.js"></script></body>
</html>main.js:
js
import { createGame } from 'portal-kit';
let tap;
let painter;
let score = 0;
createGame({
id: 'feedback-demo',
build: '0.1.0',
score: { unit: 'hits', format: 'int', higherIsBetter: true },
hud: { unit: true },
load(ctx) {
painter = ctx.canvas.getContext('2d');
tap = ctx.input.tap();
ctx.hud.label('HITS');
},
start(ctx) {
score = 0;
ctx.score(score);
},
update(dt, ctx) {
if (!tap.pressed) return;
score += tap.presses;
ctx.score(score);
ctx.hud.toast('HIT', 900);
ctx.juice.shake(6, 160);
ctx.juice.hitStop(60);
},
render(alpha, ctx) {
const { w, h } = ctx.view;
painter.setTransform(ctx.canvas.width / w, 0, 0, ctx.canvas.height / h, 0, 0);
painter.fillStyle = '#07080c';
painter.fillRect(0, 0, w, h);
const { x, y } = ctx.juice.offset;
painter.save();
painter.translate(x, y);
painter.fillStyle = '#7af7ff';
painter.fillRect(w / 2 + Math.sin(ctx.time * 2) * w * 0.2 - 20, h / 2 - 20, 40, 40);
painter.restore();
},
});Bundle and serve it from the checkout root:
sh
npx --no-install esbuild artifacts/feedback-demo/main.js --bundle --format=esm --outfile=artifacts/feedback-demo/bundle.js --servedir=artifacts/feedback-demo --serve=127.0.0.1:4174Open http://127.0.0.1:4174/. Tap to start; subsequent taps or Space/Enter increase the score. Switch tabs to pause, then return to resume. With reduced motion enabled before loading the page, the score and toast still work while shake and hit-stop are suppressed. Stop the server with Ctrl+C.
HUD methods
| Member | Behavior |
|---|---|
score(n) | Format and display a number; use ctx.score(n) in a hosted game to report it too |
best(n) | Show BEST with the score formatter; hidden for null, zero or negative values |
label(text) | Show a short score label; falsy text hides it |
timer(seconds) | Show elapsed time, rounded down; null hides it |
countdown(seconds) | Show remaining time, rounded up so it reaches 0.0 only when time is exhausted; null hides it |
lives(n) | Show whole non-negative lives as pips; lost lives remain empty; null hides the row |
combo(n) | Show a multiplier above one; hide otherwise. Changes animate unless reduced motion is enabled |
toast(text, ms = 1400) | Show one brief message; the next replaces it; falsy text clears it |
height | CSS-pixel distance from the top of the view to the bottom of the score column, reserving the best line; excludes the combo row |
setVisible(on, instant?) | Show immediately or hide with a fade; instant: true disables the fade |
resize() | Reposition for current view/insets; call when a custom host changes insets |
frame() | Paint clock-driven combo animation; managed by createGame |
dispose() | Cancel the toast timer, remove resize listener and HUD element |
Timers accept seconds, but toast accepts milliseconds. HUD methods format values; they do not run timers, award scores or decide game rules. createGame owns HUD visibility according to the lifecycle; do not force it visible after a run ends. Capture mode hides the HUD, including toasts. Text styling follows the HUD requirements.
Standalone HUD and formatting
Import createHud from portal-kit/ui. Pass { root, getView } and optionally { format, unit, position, toastY, colors, capture, reducedMotion, now, after }. root is a DOM surface/canvas; getView returns the same CSS-pixel view used by input. format, unit, position, toastY and colors correspond to the settings above; standalone unit is the suffix text, not the createGame boolean shorthand.
reducedMotion accepts a boolean or function; omitted, it follows the browser preference. now() returns effect time in milliseconds. after(ms, fn) must return a cancellation function. Pass both from a pause-aware effect clock, then call hud.frame() on rendered frames. Defaults use browser animation/timeouts and do not know your engine's pause state. The returned el is the HUD's DOM element.
Pure exports are available without a DOM:
| Function | Result |
|---|---|
formatInt(n) | Comma-separated integer, truncating magnitude; 12345.9 → '12,345', -1.9 → '-1' |
formatTime(seconds, up = false) | Minutes/seconds from one minute, whole seconds from ten, tenths below ten; up: true rounds up for a countdown |
fitSize(base, length, fit = 7, floor = 28) | Font size in pixels; shrink long strings while respecting the floor |
Non-finite numeric inputs format as zero. Time is never negative. Distance format appends m; percent format appends % to the supplied number (25 displays 25%, not 2500%). Neither converts units. FONT_DISPLAY, FONT_TEXT, DISPLAY_CSS, COLORS and TYPE_FLOOR provide the shared typography/color tokens. They use system fonts and fetch no font assets.
Visual effects
ctx.juice exposes these effects. Durations are milliseconds; shake distances are CSS pixels.
| Member | Behavior |
|---|---|
shake(px = 6, ms = 200) | Start a decaying shake; retain an existing stronger shake |
offset | { x, y, nx, ny }; CSS-pixel offset and fractions of view height for both axes |
hitStop(ms = 60) | Request a brief simulation freeze; createGame skips updates while continuing to render |
stopped, timeScale | Whether hit-stop is active; scale is 0 during the stop and 1 otherwise |
flash(color = '#ffffff', ms = 120) | Request a fading full-view overlay; calls inside the flash cooldown are ignored |
flashAlpha, flashColor | Current flash values for a renderer that draws its own overlay |
frame() | Paint the overlay when using a supplied clock; managed by createGame |
reset() | Clear active effects; does not clear the flash cooldown |
dispose() | Reset effects and remove the overlay |
Read offset in render and translate only the world. The SDK does not move your scene/camera for you. For a world height of H, use offset.nx * H and offset.ny * H, with the vertical sign adjusted for your renderer's axis convention. Do not accumulate the offset into the camera's permanent position. Do not shake the HUD or controls.
The module's LIMITS exports cap shake at 16 px / 400 ms, hit-stop at 120 ms, flash at 400 ms / 0.6 alpha, and set the flash gap to 334 ms. These are caps, not suggested intensities. Follow the motion and flashing requirements for all effects, including ones you draw yourself. Reduced motion suppresses new shake, hit-stop and flash requests.
Effects with your own engine
Import createJuice from portal-kit/juice and pass { root?, getView?, now?, reducedMotion?, capture? }. Use the same view as input/HUD; supply getView when using normalized offsets. reducedMotion accepts a boolean or function and defaults to the browser preference.
now() must return a monotonic effect clock in milliseconds. It must continue during hit-stop so the stop can expire, and stop while the game is paused. Sample input when you run a gameplay update; continue rendering and calling juice.frame() during hit-stop. Your loop can skip simulation while stopped or use timeScale to scale its simulation delta. createGame already performs this integration.
With root, the factory creates a fixed overlay. With no root, or with capture: true, it creates no overlay; flashAlpha/flashColor still work for a scene-rendered flash. Avoid drawing both overlays. Without now, effects use wall-clock time. Your adapter must reset effects on pause/restart and dispose them when the document is removed; see bringing your own loop.