Appearance
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.
| Option | Meaning |
|---|---|
id, build | Game identity and version, matching game.json |
three | Your imported Three.js namespace; required for ctx.gfx.renderer() |
canvas | Optional existing canvas |
score | Score metadata such as { unit: 'pts', format: 'int', higherIsBetter: true } |
hud, colors | HUD placement, score units and feedback colors |
step | Fixed update step in seconds; defaults to 1 / 60; null selects variable steps |
caps | Host capabilities supported by the game |
boot | Loading mode, matching the manifest's cooperative or blocking value |
parts | Optional factory overrides for this game; see factory registration |
Callbacks
| Callback | When 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 |
test | Object 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
| Member | Meaning |
|---|---|
state, run | Current lifecycle state and run number |
time | Simulation seconds in the current run |
look | Visual 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 |
signal | AbortSignal for document disposal |
capture, reducedMotion, container | Host rendering preferences and container type |
Context: loading and results
| Member | Meaning |
|---|---|
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, store | Best 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.inputsupplies sampled tap, zones, drag, swipe, aim, stick and button state.ctx.hudowns score and transient messages.ctx.scorekeeps the displayed and reported score in sync.ctx.audiosupplies effects, music patterns, decoded loops and audio buses, with SDK-managed lifecycle handling.ctx.juicesupplies shake, hit-stop and flash, with controlled clocks and reduced-motion support.ctx.viewandctx.gfxprovide 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
| Import | Public functions and values |
|---|---|
portal-kit/input | createInput, createInputCore, RULES, SIZES, checkLayout, stickOutput, swipeDir, aimFrom, areaRect, playRect, safeRect |
portal-kit/ui | createHud, formatInt, formatTime, fitSize, FONT_DISPLAY, FONT_TEXT, COLORS, TYPE_FLOOR, DISPLAY_CSS |
portal-kit/audio | createAudio, midiHz |
portal-kit/juice | createJuice, LIMITS |
portal-kit/gfx | createGovernor, 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.
| Option | Meaning |
|---|---|
id, build | Required non-empty game identity and build strings, matching the manifest |
caps | Supported 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 |
autoRun | Defaults to true: each received start increments bridge.run. Set false when your lifecycle adapter assigns run numbers itself |
boot | Defaults to 'cooperative'; match the manifest. Loading requirements are defined in Game requirements |
simNow | Optional function returning simulation time in milliseconds, used for payoff/haptic limits. Defaults to wall-clock time |
audio | Optional 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:
| Event | Payload and responsibility |
|---|---|
gesture | No 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 |
fatal | Error 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
| Member | Behavior |
|---|---|
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 |
store | The game's namespaced local store |
run, tier | Read/write values; set the run when using autoRun: false, and report the resolved tier after an 'auto' probe |
muted, best, test | Current mute setting, best score and test-mode flag |
mode | Transport mode: 'pending', 'frame', 'host' or 'alone' |
alone | Whether initialization was synthesized for local start behavior |
busy, touching | Whether 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
- Register handlers, await
bridge.init, apply its settings and load resources cooperatively. - Call
loaded(), draw the armed scene and measure readiness withcreateReadyGate. When it passes, callready()andstate('armed'). - 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'). - Implement the pause reasons and lifecycle, including hidden/blur handling, audio silence and input release. On resume, call
clock.resume()before restarting your loop. - Call
frame()after rendering andfirst()only after an update consumes input. Send the final score withover(). - In the
disposehandler, stop scheduling and release engine-owned resources synchronously. The bridge then reports disposal, acknowledges the command and closes its transport. Callingstate('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.
| Member | Behavior |
|---|---|
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, step | Update 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, ornullwhen missing, unreadable or invalid JSON.store.set(key, value)keeps the value in memory and attempts persistence. It returnstrueonly if the persistent write succeeded. Afalseresult 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.