VibeGames

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
FieldMeaning
player.idStable 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.
onlineWhether the host currently has a connection. Changes arrive as 'online' / 'offline' events.
safeAreaInsets in CSS pixels to keep clear of notches and the home indicator.
viewportSize of your frame in CSS pixels. Updated through the 'resize' event.
hostControlsWhere 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.

StoreValuesWhereUse it for
vibe.cloudJSONServer, 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.localstringsDevice only (like localStorage).Volume, last screen, small preferences.
vibe.dbstructured, incl. Blob and ArrayBufferDevice 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"
});
CallReturnsNotes
players.pick(){ id, nickname } or nullThe 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.report and feedback.open, and not right after another (RATE_LIMITED); both calls need a connection (OFFLINE) and are not available in previews (NOT_ALLOWED). addFriend answers INVALID_ARGS for 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 you ack() 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 with queued: true and 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 with NOT_ALLOWED. When your scripts use vibe.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 QUOTA error 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
CallNotes
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; participating tells 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 offer vibe.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; sent is true then too.
  • Limits. One dialog at a time, and not right after another (RATE_LIMITED); not in previews (NOT_ALLOWED); an unknown kind or a context that is not a string is INVALID_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 throws INVALID_ARGS at 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.

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 shell

The 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)
EventPayloadWhen
messageVibeMessageA 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.
fullscreenchangebooleanEntered 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;
}
CodeMeaning
NOT_READYCalled before vibe.ready resolved.
OFFLINEThe call needs the network (player search and pick) and there is none.
NOT_ALLOWEDThe host refused: unknown method, blocked URL, missing permission, leaderboards turned off (reading a board).
INVALID_ARGSWrong types or sizes, for example a key over 128 characters.
NOT_FOUNDUnknown player id, asset path or leaderboard (not declared in vibe-app.json).
QUOTAOver the storage limits below.
RATE_LIMITEDToo many messages or searches in a short time, or a shell dialog (pick, report, feedback) is already open or was just closed.
TIMEOUTThe host did not answer in time.
INTERNALUnexpected host or server error.

Quotas

ResourceDefault limit
Cloud keys per player and app200
Cloud value size64 KB
Cloud total per player and app4 MB
Cloud key length128 characters
Device storage (local + db) per app25 MB
Messages per minute per sender60
Messages per minute per recipient120
Message title / body / data120 characters / 1000 characters / 4 KB
Leaderboards per app / entries per page10 / 100
ScoresWhole 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.