Documentation

Smart NPCs developer docs

Integrate the hosted NPC brain in an afternoon: one import, a public key, and a behaviour enum your game loop already understands.

Quickstart

Five steps from zero to a reactive NPC. If anything misbehaves, each step links to the fix.

  1. Install the client.
    terminal
    npm install @npc/client
  2. Copy your public key. Open Studio → Overview and copy the game's pk_live_… key. Public keys are safe in browser code; the sk_live_… secret is shown once at creation and must never leave your server (see Keys & Origins).
  3. Create a character. In Studio → Characters, add a slug such as smith with a persona and allowed behaviours. The slug is the id you pass to npc() — a typo silently spawns a blank default character (why).
  4. Allow your origin. In Studio → My Games, add every origin that serves your game (http://localhost:5173 for dev, https://yourgame.com for prod). Browsers send an Origin header; anything not on the list gets FORBIDDEN_ORIGIN (fix).
  5. Paste the snippet and fire an event. Then verify it in Studio → Test console and Studio → Ops → Interaction Transcripts — every exchange lands there with its behaviour, latency, and turn id.
main.js
import { npc } from '@npc/client';

// 1. Public key + character slug — no secrets in the browser.
const smith = npc({
  key: 'pk_live_your_game_key',
  id: 'smith',
});

// 2. Report events, switch on the guaranteed behaviour enum.
async function onPlayerInteract(event, worldState = {}) {
  // react() NEVER throws. It always returns a behaviour.
  const reaction = await smith.react(event, worldState);

  switch (reaction.behaviour) {
    case 'greet':      npcSprite.play('wave'); break;
    case 'warn':       npcSprite.play('angry'); break;
    case 'offer_deal': npcSprite.play('trade'); break;
    default:           npcSprite.play('idle'); break;
  }

  if (reaction.say) {
    showDialogueBubble(reaction.say);
  }
}

// 3. Fire-and-forget memory (returns immediately, never throws).
smith.observe('player completed the dragon cavern quest');

Client SDK reference

The @npc/client surface is four methods. Every one is safe to call from a render loop: nothing throws, ever — failures degrade to your fallback instead of crashing the frame.

npc(options)

OptionRequiredMeaning
keyyesGame public key (pk_live_…).
idyesCharacter slug from the registry (e.g. smith).
endpointnoCustom worker URL. Defaults to the hosted API.
fallbacknoOffline behaviour: a behaviour string or { behaviour, say }. Used when the server is unreachable, the request times out, or the spend cap blocks.
fallbackBehaviournoShorthand when you only need a behaviour (default idle).
timeoutMsnoPer-request timeout, default 4000ms.

react(event, world?, options?) → NpcReaction

The primary call: describe what happened, optionally attach world state, and get back a behaviour enum plus an optional dialogue line. Never throws.

FieldMeaning
behaviourAlways present. One of the character's allowed behaviours (or your fallback).
sayDialogue line, or null when the NPC stays silent (why null).
actionStructured game action ({ name, args }) or null.
moodCurrent mood tag or null.
cachedtrue when served from cache — still counts as a billed event.
degraded0 is full quality; higher means a fallback path answered (3 = server degraded, 5 = client offline fallback).
degradedReasonMachine-readable cause (spend_cap_degraded, client_fallback, …). See troubleshooting.
turnIdUnique id for this exchange — quote it in support requests. trn_offline means the request never reached the server.
worldTrustedtrue only when a valid HMAC attested the world state.
usage{ in, out, costMicros, currency } token/cost accounting when available.

say(text, world?, options?) → NpcDialogue

Conversation call: the player says a line, the NPC answers in character. Same guarantees as react (never throws, same fallback semantics); the result carries text instead of behaviour/say.

observe(event) → void

Fire-and-forget memory write. Returns immediately; delivery failures are swallowed because observations must never break gameplay.

forget() → Promise<boolean>

Erases this player's relationship and memory with the character (your GDPR/COPPA “forget me” button) and drops the local player id, so the next call starts a fresh stranger. Resolves true on success,false otherwise — never throws.

Players, sessions & trust

  • Player identity. The SDK mints a plr_… id and persists it in localStorage (npc_player_id). Memory, disposition, and revocation all key off it — clearing site data starts a fresh stranger.
  • Session tokens. The client exchanges the public key for a short-lived session token (15 minutes). An expired token returns 401, which the SDK refreshes and retries once, transparently.
  • Duplicate suppression. The identical event + world state within 3 seconds returns the previous response without a network call — safe to fire from overlap handlers every frame.
  • Idempotency. Every request carries an idempotency key, so retried calls (including the 401 refresh above) replay the original response instead of executing twice.
  • World attestation (optional). If your server holds the game secret, it can HMAC-sign the world state; the browser forwards the signature via worldSignature and the response reports worldTrusted: true. Unsigned worlds work fine — they are just advisory, so never gate prizes or purchases on unattested state.

Keys & origins

KeyLives inRules
pk_live_…Browser / game clientPublic. Paste it into npc(). Always visible in Studio → Overview.
sk_live_…Your server onlySecret. Shown once at creation, never again. Signs world attestations. Leaked? Rotate in Settings → Keys — rotation revokes the previous secret immediately.

Origin allowlist

Browsers attach an Origin header, and the worker checks it against the game's allowlist (Studio → My Games → origins) before doing any work:

  • Exact match: https://yourgame.com (scheme and port matter).
  • Prefix match: a trailing * matches a URL prefix — https://yourgame.com/* covers every path on that host.
  • Allow all: a lone * skips the check. Fine for local dev, never for production.
  • No header, no check: native apps and curl send no Origin, so they pass. The allowlist stops browser hotlinking, not determined attackers — pair it with world attestation for anything valuable.

Content rating

Each game carries an E / T / M rating that scopes moderation strictness. Pick the lowest rating your content actually needs — stricter ratings reject more edge-case dialogue.

Limits & quotas

Hard numbers, so you can design around them instead of discovering them at 2am.

LimitValueWhen exceeded
Player rate (react)60 / minute · 2000 / hour429 RATE_LIMITED → client fallback (fix)
Player rate (say)30 / minute · 1000 / hour429 RATE_LIMITED → client fallback
Game rate10,000 / minute (react) · 5,000 / minute (say)429 RATE_LIMITED across all players
Session rate60 / minute per IP429 RATE_LIMITED on session mint
Request body4 KB (react/say) · 1 KB (session)Rejected before handling — keep world state small
TimeoutsWorker 2.5s · client 4s defaultSlow calls fall back (fix); raise timeoutMs on slow networks
Monthly eventsHobby 5k · PAYG metered · Pro 100k · Studio 1MTracked on the dashboard meter (PAYG bills per event); hard enforcement is the spend cap below
Hard spend capPer game, enforced pre-requestdegrade: idle fallback · block: 402 SPEND_CAP_EXCEEDED (fix)

Troubleshooting

Every failure the client can surface, mapped to its cause and fix. Start here before support — and when you do contact support, quote the turnId from the failing response.

403 FORBIDDEN_ORIGIN — game loads, every call fails

Cause: the page's origin is not on the game's allowlist.

Fix: Studio → My Games → edit the game and add the exact origin from the error message — scheme and port included (http://localhost:5173 ≠ http://localhost:3000). Use a trailing-* prefix entry to cover paths, or * while developing locally.

402 SPEND_CAP_EXCEEDED / spend_cap_degraded — NPCs went idle

Cause: the game's hard spend cap was reached. With onExceed: degrade the worker answers idle with reason spend_cap_degraded; with block it returns 402 and the client serves your configured fallback.

Fix: check Studio → Overview → Hard Spend Cap for the burn rate, then raise the cap (Hobby ≤ $10, PAYG ≤ $5, Pro/Studio configurable) or upgrade the plan. No surprise bills either way — that is what the cap is for.

429 RATE_LIMITED — bursts of fallback responses

Cause: over budget for one player — 60/minute for react, 30/minute for say (or the hourly and game-wide caps in Limits). Overlap handlers firing every frame are the usual suspect.

Fix: debounce event reports (the 3-second duplicate suppression already absorbs identical repeats), batch world updates, and move per-frame checks behind a distance or cooldown gate.

403 PLAYER_REVOKED — one player always fails

Cause: the player id was revoked in Studio → Ops. Revocation has no undo: that plr_… identity is permanently refused.

Fix: if the revocation was a mistake, the player needs a fresh identity (clear site data to mint a new npc_player_id). Reserve revocation for cheaters and abuse.

client_fallback / trn_offline — fallback on every call

Cause: the request never got a usable server answer: offline network, a 4-second client timeout on a slow connection, a blocked spend cap, or a wrong endpoint.

Fix: check connectivity and the endpoint URL, raise timeoutMs for slow networks, and rule out the spend cap above. turnId: trn_offline specifically means “never reached the server” — start with the network, not the NPC config.

moderation_input_blocked — specific events always degrade

Cause: the event text tripped the input safety filter for the game's rating.

Fix: rephrase the event (describe mechanics, not gore), or check the game rating fits the content. The filter sees the raw event string — log it to find the trigger.

global_kill_switch — everything idle, all games

Cause: a platform-level degradation is active (incident response, not your code).

Fix: check the API status page, keep your fallback dialogue graceful, and wait it out. Your quota and caps are unaffected — degraded answers cost nothing.

rewrite_failed — behaviour right, dialogue missing

Cause: the behaviour classifier answered but the dialogue rewrite step failed.

Fix: retry the call (it is idempotent), simplify the persona's speak constraints, and quote the turnId if it persists — that one is on us to investigate.

say: null — NPC acts but never talks

Cause: usually configuration, not failure: the character's speak setting is never, or sometimes rolled silence for this turn.

Fix: set speak: always on the character while debugging, and always null-check say before showing a bubble (the quickstart snippet does).

Wrong personality — a gruff blacksmith answers for every slug

Cause: unknown character slugs auto-create a placeholder dwarf (persona, moods, and all) instead of erroring — a dev convenience that turns typos into confusing NPCs.

Fix: verify the slug in Studio → Characters matches the id in your code exactly, then delete or repurpose the accidental placeholder.

cached: true on everything — stale reactions?

Cause: usually the 3-second client duplicate suppression (identical event + world), or the server behaviour cache for repeated situations. Both are working as designed.

Fix: vary the world state between calls while testing. Note cached reactions still count as billed events — the meter counts exchanges, not inference runs.

FAQ

Do cached or degraded calls cost me money?

Cached calls count as events (they still hit the meter) but skip inference, so they burn quota without burning spend. Fully degraded answers (spend_cap_degraded, kill switch, client fallback) record zero usage cost.

Quota vs spend cap — which one stops my game?

The spend cap. Monthly quotas are the plan meter you watch on the dashboard; the per-game hard spend cap is what the worker enforces pre-request. Set caps per environment: tiny for dev games, real for production.

How do I handle “delete my data” requests?

Call forget() — it erases the player's memory and relationship with that character. Repeat per character the player met.

Can I test without spending quota?

The Test console and scorecard run the live player path, so they count like production traffic — deliberately, so load and cost match reality. Keep a small spend cap on dev games and the bill stays near zero.

Where do I get help?

Hobby and PAYG: community Discord. Pro: priority developer support. Studio: dedicated engineering bridge. Every tier gets faster answers with a turnId and the failing event text.

Does the client work outside the browser?

Yes — Node, native shells, and game engines can call the same endpoints. Non-browser clients send no Origin header (so the allowlist passes them) and get a random player id per process unless you persist one.