worldsmith
v0.2.0
Published
AI game engine — LLM-powered plan generation, persistent entity state, vector memory
Downloads
29
Readme
Worldsmith
AI game engine library — LLM-powered plan generation, schema-driven entity mutation, persistent state, vector memory.
Your game owns the data, the mutations, and the rendering. Worldsmith owns the LLM orchestration, plan execution, and memory.
Install
npm install worldsmithQuickstart
Scaffold a new game:
npx worldsmith initThis creates a starter project with a .env.example, prompt templates, and example content.
Or wire it up manually:
import "dotenv/config";
import { createEngine } from "worldsmith";
const engine = createEngine({
llm: { provider: "openai", model: "gpt-4.1", apiKey: process.env.OPENAI_API_KEY },
});
// Register callbacks — the engine delegates all game logic to you
engine.onMutate((entityId, property, op, value) => {
// Apply mutation to your entity store
});
engine.onRoll((entityId, stat, difficulty) => {
// Return { outcome: string, roll: number, margin: number }
// outcome can be any string: "success", "failure", "critical", "fumble", etc.
});
engine.onGetEntityLocation((entityId) => {
// Return the location ID for this entity
});
// Handle a player action
const result = await engine.handleAction("player-1", "attack the goblin", ["goblin-3"]);
console.log(result.description);How It Works
- A player performs an action (e.g. "buy the sword", "pick the lock")
- The engine sends game context + action to an LLM
- The LLM returns a plan — a JSON array of typed step primitives
- The engine executes the plan step by step, delegating each step to your registered callbacks
- Mutations are batched into transactions; remember/emit steps are deferred until after commit
The 8 Primitives
Every plan the LLM generates is composed of these step types:
| Step | Purpose | Callback |
|------|---------|----------|
| mutate | Change entity state (set, add, subtract, append, remove). Supports $ref for runtime values. | onMutate |
| roll | Stat check with arbitrary named outcome branches | onRoll |
| resolve | Delegate to game logic with arbitrary named branches | onResolve |
| remember | Store a vector memory for an entity | onRemember |
| emit | Push an event to another entity's session | onEmit |
| do | Trigger a sub-action by another entity | onDo |
| move | Change an entity's location | onMove |
| for_each | Iterate over entities from a query, executing inner steps for each | onQueryEntities |
Example plan (LLM output)
{
"steps": [
{ "step": "roll", "entity": "player-1", "stat": "agility", "difficulty": 12,
"branches": {
"success": [
{ "step": "mutate", "entity": "goblin-3", "property": "hp", "op": "subtract", "value": 8 },
{ "step": "emit", "entity": "goblin-3", "event": {
"type": "attacked", "description": "A swift blade finds its mark.", "actor": "player-1"
}}
],
"failure": [
{ "step": "emit", "entity": "goblin-3", "event": {
"type": "attacked", "description": "The goblin sidesteps the clumsy swing.", "actor": "player-1"
}}
]
}
},
{ "step": "remember", "entity": "player-1", "text": "Fought the goblin in the east corridor" }
],
"result": {
"outcome": "depends_on_outcome",
"outcomes": {
"success": { "description": "Your blade strikes true." },
"failure": { "description": "The goblin dodges your attack." }
}
}
}Data flow with $ref
Mutation values can reference resolve/roll results at runtime instead of using LLM-guessed literals:
{
"step": "resolve", "type": "combat", "config": { "attacker": "player", "defender": "goblin" },
"branches": {
"hit": [
{ "step": "mutate", "entity": "goblin", "property": "hp", "op": "subtract",
"value": { "$ref": "resolve.data.damage" } }
]
}
}Supported paths: resolve.data.<key>, roll.outcome, roll.roll, roll.margin.
Loops with for_each
Apply steps to multiple entities without enumerating each one:
{
"step": "for_each",
"query": { "type": "crew_at_location", "config": { "location": "bridge" } },
"as": "member",
"steps": [
{ "step": "mutate", "entity": "{{ member }}", "property": "morale", "op": "subtract", "value": 10 }
]
}The onQueryEntities callback resolves the query to a list of entity IDs. Inner steps execute for each, with {{ member }} replaced.
Configuration
All configuration is done in TypeScript. Secrets come from environment variables via dotenv.
import "dotenv/config";
import { createEngine } from "worldsmith";
const engine = createEngine({
llm: {
provider: "openai", // openai | groq | chromagolem
model: "gpt-4.1",
apiKey: process.env.OPENAI_API_KEY, // or LLM_API_KEY
baseUrl: "https://api.openai.com/v1",
temperature: 0.4,
maxTokens: 1500,
},
prompts: {
system: "...", // inline system prompt content
systemFile: "./prompts/rules.md", // or load from file
},
memory: {
path: "./data/memories", // LanceDB path for entity memories
ledgerPath: "./data/ledger", // LanceDB path for world ledger
},
plan: { maxSteps: 30 }, // max steps per plan (default: 20)
dsl: { excludeSteps: ["move"] }, // composable DSL — omit step types, rules, or add custom ops
allowSelfEmit: false, // allow emit steps targeting the acting entity (default: false)
logger: false, // false = silent, omit = console, or pass custom logger
});Callbacks
Register callbacks to control how the engine interacts with your game:
// Required for mutations
engine.onMutate((entityId, property, op, value) => { });
// Required for roll steps — return any outcome string
engine.onRoll((entityId, stat, difficulty) => {
return { outcome: "success", roll: 15, margin: 3 };
// Or multi-outcome: { outcome: "critical", roll: 20, margin: 8 }
});
// Register resolve handlers by type
engine.onResolve("negotiation", async (type, config, context) => {
return { outcome: "accepted", data: { price: 50 } };
});
// Location changes
engine.onMove((entityId, destination) => {
return { success: true, newLocationId: "room-2", entityUpdates: {} };
});
// Event delivery to other entities
engine.onEmit((targetEntityId, event) => { });
// Memory storage (deferred, async)
engine.onRemember(async (entityId, text) => { });
// Sub-action delegation
engine.onDo(async (actor, action, targets, parentContext) => {
return engine.handleAction(actor, action, targets);
});
// Scope validation — throw ScopeError to reject a step
engine.onScopeCheck((step, actorId, actorLocationId) => { });
// Entity location lookup
engine.onGetEntityLocation((entityId) => "room-1");
// Notified when mutations change location-relevant state
engine.onLocationMutate((locationId) => { });
// Detect which mutations are location-relevant
engine.onIsLocationMutation((entityId, property) => property === "locationId");
// Wrap mutation batches in a transaction
engine.onTransaction((fn) => db.transaction(fn));
// Resolve entity queries for for_each loops
engine.onQueryEntities(async (type, config) => {
if (type === "crew_at_location") return db.getEntitiesAt(config.location as string);
return [];
});
// Build the user message the LLM sees for each action
engine.onBuildUserMessage(async (entityId, action, targets) => {
const entity = db.get(entityId);
return `Action: ${action}\nHP: ${entity.hp}\nInventory: ${entity.inventory.join(", ")}`;
});
// Await memory/ledger initialization before first use
await engine.ready();Tools
Register tools that the LLM can call during plan generation:
engine.registerTool(
{
type: "function",
function: {
name: "lookup_price",
description: "Get the price of an item",
parameters: {
type: "object",
properties: { item: { type: "string" } },
required: ["item"],
},
},
},
(args) => JSON.stringify({ price: 25 }),
);Memory & Ledger
Worldsmith includes a vector memory system backed by LanceDB with OpenAI embeddings.
Memory — per-entity semantic memories with automatic deduplication:
// Store with optional subject and tags
await engine.memory.store("player-1", "Found a secret door behind the waterfall", {
subject: "exploration",
tags: ["secret", "waterfall_cave"],
});
// Semantic search — returns { text, score, subject, tags, created_at }
const memories = await engine.memory.search("player-1", "hidden passages", 5);
// Recent memories sorted by time
const recent = await engine.memory.recent("player-1", 10);Ledger — world-level event log, searchable by semantic similarity:
await engine.ledger.store({ type: "quest", location: "tavern", description: "The barkeep offered a bounty" });
const events = await engine.ledger.search("tavern rumors", 5);Custom embedding provider — swap out OpenAI embeddings for any provider:
const engine = createEngine({
embeddingProvider: {
dimensions: 768,
embed: async (text) => myEmbeddingApi.embed(text),
embedBatch: async (texts) => myEmbeddingApi.embedBatch(texts),
},
});By default, embeddings use OpenAI text-embedding-3-small via OPENAI_API_KEY. If no provider and no key, memory features are silently disabled.
LLM Providers
| Provider | Config | Notes |
|----------|--------|-------|
| OpenAI | provider: "openai" | Default. Full tool-calling support. |
| Groq | provider: "groq" | OpenAI-compatible API. Fast inference. |
| ChromaGolem | provider: "chromagolem" | Custom API format. Requires CHROMAGOLEM_CLIENT_ID. |
Set credentials via llm.apiKey in config or LLM_API_KEY / OPENAI_API_KEY environment variables.
Architecture
Your Game
│
├── .env (API keys via dotenv)
├── prompts/rules.md (game rules injected into LLM system prompt)
└── game code (config, callbacks, entity store, rendering)
│
▼
┌──────────────────────────────────────────────┐
│ Worldsmith Engine │
│ │
│ Orchestrator ──► LLM ──► Plan (JSON) │
│ │ │ │
│ │ Executor loop │
│ │ mutate → roll → resolve → │
│ │ remember → emit → do → │
│ │ move → for_each │
│ │ │ │
│ Memory (LanceDB) Ledger (LanceDB) │
└──────────────────────────────────────────────┘
│
▼
Game callbacks (you implement these)Thin core, maximum control. The engine owns plan execution, LLM orchestration, and memory. Your game owns everything else — mutations, scope rules, entity storage, context assembly, and rendering.
License
ISC
