# The SDK

> The JavaScript SDK a hosted game uses for mutations, scoreboards, playtest events and feedback. Files, API, modes and patterns.

Five calls, no keys, no accounts to manage. The site knows who is playing, so your game doesn't have to.

The SDK gives a game mutations (achievements), scoreboards, and playtest events and feedback. Mutations and boards are declared in [fradiation.json](/docs/fradiation-json) first, then referenced by key from code.

## Get the SDK

The SDK is not on npm. Download one of these files and ship it inside your build.

| File | Use it for |
| --- | --- |
| `https://www.fradiation.games/sdk/fradiation-sdk.js` | A script tag. Sets `window.fradiation`. |
| `https://www.fradiation.games/sdk/fradiation-sdk.mjs` | An ES module: `import { fradiation } from "./fradiation-sdk.mjs"` |
| `https://www.fradiation.games/sdk/fradiation-sdk.d.mts` | TypeScript types for the ES module. Save it next to `fradiation-sdk.mjs` and TypeScript picks it up. |

```bash
curl -fsSLO https://www.fradiation.games/sdk/fradiation-sdk.js
curl -fsSLO https://www.fradiation.games/sdk/fradiation-sdk.mjs
curl -fsSLO https://www.fradiation.games/sdk/fradiation-sdk.d.mts
```

```powershell
Invoke-WebRequest -UseBasicParsing https://www.fradiation.games/sdk/fradiation-sdk.js -OutFile fradiation-sdk.js
Invoke-WebRequest -UseBasicParsing https://www.fradiation.games/sdk/fradiation-sdk.mjs -OutFile fradiation-sdk.mjs
Invoke-WebRequest -UseBasicParsing https://www.fradiation.games/sdk/fradiation-sdk.d.mts -OutFile fradiation-sdk.d.mts
```

Take only the file you use. The current version is 0.2.0, also available as `fradiation.VERSION`.

## Load it

All paths in a build must be relative. `fradiation-sdk.js`, not `/fradiation-sdk.js`.

### Script tag

Load it before your game's scripts. It sets `window.fradiation`.

```html
<script src="fradiation-sdk.js"></script>
<script>
  fradiation.ready().then((session) => console.log(session.mode));
</script>
```

### ES module

```js
import { fradiation } from "./fradiation-sdk.mjs";

const session = await fradiation.ready();
```

The module also has a default export (`import fradiation from "./fradiation-sdk.mjs"`). Bundlers such as Vite pick it up like any other local file.

## API

Every call is safe to make from anywhere in your game. None of them throws or rejects. The module exports these types:

```ts
type Mode = "live" | "preview" | "playtest";

interface Player {
  handle: string;
  displayName: string;
}

interface Session {
  connected: boolean;
  player: Player | null;
  mode: Mode;
}

interface UnlockResult {
  signedIn: boolean;
  unlocked: boolean;
}

interface ScoreResult {
  signedIn: boolean;
  isBest: boolean;
  best: number | null;
  rank: number | null;
}

interface FeedbackResult {
  opened: boolean;
}
```

> [!NOTE]
> There is no save or load call. Keep saves in `localStorage` or IndexedDB. Each game runs on its own origin, so its storage is its own and persists across builds.

### ready

```ts
ready(timeoutMs?: number): Promise<Session>
```

Handshake with the site. `timeoutMs` defaults to 1500.

- `connected`: `true` when the site answered.
- `player`: the signed-in player, or `null`.
- `mode`: `"live"`, `"preview"` or `"playtest"`. See [Modes](#modes).
- Safe to call repeatedly. Every call returns the same promise, and only the first call's timeout counts.
- Outside the site it resolves with `{ connected: false, player: null, mode: "live" }`: at once at the top level of a page, after the timeout inside someone else's frame.
- The other calls run it for you. Call it yourself to read `player` and `mode`, and call it early: error capture starts when the handshake completes.

```ts
const { connected, player, mode } = await fradiation.ready();
if (player) {
  document.querySelector("#player-name")!.textContent = player.displayName;
}
```

### unlockMutation

```ts
unlockMutation(key: string): Promise<UnlockResult>
```

Unlocks a mutation declared in `fradiation.json`.

- `unlocked` is `true` the first time this player unlocks it and `false` after that. Calling again is harmless.
- `signedIn` is `false` when nobody is signed in. Nothing is kept, and the site shows a one-time notice that progress isn't saved.
- The site shows the unlock toast. Your game does not need to draw one.
- The player earns rads by tier. See [Tiers](/docs/fradiation-json#tiers). Developers get none from their own games.
- A key that isn't in `fradiation.json` resolves with the defaults: `{ signedIn: false, unlocked: false }`.

```ts
async function onBossDefeated(difficulty: "normal" | "hard") {
  await fradiation.unlockMutation("first-blood");
  if (difficulty === "hard") await fradiation.unlockMutation("boss-hard");
}
```

### submitScore

```ts
submitScore(board: string, value: number): Promise<ScoreResult>
```

Submits a score to a board declared in `fradiation.json`. The site keeps each player's best only.

- `isBest`: this score beat the player's previous best.
- `best`: the player's best on this board after this submission.
- `rank`: 1-based position on the board.
- Signed out: `{ signedIn: false, isBest: false, best: null, rank: null }`.
- Rejected, so the defaults come back: a board key that isn't in the file, a negative value, `NaN` or `Infinity`, and any value above the board's `max`.
- Time boards take milliseconds.
- The first score on a board pays the player 5 rads, once. Developers get none.

```ts
const { signedIn, isBest, rank } = await fradiation.submitScore("high-score", 48210);
if (signedIn && isBest) console.log(`New best, rank ${rank}`);
```

### track

```ts
track(name: string, data?: unknown): void
```

Marks a moment on the playtest timeline. It returns at once and there is nothing to await.

- `name` is cut to 80 characters.
- `data` must be cloneable: objects, arrays, strings, numbers, booleans, `null`. Anything else is dropped without an error. Data over 1,500 characters of JSON is dropped and the event keeps its name.
- Ignored outside playtests, so leave the calls in.
- A playtest session keeps at most 300 events.

```ts
fradiation.track("level-start", { level: 3 });
fradiation.track("died", { level: 3, cause: "lava" });
```

### askForFeedback

```ts
askForFeedback(prompt?: string): Promise<FeedbackResult>
```

Asks the player for feedback now, for example at game over.

- In a playtest it opens the feedback panel: the panel scrolls into view, shows the prompt and focuses the notes field. The prompt is saved with the feedback.
- `prompt` is cut to 200 characters. Without one the panel says the game is asking for the player's thoughts.
- Everywhere else it resolves `{ opened: false }`. That includes you playing your own playtest link.

```ts
void fradiation.askForFeedback("How was that last section?");
```

### Automatic error capture

Once connected, the SDK reports uncaught errors and unhandled promise rejections. You don't call anything.

- Message up to 300 characters, stack up to 1,500.
- The same message is reported once. At most 20 errors per page load.
- Only playtests keep them: they show on the tester's timeline and in the error count next to their feedback. Elsewhere they are dropped.
- Errors before the handshake completes aren't captured. Call `ready()` first thing.
- `console.error` calls are not captured.

## Modes

The site tells the game which mode it is in. Read it from `ready()`.

| Mode | Where | Mutations and scores | `track` and errors | `askForFeedback` |
| --- | --- | --- | --- | --- |
| `live` | The public game page | Checked and kept for signed-in players. Rads awarded. | Ignored | `{ opened: false }` |
| `preview` | Your draft at `/games/<slug>`, visible to you and admins | Checked, not kept. A "Test unlock" or "Test score" toast says so. | Ignored | `{ opened: false }` |
| `playtest` | A private `/t/<token>` link | Logged on the timeline, not checked, not kept. A test toast says so. | Logged | Opens the feedback panel for testers |

In `preview` and `playtest` the results are canned. `unlockMutation` returns `unlocked: true` every time and `submitScore` returns `isBest: true`, with the score as `best`. `rank` is `1` in preview and `null` in a playtest. In a playtest, `signedIn` reflects whether the tester is signed in. Don't build logic on these values while testing.

Standalone reports `mode: "live"` with `connected: false`. Test `connected`, not `mode`, to know whether the site is there.

## Testing

Outside the site, every call resolves with defaults and does nothing. Your game runs the same code path everywhere.

| Where | What you get |
| --- | --- |
| Local dev, any other host | `connected: false`. `unlockMutation` gives `{ signedIn: false, unlocked: false }`. `submitScore` gives `{ signedIn: false, isBest: false, best: null, rank: null }`. `askForFeedback` gives `{ opened: false }`. |
| A draft on the site | Preview mode. Upload the build to `/dev/<slug>` and open the game page. Calls are checked against the mutations and boards registered from your uploaded builds. |
| A playtest link | Playtest mode. `track` calls and errors appear on the tester's timeline in `/dev/<slug>/feedback`. |

- In preview, a successful call shows a toast. If no toast appears, the site rejected the call: an unknown key or board, a bad or too-large score, or the rate limit. The SDK returns the defaults and no error text.
- Your own plays on your playtest link don't create sessions, so nothing reaches the timeline. Open the link in a private window as a guest, or from another account, to see events and errors. See [Playtests](/docs/playtests).

## Patterns

### Unlock on events

Keep a local record so each event calls the SDK once. Every call is a round trip and counts against the [rate limit](#how-it-talks-to-the-site).

```ts
const unlocked = new Set<string>();

function unlock(key: string) {
  if (unlocked.has(key)) return;
  unlocked.add(key);
  void fradiation.unlockMutation(key);
}

let kills = 0;
function onDroneDestroyed() {
  kills += 1;
  if (kills === 1) unlock("first-blood");
}
```

### Submit a time score

Declare the board with `"order": "asc"` and `"format": "time"`. Submit whole milliseconds.

```ts
let runMs = 0;
let paused = false;

function update(dtMs: number) {
  if (!paused) runMs += dtMs;
}

async function onGameCleared() {
  const { isBest, rank } = await fradiation.submitScore("fastest-clear", Math.round(runMs));
  if (isBest) console.log(`Personal best. Rank ${rank}.`);
}
```

### Ask for feedback at game over

```ts
function onGameOver() {
  showGameOverScreen();
  void fradiation.askForFeedback("How was that last section?");
}
```

Outside playtests it does nothing, so you don't need to check the mode.

### Handle signed-out players

Anonymous players are normal. Don't block the game on the site and don't show a sign-in wall of your own. Draw your own results first, then use the reply when it arrives.

```ts
async function finishRun(ms: number) {
  showResults(ms);
  const result = await fradiation.submitScore("fastest-clear", ms);
  if (result.signedIn && result.isBest) showBest(result.best, result.rank);
}
```

The site tells anonymous players once per page load that their progress isn't kept.

## Engines

- **Unity:** the Unity kit wraps this SDK in a C# API and a WebGL template. See [Unity](/docs/unity).
- **Godot:** see [Godot](/docs/godot).
- **Three.js and plain web:** see [Three.js and web](/docs/web).

## How it talks to the site

- The game sends one `hello` message to its parent window. It carries the SDK version and nothing sensitive.
- The site checks that the message came from the game's own frame and from the game's own origin, `https://<slug>.containment.cloud`. It then answers with a private `MessageChannel` port. Every later message goes over that port.
- The SDK accepts the answer only from its direct parent, and only if that parent is `https://www.fradiation.games`, `https://fradiation.games` or `http://localhost:3000`. The SDK ignores any other parent, and every call resolves with defaults.
- The game runs in a sandboxed iframe on its own origin. It can't read the site's page, cookies or session, and it never handles a token. This channel is the only way the game reaches the site.
- Rate limits apply per game frame. Per 10 seconds: 30 calls (`unlockMutation`, `submitScore`, `askForFeedback`) and 60 events (`track` and errors). Calls over the limit resolve with defaults. Events over the limit are dropped.
- A call with no reply after 10 seconds resolves with defaults.
