Publisher guide
The SDK: window.vibe
Everything an app needs from the outside world goes through the host. Every call returns a promise, every failure is a VibeError with a code.
Ready and context
The SDK is injected by the host; nothing needs to be imported. Wait for vibe.ready before using any other call. It resolves once the handshake is complete and all package files are available.
const ctx = await vibe.ready;
// ctx = {
// appId, appName, versionId, versionLabel,
// player: { id, nickname }, // id is scoped to your app; never an e-mail address
// locale: 'en-GB', theme: 'dark', online: true,
// permissions: [], // granted manifest permissions
// safeArea: { top, right, bottom, left },
// viewport: { width, height },
// hostControls: [{ kind: 'menu', x, y, width, height }] // covered by the shell's menu handle
// }
vibe.context; // the same object after ready, null before| Field | Meaning |
|---|---|
player.id | Stable per player and app, but different in every app, so apps cannot correlate players. Use it as the recipient of messages. |
theme | 'dark' today. The shell decides it, not the device; see Theme below. |
online | Whether the host currently has a connection. Changes arrive as 'online' / 'offline' events. |
safeArea | Insets in CSS pixels to keep clear of notches and the home indicator. |
viewport | Size of your frame in CSS pixels. Updated through the 'resize' event. |
hostControls | Where the shell's menu handle covers your frame (CSS pixels). Keep important controls out of it. Updated through the 'resize' event. |
Assets at runtime
Static references are rewritten when you publish (see Assets and paths). For a path you compute at runtime call vibe.asset(path); it returns the blob: URL of that package file synchronously, or throws when the file does not exist.
img.src = vibe.asset(`levels/${n}.png`);
const cfg = await fetch(vibe.asset('config.json')).then((r) => r.json());Storage
Three key/value stores with the same shape: get, set, remove, keys, clear.
| Store | Values | Where | Use it for |
|---|---|---|---|
vibe.cloud | JSON | Server, per player and app. Mirrored on the device; works offline and syncs in the background. | Save games, progress, settings that should follow the player across devices. |
vibe.local | strings | Device only (like localStorage). | Volume, last screen, small preferences. |
vibe.db | structured, incl. Blob and ArrayBuffer | Device only (like IndexedDB). | Replays, generated levels, cached render data. |
await vibe.cloud.set('save', { level: 3, hp: 12 }); // any JSON value
const save = await vibe.cloud.get('save'); // undefined when missing
await vibe.cloud.remove('save');
const keys = await vibe.cloud.keys();
await vibe.cloud.clear();
await vibe.local.set('volume', '0.5'); // strings only
const volume = await vibe.local.get('volume');
await vibe.db.set('replay', new Blob([bytes])); // structured values, Blob and ArrayBuffer ok
const replay = await vibe.db.get('replay');Players
const me = await vibe.players.me(); // { id, nickname }
const found = await vibe.players.search('ann'); // other players of this app, by nickname: [{ id, nickname }]
// search needs at least 2 characters, returns up to 10 by default (second argument, max 20) and requires network
// Report a player (or a message they sent) to the VibeGames admins; the player confirms in the shell's dialog
const { reported } = await vibe.players.report(found[0].id, { messageId: message.id });A player is an app-scoped id and the nickname they chose (2 to 50 characters). Your app never learns their e-mail address or account. Messages carry the sender's name as fromNickname. The older displayName and fromDisplayName still hold the same values for apps written before; use the new names in new code.
Search only finds players of your app who have not opted out of being discoverable. A player who opted out still receives messages from their friends and from players they already exchanged messages with. To let a player choose an opponent, prefer vibe.players.pick(): it offers their friends first and includes the same search.
If players can contact each other in your app, offer a way to report a player, e.g. next to their name or a message. vibe.players.report only asks: the shell shows its own dialog where the player chooses what is wrong and why, and sends the report to the VibeGames admins, who can remove content, reset names and suspend accounts. reported is false when the player cancelled. In the devkit the call is logged and answers { reported: true }.
Friends
Players keep a friends list in VibeGames. They add each other with a friend code, which they show as a QR code or send as a link, or with a friend request after playing together in a game. Your app uses the list in two ways, and both go through the shell's own dialogs: the player chooses an opponent among their friends, and after a game the player can ask to become friends with their opponent.
// Lobby, "Play with a friend": the shell's picker (friends who have your game, and a search)
newGameButton.addEventListener('click', async () => {
const opponent = await vibe.players.pick(); // { id, nickname }, or null: cancelled
if (opponent) await vibe.messages.send({ to: opponent.id, title: 'Want to play?' });
});
// Game over against a person, "Add as friend". The answer never comes back:
// remember whom you asked.
const asked = new Set(JSON.parse((await vibe.local.get('friend-asked')) ?? '[]'));
addFriendButton.hidden = !gameOver || vsComputer;
addFriendButton.disabled = asked.has(opponent.id);
addFriendButton.addEventListener('click', async () => {
const { requested } = await vibe.players.addFriend(opponent.id); // the shell asks first
if (!requested) return; // the player cancelled
asked.add(opponent.id);
await vibe.local.set('friend-asked', JSON.stringify([...asked]));
addFriendButton.textContent = 'Asked'; // not "Friends now"
});| Call | Returns | Notes |
|---|---|---|
players.pick() | { id, nickname } or null | The shell lists the player's friends who have your app and can receive messages, with a name search below them (the rules of search). The result is the same app-scoped player as from search; null when the player cancelled. |
players.addFriend(playerId) | { requested } | The shell asks the player whether to send a friend request; the other player accepts or declines it in VibeGames. true when the player confirmed, false when they cancelled. |
- When a request is sent. Only between players who played together (each sent the other a message in your app), never to a player under 18 (they are added only with a friend code), and at most 20 a day per player. The shell tells the player when a request cannot be sent; your app is not told. Offer the button after a finished game against a person, not against the computer.
- Children and teens. Players under 18 appear to adults in
pick()only as their friends (search keeps hiding them), and children only while their parent allows messaging. - The dialogs are the shell's, with their own titles; you cannot pass text into them. Open them from your own buttons ("Play with a friend", "Add as friend"). The promises wait for the player, without a timeout.
- Limits. One shell dialog at a time, shared with
players.reportandfeedback.open, and not right after another (RATE_LIMITED); both calls need a connection (OFFLINE) and are not available in previews (NOT_ALLOWED).addFriendanswersINVALID_ARGSfor an id that is not a player id or the player's own. - Devkit.
pick()lists the other simulated players, all of them as friends;addFriend()is logged and asks you to confirm in the browser, and stores nothing (Players and messaging).
Messages
Messages are the way players interact: a move, an invitation, a taunt. They carry a title, an optional body and an optional data payload (JSON, up to 4 KB).
// Send a move to another player (queued when offline, delivered when back online)
const { id, queued } = await vibe.messages.send({
to: opponent.id,
title: 'Your move',
body: 'Ann played the centre.',
data: { cell: 4 },
});
// Live delivery while your app is open
const stop = vibe.messages.onMessage((m) => applyMove(m.data));
// Messages that arrived while your app was closed
for (const m of await vibe.messages.pending()) {
applyMove(m.data);
await vibe.messages.ack(m.id);
}- While the recipient has your app open, the message arrives live through
onMessage. - Otherwise it waits in
pending()until the recipient opens your app and youack()it. - If the recipient allowed push notifications, the device shows a fixed template such as "New message in My Game". Your title and body appear only inside your app; this prevents phishing through notifications.
- Offline,
send()resolves withqueued: trueand the message goes out on reconnect. - Sends that overlap can arrive in either order. When order matters (a move after its invitation), wait for each
send()before the next. - Children message only once their parent allows it (it is off for new child accounts);
send()then fails withNOT_ALLOWED. When your scripts usevibe.messages, the validator notes that your game is played with others, and the store page tells such a child before they install it. Keep what works alone (a mode against the computer, practice) working without messages. - A
QUOTAerror means the player sent too many messages in the last minute (see Quotas). Nothing was sent: wait a few seconds and send the message again, so a quick game does not get stuck.
Leaderboards
Declare up to ten boards under leaderboards in vibe-app.json, then turn them on in the app's settings (Leaderboards). The server keeps each player's best score per board, by the board's order; players see the boards in your game and in VibeGames under My Games.
const { enabled, participating, boards } = await vibe.leaderboards.info(); // works offline
// Ask before the first score goes on a board (see "Ask the player first" below)
if (enabled && participating && (await vibe.cloud.get('leaderboard-ok'))) {
const { recorded, improved, best, rank, queued } = await vibe.leaderboards.submit('high-score', 1250);
}
const page = await vibe.leaderboards.top('high-score', { limit: 10 });
// page = { board, entries: [{ rank, playerId, nickname, score, updatedAt }], me, total, offline }
const near = await vibe.leaderboards.around('lap-1', { limit: 5 }); // the entries around the player| Call | Notes |
|---|---|
info() | Whether you turned the leaderboards on, whether the player takes part, and the boards of the running version. Works offline from the last known state. |
submit(board, score) | A whole number (times in milliseconds). recorded is false while the leaderboards are off, the player opted out or you removed them: nothing is stored, and it is not an error. Offline, a new best is kept on the device and sent later (queued: true, rank: null). |
top(board, { limit }) | The best entries (limit 1 to 100, default 10) and the player's own entry as me. Equal scores share a rank (1, 2, 2, 4); the earlier one is listed first. Offline: the last page fetched on this device, with offline: true. |
around(board, { limit }) | The entries around the player's own; the best entries when the player has none. |
async function mayJoinLeaderboard() {
const asked = await vibe.cloud.get('leaderboard-ok'); // remember the answer, on every device
if (asked !== undefined) return asked;
const ok = await myDialog('Show your nickname and best score on the leaderboard?', ['Yes', 'Not now']);
await vibe.cloud.set('leaderboard-ok', ok);
return ok;
}
if (newBest && (await mayJoinLeaderboard())) await vibe.leaderboards.submit('high-score', score);- Opting out. Players opt out per game in VibeGames (My Games, the game's menu, Leaderboard). That deletes their scores on your boards and stops recording new ones;
participatingtells you, so you can hide the prompt. - Children appear on leaderboards only while their parent lets other players find them, and then only to other children and their parent; ranks count the entries the viewer sees.
- Trust. Scores come from the player's own device, so a determined player can submit any number. Keep it fun rather than high stakes; boards written only by the server are planned with server logic.
- Removing players. You can remove a player who breaks your rules from all boards of your game in the publisher console (Leaderboards). Their scores are deleted and new ones are not recorded until you let them back on; your game sees
participating: false, the player sees your reason in VibeGames. - Reports. Entries carry the app-scoped
playerId, so you can offervibe.players.report(playerId)next to a name. The leaderboard in VibeGames has its own Report button. - Preview and devkit. A draft's preview has the leaderboards on, with you as the only player, in memory. The devkit stores scores of all simulated players in the browser and has switches for On and Opted out.
Feedback
Players can send you feedback from the shell's menu at any time. Offer it in your own UI as well: vibe.feedback.open opens the same dialog of the shell, where the player writes what does not work, an idea or something else, and sends it to you, or cancels.
// e.g. a "Report a bug" button in your settings or on the game-over screen
settingsBugButton.addEventListener('click', async () => {
const { sent } = await vibe.feedback.open({
kind: 'bug', // preselected: 'bug', 'idea' or 'other' (optional)
context: `Level ${level}, ${score} points, hard mode`, // up to 500 characters of plain text (optional)
});
if (sent) showToast('Thanks for your feedback!'); // false: the player cancelled
});- What you get. The message, the version the player has installed, their nickname (not for children) and, unless they switch it off, their device (system, browser, screen, language), the last uncaught errors of your game and your
context. Read it in the publisher console (Player feedback). - The context is shown to the player in full before they send, and goes under the same switch as the device information. Put in what helps you reproduce a bug (level, score, settings), nothing the player would not want you to see.
- Offline the feedback is saved and sent once the device is online;
sentis true then too. - Limits. One dialog at a time, and not right after another (
RATE_LIMITED); not in previews (NOT_ALLOWED); an unknownkindor acontextthat is not a string isINVALID_ARGS, and longer context is cut. The promise waits for the player, without a timeout. - Devkit. The call is logged with what you passed and answers
{ sent: true }.
Analytics events
Your statistics count installs, players and play time by themselves. To see what happens inside your game, such as how many players finish a level, declare events under events in vibe-app.json and count them with vibe.analytics.track.
// vibe-app.json declares the events: "events": [{ "id": "level-complete", "name": "Level completed" }]
vibe.analytics.track('level-complete'); // counts it
vibe.analytics.track('level-complete', { value: seconds }); // and adds a value (0 or more), e.g. seconds or points
// Fire and forget: returns nothing, works offline, counts nothing when the player turned off usage statistics- What you see. Publish → the app → Statistics → Events, in the order you declare them: how often each happened, how many players had it (once per player and day), their share of the day's players and the average value. A sequence of events reads as a funnel.
- Only declared ids count. Others are ignored, so an event never carries data of its own such as a nickname. Put numbers into
value, which only adds up. Pass a value on every call of an event or on none, since the average divides by all calls. - Errors. An id that cannot be one (1 to 32 letters, digits,
.,_or-) or a value that is not a number of 0 or more throwsINVALID_ARGSat once. An id that is valid but not declared by the running version is dropped; the devkit warns about it. - Limits. Values are rounded and capped at 1,000,000,000 per call, and at most 1,000 events are counted per session. Previews and the devkit count nothing: they log each event instead.
- Privacy. Events are anonymous daily totals like the rest of your statistics; your game never learns whether the player turned off usage statistics.
External links
Your app cannot navigate or open windows. vibe.openUrl() asks the host, which shows the target host name to the player and opens the link in a new tab after they confirm. Plain <a href="https://…" target="_blank"> links are routed the same way automatically.
Fullscreen
Fullscreen is host-controlled: the shell goes fullscreen with your frame and keeps its menu handle at the top; the player leaves fullscreen from the shell menu or with Esc. Listen to fullscreenchange and resize to relayout.
Exit
await vibe.openUrl('https://example.com/help'); // host shows a confirmation, opens a new tab
await vibe.fullscreen.enter(); // host-controlled; keeps a thin host bar
await vibe.fullscreen.exit();
vibe.exit(); // back to the shellThe shell's own way out is the menu handle, a small tab centred at the top edge of your frame, below the top safe-area inset (56 × 28 CSS pixels; ctx.hostControls has the exact rectangle). It fades after a few seconds and opens the shell menu: Resume, Fullscreen and Leave game. Holding Esc for about a second opens the same menu; a short Esc still reaches your app. Opening the menu sends pause, Resume sends resume, and Leave game closes your app without another event, so save when pause arrives. Your app cannot hide, cover or block the handle.
Events
const off = vibe.on('pause', () => music.pause());
vibe.on('resume', () => music.play());
vibe.on('resize', ({ viewport, safeArea }) => layout(viewport));
vibe.on('offline', () => status('Offline: moves are queued'));
off(); // or vibe.off('pause', handler)| Event | Payload | When |
|---|---|---|
message | VibeMessage | A message arrived while the app is open (same as onMessage). |
pause | — | The tab or app went to the background, or the player opened the shell menu. Save, pause timers and audio. |
resume | — | Back in the foreground and the shell menu closed. |
resize | { viewport, safeArea, hostControls } | The frame, the safe area or the area covered by the menu handle changed. |
fullscreenchange | boolean | Entered or left fullscreen. |
online | — | Connection is back; queued writes and messages are being flushed. |
offline | — | Connection lost; everything keeps working from the mirror. |
quota | { key, error } | A queued cloud write was rejected by the server (over quota); the key keeps its local value. |
theme | 'dark' | 'light' | The theme changed (the shell has one dark theme today; the devkit's Theme button sends it). ctx.theme, data-theme and the color-scheme have already switched. |
Errors
Rejections are VibeError instances: an Error with a code.
try {
await vibe.players.search('an');
} catch (err) {
if (err.code === 'OFFLINE') status('Search needs a connection');
else if (err.code === 'RATE_LIMITED') status('Slow down');
else throw err;
}| Code | Meaning |
|---|---|
NOT_READY | Called before vibe.ready resolved. |
OFFLINE | The call needs the network (player search and pick) and there is none. |
NOT_ALLOWED | The host refused: unknown method, blocked URL, missing permission, leaderboards turned off (reading a board). |
INVALID_ARGS | Wrong types or sizes, for example a key over 128 characters. |
NOT_FOUND | Unknown player id, asset path or leaderboard (not declared in vibe-app.json). |
QUOTA | Over the storage limits below. |
RATE_LIMITED | Too many messages or searches in a short time, or a shell dialog (pick, report, feedback) is already open or was just closed. |
TIMEOUT | The host did not answer in time. |
INTERNAL | Unexpected host or server error. |
Quotas
| Resource | Default limit |
|---|---|
| Cloud keys per player and app | 200 |
| Cloud value size | 64 KB |
| Cloud total per player and app | 4 MB |
| Cloud key length | 128 characters |
| Device storage (local + db) per app | 25 MB |
| Messages per minute per sender | 60 |
| Messages per minute per recipient | 120 |
| Message title / body / data | 120 characters / 1000 characters / 4 KB |
| Leaderboards per app / entries per page | 10 / 100 |
| Scores | Whole numbers up to 2^53 − 1 (Number.MAX_SAFE_INTEGER); submitting counts like a cloud write |
Operators can change these limits; the QUOTA and RATE_LIMITED errors tell you when you hit one.
TypeScript typings
Download vibe-sdk.d.ts for editor support. It declares window.vibe and the global vibe together with every type used on this page.
// Copy vibe-sdk.d.ts into your project, then either reference it:
/// <reference path="./vibe-sdk.d.ts" />
// or include it in tsconfig "files" / "include". window.vibe and the global
// `vibe` are declared; the SDK itself is injected by the host at runtime.