> 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/app.md).

# App API (BETA)

Server-verified role detection and admin moderation tools for game owners and editors.

***

## Overview

The App API lets your game detect whether the current player is a member of the development team and, if so, perform admin operations like content moderation.

Roles are **server-verified**: the backend checks the user's Firebase Auth email against the game's owner and editor list. There is no client-side role assignment to tamper with.

**Key Features:**

* Detect whether the current user is an owner, editor, or regular player
* Browse and remove UGC entries as an admin
* Browse and remove AI-generated content as an admin: images, video, sprites, audio, and 3D models
* Review and resolve player reports across every content type

***

## Role Detection

### Quick Start

```typescript
import RundotGameAPI from '@series-inc/rundot-game-sdk/api'

const role = await RundotGameAPI.app.getMyRole()

if (role === 'owner' || role === 'editor') {
  showAdminPanel()
} else {
  showPlayerUI()
}
```

### `getMyRole()`

Returns the current user's role for this game.

```typescript
const role: AppRole = await RundotGameAPI.app.getMyRole()
```

**Returns:** `'owner' | 'editor' | 'none'`

| Role     | Meaning                                         |
| -------- | ----------------------------------------------- |
| `owner`  | The user's email matches the game's owner email |
| `editor` | The user's email is in the game's editor list   |
| `none`   | No match, anonymous user, or game not found     |

> **Security:** Roles are resolved server-side from the verified Firebase ID token. Anonymous users and unrecognized emails always receive `'none'`.

***

## Launch Intent

`RundotGameAPI.app.resolveLaunchIntent()` is the single, supported way to find out **how your game was launched** — from a share link, a deep link, a notification tap, or a cold/organic start. It replaces the deprecated `RundotGameAPI.context.{launchParams,shareParams,notificationParams,shareLinkId}` snapshot fields (removed in v6.0.0).

### Why a method (and not a field)?

There are two classes of launch intent:

* **Class A — deterministic.** An OS universal link, a URI scheme, a notification tap, or an already-attached share link. These are known at (or near) boot, so `resolveLaunchIntent()` returns them with \~0 wait regardless of `maxWaitMs`.
* **Class B — deferred.** Post-install attribution (AppsFlyer). This resolves **asynchronously** — on iOS it can be postponed up to \~15s by the ATT prompt — so a fresh share install often resolves *after* `INIT_SDK` already returned. A one-shot `context` snapshot is empty in this case; this is exactly the gap `resolveLaunchIntent` closes.

`maxWaitMs` is **your** patience budget: the host returns the intent as soon as the launch route resolves, but waits no longer than your budget. If it hasn't resolved in time you get `{ kind: 'timed_out' }` — which is **not** terminal. Call again to keep waiting; once the signal settles, every call returns it instantly.

### `resolveLaunchIntent(options?)`

```typescript
const intent: LaunchIntent = await RundotGameAPI.app.resolveLaunchIntent({
  maxWaitMs: 800, // optional; omit for the platform default. Clamped host-side.
})
```

**Returns:** `Promise<LaunchIntent>`. The host **always resolves** a `LaunchIntent` (it never rejects with a launch-intent value). Like every RPC, the promise can still reject on transport-level failures (a host too old to support the message, or the RPC call timeout) — handle that the same way you handle any other SDK rejection; it is distinct from a `timed_out` intent.

```typescript
type LaunchIntentKind = 'share' | 'deeplink' | 'notification' | 'none' | 'timed_out'

interface LaunchIntent {
  kind: LaunchIntentKind
  shareLinkId?: string             // present for kind === 'share'
  sharerId?: string                // sharer's profile id, for kind === 'share'
  params: Record<string, string>   // flat share / deeplink / notification payload
}
```

| `kind`         | Meaning                                                                                                             |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `share`        | Launched from a share link. `shareLinkId`, `sharerId`, and the share payload (in `params`) are populated.           |
| `deeplink`     | Generic deep link (OS initial URL, google-ads `af_dp`, runtime URL). `params` carries the route params.             |
| `notification` | Launched by tapping a notification. `params` carries the notification payload.                                      |
| `none`         | The host **definitively** reported no launch link (organic install / resolution completed with no route). Terminal. |
| `timed_out`    | Your budget elapsed before any answer. **Indeterminate and re-callable** — call again to keep waiting.              |

### Canonical pattern (handles deferred deep links)

```typescript
import RundotGameAPI from '@series-inc/rundot-game-sdk/api'

let intent = await RundotGameAPI.app.resolveLaunchIntent({ maxWaitMs: 800 })

// Keep waiting only while it's still resolving and you're willing to wait.
while (intent.kind === 'timed_out' && stillWaiting()) {
  intent = await RundotGameAPI.app.resolveLaunchIntent({ maxWaitMs: 1500 })
}

if (intent.kind === 'share') {
  routeToShared(intent.shareLinkId, intent.sharerId, intent.params)
} else {
  startNormally() // 'deeplink' / 'notification' / 'none' / gave-up 'timed_out'
}
```

`maxWaitMs: 0` means "return what's known now, don't wait." A value above the host ceiling is clamped.

{% hint style="warning" %}
**Delete any `setTimeout`-based polling for share/launch params.** The old workaround — waiting a few seconds before reading `context.shareParams` to give a deferred deep link time to land — is no longer needed and should be removed. `resolveLaunchIntent({ maxWaitMs })` is the deterministic replacement: it returns the instant the host resolves the route and bounds the wait to your budget.
{% endhint %}

***

## Admin UGC

Available at `RundotGameAPI.app.adminUgc`. All methods require the caller to be an owner or editor; the server returns `403 Forbidden` otherwise.

### Browse All Entries

Browse all UGC entries for your game, including non-public entries that the player-facing `ugc.browse()` hides.

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminUgc.browse({
  contentType: 'character',   // Optional: filter by content type
  authorId: 'some-profile',   // Optional: filter by author
  isPublic: false,            // Optional: filter by visibility (omit to see all)
  sortBy: 'recent',           // 'recent' | 'mostLiked' | 'mostUsed'
  sortOrder: 'desc',          // 'asc' | 'desc'
  limit: 20,                  // Max entries per page
  cursor: nextCursor,         // Pagination cursor from previous response
})

for (const entry of entries) {
  console.log(`${entry.title} by ${entry.authorName} (public: ${entry.isPublic})`)
}
```

#### Parameters

| Param         | Type                      | Default    | Description                                                                                                                   |
| ------------- | ------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `contentType` | `string \| string[]`      | -          | Filter by app-defined content type. Pass an array to match any of several types in one query (e.g. `['character', 'level']`). |
| `authorId`    | `string`                  | -          | Filter by author profile ID                                                                                                   |
| `isPublic`    | `boolean`                 | -          | Filter by visibility; omit to see all                                                                                         |
| `sortBy`      | `string`                  | `'recent'` | Sort field: `'recent'`, `'mostLiked'`, `'mostUsed'`                                                                           |
| `sortOrder`   | `'asc' \| 'desc'`         | `'desc'`   | Sort direction                                                                                                                |
| `limit`       | `number`                  | `20`       | Max entries per page (max 100)                                                                                                |
| `cursor`      | `string`                  | -          | Pagination cursor                                                                                                             |
| `filters`     | `Record<string, unknown>` | -          | Custom indexed field filters (fields must have `idx_` prefix)                                                                 |

### Remove an Entry

Soft-delete any entry regardless of author. The entry's Firestore record is marked as `status: 'removed'` and excluded from all player-facing queries. The content is **not permanently deleted**; it can be reviewed later.

```typescript
await RundotGameAPI.app.adminUgc.removeEntry('entry-id-to-remove')
```

### List Reports

Browse player-submitted reports for UGC content, with optional status filtering.

```typescript
const { reports, nextCursor } = await RundotGameAPI.app.adminUgc.listReports({
  status: 'pending',  // Optional: 'pending' | 'reviewed' | 'dismissed'
  limit: 20,          // Optional: max reports per page
  cursor: nextCursor, // Optional: pagination cursor from a previous response
})

for (const report of reports) {
  console.log(`Report on entry ${report.entryId}: ${report.reason}`)
  console.log(`  Status: ${report.status}, Filed: ${new Date(report.createdAt)}`)
}
```

#### Parameters

| Param    | Type                                     | Default | Description                                               |
| -------- | ---------------------------------------- | ------- | --------------------------------------------------------- |
| `status` | `'pending' \| 'reviewed' \| 'dismissed'` | -       | Filter by report status; omit to see all                  |
| `cursor` | `string`                                 | -       | Pagination cursor from a previous response's `nextCursor` |
| `limit`  | `number`                                 | -       | Max reports per page                                      |

### Resolve a Report

Update a report's status after reviewing it. The `action` argument accepts only `'reviewed'` or `'dismissed'` (there is no `'pending'` option when resolving).

```typescript
// Mark as reviewed (acknowledged, action taken)
await RundotGameAPI.app.adminUgc.resolveReport('report-id', 'reviewed')

// Dismiss (no action needed)
await RundotGameAPI.app.adminUgc.resolveReport('report-id', 'dismissed')
```

***

## Admin ImageGen

Available at `RundotGameAPI.app.adminImageGen`. All methods require the caller to be an owner or editor.

### Browse Generated Images

Browse all AI-generated images for your game with optional filtering by creator or status.

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminImageGen.browse({
  profileId: 'some-user',   // Optional: filter by creator
  status: 'active',         // Optional: 'active' | 'removed' (omit to see all)
  sortOrder: 'desc',        // 'asc' | 'desc'
  limit: 20,
})

for (const entry of entries) {
  console.log(`"${entry.prompt}" by ${entry.profileId}`)
  console.log(`  Status: ${entry.status}, Model: ${entry.model}`)
}
```

#### Parameters

These are the shared `AdminGenBrowseParams`: the same shape applies to every admin-gen namespace (`adminImageGen`, `adminVideoGen`, `adminSpriteGen`, `adminAudioGen`, `adminThreeDGen`).

| Param       | Type                    | Default  | Description                       |
| ----------- | ----------------------- | -------- | --------------------------------- |
| `profileId` | `string`                | -        | Filter by creator profile ID      |
| `status`    | `'active' \| 'removed'` | -        | Filter by status; omit to see all |
| `sortOrder` | `'asc' \| 'desc'`       | `'desc'` | Sort direction (by creation time) |
| `limit`     | `number`                | `20`     | Max entries per page (max 100)    |
| `cursor`    | `string`                | -        | Pagination cursor                 |

### Remove a Generated Image

Soft-delete a generated image. The original image is moved to a quarantine area and replaced with a placeholder so existing references don't break with a 404. The Firestore record is marked `status: 'removed'`.

```typescript
await RundotGameAPI.app.adminImageGen.removeEntry('generation-id')
```

### List Reports

Browse player-submitted reports for generated images. Reports paginate with `cursor` the same way `browse()` does.

```typescript
const { reports, nextCursor } = await RundotGameAPI.app.adminImageGen.listReports({
  status: 'pending',  // Optional: 'pending' | 'reviewed' | 'dismissed'
  limit: 20,          // Optional
  cursor: nextCursor, // Optional: pagination cursor from a previous response
})

for (const report of reports) {
  console.log(`Report on generation ${report.generationId}: ${report.reason}`)
}
```

### Resolve a Report

The `action` argument accepts only `'reviewed'` or `'dismissed'`.

```typescript
await RundotGameAPI.app.adminImageGen.resolveReport('report-id', 'reviewed')
```

***

## Admin VideoGen

Available at `RundotGameAPI.app.adminVideoGen`. All methods require the caller to be an owner or editor.

This namespace has the same four methods as Admin ImageGen: `browse(params?)`, `removeEntry(generationId)`, `listReports(params?)`, and `resolveReport(reportId, action)`. `browse()` returns `VideoGenEntry` objects.

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminVideoGen.browse({
  status: 'active',   // Optional: 'active' | 'removed'
  sortOrder: 'desc',
  limit: 20,
})

for (const entry of entries) {
  console.log(`"${entry.prompt}" (${entry.provider}/${entry.mode})`)
  console.log(`  ${entry.videoUrl} (${entry.durationSeconds}s, ${entry.width}x${entry.height})`)
}

// Moderation
await RundotGameAPI.app.adminVideoGen.removeEntry('generation-id')

const { reports } = await RundotGameAPI.app.adminVideoGen.listReports({ status: 'pending' })
await RundotGameAPI.app.adminVideoGen.resolveReport('report-id', 'reviewed')
```

`browse()` accepts the shared `AdminGenBrowseParams`; `listReports()` accepts `status`, `cursor`, and `limit`.

***

## Admin SpriteGen

Available at `RundotGameAPI.app.adminSpriteGen`. All methods require the caller to be an owner or editor. Same four methods as Admin ImageGen; `browse()` returns `SpriteGenEntry` objects.

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminSpriteGen.browse({
  status: 'active',
  limit: 20,
})

for (const entry of entries) {
  console.log(`"${entry.prompt}" (${entry.type})`)
  console.log(`  ${entry.imageUrl}${entry.pixel ? ' (pixel art)' : ''}`)
}

await RundotGameAPI.app.adminSpriteGen.removeEntry('generation-id')
await RundotGameAPI.app.adminSpriteGen.resolveReport('report-id', 'reviewed')
```

***

## Admin AudioGen

Available at `RundotGameAPI.app.adminAudioGen`. All methods require the caller to be an owner or editor. Same four methods as Admin ImageGen; `browse()` returns `AudioGenEntry` objects.

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminAudioGen.browse({
  status: 'active',
  limit: 20,
})

for (const entry of entries) {
  console.log(`"${entry.prompt}" (${entry.type})`)
  console.log(`  ${entry.audioUrl} (${entry.durationSec}s)`)
}

await RundotGameAPI.app.adminAudioGen.removeEntry('generation-id')
await RundotGameAPI.app.adminAudioGen.resolveReport('report-id', 'reviewed')
```

***

## Admin ThreeDGen

Available at `RundotGameAPI.app.adminThreeDGen`. All methods require the caller to be an owner or editor. Same four methods as Admin ImageGen; `browse()` returns `ThreeDGenEntry` objects (3D models).

```typescript
const { entries, nextCursor } = await RundotGameAPI.app.adminThreeDGen.browse({
  status: 'active',
  limit: 20,
})

for (const entry of entries) {
  console.log(`${entry.operation} via ${entry.provider}`)
  console.log(`  Model: ${entry.modelUrl}`)
}

await RundotGameAPI.app.adminThreeDGen.removeEntry('generation-id')
await RundotGameAPI.app.adminThreeDGen.resolveReport('report-id', 'reviewed')
```

{% hint style="info" %}
All five generated-content namespaces (`adminImageGen`, `adminVideoGen`, `adminSpriteGen`, `adminAudioGen`, `adminThreeDGen`) share the same `AdminGenApi` shape: identical `browse` / `removeEntry` / `listReports` / `resolveReport` methods and identical params. Only the entry shape returned by `browse()` differs per media type (see the Type Reference below).
{% endhint %}

***

## Building an Admin Panel

Here's a complete example of a simple admin moderation flow:

```typescript
import RundotGameAPI from '@series-inc/rundot-game-sdk/api'

async function initAdminPanel() {
  const role = await RundotGameAPI.app.getMyRole()

  if (role === 'none') {
    return // Not an admin, nothing to show
  }

  // Load pending reports across both UGC and ImageGen
  const [ugcReports, imageGenReports] = await Promise.all([
    RundotGameAPI.app.adminUgc.listReports({ status: 'pending' }),
    RundotGameAPI.app.adminImageGen.listReports({ status: 'pending' }),
  ])

  console.log(`Pending: ${ugcReports.reports.length} UGC, ${imageGenReports.reports.length} ImageGen`)

  // Example: remove a flagged UGC entry and resolve the report
  for (const report of ugcReports.reports) {
    if (report.reason === 'inappropriate') {
      await RundotGameAPI.app.adminUgc.removeEntry(report.entryId)
      await RundotGameAPI.app.adminUgc.resolveReport(report.id, 'reviewed')
    }
  }
}
```

***

## Type Reference

### AppRole

```typescript
type AppRole = 'owner' | 'editor' | 'none'
```

### LaunchIntent

```typescript
type LaunchIntentKind = 'share' | 'deeplink' | 'notification' | 'none' | 'timed_out'

interface LaunchIntent {
  kind: LaunchIntentKind
  shareLinkId?: string
  sharerId?: string
  params: Record<string, string>
}

interface ResolveLaunchIntentOptions {
  maxWaitMs?: number
}
```

### AdminUgcEntry

```typescript
interface AdminUgcEntry {
  id: string
  appId: string
  authorId: string
  authorName: string
  authorAvatarUrl: string | null
  contentType: string
  data: Record<string, unknown>
  createdAt: number
  updatedAt: number
  isPublic: boolean
  title?: string
  tags?: string[]
  useCount?: number
  likeCount?: number
}
```

### UgcReport

```typescript
interface UgcReport {
  id: string
  entryId: string
  appId: string
  reporterId: string
  reason: 'inappropriate' | 'spam' | 'harassment' | 'other'
  details?: string
  createdAt: number   // milliseconds since epoch
  status: 'pending' | 'reviewed' | 'dismissed'
}
```

### ImageGenEntry

```typescript
interface ImageGenEntry {
  id: string
  appId: string
  profileId: string
  prompt: string
  negativePrompt?: string
  aspectRatio: string
  imageUrl: string
  removeBackground: boolean
  model: string
  createdAt: number   // milliseconds since epoch
  status: 'active' | 'removed'
}
```

### ImageGenReport

```typescript
interface ImageGenReport {
  id: string
  generationId: string
  appId: string
  reporterId: string
  reason: 'inappropriate' | 'spam' | 'harassment' | 'other'
  details?: string
  createdAt: number   // milliseconds since epoch
  status: 'pending' | 'reviewed' | 'dismissed'
}
```

### VideoGenEntry

Returned by `adminVideoGen.browse()`. `posterUrl` and `generationCostUsd` are optional; `provider` and `mode` are free-form strings.

```typescript
interface VideoGenEntry {
  id: string
  appId: string
  profileId: string
  provider: string
  mode: string
  prompt: string
  videoUrl: string
  posterUrl?: string
  durationSeconds: number
  width: number
  height: number
  generationCostUsd?: number
  createdAt: number   // milliseconds since epoch
  status: 'active' | 'removed'
}
```

### SpriteGenEntry

Returned by `adminSpriteGen.browse()`. `type` distinguishes a still sprite (`'generate'`) from an animated sprite sheet (`'animate'`); every field except `id`, `appId`, `profileId`, `type`, `prompt`, `imageUrl`, `createdAt`, and `status` is optional.

```typescript
interface SpriteGenEntry {
  id: string
  appId: string
  profileId: string
  type: 'generate' | 'animate'
  prompt: string
  model?: string
  pixel?: boolean
  width?: number
  height?: number
  outputFrames?: number
  outputFormat?: string
  frameWidth?: number
  frameHeight?: number
  imageUrl: string
  creditsUsed?: number
  createdAt: number   // milliseconds since epoch
  status: 'active' | 'removed'
}
```

### AudioGenEntry

Returned by `adminAudioGen.browse()`. `type` is the kind of audio; duration is `durationSec`. `model` and `voiceId` are optional.

```typescript
interface AudioGenEntry {
  id: string
  appId: string
  profileId: string
  type: 'sfx' | 'music' | 'tts'
  provider: string
  prompt: string
  audioUrl: string
  durationSec: number
  model?: string
  voiceId?: string
  createdAt: number   // milliseconds since epoch
  status: 'active' | 'removed'
}
```

### ThreeDGenEntry

Returned by `adminThreeDGen.browse()`. Unlike the other entries, `prompt` is optional here (a 3D asset can come from an operation other than a text prompt). `modelUrl` is the required asset URL; `rawModelUrl`, `mode`, `quality`, and `estimatedCostUsd` are optional.

```typescript
interface ThreeDGenEntry {
  id: string
  appId: string
  profileId: string
  operation: string
  provider: string
  mode?: string
  quality?: string
  prompt?: string
  modelUrl: string
  rawModelUrl?: string
  estimatedCostUsd?: number
  createdAt: number   // milliseconds since epoch
  status: 'active' | 'removed'
}
```

### VideoGenReport, SpriteGenReport, AudioGenReport, ThreeDGenReport

The report shape is the same for every generated-content namespace. `VideoGenReport`, `SpriteGenReport`, `AudioGenReport`, and `ThreeDGenReport` are each identical to `ImageGenReport`: `generationId` points at the offending entry; `reason` is one of the four enum values; `status` tracks moderation state.

```typescript
interface VideoGenReport {  // same fields for Sprite / Audio / ThreeD
  id: string
  generationId: string
  appId: string
  reporterId: string
  reason: 'inappropriate' | 'spam' | 'harassment' | 'other'
  details?: string
  createdAt: number   // milliseconds since epoch
  status: 'pending' | 'reviewed' | 'dismissed'
}
```


---

# 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/app.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.
