Appearance
Audio
Use ctx.audio for game sound: short synthesized effects, repeating note patterns, decoded music loops and custom Web Audio nodes. createGame prepares audio during loading and handles gesture activation, mute, pause, the end of a run and disposal. Use this existing audio object from load onward.
Start music in start when it should restart with each run. Play effects when gameplay produces a reward, collision or other event. Audio availability must not determine whether a game can be played. The game audio requirements and lifecycle define when sound is allowed.
Run a game with sound
This Canvas 2D example plays a quiet pattern during a 12-second practice run. Taps or Space/Enter play notes, update the score and show visual feedback. When the run ends, music continues at a lower level and the HUD hides. Tap again to start another run. The example synthesizes all its sound, so no audio files or network services are needed.
In the complete SDK checkout, after npm ci --ignore-scripts, create artifacts/audio-demo/ and save the following files there. The public imports require the complete checkout or installed SDK packages. Use the playable template when preparing a game with its manifest and preview assets.
index.html:
html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Game audio demo</title>
<body><script type="module" src="./bundle.js"></script></body>
</html>main.js:
js
import { createGame } from 'portal-kit';
const pattern = {
bpm: 100, bars: 1, steps: 8,
tracks: [{ wave: 'triangle', notes: [48, null, 55, null, 60, null, 55, null], vol: 0.08 }],
};
const notes = [60, 64, 67, 72];
let tap, painter;
let score = 0;
createGame({
id: 'audio-demo', build: '0.1.0',
score: { unit: 'notes', format: 'int', higherIsBetter: true },
load(ctx) {
painter = ctx.canvas.getContext('2d');
tap = ctx.input.tap();
ctx.hud.label('NOTES');
},
start(ctx) {
score = 0;
ctx.score(score);
ctx.hud.countdown(12);
ctx.audio.music(pattern);
},
update(dt, ctx) {
ctx.hud.countdown(Math.max(0, 12 - ctx.time));
if (tap.pressed) {
ctx.audio.sfx.note(notes[score % notes.length], { dur: 0.18, vol: 0.16 });
score += tap.presses;
ctx.score(score);
ctx.hud.toast('NICE', 500);
ctx.payoff('reward', score);
}
if (ctx.time >= 12) {
ctx.audio.sfx.chord([261.63, 329.63, 392], { dur: 0.3, vol: 0.12 });
ctx.over({ score, summary: 'Practice complete' });
}
},
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);
painter.fillStyle = '#7af7ff';
painter.fillRect(w / 2 - 24, h / 2 - 24, 48, 48);
},
});Bundle and serve from the checkout root:
sh
npx --no-install esbuild artifacts/audio-demo/main.js --bundle --format=esm --outfile=artifacts/audio-demo/bundle.js --servedir=artifacts/audio-demo --serve=127.0.0.1:4176Open http://127.0.0.1:4176/ and tap to start. A browser may need another real gesture before sound becomes available; the game keeps working. Switching away pauses the run and returning resumes it in standalone mode. Stop the server with Ctrl+C. To exercise host mute, pause and disposal commands, use the host integration guide with this game page.
Synthesized effects
All audio.sfx methods are fire-and-forget: they return no playback handle and drop requests that cannot play now. They do not queue missed sounds for later. Frequencies are Hz, durations and strum spacing are seconds, MIDI values are note numbers, and volumes are linear gains, not decibels.
| Method | Options and defaults |
|---|---|
sfx.blip(options?) | { freq: 660, dur: 0.09, type: 'square', vol: 0.22, slide: 0, vary: 0.12 }; slide is the pitch change in semitones |
sfx.noise(options?) | { dur: 0.18, vol: 0.3, filter: 1800, vary: 0.12 }; filtered noise for impacts and whooshes |
sfx.sweep(options?) | { from: 220, to: 880, dur: 0.25, type: 'sawtooth', vol: 0.18, vary: 0.12 }; glide between two frequencies |
sfx.chord(frequencies, options?) | { dur: 0.4, type: 'triangle', vol: 0.16, strum: 0.012, vary: 0 }; at most six frequencies, with volume distributed across the chord |
sfx.note(midi, options?) | { dur: 0.2, type: 'triangle', vol: 0.2, vary: 0 }; MIDI 69 is A4 at 440 Hz |
Use finite positive frequencies and durations longer than the 4 ms attack. Use non-negative strum spacing and modest gains between zero and one. These options are passed to Web Audio without general input validation. Supported oscillator type values are 'sine', 'square', 'sawtooth' and 'triangle'.
For noise, a numeric filter is a low-pass cutoff in Hz. An object such as { type: 'highpass', freq: 6000, q: 1 } selects a BiquadFilter type, cutoff and optional Q. vary controls pitch/filter variation for the same sound repeated within 1.5 seconds: 0.12 allows up to ±6%; 0 preserves exact tuning. It does not vary the first request's pitch.
New effects are dropped before activation, while muted/paused, after the run ends, after close, or when the 16 effect slots are occupied. A chord uses one slot; sequenced music does not use those slots. Play a final impact before ctx.over(). See ending a run for output behavior.
midiHz(note) is also exported from portal-kit/audio as a pure helper; midiHz(69) returns 440.
Sequence a music pattern
audio.music(definition) replaces the current pattern and starts at step zero. Before activation it waits; after activation the SDK schedules it automatically. Call it once in load to keep one pattern across runs, or in start to restart it for each run, as in the example. Do not call it every update.
| Pattern field | Default and meaning |
|---|---|
bpm | 120; positive finite beats per minute |
bars | 1; positive integer number of bars |
steps | 16; positive integer steps per bar, in 4/4 time |
loop | Repeats unless explicitly false |
tracks | Array of track definitions, default [] |
A step lasts (60 / bpm) * (4 / steps) seconds; the pattern contains bars * steps steps. Track note arrays repeat within that pattern when shorter, and only the pattern's step range is used when longer.
| Track field | Default and meaning |
|---|---|
wave | 'square'; an oscillator type or 'noise' for percussion |
notes | []; MIDI numbers for tonal tracks; null, 0 and false are rests. In noise tracks, use 1 for a hit |
vol | 0.12; gain before the music bus |
len | 0.9; note duration in steps, not seconds |
filter | Noise only; default { type: 'highpass', freq: 6000 }, with the same object fields as sfx.noise |
The returned handle has stop(), playing and step. It refers to the audio object's current sequencer, including a replacement pattern. playing means a pattern is set, even when paused or muted. step is the next step to schedule, not a precise audible playhead. The SDK schedules slightly ahead. tick() is available for controlled scheduling tests; ordinary games leave scheduling to the SDK.
handle.stop() or audio.music(null) stops scheduling. Notes already scheduled can finish their tails; use lifecycle mute/pause when all output must become silent. Muting keeps the pattern's timing moving; pausing stops its scheduler and preserves its position. Restarting a run only restarts the pattern if your start callback calls music again.
Play a decoded music loop
audio.loopTrack(buffer, { at = 0, vol = 1 }) plays a decoded AudioBuffer through the music bus. at is a non-negative starting offset in seconds, wrapped to the buffer's duration; vol is a gain. Supply a buffer with a positive duration. It waits for activation, preserves its position across pauses, and keeps advancing silently while muted. Multiple loop handles can coexist; stop an old handle before replacing it.
The returned { stop(), time() } handle stops that loop permanently and reports its position in seconds. A new run needs a new handle to restart from the beginning. audio.close() ends all loops. Use loopTrack for background audio whose playback position must survive a pause on a shared context.
For a same-origin file, add these callbacks to your game options, merging them with existing callbacks. Place your licensed music.wav next to the game page; decoding support depends on the target browser.
js
let musicBuffer, musicLoop;
const recordedMusic = {
async load(ctx) {
await ctx.breathe('prepare audio');
if (ctx.signal.aborted || !ctx.audio.prepare()) return;
const response = await fetch('./music.wav', { signal: ctx.signal });
if (!response.ok) throw new Error(`Music request failed: ${response.status}`);
const bytes = await response.arrayBuffer();
if (ctx.signal.aborted) return;
const decoded = await ctx.audio.ctx.decodeAudioData(bytes);
if (!ctx.signal.aborted) musicBuffer = decoded;
},
start(ctx) {
musicLoop?.stop();
if (musicBuffer) musicLoop = ctx.audio.loopTrack(musicBuffer, { vol: 0.35 });
},
dispose() {
musicLoop?.stop();
musicLoop = musicBuffer = null;
},
};createGame normally prepares audio after load finishes. To decode or create custom nodes inside load, yield with ctx.breathe() and call the idempotent audio.prepare() first, as above. It does not start playback. Load and decode before play; avoid decoding at the starting gesture. Pass ctx.signal to fetch and check it after asynchronous work so a disposed game cannot start a new loop.
Connect custom nodes
Create nodes with factories such as audio.ctx.createGain() and audio.ctx.createBufferSource(). audio.ctx is initially null until preparation succeeds. Under a shared host it is a facade, so constructors such as new GainNode(audio.ctx) are incompatible.
Connect effect chains to audio.sfxOut and background music chains to audio.musicOut. Both feed the SDK's master gain and limiter. The master implements mute; the buses implement the end-of-run behavior. Connecting directly to audio.master or audio.ctx.destination bypasses the effect/music distinction. Do not use these as game output routes. Give third-party libraries the SDK context and an explicit bus destination.
Own the lifetimes of your custom nodes: stop sources and disconnect graph nodes when finished, and clean up game references/listeners in dispose. In shared mode, factory-created sources are also stopped by SDK close, but a raw looping source continues advancing behind the closed gate during pause. The SDK cannot rewind an arbitrary custom graph. Use loopTrack when the position must be preserved.
The host's context can keep advancing while your game is paused. Use ctx.time/ctx.after for gameplay; do not use audio.ctx.currentTime as the game's simulation clock. The shared audio protocol defines host ownership and channel routing.
Use audio with your own engine
Import createAudio from portal-kit/audio. Your adapter owns the lifecycle that createGame normally manages; create one audio object for the game document and use its methods, not context-level lifecycle calls.
| Factory option | Default and purpose |
|---|---|
muted | false; apply the host's initial mute setting |
volume | 0.8; unmuted master gain |
onSound | Optional callback receiving 'running' or 'locked' while sound is requested |
rng | Math.random; supplies repeat pitch variation, not the noise buffer's random samples |
test | false; report direct master connections in standalone mode through violations() |
shared | Auto-detect the parent host's supported audio interface once at construction; pass null to force standalone ownership in an adapter/test |
target | Window event target for gesture retries; injected event targets can be used in tests |
AudioContext | Optional context constructor override for tests; defaults to the browser implementation |
setInterval, clearInterval, setTimeout, clearTimeout | Optional paired timer implementations for controlled tests |
| Method | Responsibility |
|---|---|
prepare() | Build the context/channel and graph during loading, without starting sound; returns whether preparation succeeded |
prime() | Request activation inside a pre-start user gesture; creates the graph if needed but starts no SDK sources |
nap() | Prepare a parked game's standalone context during a gesture, then let it sleep; in shared mode only prepare its graph |
unlock() | Allow playback at start and request activation inside the gesture; starts a waiting pattern/loop when possible |
suspend() | Pause the game's output and music scheduler |
resume() | Request output again after pause; cannot activate a never-started game by itself |
setMuted(on) | Apply the current host mute setting in every state |
setOver(on) | Select end-of-run output behavior; pass false for a new run |
close() | Permanently stop scheduling and release the game's audio resources; repeated calls are harmless |
Call prepare during loading, setOver(false) and unlock when a run starts, suspend/resume for pause changes, setOver(true) at the end and close on disposal. Preserve all host/visibility/blur pause reasons as described in Game lifecycle. prime/nap support pre-start warming; they are not substitutes for starting a run.
prime() and unlock() return booleans indicating that the request could be made, not Promises or proof of audible playback. Context transitions can settle later or remain blocked by browser activation policy. Gesture retries are installed while playback is requested and the context is locked. createGame already wires activation and lifecycle transitions; games using it should leave those to the SDK.
If Web Audio is unavailable or an advertised host channel cannot be opened, preparation/activation returns false and synthesized effects are dropped. A missing or unsupported host interface selects standalone mode; a supported interface whose open fails does not create a second fallback context.
Inspect sound availability
| Member | What it tells you |
|---|---|
state() | 'none' before preparation, 'closed' after close, otherwise the context state; a closed game gate on a running shared context reports 'suspended' |
sound() | null before preparation/after close, otherwise 'running' or 'locked' for the underlying context; does not reflect mute or the shared game's gate |
muted() | Current mute setting |
onSound(fn) | Replace the single sound-state callback; createBridge({ audio }) uses this slot for reporting |
stats() | Context/source counters, active effect slots, dropped effects, scheduler state, ownership mode and requested bus gains |
violations() | Test-mode routing diagnostics; shared mode does not patch the host's AudioNode methods |
The callback reports a running context immediately and a persistent lock after 250 ms while sound is requested. stats().started counts started or scheduled sources, including the standalone unlock sample; it is not a count of audible rewards. Neither state nor counters prove that a device's speakers are audible.
If sound is missing, check mute, game pause reasons, activation and output routing. The host should forward real gestures and respect the channel ownership protocol. Exercise target phones and repeated mounts as well as the automated checks.