VibeCade
vibecade-sdk · v0.1

Build the next thing everyone challenges their friends to.

VibeCade handles identity, matchmaking, realtime rooms, results, XP and the crowd. You write the fun part — in any framework, hosted anywhere. It hosts both games people challenge each other to and standalone apps people simply open, like a news reader, a dashboard or a meme maker.

terminal
# a new game, ready to run
npm create vibecade@latest my-game

# or add the SDK to your own app
npm install vibecade-sdk

// or, no build step at all:
import { connect } from "https://YOUR-XAPPS-HOST/sdk/v1.js";

X identity

Every player arrives signed in with their X handle and avatar. No auth code on your side.

Realtime rooms

Broadcast moves and see who's connected. Seeded randomness keeps both screens in sync.

Matchmaking

Quick match, invites by @handle and open challenge links that go viral on the timeline.

2 to 8 players

Free-for-alls, 2–4 teams and spectators. Lobbies, seats and podiums are handled for you.

Shared state & turns

A versioned match document every client sees, plus turns and rounds — live or over days.

Results & XP

Submit a score; the platform settles the match, awards XP and updates leaderboards.

Crowd judging

Contest apps submit an entry and the Arena crowd votes. Ideal for creative formats.

Host-rendered drama

VS intro, countdown, HUD, emoji reactions, confetti and results screens come free.

Your server as referee

An app secret, a server API and signed webhooks. Declare server authority and only your backend can settle a match.

Media entries

Upload images, audio and video from your app. Entries can be clips, sounds or swipeable galleries — the Arena plays them all.

Stats & achievements

Declare stats and get leaderboards on your listing. Unlock achievements with a host-rendered moment and XP on profiles.

Quickstart

From 1v1 to 8 players in 20 lines

App.tsx
import { VibeCadeProvider, useVibeCade, useRoomEvent, useMatchStarted } from "vibecade-sdk/react";

export default function App() {
  return (
    <VibeCadeProvider fallback={<p>Loading…</p>}>
      <Game />
    </VibeCadeProvider>
  );
}

function Game() {
  const xapps = useVibeCade();
  const started = useMatchStarted();
  const [taps, setTaps] = useState(0);

  useEffect(() => void xapps.ready(), [xapps]);            // show your intro, then say ready
  useRoomEvent("tap", (payload, from) => bump(from));     // messages from the other player

  const tap = () => {
    setTaps((n) => n + 1);
    xapps.room.send("tap", { at: Date.now() });
  };

  const done = () => xapps.submit({ score: taps });        // the platform decides the winner
  return started ? <button onClick={tap}>Tap! {taps}</button> : <p>Get ready…</p>;
}

A complete example lives at /examples/rps — one HTML file, commit-reveal included. Play it as RPS Showdown.

Lifecycle

What happens in a match

  1. 01

    Load

    Host opens your URL in a sandboxed iframe.

  2. 02

    Handshake

    connect() receives players, seed and mode.

  3. 03

    ready()

    You render, then signal you're ready.

  4. 04

    VS intro

    Host plays the versus + 3-2-1.

  5. 05

    onStart

    Game on. Talk through the room.

  6. 06

    submit()

    Send your score or entry.

  7. 07

    Settle

    Platform picks the winner, awards XP.

  8. 08

    onEnd

    Host shows results, rematch, share.

Reference

The whole API

Connect & context

connect(options?)
Handshake with the host. Returns the shared client (safe to call many times). Opened directly, it starts a local mock host.
xapps.me · xapps.players · xapps.opponents
PlayerInfo: id, handle, name, avatarUrl, seat, team, role, isBot, submitted, score. `opponent` is the first opponent (1v1 apps).
xapps.teammates · xapps.role · xapps.isSpectator
Team play and watching: spectators see everything but can't submit, send or write state.
xapps.match
id, mode (live | async | practice | sandbox), status, scoring, seed, settings, minPlayers, maxPlayers, teams.
xapps.purpose
"match", or "setup" when the host opens you to set up a challenge (manifest setup: true).
xapps.random
Seeded RNG shared by every client: next(), int(), pick(), shuffle(), normal(), fork(label).
xapps.isHost
True for seat 0 — handy when one client should referee.

Lifecycle

xapps.ready()
Call once your first screen is rendered. The match starts when everyone is ready.
xapps.onStart(fn)
Fires after the host's VS intro (immediately if the match already started).
xapps.submit({ score?, data?, display? })
Your final result. Score for high/low scoring, display for crowd-judged apps.
xapps.submitFor(botId, submission)
Submit on behalf of a bot you're simulating (practice & sandbox).
xapps.onUpdate(fn) · xapps.onEnd(fn)
Match changes (opponent submitted, votes) and the final result.
xapps.forfeit()
Concede the match.

Realtime room

xapps.room.send(type, payload)
Broadcast JSON to the other players (≤ 8 KB, ≤ 30/s).
xapps.room.on(type, (payload, from) => …)
Listen for one event type. Returns an unsubscribe function.
xapps.room.onPresence(fn) · room.online()
Who's connected right now.
xapps.room.direct(options?)
Direct WebRTC connections to the other players, signaled over the room: send(data, { reliable?, to? }), onMessage, status. Falls back to the room per player when a direct path can't be made.
xapps.onReaction(fn)
Emoji reactions players fire from the HUD.

Shared state, turns & rounds

xapps.state.get() · set(value, version?)
One JSON document per match (≤ 64 KB), versioned: a stale write fails with "conflict".
xapps.state.update(draft => next)
Read-modify-write that re-reads and retries on conflicts. Persists, so turn-based games resume days later.
xapps.state.onChange(fn)
Every change from any player (your own writes included).
xapps.turn.current · isMine · deadline · end(next?)
Whose move it is. end() passes to the next seated player (or who you name). Async turns have a 3-day deadline.
xapps.round.set(n) · onRound(fn)
Round counter shown in the host HUD. Never goes backwards.

Challenge setup (setup: true)

xapps.setup.submit(settings, summary?)
Render your own setup screen inside the challenge sheet; settings (≤ 4 KB) become match.settings.
xapps.setup.cancel()
Close setup without choosing.
?xapps-purpose=setup
Open your app directly with this to test the setup screen against the mock host.

Host UI & extras

xapps.ui.setStatus(text) · setScores(map) · setTurn(id)
Drive the host HUD above your app.
xapps.ui.toast(msg) · celebrate() · haptic(style)
Host-rendered toasts, confetti and vibration.
xapps.ui.lockGestures(element)
Makes a canvas/board/slider a drag surface: no page scroll, bounce or pull-to-refresh while touching it, iOS included. Returns an undo function. React: useGestureLock(ref). connect() already stops your page from bouncing inside VibeCade.
xapps.social.share(text, url?)
Opens the X composer, pre-filled. The user always confirms. Pass a media.upload image URL and X shows the picture as a large card.

Media & entries

xapps.media.upload(blob, { alt? })
→ MediaRef { url, kind, mime, bytes, width?, height?, duration? }. Images (JPEG/PNG/WebP/GIF) ≤ 8 MB, audio (MP3/M4A/Ogg/WebM/WAV) ≤ 10 MB, video (MP4/WebM/MOV) ≤ 25 MB.
Quota
60 uploads and 200 MB per player per app per rolling 24 h (error code rate_limited). Match, setup and standalone apps; spectators can't upload.
display: { kind: "video", url, alt, poster? }
Plays muted when in view and loops; tap for sound.
display: { kind: "audio", url, alt, cover? }
A compact waveform player with play/pause and seeking.
display: { kind: "gallery", items: [{ url, alt }] }
2–6 images, swipeable with dots and arrow keys.
Allowed media URLs
Only files from media.upload (our app-media storage). Other URLs, blob: and data: are refused in video/audio/gallery entries and meme drops.

Storage

xapps.storage.get(key, { scope? }) · set(key, value)
scope "user" (default) is private to the player: ≤ 64 KB per value, ≤ 200 keys per app.
xapps.storage.delete(key) · list({ prefix?, scope? })
Remove a key; list keys (sorted), optionally by prefix.
scope: "app"
One public key/value space per app: every player reads it, only your server writes it (PUT /api/v1/storage/:key). Daily puzzles, config, seasons.

Stats & achievements

xapps.stats.report({ [key]: number })
Applies each stat's aggregate (max · min · sum · last) and resolves with the new values. Keys must be in your manifest.
xapps.stats.leaderboard(key, { limit? })
→ { top, me, total }: the stat's global board (best first, ties share a rank; 10 rows, up to 50), the viewer's { rank, value } (null before their first value) and how many people have one. "#14 of 2,380". Read-only: every purpose, spectators too.
xapps.stats.defs · xapps.achievements.defs
What your manifest declares, from the launch context.
xapps.achievements.unlock(id)
→ { unlocked } (false if the player already had it). XP is added to their profile once; the host shows the unlock moment.
xapps.onAchievement(fn)
achievement.unlock { id, userId } — the player, or someone else in a live match.
Server authority
Server-authoritative apps report stats and unlock achievements only from their server: POST /api/v1/stats · /api/v1/achievements.
useStats() · useAchievements() · useMediaUpload()
React hooks (vibecade-sdk/react).

Server SDK (vibecade-sdk/server)

verifyWebhook(rawBody, signatureHeader, secret, { toleranceSeconds? })
Checks X-XApps-Signature and returns the parsed event. Throws VibeCadeError invalid_signature or stale_signature (older than 5 min by default). Accepts several v1 signatures during a rotation. Web Crypto only: Node 18+, Deno, Workers, edge.
createServerClient({ secret, baseUrl })
A client for the server API, authenticated with your xas_ secret. Never ship it to the browser.
server.getMatch(id)
The match with every player's submission, data included.
server.setState(id, state, version) · updateState(id, fn)
Compare-and-set the shared state; updateState re-reads and retries on 409.
server.endTurn(id, next?) · setRound(id, round)
Drive turns and rounds from your server.
server.reportResult(id, { scores } | { ranks }, { leavers? })
Settle the match (ranks win if both are given). The only way to settle with server authority.

React (vibecade-sdk/react)

<VibeCadeProvider fallback errorFallback>
Connects once and provides the client.
useVibeCade() · useMatch() · usePresence()
Client, live match object, connected players.
useRoomEvent(type, handler) · useReactions(handler)
Subscriptions that clean themselves up.
useMatchStarted() · useMatchResult()
Booleans/results that re-render when they change.
useMatchState() · useTurn() · useRound()
{ state, version, set, update } · { turn, isMine, deadline, end } · { round, set }.
usePlayers() · useSetup()
{ players, opponents, teammates, me, role } · { isSetup, submit, cancel }.

App manifest

slug
URL id, lowercase letters/numbers/dashes (e.g. tap-race)
name · tagline · description
How your app is listed
category
games · contests · debates · trivia · creative · social
icon · accent
One emoji + a two-color gradient
url
https URL of your app (must allow framing by VibeCade)
modes
live (same time) · async (play anytime) · practice (vs bots)
players
{ min, max } seats per match, 2–8
teams
0 for free-for-all, or 2–4 teams (seat s plays for team s % teams)
spectators
Let others watch live matches (default on)
turnBased
Players take turns; the host shows turn UI and pings whoever's up
setup
You render your own challenge setup screen
scoring
high (bigger wins) · low (smaller wins) · votes (crowd decides)
authority
client (default) · server — set on your app's Server panel once it has a secret; not for votes apps
stats
Up to 8: { key, label, aggregate: max | min | sum | last, format?: number | ms | percent (0–100) }. Key like best_time
achievements
Up to 30: { id, name, description, icon (one emoji), xp (0–100), secret? }; at most 500 XP in total
howTo
Up to 3 short steps shown on your listing

Trust

Let your server referee

Players' browsers are untrusted. Give your app a secret and its own backend can read and write matches, get signed webhooks and — with server authority — be the only one that decides who won.

App secret

xas_ + 48 hex, shown once when you create or rotate it. We keep a SHA-256 hash and an 8-character prefix; rotating kills the old one instantly.

Signed webhooks

An https URL plus its own whsec_ signing secret. Deliveries retry with backoff (1 → 256 min, 9 attempts) and show up live on your Server panel.

Server authority

Your server settles every match via POST /api/v1/matches/:id/result; players' scores become claims. If it stays silent 24 h after everyone submits, the match is a draw.

Server API

Send Authorization: Bearer xas_…. Match routes return the match JSON with all submissions visible to your server; storage, stats and achievements act for your app.

GET/api/v1/matches/:id

The match, with every player's submission

PUT/api/v1/matches/:id/state

{ state, expectedVersion }→ { version } · 409 if the version moved

POST/api/v1/matches/:id/turn

{ next? }Pass the turn (default: next seated player)

POST/api/v1/matches/:id/round

{ round }Set the round counter (never backwards)

POST/api/v1/matches/:id/result

{ scores } or { ranks }, leavers?Settle the match. The only way with server authority

GET/api/v1/storage/:key

→ { value } from your app-scope storage (GET /api/v1/storage?prefix= lists keys)

PUT/api/v1/storage/:key

{ value }Write app-scope storage (≤ 64 KB, public to players)

DELETE/api/v1/storage/:key

Remove an app-scope key

POST/api/v1/stats

{ userId, values }→ { values } after each stat's aggregate

POST/api/v1/achievements

{ userId, id }→ { unlocked }; XP is awarded once

Errors are { error: { code, message } }: 401 bad or missing secret · 403 another app's match · 404 no match or player · 409 conflict or already settled · 422 invalid body · 429 a limit reached (200 storage keys) · 501 demo mode.

Webhooks

POSTed as { id, type, createdAt, app, match }.

match.created
A challenge, lobby or practice match was opened
match.started
The table filled (or the creator started it)
match.state
Shared state changed (debounced: one pending per match)
match.turn
The turn passed to someone else
match.submitted
A player submitted. With server authority, your cue to referee
match.ended
Settled. reason: "server_timeout" when the 24 h safety valve fired
achievement.unlocked
A player unlocked an achievement: { userId, achievementId }, match: null
ping
Sent by “Send test event” on your app's Server panel

Headers

X-XApps-Event · X-XApps-Delivery (uuid)
X-XApps-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>

Reject timestamps older than 5 minutes — verifyWebhook does both checks.

app/api/xapps/webhook/route.ts
// app/api/xapps/webhook/route.ts — a Next.js route handler
import { verifyWebhook } from "vibecade-sdk/server";

export async function POST(request: Request) {
  const body = await request.text();                  // the raw body, before any JSON.parse
  const signature = request.headers.get("x-xapps-signature") ?? "";

  let event;
  try {
    // Checks the HMAC and rejects anything older than 5 minutes.
    event = await verifyWebhook(body, signature, process.env.XAPPS_WEBHOOK_SECRET!);
  } catch {
    return new Response("invalid signature", { status: 401 });
  }

  switch (event.type) {
    case "match.submitted":                           // a player finished: referee it
      await referee(event.match.id);
      break;
    case "match.ended":
      await archive(event.match);
      break;
  }
  return Response.json({ ok: true });                 // any 2xx marks the delivery delivered
}

Create the secret, webhook and authority on your app's page under Server. The server API and webhooks need the Supabase backend; demo mode previews the settings only.

Sandboxed & origin-pinned

Apps run in a sandboxed iframe and never see the player's session. The host only talks to your registered origin, and the SDK pins the host's.

Test without the marketplace

Open your app directly and the SDK's mock host plays you against a bot. The Sandbox runs two copies side by side with a live protocol log.

Trust model

By default players' clients report scores. For anything competitive, give your app server authority: your server settles every match and client scores are only claims. Client-settled apps should still lean on seeded RNG and commit-reveal.

Build your first game

From an empty folder to a game people play inside a post on X.

  1. 1Create it. A starter game with the SDK wired in; npm run dev plays it against a practice bot.npm create vibecade@latest my-game
  2. 2Host it. Any static host with https. Allow this site and X to frame it (posts on X frame the player card, which frames your game), or send no frame-ancestors at all:Content-Security-Policy: frame-ancestors https://YOUR-XAPPS-HOST https://x.com https://*.x.com https://twitter.com https://*.twitter.com
  3. 3Test it. Play both seats in the Sandbox until its log is clean.
  4. 4Register it. Sign in with X and scan your app (its URL or project folder) to fill in the listing from vibecade.manifest.json and your SDK calls. It's reviewed, then you publish it.
  5. 5Share it. Post its link on X: people play it right inside the post, and challenge each other from there.https://YOUR-XAPPS-HOST/x/<your-game>