Synced audio

Use Plot.toolkit.syncedAudio() to keep a local sound aligned with Plot.time. Give every game copy the same audio assets and timestamp; the player loads the clips, skips elapsed content when late, and corrects its position as the shared clock changes. Plot.toolkit contains optional conveniences built from the public SDK and browser APIs.

import Plot from '@plot.game/sdk'

const sound = Plot.toolkit.syncedAudio({
  mode: 'loop',
  introSrc: '/audio/intro.wav',
  src: '/audio/loop.wav',
  volume: 0.6,
})

await Plot.ready()
await sound.load() // Optional preload; play() also loads when needed.

// Start on game time 5 seconds. In a game, read this timestamp from Game State.
await sound.play(5_000)
sound.volume = 0.35
sound.stop()

// On component or game teardown:
sound.dispose()

Scheduling timestamps are game-time milliseconds, as returned by Plot.time.now(). Audio offsets and durations are seconds, matching browser audio conventions. play(5_000) means game time five seconds, not five seconds after the call and not a Unix timestamp. If that clip finishes loading at game time ten seconds, playback begins approximately five seconds into it.

These controls affect this object on this device. They do not broadcast commands or audio. Store a shared cue in Game State when everyone should hear the same sound, including late joiners and recovering players.

Choose a playback mode

// Standard: one clip, played once. This is the default mode.
const announcement = Plot.toolkit.syncedAudio({ src: '/audio/message.wav' })

// Loop: repeat one clip indefinitely.
const rain = Plot.toolkit.syncedAudio({ mode: 'loop', src: '/audio/rain.wav' })

// Intro plus loop: play the intro once, then repeat the second clip.
const music = Plot.toolkit.syncedAudio({
  mode: 'loop',
  introSrc: '/audio/music-intro.wav',
  src: '/audio/music-loop.wav',
})

Loop transitions use the decoded clips' exact lengths without waiting for a JavaScript callback. Late playback skips an elapsed intro and starts at the current loop position. The toolkit adds no silence between clips or repetitions; the assets must still have matching boundaries to sound seamless. Trim silence and encoder padding, and audition the intro transition and the loop wrap.

Sources and mode are immutable. Create another object to change them. Relative URLs resolve against the game page's base URL. Use public game assets or URLs that allow the game to fetch them; cross-origin assets need CORS access. Asset requests do not include browser credentials. Supported URLs use HTTP or HTTPS and cannot contain embedded usernames or passwords. Ordinary browser restrictions still apply, including HTTPS pages blocking insecure assets.

Pause, resume, and seek

sound.pause()
const offsetSeconds = sound.elapsedTime

// Put audio position 12 seconds at Plot time 20 seconds.
await sound.resume(12, 20_000)

pause() takes no arguments and freezes this object's intended position. It cancels loading-related playback and future starts, while allowing useful asset loading to finish. Repeating it does not advance the checkpoint. Pausing an object with no playback request, including an object only being preloaded, does nothing. It does not stop the shared clock or other audio objects.

resume(offsetSeconds, resumeAtGameTimeMs) works with or without a previous pause. It replaces the current playback request immediately, then remains silent until its timestamp. A client receiving resume(12, 20_000) at game time 23 seconds starts at position 15 seconds. An offset beyond a standard clip's end finishes successfully without playing. In loop mode, offsets can span any number of repetitions.

play(T) is equivalent to resume(0, T). Reapplying the same offset/timestamp pair while pending, active, or already ended does not restart it. A different pair replaces it. After pause, stop, disconnection, or failure, the same pair is a new attempt and catches up to the current clock.

stop() clears the playback anchor and resets the position to zero. Both stop and pause retain loaded assets. muted = true silences playback while its timeline keeps advancing; unmuting catches the current sound, without restarting. Volume is a local linear gain from 0 to 1.

For a multiplayer pause, the game's host stores one common paused offset. Each client calls pause() when it sees that cue, so local stop positions may differ slightly with message arrival time. Resume everyone using the stored offset and a single new shared timestamp. Do not independently resume from each client's local elapsedTime value.

Enable sound after a player interaction

The SDK automatically listens for a real click, tap, or qualifying keypress inside the game and attempts to enable its shared audio engine. It starts listening when the SDK loads, so an interaction before Plot.ready() or before you create a sound can prepare audio for later playback. Loading the SDK alone does not open browser audio or start sound.

For ordinary playback, no manual unlock handler is needed. Browser settings and autoplay rules still apply: interaction with the surrounding Plot page does not guarantee that the game can play sound. Script-generated events do not count as player interaction. If the browser later suspends audio, the SDK listens for another interaction and tries again.

Keep an Enable sound action as a fallback. Call Plot.toolkit.unlockAudio() directly in that handler, before awaiting loading or other work:

enableSoundButton.addEventListener('click', () => {
  void Plot.toolkit.unlockAudio()
    .then(() => applyCurrentCue())
    .catch(reportAudioError)
})

Automatic and explicit unlocking work before Plot.ready() and never start readiness. Preloading also works before readiness. play() and resume() require connected, ready game state. They can be called inside the initial connected lifecycle callback and can also unlock sound when called directly from a gesture.

When a new attempt rejects with PlotErrorCode.UserActivationRequired, show an Enable sound action, unlock, then explicitly retry the stored cue. Automatic unlocking and unlockAudio() alone do not play a previously rejected request. Neither restarts stopped or explicitly paused sounds. If a standard cue has already elapsed, it ends successfully without requiring sound to be enabled.

An accepted request interrupted by the browser retains its timeline and catches up when audio becomes available again. A paused object stays paused. A Plot disconnection instead stops playback and clears the anchor; reapply current Game State in the connected lifecycle callback after recovery.

Share a recoverable soundtrack

This example assumes the game page includes an Enable sound button:

<button id="enable-sound" type="button" hidden>Enable sound</button>

Register lifecycle and state-change listeners before readiness. The lifecycle callback handles startup and recovery; the focused listener handles live changes.

import Plot, { PlotError, PlotErrorCode } from '@plot.game/sdk'

type AudioCue =
  | { status: 'playing'; offsetSeconds: number; resumeAtGameTimeMs: number }
  | { status: 'paused'; offsetSeconds: number }
type GameState = { ambience?: AudioCue }

const state = Plot.gameState.as<GameState>()
const sound = Plot.toolkit.syncedAudio({
  mode: 'loop',
  introSrc: '/audio/intro.wav',
  src: '/audio/loop.wav',
})
const enableSoundButton = document.querySelector<HTMLButtonElement>('#enable-sound')!

function reportAudioError(error: unknown) {
  if (error instanceof PlotError) {
    if (error.code === PlotErrorCode.UserActivationRequired) {
      enableSoundButton.hidden = false
      return
    }
    if (error.code === PlotErrorCode.OperationCancelled ||
        error.code === PlotErrorCode.NotConnected) return
  }
  console.error('Unable to play soundtrack', error)
}

function applyCurrentCue() {
  if (!Plot.connection.isConnected) return
  const cue = state.get().ambience
  if (!cue) sound.stop()
  else if (cue.status === 'paused') sound.pause()
  else void sound.resume(cue.offsetSeconds, cue.resumeAtGameTimeMs).catch(reportAudioError)
}

function enableSound() {
  void Plot.toolkit.unlockAudio().then(() => {
    enableSoundButton.hidden = true
    applyCurrentCue()
  }).catch(reportAudioError)
}

enableSoundButton.addEventListener('click', enableSound)
const offLifecycle = Plot.onLifecycleChange(status => {
  if (status === 'connected') applyCurrentCue()
})
const offChange = state.onChanged(event => {
  if (event.affects('ambience')) applyCurrentCue()
})

await Plot.ready()

// Wire these actions to the game's controls. Only the host writes Game State.
async function startOrResumeAmbience() {
  const party = Plot.party.get()
  if (party.self.playerId !== party.host.playerId) return
  const resumeAtGameTimeMs = Plot.time.now() + 1_000
  await state.update(current => ({
    ...current,
    ambience: {
      status: 'playing',
      offsetSeconds: current.ambience?.status === 'paused'
        ? current.ambience.offsetSeconds : 0,
      resumeAtGameTimeMs,
    },
  }))
}

async function pauseAmbience() {
  const party = Plot.party.get()
  if (party.self.playerId !== party.host.playerId) return
  const pausedAtGameTimeMs = Plot.time.now()
  await state.update(current => {
    const cue = current.ambience
    if (!cue || cue.status === 'paused') return current
    const offsetSeconds = cue.offsetSeconds + Math.max(
      0, (pausedAtGameTimeMs - cue.resumeAtGameTimeMs) / 1_000,
    )
    return { ...current, ambience: { status: 'paused', offsetSeconds } }
  })
}

function disposeSoundtrack() {
  offLifecycle()
  offChange()
  enableSoundButton.removeEventListener('click', enableSound)
  sound.dispose()
}

Handle rejected Game State writes using the error guide. Only the host computes the future timestamp; receivers use the stored value. One second of lead after preloading can help everyone hear the opening, but is not a loading guarantee. A player joining while paused stays silent and uses the shared paused offset for UI; its local object need not have a matching checkpoint.

For multiple tracks, store an asset key and cue identity with the anchor, map the key to your game's URLs, and dispose the old object when assets change. A reliable message can signal a transient sound, but cannot alone tell a late joiner which soundtrack is current. Audio completion is local presentation, not proof that the game should advance for everyone.

Observe playback

The full interface is SyncedAudio.

PropertyMeaning
stateidle, loading, scheduled, playing, blocked, paused, stopped, ended, error, or disposed.
anchorImmutable { offsetSeconds, atGameTimeMs }, or null without a request. Retained while paused for diagnostics.
durationStandard clip length; Infinity for a loaded loop; NaN before required metadata loads.
introDuration / loopDurationClip lengths in seconds; 0 when absent and NaN while needed metadata is unknown.
elapsedTimeIntended unwrapped position, including resume offset and completed loop cycles. Freezes while paused; clamps at a standard clip's end.
currentTimeIntended position in the audio timeline. After the intro it wraps through [introDuration, introDuration + loopDuration).
errorLatest operational PlotError, or null. A valid new attempt clears it.

Position getters describe the intended timeline rather than a measured sample at the speaker. They hold the requested offset before a future boundary and return zero without a request or checkpoint. While loading, a position that needs unknown clip lengths maps to NaN in currentTime. currentTime is read-only; use resume(offset, timestamp) to seek with a shared anchor. There is no variable playback rate.

subscribe(listener) immediately delivers an immutable snapshot, then reports state, anchor, metadata, gain, mute, and error changes. It is not a progress timer; read the position getters when rendering a progress display.

onEnded(listener) reports { anchor, reason } once per accepted standard request. completed means playback reached its end; missed means no samples could be scheduled before it expired. A missed cue is a successful no-op. Pause, stop, replacement, and loop boundaries do not emit ended events. onError(listener) reports operational failures, including failures after the playback promise has resolved. Neither focused listener replays past events. Every listener registration returns an idempotent unsubscribe function.

play() and resume() resolve when scheduled or already elapsed, not when the sound starts or finishes. Expected cancellation rejects pending promises with operation-cancelled without setting error or emitting onError. Other initial operational failures both reject and notify onError; choose one path for player-facing messages to avoid duplicates. Invalid arguments reject with TypeError and leave existing playback intact. Invalid factory options or property assignments throw synchronously.

Asset and browser limits

This player fully loads and decodes compact cues and music or ambience loops. It is not a streaming player for long soundtracks. Assets decode to a common 48 kHz timeline and support mono or stereo. Keep the same asset versions on every device; browser differences in compressed formats can still affect trimming. PCM WAV is a useful reference for checking authored boundaries.

The toolkit permits up to 16 MiB compressed per clip and 128 MiB of retained decoded audio across objects, with two concurrent loads and a 30-second load deadline. Intro and loop clips share decoded buffers without an extra combined copy. Stereo audio at 48 kHz uses about 22 MiB per minute; browser decoding also needs working memory beyond the retained limit.

audio-resource-limit means to use smaller clips or dispose unused objects. Fetch/CORS failures use audio-load-failed; invalid or unsupported audio uses audio-decode-failed; audio-engine failures use audio-unavailable. operation-timeout can describe an asset load deadline, and unsupported-browser means the required browser audio features are unavailable. See error codes and recovery.

Disposal releases this object's resources and listeners. It never stops other objects. Getters remain readable; repeated stop() and dispose() are safe. Other valid method calls and gain/mute assignments after disposal fail with object-disposed.

Playback uses the shared clock with best-effort output-latency estimates. It cannot promise simultaneous sound at different speakers: devices, Bluetooth, browser suspension, and clock estimates affect that result. Corrections after a clock change may be audible. Timers maintain synchronization rather than drive loop boundaries; a suspended browser can delay correction until execution returns. Test your game on the browsers and output devices your players use.