YouTube Playables SDK basics: ready signals, audio, pause and saves
The ytgame calls a Playable must make: load order, firstFrameReady and gameReady, YouTube's audio setting, onPause/onResume, cloud saves within 3 MiB, and ads.
YouTube Playables are web games that run inside YouTube. Access is by invitation (see the YouTube Playables page), and every release goes through certification against published requirements. Many of those requirements are about how the game uses the ytgame SDK. This guide walks through the calls a game must make, in the order it makes them, with a tested adapter and a save helper. Documentation checked on 2026-10-01.
Load the SDK before anything else
From the getting-started page: the game needs an index.html at its root, and the SDK script must be loaded before any game code:
<script src="https://www.youtube.com/game_api/v1"></script>
This is validated when you publish. The SDK creates a global ytgame, which you must not override. Locally the SDK runs as a no-op, so use ytgame.IN_PLAYABLES_ENV (together with a check that ytgame exists) to tell whether the game runs inside Playables.
Two ready signals, in order
The SDK reference and the integration requirements define two calls:
ytgame.game.firstFrameReady()once the game is rendering frames, for example a loading or splash screen that shows loading is under way. If it is never called, the game is not shown to users.ytgame.game.gameReady()once the game is ready for interaction, such as a main menu. It must come afterfirstFrameReady(), and must not be called while splash or loading screens are still displayed. YouTube keeps its loading spinner until this call.
These two calls also bound the size budget: the YouTube Playables page lists the published initial-bundle limit, which is measured up to gameReady.
Audio follows YouTube, not just your toggle
The requirements say the game must respect the system audio setting, the device volume, and YouTube’s own mute button, using ytgame.system.isAudioEnabled() to initialise and ytgame.system.onAudioEnabledChange() to follow changes. When YouTube mute is set, no audio may play and your in-game controls must not override it. Sound must never play unexpectedly.
Pause and resume come from YouTube
ytgame.system.onPause(callback) and onResume(callback) deliver YouTube’s pause and resume events, and the requirements say games must pause and resume accordingly. The reference adds that onPause fires for every kind of pause, including the player leaving, and that the game has only a short window to save state before it is evicted, with no guarantee it will resume.
Cloud saves
Saving has the strictest rules:
- Inside Playables, save progress only with
ytgame.game.saveData(); no other mechanism is allowed. - Await
loadData()before the firstsaveData(). Saves sent before loading completes are rejected, which protects existing progress. - The saved string must be well-formed UTF-16 and at most 3 MiB.
String.prototype.isWellFormed()checks the first condition. - Saves from older game versions must keep loading without errors.
- Save at important milestones; the final save when a player exits is best effort and limited to 64 KiB.
The helper below enforces the first three rules and falls back to localStorage outside Playables, the pattern the SDK reference itself shows. It wraps the data in an object with a version number so later releases can migrate old saves. One assumption is ours: the docs do not say how the 3 MiB is counted, so the helper counts 2 bytes per UTF-16 code unit, the size of the string in UTF-16.
// file: examples/lib/playables-save.js
// Cloud saves for YouTube Playables, with a localStorage fallback outside Playables.
// Rules from https://developers.google.com/youtube/gaming/playables/reference/sdk and
// .../certification/requirements_integration (checked 2026-10-01):
// - inside Playables, saveData/loadData is the ONLY allowed way to save progress;
// - await loadData() before the first saveData(), or the save is rejected;
// - the saved string must be well-formed UTF-16 and at most 3 MiB;
// - old saves must keep loading after game updates (hence the version field).
/** 3 MiB, the documented maximum for saveData(). */
export const MAX_SAVE_BYTES = 3 * 1024 * 1024;
/**
* Size of a JavaScript string stored as UTF-16 (2 bytes per code unit). This is our reading of
* "a maximum of 3 MiB" for a UTF-16 string; the docs do not spell out how the size is counted.
* @param {string} s
*/
export function utf16Bytes(s) {
return s.length * 2;
}
/**
* @param {{ yt?: any, storage?: Storage | null, key?: string, version?: number }} [options]
*/
export function createPlayablesSave({ yt = /** @type {any} */ (globalThis).ytgame, storage = null, key = 'save', version = 1 } = {}) {
const inPlayables = Boolean(yt && yt.IN_PLAYABLES_ENV);
let loaded = false;
return {
inPlayables,
/** @returns {Promise<any>} the saved data object, or null when there is no save yet */
async load() {
let raw = '';
if (inPlayables) {
raw = await yt.game.loadData();
} else {
try {
raw = storage?.getItem(key) ?? '';
} catch {
raw = ''; // storage blocked (e.g. partitioned third-party iframe): start fresh
}
}
loaded = true;
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && 'data' in parsed ? parsed.data : null;
} catch {
return null; // corrupt save: never crash on load
}
},
/** @param {any} data */
async save(data) {
if (!loaded) throw new Error('Call load() before save(): Playables rejects saveData() until loadData() has completed.');
const raw = JSON.stringify({ v: version, data });
if (typeof raw.isWellFormed === 'function' && !raw.isWellFormed()) throw new Error('Save string is not well-formed UTF-16.');
if (utf16Bytes(raw) > MAX_SAVE_BYTES) throw new Error(`Save is ${utf16Bytes(raw)} bytes; the limit is ${MAX_SAVE_BYTES}.`);
if (inPlayables) {
await yt.game.saveData(raw);
} else {
try {
storage?.setItem(key, raw);
} catch {
// QuotaExceededError or blocked storage outside Playables: keep playing without a save.
}
}
},
};
}
Ads
YouTube runs pre-roll ads itself. For interstitial and rewarded ads, the monetization guide uses two SDK calls:
ytgame.ads.requestInterstitialAd()at natural breaks such as level completion. It makes no guarantee that an ad was shown, so never use it to grant rewards.ytgame.ads.requestRewardedAd(rewardId)when the player asks for a reward. It resolvestrueonly if the player earned it. The reward ID must identify the reward type, be reused every time that reward is offered, and contain no user data.
Both can reject, and the reference notes the error may be undefined; the game must carry on. The monetization requirements allow in-game ads only through these YouTube-provided functions, require the game to keep honouring the audio setting and onPause/onResume while using them, and forbid off-platform ads and purchases. The requirements also say that if you use in-game ads you must configure the monetization settings in the Playables Developer Portal to turn them on, while the monetization guide says those ad types default to “on”. Check the settings for your release in the portal.
The adapter
// file: examples/lib/adapters/youtube-playables.js
// YouTube Playables SDK adapter.
// Docs: https://developers.google.com/youtube/gaming/playables/reference/sdk ,
// .../reference/getting_started , .../reference/monetization ,
// .../certification/requirements_integration (checked 2026-10-01)
// index.html must load the SDK before ANY game code:
// <script src="https://www.youtube.com/game_api/v1"></script>
import { gameplayGuard } from './types.js';
import { createNoopAdapter } from './noop.js';
/**
* @param {any} [yt] window.ytgame (injectable for tests)
* @returns {import('./types.js').PortalAdapter}
*/
export function createPlayablesAdapter(yt = /** @type {any} */ (globalThis).ytgame) {
// The SDK is a no-op when served locally; outside Playables, behave like any other site.
if (!yt || !yt.IN_PLAYABLES_ENV) return createNoopAdapter('not running inside YouTube Playables');
let firstFrameSent = false;
let readySent = false;
// Playables has no gameplay start/stop events; the guard keeps game code identical across portals.
const guard = gameplayGuard(
() => {},
() => {},
);
return {
name: 'youtube-playables',
// firstFrameReady() while the loading/splash screen is visible...
loadingStart() {
if (!firstFrameSent) {
firstFrameSent = true;
yt.game.firstFrameReady();
}
},
// ...gameReady() only once the game is interactive (and never before firstFrameReady()).
loadingStop() {
if (!firstFrameSent) {
firstFrameSent = true;
yt.game.firstFrameReady();
}
if (!readySent) {
readySent = true;
yt.game.gameReady();
}
},
...guard,
async commercialBreak() {
guard.gameplayStop();
try {
// No guarantee an ad shows. Pausing and muting go through onPause/onResume and the audio
// setting, which the game registers once (see onSystemPause and onMuteRequest).
await yt.ads.requestInterstitialAd();
} catch {
// The error may be undefined; the game continues either way.
}
},
rewardedBreak(hooks) {
return new Promise((resolve) => {
hooks.showPrompt(
async () => {
guard.gameplayStop();
try {
// The reward ID must identify the reward type, stay stable, and contain no user data.
resolve(Boolean(await yt.ads.requestRewardedAd(hooks.name)));
} catch {
resolve(false);
}
},
() => resolve(false),
);
});
},
onMuteRequest(listener) {
// Audio MUST follow YouTube's setting: initialise from it and follow its changes.
listener(!yt.system.isAudioEnabled());
yt.system.onAudioEnabledChange((/** @type {boolean} */ enabled) => listener(!enabled));
},
onSystemPause(pause, resume) {
// onPause covers every pause, including the player leaving; save state there if needed.
yt.system.onPause(pause);
yt.system.onResume(resume);
},
};
}
Unlike the other adapters, this one exposes onSystemPause. The example game registers its pause and resume functions there once at startup, so YouTube’s pause events and ad breaks share the same code. The entry point sends the two ready signals as soon as the menu is on screen:
// file: examples/vanilla-playables/main.js
// YouTube Playables SDK example, vanilla JS. Build: npm run examples:build, then serve examples/dist/ on localhost.
import { createPlayablesAdapter } from '../lib/adapters/youtube-playables.js';
import { startTapGame } from '../lib/tap-game.js';
const adapter = createPlayablesAdapter();
adapter.loadingStart();
// Load your assets here. This example has none, so loading ends immediately.
adapter.loadingStop();
startTapGame({
canvas: /** @type {HTMLCanvasElement} */ (document.getElementById('game')),
overlay: /** @type {HTMLElement} */ (document.getElementById('overlay')),
adapter,
});
console.info(`[example] portal adapter: ${adapter.name}`);
The Phaser 3.90.0 build registers the same hooks from its boot scene:
// file: examples/phaser-playables/main.js
// YouTube Playables SDK example, Phaser 3. Build: npm run examples:build, then serve examples/dist/ on localhost.
import { createPlayablesAdapter } from '../lib/adapters/youtube-playables.js';
import { createPhaserGame } from '../lib/phaser-game.js';
const adapter = createPlayablesAdapter();
createPhaserGame(adapter, 'game');
console.info(`[example] portal adapter: ${adapter.name}`);
Test with the test suite
The SDK is a no-op outside YouTube, so local runs cannot prove the integration. Google provides a test suite for validating it before you submit. The Portal Readiness Checker can still catch structural problems first: it recognises the game_api/v1 tag, reports whether firstFrameReady() and gameReady() appear in your code, and compares your build with the published Playables size limits.
What to check before you submit
- The SDK tag comes before every game script in
index.html, and nothing overridesytgame. firstFrameReady()fires on the loading screen;gameReady()fires once the game is interactive, never earlier.- Audio starts from
isAudioEnabled()and followsonAudioEnabledChange(), and YouTube mute always wins. onPausepauses the game (and saves if needed);onResumeresumes it.- Saves go through
saveData()only, afterloadData(), within 3 MiB, and old saves still load. - Ads use only
requestInterstitialAd()andrequestRewardedAd(), with stable reward IDs, and the game continues when they reject. - The test suite passes.
Limits of this guide
This guide covers the required integrations and ads. It does not cover sendScore, openYTContent, getLanguage, health logging, or the Unity, Godot and Flutter samples Google publishes. The adapter and save helper were unit-tested against a mock of ytgame; they have not run inside YouTube, which requires Developer Portal access.
Questions
- What is the difference between firstFrameReady() and gameReady()?
- firstFrameReady() tells YouTube the game is showing frames, typically a loading or splash screen, and must come first. gameReady() tells YouTube the game is interactive; it must not be called while a loading screen is still shown.
- Can a Playable save progress in localStorage?
- Not inside Playables. The integration requirements say games must save progress with saveData and must not use any other mechanism. Outside Playables, the SDK documentation shows falling back to localStorage.
- Can a Playable show its own ads?
- Only through the SDK's ad functions. The monetization requirements allow in-game ads via YouTube-provided ads functions and forbid off-platform ad services; pre-roll ads are handled by YouTube automatically.