Skip to content

SDK API ​

portal-kit is an ES module package. Import its root entry when using createGame; that entry registers the input, audio, HUD, effects and graphics implementations. Factories that create games or bridges require a browser. Use the complete SDK snapshot or install its packages for the public imports shown on this page.

js
import { createGame, KIT_VERSION } from 'portal-kit';

createGame(options) ​

Use the playable template as the complete implementation example. createGame creates the canvas unless you provide one, initializes the bridge and owns the document lifecycle. Use one game per document: it installs page-level styles and input guards. Embed that document in an iframe when a larger application needs other UI alongside it. A supplied canvas needs its CSS dimensions set by your page.

It returns ctx immediately, before asynchronous initialization. Access initialized subsystems from callbacks, starting with load(ctx). ctx.canvas is the drawing surface. Start, pause, restart and disposal are driven by the host lifecycle, or standalone input; the returned context has no public start() or dispose() method. Creating a second game in the same document is not a restart.

OptionMeaning
id, buildGame identity and version, matching game.json
threeYour imported Three.js namespace; required for ctx.gfx.renderer()
canvasOptional existing canvas
scoreScore metadata such as { unit: 'pts', format: 'int', higherIsBetter: true }
hud, colorsHUD placement, score units and feedback colors
stepFixed update step in seconds; defaults to 1 / 60; null selects variable steps
capsHost capabilities supported by the game
bootLoading mode, matching the manifest's cooperative or blocking value
partsOptional factory overrides for this game; see factory registration

Callbacks ​

CallbackWhen to use it
async load(ctx)Prepare resources; yield with ctx.breathe() and report ctx.progress()
arm(ctx)Set the initial waiting frame before play
start(ctx, first)Reset rules and seeded randomness for a new run; first is the starting input or null
update(dt, ctx)Apply sampled input and simulate one step; dt is seconds
render(alpha, ctx)Draw a frame; interpolate previous/current values with alpha
resize(view, ctx)Refit layout and cameras after viewport or inset changes
pause(ctx) / resume(ctx)Stop and restore work owned by the game
quality(tier, ctx)Adjust scene detail to low, mid or high; may run during loading, so guard resources that are not created yet
dispose(ctx)Release resources and references not owned by the SDK
testObject of getter functions exposed in test mode
autoplay(ctx)Optional deterministic preview player

Only load is awaited. Keep all other callbacks synchronous; start asynchronous preparation in load, and make dispose complete cleanup before returning. Callbacks are optional; omitted update and render are no-ops. The SDK reports loading completion and readiness after drawing qualifying frames; game code should not send those messages itself.

first in start is the host's starting input, with normalized 0–1 x/y, or null for a start without a pointer. The SDK also forwards it to the input system. Use it for an initial movement only when needed; avoid applying the same action again when the input scheme consumes it. The Gap Run walkthrough shows this integration.

test maps getter names to functions receiving ctx. They appear on the test hook when test mode is enabled; built-in hook names take precedence. autoplay(ctx) is called before simulation updates only when enabled through that hook.

Context: time and state ​

MemberMeaning
state, runCurrent lifecycle state and run number
timeSimulation seconds in the current run
lookVisual clock in seconds; also advances while armed and over
after(seconds, fn)Schedule on the simulation clock; pauses and resets with the run
rng()Seeded random value for the current run
signalAbortSignal for document disposal
capture, reducedMotion, containerHost rendering preferences and container type

Context: loading and results ​

MemberMeaning
progress(p, stage)Report progress from 0 to 1; stage is assets, shaders or world
await breathe(label)Yield before the next loading step; wait while the host is busy
score(value)Update the HUD and host score
payoff(kind, value?)Report reward, milestone, nearmiss, combo, win or fail; also requests matching haptics
over({ score?, summary? })End play; returns { best, newBest } or null if no run is active
best, storeBest score and namespaced local settings; not cloud storage
haptic(kind)Request a host haptic; unsupported hosts may ignore it

Check ctx.signal.aborted after asynchronous work that may outlive the document. breathe() does not resolve after disposal, preventing a pending load step from restarting work on a removed game.

Context: input, HUD, audio and graphics ​

  • ctx.input supplies sampled tap, zones, drag, swipe, aim, stick and button state.
  • ctx.hud owns score and transient messages. ctx.score keeps the displayed and reported score in sync.
  • ctx.audio supplies effects, music patterns, decoded loops and audio buses, with SDK-managed lifecycle handling.
  • ctx.juice supplies shake, hit-stop and flash, with controlled clocks and reduced-motion support.
  • ctx.view and ctx.gfx provide safe layout, renderer sizing, camera fitting, cooperative warm-up and GPU resource ownership.

Use these members from load onward. Graphics and input cannot be recreated after disposal.

Use individual modules ​

ImportPublic functions and values
portal-kit/inputcreateInput, createInputCore, RULES, SIZES, checkLayout, stickOutput, swipeDir, aimFrom, areaRect, playRect, safeRect
portal-kit/uicreateHud, formatInt, formatTime, fitSize, FONT_DISPLAY, FONT_TEXT, COLORS, TYPE_FLOOR, DISPLAY_CSS
portal-kit/audiocreateAudio, midiHz
portal-kit/juicecreateJuice, LIMITS
portal-kit/gfxcreateGovernor, createRenderer, warmUp, disposeScene, fitCamera, hfovOf, vfovOf, detectTier, pickTier, createController, LADDER, computeView, TIERS, LOSE_CONTEXT

The root also exports createBridge, registerParts, KIT_VERSION, createClock, createReadyGate, createStore, judgeBest and createRng. Pure helpers can be imported in Node; browser factories need browser APIs. portal-kit/bundle exposes the same root API from a standalone file with no external imports. The package's private src/* paths are not public import paths.

Bring your own game loop ​

Use createBridge when integrating an engine that already owns scheduling and rendering. You then own lifecycle transitions, the readiness gate, pause-safe timers, input release and resource disposal that createGame normally handles. The bridge handles transport, command acknowledgements, message limits and reporting. Read the host protocol for wire payloads and test your adapter against real iframe commands.

createBridge(options) ​

Import createBridge from portal-kit. Create one bridge for the game document, then register its handlers synchronously before awaiting bridge.init.

OptionMeaning
id, buildRequired non-empty game identity and build strings, matching the manifest
capsSupported host capabilities; defaults to ['restart', 'quality', 'haptic']. Advertise only capabilities your engine implements
score{ unit, higherIsBetter }; defaults to 'pts' and true. Used when reporting a result
autoRunDefaults to true: each received start increments bridge.run. Set false when your lifecycle adapter assigns run numbers itself
bootDefaults to 'cooperative'; match the manifest. Loading requirements are defined in Game requirements
simNowOptional function returning simulation time in milliseconds, used for payoff/haptic limits. Defaults to wall-clock time
audioOptional SDK audio object for sound-state reporting. Your adapter still owns unlock, mute, pause and disposal

Initialization and command handlers ​

bridge.init is a Promise resolving to the normalized init payload. Use it to prepare resources and apply the host's initial settings. Initialization alone does not make a game ready.

bridge.on(name, handler) returns an unsubscribe function. Command handlers receive the command payload; the command name and sequence are handled by the bridge. Supported command events are init, start, pause, resume, mute, unmute, layout, quality and dispose.

Additional events are:

EventPayload and responsibility
gestureNo payload; unlock or resume audio synchronously inside the forwarded user gesture
pointer{ type, id, x, y }; type is move, up or cancel, and coordinates are normalized 0–1. Forward the held starting touch to your engine
fatalError message string; stop the engine and release active input/audio

Handlers run synchronously; returned Promises are not awaited. The bridge acknowledges a command after its handlers return. Finish disposal in the dispose handler so the host can safely remove the frame after the ack. Begin asynchronous loading from initialization, and report its completion separately with loaded() and ready().

Bridge methods and state ​

MemberBehavior
loaded()Report completed loading once per document
ready()Report readiness once; returns true when sent, otherwise false. It does not measure frames; your engine must satisfy the ready gate first
state(s, why?)Report a lifecycle state. For paused, why defaults to 'cmd'. This updates reporting and heartbeat behavior; it does not pause your engine or free resources
progress(p, stage?)Report loading progress; default stage is 'assets'. Progress is monotonic and stops after loaded()
first()Report the first update that actually consumed player input, once per run
sound(value)Report 'running' or 'locked' when audio availability changes
score(value)Report a score using the protocol's rate limit
payoff(kind, value?), haptic(kind)Send the corresponding game messages. Call haptic separately when using the bridge directly
over({ score, summary? })Send the result, update best score and report over; returns { best, newBest }, or null when no result is accepted. Supply the final score: this method defaults to 0, not the last reported score
error(isFatal, message)Report a failure. A fatal error emits fatal and suppresses further ordinary messages
frame()Count one drawn frame for heartbeat reporting; call after each render
perf(stats)Send performance measurements with the fields and cadence in the protocol; your adapter schedules these calls
breathe(label?)Yield between loading steps and wait while the host is busy. Defaults to 'load'; pending calls do not resolve after fatal error or disposal
storeThe game's namespaced local store
run, tierRead/write values; set the run when using autoRun: false, and report the resolved tier after an 'auto' probe
muted, best, testCurrent mute setting, best score and test-mode flag
modeTransport mode: 'pending', 'frame', 'host' or 'alone'
aloneWhether initialization was synthesized for local start behavior
busy, touchingWhether the host is deferring loading work, and whether the handed-over starting touch is held

For loading diagnostics, note(what) labels expensive work after breathe(), and took(step, ms, slowest?) records a long task in test mode. debug() returns recent messages, dropped-message counters and long-task details.

Connect an existing engine ​

  1. Register handlers, await bridge.init, apply its settings and load resources cooperatively.
  2. Call loaded(), draw the armed scene and measure readiness with createReadyGate. When it passes, call ready() and state('armed').
  3. Queue starts that arrive before readiness. On each accepted start, reset game state and timers, apply the seed, mute setting and starting input, then report state('playing').
  4. Implement the pause reasons and lifecycle, including hidden/blur handling, audio silence and input release. On resume, call clock.resume() before restarting your loop.
  5. Call frame() after rendering and first() only after an update consumes input. Send the final score with over().
  6. In the dispose handler, stop scheduling and release engine-owned resources synchronously. The bridge then reports disposal, acknowledges the command and closes its transport. Calling state('disposed') alone does not perform that cleanup.

Simulation clock ​

createClock({ step = 1 / 60, maxFrame = 0.1 } = {}) creates a simulation clock. Both options are in seconds; step: null selects variable-step updates. Import it from portal-kit.

MemberBehavior
advance(nowMs)Accept a frame timestamp in milliseconds and return the number of updates to run. Each update uses clock.dt
tick(seconds = clock.dt)Advance simulation time after one update and fire due timers. advance() alone does not advance simulation time
dt, alpha, time, stepUpdate duration, render interpolation factor, elapsed simulation seconds and configured step
after(seconds, fn)Schedule on simulation time; returns a cancel function
reset()Begin a new run: clear time, accumulated frame time, hit-stop and timers
resume()Forget the previous frame timestamp so a paused wall-clock gap is not simulated
freeze(ms)Hold simulation for this much frame time while rendering can continue; the argument is milliseconds

Long frame deltas are capped by maxFrame; negative deltas count as zero. The first frame after reset or resume advances one fixed step, or 1/60 second in variable-step mode. Stop calling advance() and tick() while paused.

After installing the complete SDK, run this as a Node ES module. It prints timer fired, then 3 0.06; the gap to 5000 ms does not become simulation time:

js
import { createClock } from 'portal-kit';

const clock = createClock({ step: 0.02 });
let updates = 0;
clock.after(0.04, () => console.log('timer fired'));
function frame(nowMs) {
  const steps = clock.advance(nowMs);
  for (let i = 0; i < steps; i++) {
    updates++; // Apply one game update using clock.dt here.
    clock.tick();
  }
}
frame(0);
frame(20);
clock.resume();
frame(5000);
console.log(updates, clock.time.toFixed(2));

Readiness gate ​

createReadyGate({ frames = 10, maxMs = 50 } = {}) measures consecutive frame intervals. Call gate.frame(nowMs) after each armed-frame render; it returns true exactly once when the gate passes. The defaults need 11 render callbacks to measure ten qualifying intervals. A slow interval clears the count. Use the defaults to satisfy the host readiness requirement.

gate.count reports qualifying intervals and gate.done reports completion. Call gate.reset() when the loop restarts before readiness so a paused gap is excluded. Reset clears the timestamp and count; it does not rearm an already completed gate. Readiness is per document, so do not send it again on each game restart.

Local storage and best scores ​

createStore(id, getStorage?) returns { prefix, get, set }. getStorage defaults to a function reading globalThis.localStorage; tests can supply a storage object or return null for memory-only operation. Keys are namespaced as pp2.g.<id>.<key>. Values should be JSON-serializable.

  • store.get(key) returns the value, or null when missing, unreadable or invalid JSON.
  • store.set(key, value) keeps the value in memory and attempts persistence. It returns true only if the persistent write succeeded. A false result still allows subsequent reads from that store instance.
  • Storage failure must not prevent play. In-memory values last for that store instance; this is not cloud storage.

judgeBest(previousBest, score, higherIsBetter = true) returns { best, newBest }. Pass null for no previous best. Equal scores are not new records; non-finite/non-numeric scores preserve the previous best. For a first run with higher scores winning, zero is stored as the best but does not trigger a new-record celebration. With lower scores winning, the first finite score is a new best.

Deterministic random numbers ​

createRng(seed) returns a function producing numbers in [0, 1). The seed is converted to an unsigned 32-bit integer; identical seeds and call sequences produce identical results. Create a generator for each run and use it consistently for replayable game decisions. It is for simulation and tests, not security-sensitive randomness.

Factory registration ​

The portal-kit root entry registers the standard input, HUD, audio, effects and graphics implementations. registerParts(partialFactories) merges overrides into that module's registry for subsequent createGame calls; it returns no value. createGame({ parts }) applies overrides to one game and takes precedence over the registry.

Supported factory keys are createInput, createHud, createAudio, createJuice, createGovernor, createRenderer, fitCamera, warmUp and disposeScene. A replacement must implement the original factory's interface and lifecycle responsibilities. Existing game instances keep the parts they already created.

Protocol and conformance packages ​

@fah/protocol exports PP, MANIFEST_VERSION, CONTRACT_DOCUMENT_VERSION, GAME_MESSAGES, HOST_COMMANDS, TRANSPORT_DEFAULTS, lifecycle helpers and manifest validation. The lifecycle reducer consumes internal events and returns [nextState, effects]; raw host commands need translation before reduction.

Use @fah/protocol/manifest for metadata validation and @fah/protocol/lifecycle with @fah/conformance for lifecycle checks.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1