Skip to content

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 }):

OptionMeaning
score.format'int' (default), 'time', 'distance', 'percent', or a function returning a string
hud.positionDefault top-left; 'top-centre' or 'top-center' centers the score column
hud.unitOptional text suffix for integer scores; true uses score.unit
hud.toastYToast 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:4174

Open 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 ​

MemberBehavior
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
heightCSS-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:

FunctionResult
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.

MemberBehavior
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, timeScaleWhether 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, flashColorCurrent 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.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1