# Triggair — full integration guide Triggair is a full game backend for browser games. The `@triggair/sdk` client wraps it: one import, zero config beyond the publishable key, typed results, and errors that tell an agent how to fix the call. Player-facing gameplay is the SDK; server-side setup (leaderboard/economy/flag/quest definitions, moderation policy, age-gate policy, operator tools) lives in the dashboard and the MCP server. Everything below is a real SDK method — lift it verbatim. ## Install & initialize npm i @triggair/sdk import { createClient } from '@triggair/sdk'; const tg = createClient({ key: 'tg_pk_your_key' }); Only the publishable key (tg_pk_) is needed. NEVER put a secret key (tg_sk_) in client code — it belongs on a server. ## Player identity (anonymous-first) const { playerId } = await tg.login(); An anonymous player token is minted from a device id on first use and silently refreshed. Every player-scoped call attaches it automatically — no accounts, no login screen required. ## Cloud saves await tg.saves.put('slot1', { level: 4 }); // last-write-wins await tg.saves.put('slot1', data, { ifMatch: v }); // conflict-safe (throws save_conflict) const { data } = await tg.saves.get('slot1'); tg.saves.queue('slot1', data); // durable/offline (replays on reconnect) ## Leaderboards Configure a board once (dashboard → Leaderboards, or triggair_configure_leaderboard), then: await tg.leaderboards.submit('high_scores', 9000); const { entries } = await tg.leaderboards.top('high_scores', { limit: 10 }); Keyed boards (per-team / per-UGC-item) live on tg.keyedBoards. ## Player stats & profile await tg.stats.update([{ key: 'kills', op: 'increment', value: 1 }]); const stats = await tg.stats.get(); // the player's own stats Stats back leaderboards, achievements, and segment targeting. ## Achievements & daily rewards await tg.achievements.report('first_win', 1); // reward lands in the inbox on unlock const status = await tg.daily.status(); if (status.claimable) await tg.daily.claim(); // server-day gated; streaks tracked All rewards are collected from tg.inbox — the one hardened grant path. ## Inbox & reward claims const items = await tg.inbox.list(); for (const it of items) if (it.claimable) await tg.inbox.claim(it.id); Claiming is exactly-once (idempotent per item) — the single audited path that grants currency, items, or stat rewards. ## Friends & share links const friends = await tg.social.friends(); await tg.social.request(otherPlayerId); // friend request state machine const { code } = await tg.social.share({ level: 7 }); // shareable invite/deep-link const ctx = await tg.social.resolveShare(code); // on the recipient's device ## Economy: currency, store, inventory const wallet = await tg.economy.wallet(); await tg.economy.buy('main_store', listingId, { idem: uuid }); // server-authoritative price + balance const items = await tg.economy.inventory(); await tg.economy.equip(itemId); Grants are server-side and idempotent (pass idem); insufficient_funds when the player can't afford it. ## Loot boxes & energy const odds = await tg.economy.loot.odds('bronze_box'); // disclosed drop rates const result = await tg.economy.loot.open('bronze_box', { idem: uuid }); await tg.economy.energy.spend('stamina', 1, { idem: uuid }); // regenerating meter Loot/IAP are age-gated where required — see the age-gate recipe. ## Remote config, flags & live events const cfg = await tg.config.get(); // your published key→value blob const live = await tg.config.liveEvents(); Feature flags (tg.flags) resolve per-segment with a break-glass kill switch; edit config/flags in the dashboard (Setup → Config, Operations → LiveOps) or via MCP — no client deploy. ## Moderation (names, chat, UGC) const v = await tg.moderation.check('username', name); // allow | mask | block | review if (v.verdict === 'block') { /* reject */ } await tg.moderation.report('player', targetId, 'harassment'); const me = await tg.moderation.myStatus(); // the caller's bans/mutes (shadow hidden) Run moderation BEFORE anything is visible — it defeats leet/homoglyph evasion. ## Age gate & parental consent (compliance) await tg.compliance.setAge({ birthYear: 2013 }); // mapped to a bracket + discarded (no DOB stored) const view = await tg.compliance.status(); // { bracket, consent_state, gated: {feature:bool} } if (view.gated.open_chat) { /* disable in UI; the server also enforces it */ } await tg.compliance.requestConsent('parent@email'); // emails the parent a signed decision link Gate regulated features (chat, loot/IAP) with view.gated; the server is the enforcement. ## Analytics (durable & offline) tg.track('level_complete'); Events are coalesced and queued in a durable outbox; they survive a dropped connection and flush on reconnect. Counts + funnels + retention + sessions roll up nightly — view them in the dashboard Analytics tab. No per-player PII. ## Realtime rooms (presence & chat) const room = await tg.realtime.join('lobby'); // authed WebSocket; resolves on connect room.on('presence', ({ members }) => renderRoster(members)); room.on('message', ({ from, data }) => renderChat(from, data)); room.send({ text: 'gg' }); // fan-out to everyone else in the room room.close(); Presence + broadcast over a Durable Object; `room.members` is the live roster; `room.history` replays recent chat on join. Chat text is moderated in transit (mask/block) by the game's policy. Private rooms: join('team:') requires team membership, join('match:') requires being a participant (non-members get 403); any other name is public. Node/tests pass a WebSocket via createClient({ webSocket }). ## Crash reporting try { /* game loop */ } catch (e) { tg.crashes.report(e.message, { stack: e.stack, appVersion: '1.4.0' }); } Crashes are grouped server-side by a normalized-stack fingerprint into a handful of issues with a crash-free-users %. ## Turn-based matches const { match } = await tg.asyncMatch.create({ type: 'chess', opponents: [otherId] }); await tg.asyncMatch.turn(match.id, { state: next, version: match.version }); Server-enforced turn order (not_your_turn) + OCC (async_conflict). No realtime connection needed — players move whenever. ## Storage collections await tg.storage.put('decks', 'main', { cards: [] }); const { data } = await tg.storage.get('decks', 'main'); await tg.storage.incr('stats', 'wins', 1); // atomic, avoids read-modify-write races Scopes: player (private), shared (game-wide), team (members only). Configure collections in the dashboard. ## Promo codes & gifts await tg.codes.redeem('LAUNCH2026'); // grants the campaign reward into the inbox await tg.economy.gifts.send(friendId, { item: 'sword', qty: 1 }, { idem: uuid }); code_invalid / code_expired / code_already_redeemed guard redemption. ## Tournaments (scheduled, prize tables) const opens = await tg.tournaments.list(); // status: scheduled | running | closed await tg.tournaments.join(t.id); // may charge entry_fee → { joined, reason?, fee_txn } await tg.leaderboards.submit(t.board, score); // you score via the tournament's own board const top = await tg.tournaments.standings(t.id, { limit: 20 }); const me = await tg.tournaments.me(t.id); // { entered, rank, prize } Prizes land in the inbox when the operator closes it. Configure schedule + reward_table in the dashboard/MCP. ## Leagues (tiered divisions, promotion/relegation) await tg.leagues.join('ranked'); // placed in the lowest division for the season (idempotent) const me = await tg.leagues.me('ranked'); // { division, division_name, rank, zone: promoting|safe|relegating } const standings = await tg.leagues.divisionTop('ranked', me.division); Players score through tg.leaderboards.submit (the league ranks that board); promotion/relegation runs on the operator-driven season advance. ## Teams & clans const { team } = await tg.teams.create('Night Owls', 'OWL', { privacy: 'invite_only' }); await tg.teams.join(teamId); // open teams; invite_only → tg.teams.requestJoin(teamId) await tg.teams.invite(teamId, playerId); // admin/owner; recipient calls tg.teams.acceptInvite(inviteId) const t = await tg.teams.get(teamId); // team + roster const board = await tg.teams.leaderboard('weekly', { agg: 'sum' }); // members' scores aggregated Admin actions are role-gated server-side: setRole · kick · transfer · ban · disband. Errors: team_forbidden (role), team_full (cap), conflict (tag taken). ## Keyed leaderboards (per-entity boards) await tg.keyedBoards.submit('level_times', levelId, 42.5); // score keyed to any entity (a UGC level, a team, a map) const top = await tg.keyedBoards.top('level_times', { limit: 10 }); const mine = await tg.keyedBoards.entry('level_times', levelId); Use these when the ranked subject isn't the player — one board namespace, many entities. Configure the board in the dashboard. ## Battle pass & quests const bp = await tg.battlePass.get('s3'); // { bp, tier, claimed lanes } await tg.battlePass.claim('s3', tier, 'premium'); // reward → inbox; tier_not_earned / premium_required guard it const quests = await tg.quests.list(); // progress per objective await tg.quests.claim(questKey); // when complete → reward lands in the inbox Progress is driven by tg.stats.update / achievements; all rewards are collected from tg.inbox. ## Player segments const segments = await tg.segments.mine(); // [{ key, segment_id, source }] Segments materialize from stats/behaviour and are DEFINED in the dashboard (authoring, not client). When a player token is present they automatically personalize tg.config.get() and tg.flags — read mine() only if you want to branch UI on membership. ## User-generated content (create, publish, remix) const { item } = await tg.ugc.create('level', { title: 'Sky Temple', payload, allow_remix: true }); await tg.ugc.submit(item.id); // runs moderation → published or held for review const feed = await tg.ugc.browse({ sort: 'popular' }); // new | top | popular await tg.ugc.play(id); // debounced play count; unlocks rating await tg.ugc.rate(id, 5); await tg.ugc.like(id); const { item: fork } = await tg.ugc.remix(id); // fork a remix-allowed item → new draft with lineage/attribution const tree = await tg.ugc.lineage(id); // parent (remix_of) + root_id + remixes ## More domains, same shape Every domain is configured in the dashboard/MCP, then called on the typed SDK client with the same error contract: tg.progression (level curve/XP) · tg.storage (collections) · tg.codes (promo codes) · tg.asyncMatch (turn-based) · tg.crashes · tg.rng. See /docs/guides for the narrative walkthrough of each. ## CORS: works locally, 403s when deployed Browser calls are origin-checked. If a call fails only once deployed, add your deployed origin to the game's allowed_origins (dashboard → Setup → Keys/CORS, or triggair_set_allowed_origins). An empty allowlist = open to any origin — set it before you ship. ## Recovery (cross-device) const { code } = await tg.mintRecoveryCode(); // show/share once await tg.recover(code); // same anonymous player on a new device ## Errors are actionable Every failure throws a TriggairError with { code, message, agentHint, requestId }. Read agentHint — it tells you how to fix the call. rate_limited/5xx/network retry automatically. See /docs/errors/. ## Verify your integration After wiring the SDK, run the MCP tool triggair_verify_integration (or the dashboard's verify runner). It live-probes player + saves + leaderboards and advises on CORS, with pass/fail per service and fix hints — closing the loop. # Feature guides ## Getting started Triggair is a game backend for browser games. You integrate it with one import and one publishable key; player-scoped calls authenticate themselves, and every error tells an agent how to fix it. ### Keys Every game has two key kinds. The publishable key (tg_pk_…) is safe in client code — it identifies the game and is origin-checked (CORS). The secret key (tg_sk_…) is server-only and must never ship in a client. Issue and rotate keys in the dashboard (Setup → Keys) or via the API. Only the SHA-256 hash is stored; the full key is shown once. import { createClient } from '@triggair/sdk'; const tg = createClient({ key: 'tg_pk_your_key' }); ### Anonymous-first identity There are no accounts or login screens by default. On first use the SDK mints an anonymous player token from a stable device id and silently refreshes it; every player-scoped call attaches it. For cross-device rescue, mint a recovery code the player saves once and redeem it on the new device. const { playerId } = await tg.login(); const { code } = await tg.mintRecoveryCode(); // show once await tg.recover(code); // same player, new device ### CORS — the 'works locally, 403s deployed' trap Browser calls are origin-checked. If a call only fails once deployed, add your deployed origin to the game's allowed_origins (Setup → Keys/CORS). An empty allowlist is open to any origin — set it before you ship. ### Close the loop After wiring the SDK, run the self-test — the MCP tool triggair_verify_integration or the dashboard's verify runner. It live-probes player → save → leaderboard → CORS and reports pass/fail per service with fixes. ## Cloud saves A save is a JSON blob under a slot name you choose (e.g. slot1, settings). Saves are per-player and versioned. await tg.saves.put('slot1', { level: 4, coins: 120 }); const { data } = await tg.saves.get('slot1'); ### Conflict-safe writes (OCC) By default a write is last-write-wins. To avoid clobbering a concurrent write (two tabs, a flaky network retry), pass the version you read as ifMatch — a stale version returns save_conflict. Re-read, merge, and retry. const { data, version } = await tg.saves.get('slot1'); await tg.saves.put('slot1', next, { ifMatch: version }); // throws save_conflict if stale ### Offline resilience tg.saves.queue(slot, data) writes to a durable outbox in localStorage: it survives a dropped connection and replays on reconnect, coalescing to last-write-wins per slot and carrying an idempotency key. You never handle the retry logic. ## Leaderboards & competition Configure a board once (dashboard → Leaderboards, or the MCP/dev API): its aggregation (best / last / sum) and period (all-time / daily / weekly). Then submit scores and read ranks. The server keeps the aggregate for the current period. await tg.leaderboards.submit('high_scores', 9000); const { entries } = await tg.leaderboards.top('high_scores', { limit: 10 }); const around = await tg.leaderboards.aroundMe('high_scores'); ### Keyed boards Rank entities other than players — teams, UGC items, or a custom entity id — with tg.keyedBoards. Same shape, but you submit for an entity_id. ### Tournaments A tournament is a time-boxed competition over a board with a reward table. Players join (optionally paying an entry fee); at the end the server finalizes standings and pays prizes through the inbox. Configure them in the dashboard (Competition) or the dev API. ### Leagues Leagues group players into tiered divisions (bronze → silver → gold …). Each season the operator advances the league: top players are promoted, bottom players relegated. Players see their division and rank via tg.leagues. ## Economy The economy is entirely server-authoritative: the client requests an action (buy, open, spend) and the server checks the price/balance/odds and applies the result. A client can never mint currency or grant an item. ### The one grant path Every reward — from a purchase, a loot box, an achievement, a daily claim — is delivered into the player's INBOX and granted exactly-once when they claim it. This is the single audited path that mutates a wallet or inventory, so a reward can't be double-granted by a retry. const items = await tg.inbox.list(); for (const it of items) if (it.claimable) await tg.inbox.claim(it.id); ### Stores & idempotency Buying is idempotent — pass a stable idem key so a network retry doesn't double-charge. Failures are typed: insufficient_funds, out_of_stock, store_limit_reached. await tg.economy.buy('main_store', listingId, { idem: uuid }); ### Loot boxes & energy Loot boxes disclose their odds (tg.economy.loot.odds) and roll server-side with verifiable RNG. Energy meters (stamina, lives) regenerate over server time; spending fails with out_of_energy. Loot and IAP are age-gated where required — see the compliance guide. ## Social & teams Friends are a request → accept state machine. Blocking is one-directional and hides a player. Friends power the friends-only leaderboard view. await tg.social.request(otherPlayerId); const friends = await tg.social.friends(); ### Share & invite links Mint a short code carrying an opaque context blob (a level to try, a referral, a challenge). Resolve it on the recipient's device to read the context — the basis for viral invites and deep links. const { code } = await tg.social.share({ level: 7 }); const ctx = await tg.social.resolveShare(code); ### Teams / clans Teams have an owner, roles (owner/admin/member), and a privacy mode (open, closed, invite-only). Open teams are joined directly; closed teams use a request → approve flow; invite-only teams use invites. Admins can kick, ban, and change roles; the owner can transfer ownership or disband. Teams get their own leaderboards and a shared team storage scope. Every admin action is role-gated server-side — a member calling kick gets team_forbidden. const { team } = await tg.teams.create('Night Owls', 'OWL', { privacy: 'invite_only' }); await tg.teams.invite(team.id, playerId); // recipient: tg.teams.acceptInvite(inviteId) const board = await tg.teams.leaderboard('weekly', { agg: 'sum' }); ## Moderation & trust-and-safety Moderation is a pre-visibility hook: run it BEFORE any user text (a name, chat message, or UGC title) becomes visible. It normalizes the text to defeat leet-speak, homoglyph, and spacing evasion, then returns a verdict: allow, mask (with masked_text), block, or review. const v = await tg.moderation.check('username', name); if (v.verdict === 'block') reject(); ### Surfaces decide enforcement The same engine enforces differently per surface (names can't be masked — any hit blocks; chat masks light profanity and blocks severe; UGC leans to review). Realtime chat is moderated automatically in transit. ### Reports, bans, appeals Players report others (rate-limited, deduped). Operators/developers ban or mute a player. A ban is authoritative at TOKEN ISSUANCE — it blocks a new token for the account OR the device, so it survives a cleared localStorage; a temp ban auto-lifts when it expires. A banned player can appeal; granting an appeal lifts the ban atomically. ## Age gate & compliance A neutral age screen sets the player's bracket. A submitted birth year is mapped to a coarse bracket and DISCARDED — no date of birth is ever stored. Un-screened players are treated as the most-restrictive bracket (fail-closed). await tg.compliance.setAge({ birthYear: 2013 }); const view = await tg.compliance.status(); // { bracket, consent_state, gated: {...} } ### Gating regulated features The compliance view returns a gated map (feature → boolean). Use it to pre-disable UI, but the SERVER is the enforcement — every regulated surface (chat, loot boxes, IAP) checks the gate too. The developer sets which bracket each feature requires (dashboard → Moderation → Compliance). ### Parental consent (VPC) For a consent-eligible minor, request consent with a parent's email. The parent receives a signed link (their credential — never the child's) and Approves/Declines on a landing page. A grant unlocks the consent-gated features. The raw email is never stored (only a salted hash). await tg.compliance.requestConsent('parent@example.com'); ## Realtime rooms A realtime room is a broadcast channel with presence, backed by a Durable Object. Join a room to get a live connection; messages you send fan out to everyone else, and presence updates as players come and go. const room = await tg.realtime.join('lobby'); room.on('presence', ({ members }) => renderRoster(members)); room.on('message', ({ from, data }) => renderChat(from, data)); room.send({ text: 'gg' }); ### Chat is moderated in transit Any text in a message (a string, or an object's text field) is moderated before it reaches the room — masked or blocked per the game's policy. Non-text payloads (game state, moves) pass through untouched. ### Private rooms A room name can carry a scope: join('team:') requires active team membership, join('match:') requires being a participant. Non-members are refused at connect (403). Any other name is a public room. ### History Recent chat is replayed on join — room.history holds the last messages so a joiner has context immediately. Game state isn't persisted, so a high-frequency state room stays cheap. ## Achievements, dailies & progression These systems keep players coming back. All of them deliver their rewards through the inbox (the one grant path) — the client reports progress but never grants a reward. ### Achievements Report progress toward an achievement; the server clamps it, unlocks exactly-once, and escrows the reward. Secret achievements are hidden from the trophy list until unlocked. await tg.achievements.report('first_win', 1); // unlock → reward lands in tg.inbox ### Daily rewards Daily claims are gated by the SERVER day (never the client clock), and track a streak. Re-claiming the same day returns a conflict. const s = await tg.daily.status(); if (s.claimable) await tg.daily.claim(); ### Quests & battle pass Quests are objectives over a period (daily/weekly) that claim a reward when complete (quest_not_complete otherwise). A battle pass is a season of tiers earned by XP, with a free and an optional premium lane (premium_required / tier_not_earned guard claims). Configure both in the dashboard. await tg.quests.claim('win_3'); const pass = await tg.battlePass.get('s1'); ### Level curve A single XP curve (base + growth + max level) turns an XP stat into levels. tg.progression.get() returns the player's level and XP toward the next. ## Inbox & rewards Every reward in Triggair — a purchase, a loot drop, an achievement, a daily/quest/tournament payout, a developer gift, a promo code — is delivered as an INBOX item and granted only when the player claims it. Nothing else mutates a wallet or inventory. ### Why one path Claiming is idempotent per item, so a network retry can't double-grant. Because there is exactly one grant path, every currency/item change is auditable and can't be forged by the client. Reward types are currency, items, or stat increments. const items = await tg.inbox.list(); for (const it of items) { if (it.claimable) await tg.inbox.claim(it.id); else await tg.inbox.read(it.id); } ### Sending to players Developers/operators send inbox messages (announcements, make-goods, event rewards) to an audience via the dashboard or the dev API — optionally carrying a reward, which flows through the same claim path. ## Key-value storage & collections Beyond save slots, storage collections hold structured key→value entries with a scope and read/write policy the developer configures (dashboard → Storage). Three scopes: player (private), shared (game-wide), and team (members only). await tg.storage.put('decks', 'main', { cards: [] }); const { data } = await tg.storage.get('decks', 'main'); const keys = await tg.storage.list('decks'); ### Concurrency & atomic mutations Writes support optimistic concurrency (If-Match → storage_conflict on a stale version). For counters and lists, avoid read-modify-write races with server-side atomic ops (incr, append) instead. await tg.storage.incr('stats', 'wins', 1); await tg.storage.append('decks', 'main', 'cards', 'card_9'); ## User-generated content UGC lets players create content (levels, decks, skins) that others browse and play. Create a draft, then publish it; titles/content are moderated on the way in. const { id } = await tg.ugc.create({ title: 'My Level', data: { grid: [] } }); await tg.ugc.submit(id); // publish const feed = await tg.ugc.browse({ sort: 'top' }); ### The growth loop: remix + lineage Anyone can remix published content — it forks with attribution, and tg.ugc.lineage(id) returns the ancestry so original authors get credit. Plays, ratings, and likes are abuse-resistant social signals that surface the best content. const remix = await tg.ugc.remix(sourceId, { title: 'My Remix' }); await tg.ugc.rate(id, 5); await tg.ugc.play(id); ## Turn-based matches For turn-based games, players don't need to be online at the same time. A match holds shared state and a turn order; each player submits their turn when it's their move. No WebSocket or Durable Object required. const { match } = await tg.asyncMatch.create({ type: 'chess', opponents: ['p_9'] }); const mine = await tg.asyncMatch.mine(); ### Server-enforced turns + OCC The server enforces turn order (not_your_turn out of turn) and optimistic concurrency (async_conflict on a stale version) — so two devices can't both move. Read the match, submit with the version you saw. await tg.asyncMatch.turn(match.id, { state: nextState, version: match.version, end: false }); await tg.asyncMatch.forfeit(match.id); ## Verifiable RNG tg.rng gives a deterministic seed for a named stream and time period. The same (player, stream, period) always yields the same seed, derived from a server secret — so a client can't reroll a loot drop by replaying the request, and you can reproduce a roll for support/anti-cheat. const { seed } = await tg.rng.seed('loot'); // derive your rolls deterministically from `seed` ### Server-side by default The high-stakes rolls (loot boxes, gacha) already happen server-side via the economy — tg.rng is for client-side effects you want verifiable and consistent. A player-scoped seed is per-player; a shared-scope seed is the same for everyone (e.g. a daily map). ## Analytics & crash reporting Track events with tg.track — they're coalesced into a durable outbox, survive a dropped connection, and flush on reconnect. Counts only: no per-player PII leaves the device. tg.track('level_complete'); tg.track('boss_defeated', 1); ### Rollups A nightly job rolls raw events into materialized metrics: DAU/MAU, new players, day-N retention cohorts, sessions (count + length), per-currency economy health (sources/sinks/net), and developer-defined funnels. View them in the dashboard Analytics tab, read them via the dev API, or ask an agent through the MCP tools. ### Crash reporting Report crashes with tg.crashes.report(message, { stack }). The server fingerprints the normalized stack and groups a flood of reports into a handful of issues, each with a crash-free-users %. try { gameLoop(); } catch (e) { tg.crashes.report(e.message, { stack: e.stack, appVersion: '1.4.0' }); } ## LiveOps: config, flags, segments & codes LiveOps lets you tune and gate the running game from the server. All of it is edited in the dashboard (or via MCP) and read by the client — no app-store release to change a number. ### Remote config & flags Remote config is a key→value tuning blob (spawn rates, banners). Feature flags resolve per the player's segments and carry a break-glass KILL SWITCH: flip a bad feature to its safe value instantly for everyone. const cfg = await tg.config.get(); const on = await tg.flags.bool('new_hud'); ### Segments A segment is a rule tree over player state (stats, achievements, days-since-install, membership in another segment). Segments target flags and offers; build them in the dashboard's rule editor or via the API, then materialize membership. ### Live events & promo codes Schedule live events (windows the client reads to light up seasonal content) and run promo-code campaigns (bulk-generated codes that grant a reward into the inbox when redeemed — code_invalid / code_expired / code_already_redeemed guard the redeem). await tg.codes.redeem('LAUNCH2026'); ## Reliability: retries, idempotency & offline Games run on flaky mobile networks and tabs that sleep. The SDK is built so a dropped connection or a retried request never corrupts state: you write calls the obvious way and the durability is automatic. ### Automatic retries with backoff Transient failures — 429 (rate limited), 5xx, and network errors — are retried automatically with exponential backoff that honors a Retry-After header. A 401 on a player call triggers exactly one silent token refresh, then retries. You only see an error once retries are exhausted, and it's always a typed TriggairError. ### The durable outbox Analytics events and cloud saves flow through a durable outbox persisted in local storage. If the connection drops they replay on reconnect (and on an interval, and on the browser's `online` event). Events coalesce by name (counts summed) and saves coalesce by slot (last-write-wins), so a backlog flushes compactly. The flush is best-effort background work — it never throws into your game loop. tg.track('level_complete'); // queued; survives a reload or offline spell tg.saves.queue('slot1', data); // durable save — replays on reconnect ### Idempotency keys Every outbox item carries a stable Idempotency-Key, so a replay after a failure resends the identical request under the identical key and the server dedupes it — a batch is applied at most once. For grants you trigger directly (a purchase, a loot open, a gift), pass your own idem key so a double-tap or a client retry can't double-charge or double-grant. await tg.economy.buy('main_store', listingId, { idem: uuid }); await tg.economy.loot.open('bronze_box', { idem: uuid }); ### At-least-once vs exactly-once Analytics events are at-least-once — coalesced counts can over-deliver slightly, so never derive currency from a raw event count. Anything that grants value is exactly-once: it flows through the inbox and is granted only when the player claims it (see the Inbox & rewards guide). The split is deliberate — cheap telemetry is lossy-safe; money is audited. ### The error contract Every failure is a TriggairError with { code, message, agentHint, requestId }. code is stable and machine-checkable; agentHint tells you (or your AI) how to fix the call; requestId is what you quote in a bug report. Branch on code, never on message text. try { await tg.economy.buy('main_store', id, { idem }); } catch (e) { if (e.code === 'insufficient_funds') promptTopUp(); } ## Built for coding agents Triggair is designed so a coding agent can wire it end-to-end. Point your agent at the docs and it has everything it needs. ### The published artifacts /llms.txt is a concise index; /llms-full.txt is the full guide (recipes + these feature guides + error docs); /openapi.json is the machine-readable API definition; /dropin is a copy-paste prompt. All are keyless and edge-cached. ### The MCP server The Model Context Protocol server lets an agent configure games, boards, economy, flags, moderation, and more — and read analytics — directly, without dashboard round-trips. Its tools (triggair_create_game, triggair_configure_leaderboard, triggair_verify_integration, triggair_search_docs, …) mirror the dev API with validated arguments. ### Connect the MCP server It's a streamable-HTTP MCP endpoint at POST /v1/mcp — point any MCP client (Cursor, Claude, Windsurf, …) at it and authenticate with your developer session token (copy it from app.triggair.com). The token authorizes exactly as the dev API does: an agent only ever touches games you own. // ~/.cursor/mcp.json (or your client's MCP config) { "mcpServers": { "triggair": { "url": "https://api.triggair.com/v1/mcp", "headers": { "Authorization": "Bearer " } } } } ### Errors that fix themselves Every failure is a TriggairError with { code, message, agentHint, requestId }. The agentHint tells you how to fix the call; rate_limited/5xx/network retry automatically. Each code links to /docs/errors/. ### Close the loop After wiring, the MCP tool triggair_verify_integration (or the dashboard verify runner) live-probes the key services and reports pass/fail with fixes — so an agent knows it's actually done, not just that the code compiled. ## Error codes Every failure is a TriggairError `{ code, message, agentHint, requestId }` and links to /docs/errors/. ### bad_request (400) The request was malformed — a missing/invalid field, a bad slot/board/key name, or non-JSON body. Fix: Read the message: it names the offending field and its rule (e.g. slot 1–64 of A–Z a–z 0–9 _ -). Fix the input and resend. ### unauthorized (401) No valid credential. A game-scoped call needs the publishable key; a player-scoped call also needs a player token. Fix: Send `X-Triggair-Key: tg_pk_…`. For player calls, call login() first (the SDK does this automatically) so a `Authorization: Bearer ` is attached. If a token expired, the SDK refreshes and retries once. ### forbidden (403) Authenticated but not allowed — e.g. a banned player, or Turnstile is required for a new anonymous player. Fix: Do not retry a ban. For Turnstile, complete the challenge and resend `turnstile_token`. ### cors_forbidden (403) The browser Origin isn't in the game's allowed_origins. The classic 'works locally, 403s once deployed' trap. Fix: Add your deployed origin to allowed_origins (dashboard → CORS, or triggair_set_allowed_origins). An empty allowlist is open to any origin. ### game_suspended (403) A Triggair operator has suspended this game (a Terms-of-Service or abuse review). A valid key still resolves, but no game-scoped call proceeds. Fix: Do NOT retry — the suspension persists until an operator lifts it. Contact support to resolve the underlying issue. ### conflict (409) The action conflicts with current state — e.g. a handle already taken, a friendship you've blocked, or a daily reward already claimed today. Fix: The message says which. Choose another handle, or don't re-claim the same day (daily resets on the server day boundary). ### save_conflict (409) An optimistic-concurrency (OCC) save conflict: the slot's version changed since you read it (a lost race or a stale If-Match). Fix: GET the slot to read the current version, merge your change, and PUT again with the new `ifMatch`. Omit ifMatch to opt into last-write-wins. ### not_found (404) The resource doesn't exist for this caller — an empty save slot, an unconfigured/disabled board, or a game/id you don't own. Fix: Check the id/slot/board. A board must be configured (triggair_configure_leaderboard) and enabled before scores work. Ownership 404s are uniform (no existence oracle). ### payload_too_large (413) The body exceeds a limit — a save > 256 KB, a config > 64 KB, or a share context > 4 KB. Fix: Shrink the payload (store only game-relevant state) or split across slots. The message names the cap. ### quota_exceeded (402) A plan limit was hit — save slots (3 on free), the monthly-active-player cap, or another metered ceiling. Fix: Free/reuse a resource (e.g. reuse a save slot) or upgrade the plan. This is not a transient retry — the request won't succeed until the limit clears. ### insufficient_funds (402) A wallet spend was denied because the balance would go negative. Balances never go negative — the whole transaction rolled back (you did not spend and fail to receive). Fix: Do NOT retry — the player genuinely can't afford it. Show the price gap, or route them to earn/buy the currency first, then try the purchase again with a NEW idempotency key. ### unknown_currency (400) A grant or spend referenced a currency that isn't defined for this game. Currencies are explicit definitions — there is no implicit create. Fix: Define the currency first (triggair_configure_currencies or the dashboard), then retry. Do not loop retrying the same undefined-currency call. ### unknown_item (400) A grant, consume, or store listing referenced an item that isn't in the game's catalog. Items are explicit definitions — there is no implicit create. Fix: Define the item first (triggair_configure_items or the dashboard), then retry. Do not loop retrying an undefined item. ### insufficient_item (409) A consume/spend asked for more of an item than the player owns. Inventory quantities never go negative — the whole transaction rolled back. Fix: Do NOT retry — the player doesn't have enough. Read the inventory (GET /inventory) and only consume what they own. ### store_limit_reached (403) The player already bought this listing up to its per-player purchase_limit. Fix: Do not retry — the limit is hit. Surface it in the UI (e.g. 'already purchased') rather than re-attempting the buy. ### out_of_stock (409) The store listing's global stock is exhausted. Fix: Do not retry — it's sold out. Mark the listing unavailable; it may restock later if the developer raises stock. ### loot_not_enabled (403) This loot table isn't live — it hasn't been enabled with the required compliance acknowledgement (loot boxes touch gambling/age regulation). Fix: The developer must enable it with compliance_ack:true (triggair_configure_loot) after reviewing legal/disclosure obligations for their audience and platforms. Do NOT retry. ### out_of_energy (403) The player doesn't have enough energy/lives for this action. Energy regenerates over server time (never the client clock). Fix: Show the next_regen_at countdown from GET /energy, or offer a refill. Do NOT poll in a loop — re-checking sooner won't add energy. ### item_not_tradable (403) This item can't be gifted — only items defined as tradable move between players. Fix: Do NOT retry. Surface the item as non-giftable, or mark it tradable in the catalog (triggair_configure_items) if you intend it to be giftable. ### flag_not_found (404) No active feature flag with that key (it's undefined or turned off). Unknown flags are expected — the SDK resolves them to the caller's safe default. Fix: Read flags via GET /config or tg.flags.get(key, default); the default is used when the flag is absent. Define it with triggair_set_flag if you meant it to exist. Do NOT retry. ### segment_definition_invalid (422) A segment's rule tree failed validation — an unknown predicate/operator, a bad value type, or too deep/large a tree. Fix: Read the message: it names the problem. Use predicates stat/achievement/daily_streak/days_since_created/has_handle/in_segment combined with all/any/not; operators >= > <= < == !=. Fix the definition; do NOT retry the same body. ### code_invalid (404) No promo code with that string exists in this game (typo, or a never-minted code). Fix: Check the code for typos. Do NOT brute-force or loop over guesses — that trips the rate limit. ### code_expired (410) The code's redemption window has closed (server time), or the code/campaign is disabled. Fix: It can't be redeemed. Do NOT retry; surface it as expired. ### code_already_redeemed (409) This player already redeemed this code (per-player limit reached). Fix: Treat as done — the reward is not re-applied. Do NOT retry. ### code_campaign_exhausted (403) The campaign hit its total redemption cap — no more rewards remain. Fix: Do NOT retry; surface it as sold out. ### code_wrong_audience (403) The code is restricted to a player segment this player isn't a member of. Fix: Do NOT retry with the same player; the code isn't for them. ### quest_not_complete (409) A quest was claimed before its objectives were met. Progress is a server-side projection over the stat store — the client can't assert completion. Fix: Read GET /quests (or tg.quests.list()) for the current per-objective progress. Do NOT retry the claim until every objective is met. ### tournament_not_open (409) A join was attempted before the tournament's registration window opens (server time, §4.10). Fix: Read the tournament object for `starts_at`/`ends_at`. Do NOT retry until the window opens. ### tournament_closed (409) The tournament has ended — joins and submissions are closed. Prizes (if any) are already in the winners' inboxes. Fix: Read GET /tournaments/{id}/me for the final rank and prize; claim the prize via the inbox. Do NOT retry the join/submit. ### tier_not_earned (403) A battle-pass tier was claimed before the player earned enough battle points to reach it. Fix: Read GET /battle-pass/{season} for the current tier/BP; do NOT retry until the tier is reached. ### premium_required (403) A premium-lane battle-pass reward was claimed but the player doesn't own the premium pass. Fix: Grant/sell the premium pass first (operator or IAP); the free lane is always claimable. Do NOT retry. ### not_your_turn (409) A turn was submitted to an async match when it wasn't that player's turn. Fix: Read GET /async/{id} for `current_turn`; wait for your turn (an inbox `async_turn` notification arrives). Do NOT retry now. ### async_conflict (409) An async-match turn lost the optimistic-concurrency check — the `version` didn't match the current one (another turn landed first). Fix: GET /async/{id} for the latest state + version, recompute your move against it, then retry ONCE with that version. Do not loop. ### team_forbidden (403) The player's team role doesn't permit this action (or they aren't a member) — e.g. a non-admin kicking, a non-owner disbanding, or the owner trying to leave. Fix: Only admins/owners moderate; the owner must transfer ownership before leaving. Do NOT retry with the same player. ### team_full (403) The player is already in the maximum number of teams allowed. Fix: They must leave a team before joining another. Do NOT retry. ### team_banned (403) The player is banned from this team (by an admin/owner) and cannot join by any path — open join, invite-accept, or request-approve. Fix: An admin/owner must unban them first (POST /v1/teams/{id}/members/{pid}/unban). Do NOT retry the join. ### storage_conflict (409) An optimistic-concurrency write to a storage document lost the race — the `If-Match` version didn't match the current one (the multi-tab/multi-device lost-update guard). Fix: GET the document for the current `version`, merge your changes into the latest value, then retry ONCE with `If-Match: `. Do not loop. ### storage_forbidden (403) A player token tried to write a storage document its policy doesn't allow — e.g. authoritative shared/world state (write_policy: server, the fail-closed default). Fix: Write shared/world state server-side (operator / triggair_set_shared_doc), or set the collection's write_policy to 'player' if it's meant to be pk-writable. Do NOT retry on the client. ### already_reported (409) This reporter already has an open report against the same target. Fix: Treat as success — the report is already queued. Do NOT file a duplicate or retry. ### report_rate_limited (429) This player filed too many reports within the hour (per-player abuse control). Fix: Back off; do NOT resubmit in a loop. Existing reports are already queued for review. ### player_banned (403) This account or device is banned from the game, so no player token is issued. Fix: Do NOT retry token issuance in a loop — the ban survives a cleared localStorage. Surface the ban to the user (and the appeal path if the game offers one). ### appeal_already_decided (409) This ban's appeal was already granted or denied. Fix: The decision stands — do NOT re-appeal the same ban or re-decide it. Surface the outcome to the user. ### age_unknown (403) This player's age bracket isn't established, so every regulated feature (loot boxes, real-money IAP, open chat, public UGC) fails closed to the most-restrictive treatment. Fix: Run the neutral age screen once, early — `tg.compliance.setAge(...)` / POST /players/me/age. Do NOT retry the blocked call as-is. ### age_restricted (403) This feature isn't available to this player's age group in their region, and no parental consent can unlock it. Fix: Do NOT retry — hide the feature in the UI for this player. Check GET /players/me/compliance for the full gated map. ### parental_consent_required (403) A minor may use this feature only with verifiable parental consent (VPC), which hasn't been requested yet. Fix: Start the consent flow (`tg.compliance.requestConsent()`). Do NOT retry the feature until consent is granted. ### consent_pending (409) Parental consent has been requested for this minor but the parent hasn't decided yet. Fix: Wait for the parent to grant/deny via the emailed link. Do NOT resend the consent request repeatedly. ### rate_limited (429) Too many requests — the global token bucket or a per-board submission cap. A `Retry-After` header (and X-RateLimit-*) tells you when. Fix: Back off and retry after `Retry-After` seconds. The SDK does this automatically. Quote X-Request-Id if you report it. ### internal (500) An unexpected server error. Rare and transient. Fix: Retry with backoff (the SDK does). If it persists, report the request_id from the response so it can be traced.