> For the complete documentation index, see [llms.txt](https://series-1.gitbook.io/rundot-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://series-1.gitbook.io/rundot-docs/v5.21.0/readme/stats.md).

# Stats API (BETA)

A schemaless per-user key/value store for numerical game stats. Use it to track anything you'd like the platform to remember about a player — episodes watched, level cleared, score, distance traveled.

The Stats API is also the trigger surface for **collectible grants**: when a stat crosses a threshold defined in your game's collectibles config, the platform fires the matching grant rule and returns any newly-granted cards inline with your `submit()` call.

> **Beta scope.** Stats are client-authoritative for beta — any authenticated player can submit any stat ID. The server-only stat policy mechanism (`.rundot/stats.config.json`) is on the v1.1 roadmap alongside server-authoritative anti-cheat. For RUN.tv-style consumers where stats drive entitlement grants, the cheating exposure is bounded — see [Trust model](#trust-model) for details.

***

## Overview

Stats are stored per `(userId, gameId, statId)` tuple. Writes are **last-write-wins** — submitting the same value twice is a no-op; submitting a different value overwrites. There is no schema, no aggregation type negotiation, and no pre-definition step: the first `submit()` call materializes the stat.

The platform records the stored value but doesn't interpret it. Meaning is layered on top via your `.rundot/collectibles.config.json` rules — see [Wiring stats to collectible grants](#wiring-stats-to-collectible-grants).

***

## API

The Stats API lives on `RundotGameAPI.stats`. Three methods:

### `submit(statId, value)`

```ts
const { grants } = await RundotGameAPI.stats.submit('episode_watched_curse_ep1', 1)
```

Persists the value and runs any matching grant-rule evaluations server-side. Resolves with `{ grants }` containing the cards granted **by this specific statId**:

```ts
interface GrantInfo {
  cardId: string                                  // = entitlementId on the platform
  ruleId: string                                  // the rule that fired
  type: 'free' | 'completion'                     // card type from your catalog
  source: 'watch' | 'completion_reward'           // for analytics attribution
}
```

For stats with no rules attached, `grants` is `[]`. For most game use cases you can ignore the return value entirely — the platform records the grant regardless and your consumer code can read it via the [Entitlements API](/rundot-docs/v5.21.0/readme/entitlements.md) on a separate cadence.

### `getValue(statId)`

```ts
const score = await RundotGameAPI.stats.getValue('high_score')   // number | null
```

Returns the current value, or `null` if the stat has never been submitted.

### `getAllValues()`

```ts
const all = await RundotGameAPI.stats.getAllValues()
// { episode_watched_curse_ep1: 1, high_score: 42_000, ... }
```

Returns every stat for the current user in this game, as a flat map.

> Claiming a VIP collectible card given a `series_completed_*` stat is a [Collectibles API](/rundot-docs/v5.21.0/readme/collectibles.md) concern, not a Stats API concern. The corresponding method lives at `RundotGameAPI.collectibles.claimVipCard(seriesId, cardId)`.

***

## Coalescing and timing

Submits issued within the same synchronous tick coalesce into a single round-trip. The first `submit()` schedules a microtask flush; any further `submit()` calls in the same tick join the same pending batch (last-write-wins per `statId`). On the next microtask, one RPC carries the whole batch.

```ts
// Synchronous burst — one RPC for the whole batch.
RundotGameAPI.stats.submit('coins', 10)
RundotGameAPI.stats.submit('coins', 12)   // value wins: 12
RundotGameAPI.stats.submit('gems', 3)
// → single RPC with { coins: 12, gems: 3 } fires on the next microtask
```

**Per-stat outcomes.** Each caller's promise resolves or rejects based on **their own stat's** outcome. If you submit `coins` and a teammate submits `gems` in the same tick and the bridge succeeds for `coins` but fails for `gems`, your promise resolves with the `coins` grants and theirs rejects with the `gems` error. You will not observe grants from another caller's stat ID.

**Read methods are not batched** — `getValue` and `getAllValues` fire one RPC each.

***

## Wiring stats to collectible grants

To make a stat trigger a grant, declare a rule in your project's `.rundot/collectibles.config.json`. The rule watches a stat ID and grants an entitlement when the threshold is crossed.

```json
{
  "cards": [
    {
      "cardId": "curse_of_marble_ep1_intro",
      "storyId": "curse_of_marble",
      "episodeId": "ep1",
      "type": "free",
      "rarity": "common",
      "title": "The Beginning",
      "description": "Marble's first day at the academy.",
      "artPath": "Cards/curse_of_marble/ep1_intro.png",
      "thumbPath": "Cards/curse_of_marble/ep1_intro_thumb.png"
    }
  ],
  "rules": [
    {
      "ruleId": "grant_curse_of_marble_ep1_intro",
      "statId": "episode_watched_curse_of_marble_ep1",
      "operator": ">=",
      "threshold": 1,
      "rewardType": "entitlement",
      "rewardConfig": {
        "entitlementId": "curse_of_marble_ep1_intro",
        "quantity": 1,
        "consumable": false
      }
    }
  ]
}
```

Deploy via `rundot deploy`. When the client submits `episode_watched_curse_of_marble_ep1 = 1`, the rule fires, the entitlement is granted server-side, and the `{ grants }` array in the `submit()` response carries a `GrantInfo` for the new card.

See the [Entitlements API](/rundot-docs/v5.21.0/readme/entitlements.md) for ownership reads (`listEntitlements`, `getQuantity`, `getLedger`).

***

## Example: watch threshold → card grant

```ts
function onVideoProgress(seriesId: string, episodeId: string, progress: number) {
  if (progress < 0.9) return
  if (alreadyMarked(seriesId, episodeId)) return

  RundotGameAPI.stats
    .submit(`episode_watched_${seriesId}_${episodeId}`, 1)
    .then(({ grants }) => {
      for (const grant of grants) {
        showRevealOverlay(grant.cardId)
      }
    })
    .catch((err) => {
      console.error('Failed to record watch progress', err)
    })
}
```

The `then` callback runs after the rule eval. `grants` is empty for most calls (most submits aren't crossing a threshold for the first time) — when it has entries, that's a freshly-granted card.

***

## Trust model

Beta accepts that any authenticated client can submit any `statId`. A malicious client could submit `episode_watched_X = 1` directly via the SDK and self-trigger the grant rule. Three things bound the impact:

* **Cards are non-fungible.** No resale, no trading, no price — the only "value" is displaying them on the user's own Profile.
* **Grants are idempotent.** Deterministic grant doc IDs cap any given rule to one grant per user, so cheating is one-shot per rule.
* **VIP boundary preserved.** VIP card claims use a dedicated server-validated endpoint that bypasses the rule engine entirely; cheating series-completion stats doesn't get past the subscription check.

The server-only stat policy mechanism (`.rundot/stats.config.json` declaring patterns of stat IDs that only accept server-side writes) is on the v1.1 roadmap. Until it lands, treat stats wired to grant rules as best-effort tracking, not a security boundary.

***

## Related

* [Entitlements API](/rundot-docs/v5.21.0/readme/entitlements.md) — read ownership, consume, audit grants
* [Leaderboards API](/rundot-docs/v5.21.0/readme/leaderboard.md) — competitive rankings with stronger anti-cheat


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://series-1.gitbook.io/rundot-docs/v5.21.0/readme/stats.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
