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.
# 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
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
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.
/api/v1/matches/:idThe match, with every player's submission
/api/v1/matches/:id/state{ state, expectedVersion }→ { version } · 409 if the version moved
/api/v1/matches/:id/turn{ next? }Pass the turn (default: next seated player)
/api/v1/matches/:id/round{ round }Set the round counter (never backwards)
/api/v1/matches/:id/result{ scores } or { ranks }, leavers?Settle the match. The only way with server authority
/api/v1/storage/:key→ { value } from your app-scope storage (GET /api/v1/storage?prefix= lists keys)
/api/v1/storage/:key{ value }Write app-scope storage (≤ 64 KB, public to players)
/api/v1/storage/:keyRemove an app-scope key
/api/v1/stats{ userId, values }→ { values } after each stat's aggregate
/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 — 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.
- 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 - 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 - 3Test it. Play both seats in the Sandbox until its log is clean.
- 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.
- 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>