Writing a game on mystic-sdk
mystic-sdk holds everything an agent touches: the five tools, the Lua host, the checker, the reply formats, autonomy and usage. A game supplies its verbs, its rules and its content. Mystic is the flagship game, but the SDK imports nothing from it, and a test holds it to that.
It depends on zod, the MCP SDK, wasmoon (Lua 5.4 in WebAssembly) and luaparse (for the checker).
The parts
- A
ScriptGame: the game's verbs and how they act on its world. - A
ScriptHostbuilt from it: one Lua state per agent, each run a coroutine. - A
GameHostfor theGateway: what the tool layer reads. - Optionally
WorldScripts: the game's own content Lua, on triggers.
Each tick, call scripts.tick(agent) for each agent, then gateway.onTick(). startTickLoop(tickMs, step) drives that, and handleMcp serves the tools.
ScriptGame
| Field | Purpose |
|---|---|
verbs | VerbSpecs: name, action or sense, argument specs (name of a kind, count, number, text), the count's default |
start(c, verb, args) | Begins an action: { done: outcome } at once, or { active } to be stepped |
step(c, active) | One tick: the outcome when it ends, null while it goes on |
resume, cancel, settle | An action carries on after an on() handler; is dropped; has just ended |
sense(c, verb, args) | A sensing call; anything JSON can say, null for unknown |
me(c, field, key) | A field of me, or a key of one of meMaps |
resolve, known, unknownName | Names, only among what the agent knows; the same line for unknown and nonexistent |
prelude | The helper library, plain Lua |
refused | Words agents can't use, with what to say instead |
countHint | The loop to suggest when a condition was passed as a count |
The SDK supplies wait, say, stop, alert, on, run, print and mem itself, the sandbox, the budget, the guards, the checker and the error format. Outcomes are { ok, why, n }; why is any short word the game picks.
A whole game
A second, unrelated game, in full: miners hauling gold. It's the SDK's own test game, so it can't drift.
// The example on the docs page "Writing a game", word for word: the page includes this file and a test plays it.
import { formatStateLine, Gateway, newAgentState, ScriptHost, type AgentState, type GameHost, type Outcome, type ScriptGame } from "mystic-sdk";
// What a player is, and what a dig in progress remembers.
export interface Miner extends AgentState { gold: number }
type Active = { left: number; timer: number; done: number };
export function createGame() {
let tick = 0;
const miners: Record<string, Miner> = { max: { ...newAgentState("max", "Max"), gold: 0 } };
// The game's words. Lua, the sandbox, budgets, errors and `mem` come from the SDK.
const game: ScriptGame<Miner, Active> = {
verbs: [
{ name: "dig", kind: "action", args: [{ name: "n", type: "count" }], countDefault: 1, desc: "Digs n gold, two seconds each." },
{ name: "drop", kind: "action", args: [{ name: "n", type: "count" }], countDefault: 1, desc: "Drops n gold." },
],
meMaps: [],
meFields: ["gold"],
me: (c, field) => (field === "gold" ? c.gold : undefined),
sense: () => null,
// How actions touch the world: done at once, or a state stepped each tick.
start(c, verb, args): { done: Outcome } | { active: Active } {
const n = args[0] === "all" ? (verb === "drop" ? c.gold : 99) : Number(args[0]);
if (verb === "drop") {
if (c.gold < n) return { done: { ok: false, why: "short", n: 0 } };
c.gold -= n;
return { done: { ok: true, why: null, n } };
}
return { active: { left: n, timer: 2, done: 0 } };
},
step(c, a) {
if (--a.timer > 0) return null;
a.timer = 2; c.gold++; a.done++;
return --a.left > 0 ? null : { ok: true, why: null, n: a.done };
},
label: () => "digging",
resolve: () => null,
known: () => [],
unknownName: (kind, raw) => `no ${kind} you know called '${raw}'`,
prelude: "function must(ok, why) if not ok then stop(why) end return ok end\n",
ticksPerSecond: 1,
tick: () => tick,
stamp: () => String(tick),
seed: () => 1,
};
const scripts = new ScriptHost(game, (id) => miners[id]);
// What the five tools need from the game.
const host: GameHost<Miner> = {
scripts,
ticksPerMinute: 60,
currentTick: () => tick,
agent: (id) => miners[id],
agents: () => Object.values(miners),
statusLine: (c) => formatStateLine({ time: String(tick), where: "mine", fields: [`gold ${c.gold}`], activity: scripts.activity(c) }),
drainDigest: () => [],
ask: () => "Nothing you know of.",
journal: (c) => [`Gold: ${c.gold}`],
sameLocation: () => true,
};
const gateway = new Gateway<Miner>(host);
// One world tick: every agent's script, then anyone waiting on `run`.
const step = () => { tick++; for (const c of host.agents()) scripts.tick(c); gateway.onTick(); };
return { miners, gateway, step };
}
Contract
CONTRACT_VERSION is 4.2.0. The tool list and its schemas are pinned by a hash in the SDK's tests: a change there is a contract change and bumps the version. The card is assembled by buildCard({ intro, verbs, game, shareTo }) around the contract's own lines.