RYNX Labs
The AI brain for living game characters.
rynxlabs is a server-side, framework-agnostic TypeScript SDK for building stateful AI NPCs with persistent memory, explicit personality constraints, context-aware dialogue, and bounded action recommendations.
The SDK treats an AI model as a recommendation engine, never as an authority over game state. A model may propose dialogue, emotion, intent, memory updates, and one developer-approved action. It cannot execute gameplay code, mutate the world, grant rewards, start quests, or call game systems directly. Your application validates the recommendation and decides what happens.
Contents
Capabilities
-
Persistent, player-specific NPC memory
-
Declarative personality traits, goals, speaking styles, and behavioral boundaries
-
Context-aware dialogue based on location, quests, world state, and player progression
-
Structured emotion, intent, safety, and action output
-
Per-request action allowlists controlled by the game developer
-
Runtime request and response validation with Zod
-
OpenAI as the default provider through the official JavaScript SDK
-
Provider abstraction for future model vendors or self-hosted inference systems
-
Injectable memory and logging implementations
-
Typed operational errors for validation, provider, timeout, rate-limit, and response failures
-
Dependency-free integration helpers for server frameworks
-
Backend client examples for Unity, Godot, and Unreal Engine
-
ESM and CommonJS artifacts with generated TypeScript declarations
Technical principles
The model does not execute game actions
selectedAction is data. RYNX never maps an action ID to a function, dispatches an event, changes inventory, modifies player progression, or mutates game state. Execution remains an explicit application responsibility:
const response = await rynx.npc.respond(request);
if (response.safety.allowed && response.selectedAction?.id === "start_quest") {
const eligible = await questService.isEligible(request.player.id, "lost-signal");
if (eligible) {
await questService.start(request.player.id, "lost-signal");
}
}
Boundaries are layered
RYNX combines multiple controls instead of relying on a prompt alone:
-
Input schemas reject malformed configuration, profiles, contexts, and action definitions.
-
User-controlled strings are sanitized before prompt construction.
-
The system prompt marks game and player content as untrusted data.
-
The provider requests a strict JSON-schema response.
-
The returned payload is validated again with Zod.
-
selectedAction.idis checked against the request's runtime allowlist. -
Unsupported actions are removed and recorded as a safety failure.
-
Only validated memory updates are persisted.
Technology Stack
| Layer | Technology | Purpose |
|---|---|---|
| Language | TypeScript · strict mode | Public API types, provider contracts, and compile-time safety |
| Runtime | Node.js 18+ | Server-side execution with modern JavaScript APIs |
| Default AI Provider | Official openai SDK | Authentication, structured model requests, and provider error handling |
| Runtime Validation | Zod | Validates configuration, profiles, requests, actions, memories, and model output |
| Build System | tsup | ESM/CJS bundles, source maps, and type declarations |
| Compiler | TypeScript | Static type checks and declaration validation |
| Testing | Vitest | Offline unit and integration-style tests |
| Linting | ESLint + typescript-eslint | Type-aware static analysis |
| Formatting | Prettier | Consistent source and documentation formatting |
| Local Examples | dotenv | Environment-variable loading for server examples only |
| Persistence | Pluggable MemoryStore | No database dependency in the base package |
The runtime package has only two direct dependencies: openai and zod. Frameworks and databases are intentionally excluded from the core dependency graph.
Installation
npm i rynxlabs
Requirements:
-
Node.js 18 or newer
-
A modern TypeScript project or Node.js application
-
An OpenAI API key when using the default provider
-
A trusted backend runtime; never embed provider credentials in a browser or game binary
For local examples, copy .env.example to .env and set OPENAI_API_KEY. The .env file is ignored by Git; .env.example remains tracked.
Quick start
import { InMemoryMemoryStore, Rynx } from "rynxlabs";
const rynx = new Rynx({
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-5-mini",
timeoutMs: 30_000,
maxRetries: 2,
memoryStore: new InMemoryMemoryStore(),
});
const response = await rynx.npc.respond({
npc: {
id: "captain-ava",
name: "Captain Ava",
role: "Harbor commander",
personality: {
traits: ["disciplined", "protective", "observant"],
speakingStyle: "Direct, calm, and concise.",
goals: ["Protect the harbor", "Find the missing signal"],
boundaries: [
"Never reveal private military coordinates",
"Never promise rewards without player eligibility",
],
},
},
player: {
id: "player-42",
name: "Mika",
progression: {
level: 12,
completedQuests: ["first-arrival"],
reputation: "trusted",
},
},
context: {
location: "Harbor District",
quest: "lost-signal",
worldState: ["Storm approaching", "Signal tower offline"],
playerMessage: "Can you help me find the signal?",
},
allowedActions: [
{
id: "guide_player",
description: "Give the player a destination or next step",
},
{
id: "start_quest",
description: "Offer the Lost Signal quest",
},
{
id: "deny_request",
description: "Politely decline an unavailable request",
},
],
});
console.log(response.dialogue);
console.log(response.selectedAction);
Architecture and request lifecycle
Game Server
│
│ Rynx.npc.respond(request)
▼
┌─────────────────────────────────────────────┐
│ Zod Input Validation │
└─────────────────────────────────────────────┘
│
├── Invalid request → RynxValidationError
▼
┌─────────────────────────────────────────────┐
│ Load Memories │
│ npcId + playerId │
└─────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Sanitize Data & Build │
│ Constrained System Prompt │
└─────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ AIProvider.generate(...) │
│ OpenAI structured JSON by default │
└─────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Zod Response Validation │
└─────────────────────────────────────────────┘
│
├── Invalid output → RynxResponseError
▼
┌─────────────────────────────────────────────┐
│ Runtime Allowed-Action Enforcement │
│ Unsupported action → null + safety reason │
└─────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Persist Validated Memory Updates │
│ Skipped when memory is disabled │
└─────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Return Typed `NpcResponse` │
└─────────────────────────────────────────────┘
▼
Developer-Owned Authorization & Action Execution
The public Rynx client exposes two service boundaries:
class Rynx {
readonly npc: NpcService;
readonly memory: MemoryService;
}
NpcService owns profiles, prompt orchestration, provider calls, response validation, safety enforcement, and automatic memory persistence. MemoryService exposes the configured store without coupling application code to a specific database.
Response contract
Every successful respond() call returns a validated NpcResponse:
type NpcResponse = {
dialogue: string;
emotion: {
label: string;
intensity: number; // 0..1
};
intent: string;
selectedAction: {
id: string;
reason: string;
confidence: number; // 0..1
} | null;
memoryUpdates: Array<{
type: "fact" | "relationship" | "event" | "preference";
content: string;
importance: number; // 0..1
}>;
safety: {
allowed: boolean;
reasons: string[];
};
metadata: {
model: string;
requestId: string;
latencyMs: number;
};
};
Example:
{
dialogue: "The signal tower is north. Stay on the marked path.",
emotion: { label: "concerned", intensity: 0.6 },
intent: "guide the player safely",
selectedAction: {
id: "guide_player",
reason: "The player requested a safe route.",
confidence: 0.95
},
memoryUpdates: [
{
type: "event",
content: "Mika asked Captain Ava about the missing signal.",
importance: 0.7
}
],
safety: { allowed: true, reasons: [] },
metadata: {
model: "gpt-5-mini",
requestId: "req_7d3155d0-...",
latencyMs: 850
}
}
metadata.latencyMs covers SDK orchestration from request handling through validated response construction. requestId is safe to use for application-level tracing.
NPC profiles
Profiles may be supplied inline to respond() or managed through the in-process profile API:
const profile = rynx.npc.createProfile({
id: "captain-ava",
name: "Captain Ava",
role: "Harbor commander",
personality: {
traits: ["disciplined", "protective"],
speakingStyle: "Direct and calm.",
goals: ["Protect the harbor"],
boundaries: ["Never reveal military coordinates"],
},
});
const current = rynx.npc.getProfile("captain-ava");
const updated = rynx.npc.updateProfile("captain-ava", {
role: "Acting admiral",
personality: {
speakingStyle: "Formal, direct, and concise.",
},
});
The built-in profile registry is process-local. Persist canonical profile definitions in your own content system or database if they must survive restarts or be shared across server instances.
Memory system
Memory is scoped by the (npcId, playerId) pair. Valid model-generated updates are persisted after response validation unless persistMemory: false is set.
const memories = await rynx.memory.getMemories("captain-ava", "player-42");
await rynx.memory.addMemories("captain-ava", "player-42", [
{
type: "preference",
content: "Mika prefers direct answers.",
importance: 0.8,
},
]);
await rynx.memory.clearMemories("captain-ava", "player-42");
await rynx.npc.respond({
...request,
persistMemory: false,
});
Each stored record contains:
interface MemoryRecord {
id: string;
npcId: string;
playerId: string;
type: "fact" | "relationship" | "event" | "preference";
content: string;
importance: number;
createdAt: Date;
}
Custom memory store
Implement MemoryStore to connect PostgreSQL, Redis, DynamoDB, an embedding index, or another durable system:
import type { MemoryInput, MemoryRecord, MemoryStore } from "rynxlabs";
class DatabaseMemoryStore implements MemoryStore {
async getMemories(npcId: string, playerId: string, limit = 20): Promise<MemoryRecord[]> {
return database.memories.findRelevant({ npcId, playerId, limit });
}
async addMemories(
npcId: string,
playerId: string,
memories: readonly MemoryInput[],
): Promise<MemoryRecord[]> {
return database.memories.insertMany({ npcId, playerId, memories });
}
async clearMemories(npcId: string, playerId: string): Promise<void> {
await database.memories.deleteMany({ npcId, playerId });
}
}
The included InMemoryMemoryStore is intended for development, testing, prototypes, and single-process games. It prioritizes importance and then recency. It is not durable and is not shared across processes.
Allowed actions and safety
An allowed action has a stable machine-readable ID and a model-facing description:
const allowedActions = [
{
id: "guide_player",
description: "Give the player a destination or next step",
},
{
id: "deny_request",
description: "Politely decline a request that is unavailable or unsafe",
},
] as const;
Treat the response as a proposal and independently enforce domain rules:
switch (response.selectedAction?.id) {
case "guide_player":
navigationUi.showObjective("signal-tower");
break;
case "deny_request":
case undefined:
break;
default:
// Defensive application-level fallback.
logger.warn("Unhandled NPC recommendation", {
requestId: response.metadata.requestId,
});
}
If a provider returns launch_missile when only guide_player and deny_request are allowed, the SDK returns:
{
selectedAction: null,
safety: {
allowed: false,
reasons: ["Unsupported action 'launch_missile' was removed."]
}
}
This behavior prevents model output from expanding its own authority. It does not replace authentication, player eligibility checks, server-side authorization, cooldown validation, anti-cheat systems, or transaction controls.
Provider architecture
OpenAI is used when no custom provider is supplied. The built-in adapter:
-
Uses the official OpenAI JavaScript SDK
-
Requests structured JSON-schema output
-
Disables SDK-internal retries and applies the configured RYNX retry policy
-
Retries eligible rate-limit, connection, and server failures
-
Maps timeout and rate-limit failures into typed RYNX errors
-
Avoids returning credentials in response objects
Custom provider
Implement AIProvider to add another inference service without changing game-facing SDK calls:
import type { AIProvider, AIProviderRequest, AIProviderResponse } from "rynxlabs";
class StudioInferenceProvider implements AIProvider {
async generate(request: AIProviderRequest): Promise<AIProviderResponse> {
const result = await studioModel.generate({
system: request.systemPrompt,
requestId: request.requestId,
});
return {
content: result.structuredOutput,
model: result.modelVersion,
};
}
}
const rynx = new Rynx({
// Configuration validation still requires a non-empty key. Custom providers
// should receive and manage their own credentials outside returned objects.
apiKey: process.env.STUDIO_PROVIDER_KEY!,
provider: new StudioInferenceProvider(),
});
Provider output is always treated as unknown and must pass the same Zod response validation and action allowlist enforcement as OpenAI output.
Validation and error model
Public Zod schemas are exported for validating incoming HTTP bodies before invoking the SDK:
import { NpcRespondRequestSchema } from "rynxlabs";
const parsed = NpcRespondRequestSchema.safeParse(untrustedBody);
if (!parsed.success) {
return {
status: 400,
body: { error: "Invalid NPC request", issues: parsed.error.issues },
};
}
const response = await rynx.npc.respond(parsed.data);
Typed errors
| Error | Meaning | Typical handling |
| ------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------- |
| RynxConfigurationError | Invalid API key, model, URL, timeout, retry count, or injectable dependency | Fail during application startup |
| RynxValidationError | Invalid profile, request, action, or profile patch | Return a client-safe 400 response |
| RynxProviderError | Provider transport, API, or malformed JSON failure | Log request ID and return a temporary service error |
| RynxTimeoutError | Provider request exceeded its timeout | Retry at the application boundary when appropriate |
| RynxRateLimitError | Provider rate limit was reached | Apply backoff, queueing, or load shedding |
| RynxResponseError | Provider output failed the strict RYNX response contract | Do not execute or persist the output |
import {
RynxProviderError,
RynxRateLimitError,
RynxResponseError,
RynxTimeoutError,
RynxValidationError,
} from "rynxlabs";
try {
const response = await rynx.npc.respond(request);
return response;
} catch (error) {
if (error instanceof RynxValidationError) {
logger.info("Rejected NPC request", { issues: error.issues });
} else if (error instanceof RynxRateLimitError) {
logger.warn("NPC provider rate limited");
} else if (error instanceof RynxTimeoutError) {
logger.warn("NPC provider timed out");
} else if (error instanceof RynxResponseError) {
logger.error("NPC provider returned an invalid contract");
} else if (error instanceof RynxProviderError) {
logger.error("NPC provider request failed");
}
throw error;
}
Do not serialize raw error causes to untrusted clients. Provider causes may include operational metadata even though the SDK uses key-safe public messages.
Configuration reference
type RynxConfig = {
apiKey: string;
model?: string;
timeoutMs?: number;
maxRetries?: number;
memoryStore?: MemoryStore;
logger?: Logger;
baseUrl?: string;
provider?: AIProvider;
};
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | Required | Server-side provider credential. Must be non-empty. |
model | string | gpt-5-mini | Model used by the default OpenAI provider. |
timeoutMs | Positive integer | 30000 | Provider request timeout, in milliseconds. |
maxRetries | Integer (0–10) | 2 | Retries for eligible transient provider failures. |
memoryStore | MemoryStore | InMemoryMemoryStore | Memory persistence and retrieval implementation. |
logger | Logger | Silent logger | Structured debug, info, warn, and error sink. |
baseUrl | Absolute URL | OpenAI default | Optional OpenAI-compatible API endpoint. |
provider | AIProvider | OpenAIProvider | Custom AI provider implementation. |
Logger contract
interface Logger {
debug(message: string, context?: Readonly<Record<string, unknown>>): void;
info(message: string, context?: Readonly<Record<string, unknown>>): void;
warn(message: string, context?: Readonly<Record<string, unknown>>): void;
error(message: string, context?: Readonly<Record<string, unknown>>): void;
}
Logger contexts contain safe SDK metadata such as request IDs and rejected action IDs. Never add provider credentials to custom logger wrappers.
Integrations
Express
See the complete Express server example. The endpoint validates the request before contacting the provider:
import express from "express";
import { NpcRespondRequestSchema, Rynx } from "rynxlabs";
const app = express();
const rynx = new Rynx({ apiKey: process.env.OPENAI_API_KEY! });
app.use(express.json({ limit: "64kb" }));
app.post("/api/npc/respond", async (req, res) => {
const parsed = NpcRespondRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid request", issues: parsed.error.issues });
return;
}
try {
res.json(await rynx.npc.respond(parsed.data));
} catch {
res.status(502).json({ error: "NPC provider request failed" });
}
});
createExpressNpcHandler() is also exported as a lightweight Express-compatible adapter without making Express a runtime dependency.
Next.js App Router
See the Next.js route example. Place it at app/api/npc/respond/route.ts, import server-only, and read OPENAI_API_KEY exclusively from the server environment. Browser code should call this route, never OpenAI directly.
Unity
The Unity example uses UnityWebRequest to call the developer's authenticated backend endpoint. Do not place an OpenAI key in C# source, PlayerPrefs, Resources, asset bundles, or build-time client configuration.
Godot
The Godot example uses HTTPRequest and JSON to call the backend. Exported games must not contain provider credentials.
Unreal Engine
The Unreal example uses the HTTP module from C++. Add HTTP to module dependencies and send requests only to your trusted backend.
Security and production hardening
RYNX constrains model output, but production deployments should also provide:
-
Authentication for every NPC endpoint
-
Authorization binding the authenticated account to
player.id -
Request-body size limits
-
Per-player, per-IP, and global rate limits
-
Timeouts and concurrency limits at the reverse proxy and application layers
-
Input moderation appropriate to the game's audience
-
Server-derived progression and eligibility data instead of client-asserted authority
-
Idempotency and transactional checks for consequential game operations
-
Audit logs keyed by
metadata.requestId -
Memory retention, deletion, privacy, and regional storage policies
-
Encryption and secret management for provider credentials
-
Output escaping appropriate to the rendering target
-
Monitoring for elevated validation failures, unsupported action attempts, and provider errors
Prompt injection defenses reduce risk but cannot turn arbitrary model output into trusted authorization. The final authority must remain deterministic server-side game code.
Package formats and exports
The package is built with tsup and publishes only the compiled distribution, README, license, and package metadata.
dist/index.js ESM entry
dist/index.cjs CommonJS entry
dist/index.d.ts ESM TypeScript declarations
dist/index.d.cts CommonJS TypeScript declarations
*.map Source maps
The package export map supports both module systems:
// ESM
import { Rynx } from "rynxlabs";
// CommonJS
const { Rynx } = require("rynxlabs");
Development and testing
npm install
npm run typecheck
npm run lint
npm test
npm run build
npm run format:check
Additional scripts:
| Command | Purpose |
| ------------------------ | ------------------------------------------------------ |
| npm run dev | Rebuild in watch mode |
| npm run test:watch | Run Vitest interactively |
| npm run format | Apply Prettier formatting |
| npm run prepublishOnly | Run typecheck, lint, tests, and build before packaging |
Tests inject mock providers and make no outbound OpenAI requests. The suite covers configuration defaults and failures, typed response validation, invalid output rejection, unsupported action removal, memory persistence and opt-out, provider error propagation, prompt-injection resistance, and server-side key usage in integration examples.
Project structure
rynxlabs/
src/
index.ts Public exports
client.ts Rynx composition root
config.ts Configuration schema and defaults
errors.ts Typed error hierarchy
schemas.ts Public schema exports
types.ts Public type exports
providers/
types.ts Provider interfaces
openai-provider.ts Official OpenAI adapter
npc/
npc-service.ts Response orchestration and NPC profiles
prompts.ts Constrained prompt construction
schemas.ts NPC request and response schemas
types.ts NPC domain types
memory/
types.ts Memory contracts
memory-store.ts Public memory facade
in-memory-store.ts Volatile reference implementation
integrations/ Dependency-free integration helpers
utils/ IDs, sanitization, retry, and logging
tests/ Offline Vitest suite and fixtures
examples/
basic-node/
express-server/
nextjs-api-route/
unity/
godot/
unreal/
License
MIT - Copyright 2026 RYNX Labs.
