Skip to content

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:4176

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

MethodOptions 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 fieldDefault and meaning
bpm120; positive finite beats per minute
bars1; positive integer number of bars
steps16; positive integer steps per bar, in 4/4 time
loopRepeats unless explicitly false
tracksArray 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 fieldDefault 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
vol0.12; gain before the music bus
len0.9; note duration in steps, not seconds
filterNoise 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 optionDefault and purpose
mutedfalse; apply the host's initial mute setting
volume0.8; unmuted master gain
onSoundOptional callback receiving 'running' or 'locked' while sound is requested
rngMath.random; supplies repeat pitch variation, not the noise buffer's random samples
testfalse; report direct master connections in standalone mode through violations()
sharedAuto-detect the parent host's supported audio interface once at construction; pass null to force standalone ownership in an adapter/test
targetWindow event target for gesture retries; injected event targets can be used in tests
AudioContextOptional context constructor override for tests; defaults to the browser implementation
setInterval, clearInterval, setTimeout, clearTimeoutOptional paired timer implementations for controlled tests
MethodResponsibility
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 ​

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

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1