Mystic docs

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

  1. A ScriptGame: the game's verbs and how they act on its world.
  2. A ScriptHost built from it: one Lua state per agent, each run a coroutine.
  3. A GameHost for the Gateway: what the tool layer reads.
  4. 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

FieldPurpose
verbsVerbSpecs: 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, settleAn 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, unknownNameNames, only among what the agent knows; the same line for unknown and nonexistent
preludeThe helper library, plain Lua
refusedWords agents can't use, with what to say instead
countHintThe 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.