Sign In

antics-mcp

Package Overview
Dependencies
Maintainers
1
Versions
20
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

antics-mcp - npm Package Compare versions

Comparing version
0.1.9
to
0.2.0
+294
-14
dist/server.js

@@ -35,2 +35,68 @@ #!/usr/bin/env node

// src/local-capture.ts
import { runCapture } from "antics-harness";
var CHROMIUM_ARGS = [
"--enable-unsafe-swiftshader",
"--use-gl=angle",
"--use-angle=swiftshader",
"--ignore-gpu-blocklist",
"--no-sandbox"
];
async function resolveChromium() {
const pref = process.env["ANTICS_LOCAL_CAPTURE"];
if (pref === "0" || pref === "false") return null;
try {
const pw = await import("playwright");
pw.chromium.executablePath();
return pw.chromium;
} catch {
return null;
}
}
async function tryLocalCapture(client, args) {
const chromium = await resolveChromium();
if (!chromium) {
return {
fellBack: process.env["ANTICS_LOCAL_CAPTURE"] === "0" || process.env["ANTICS_LOCAL_CAPTURE"] === "false" ? "local capture disabled by ANTICS_LOCAL_CAPTURE=0" : "no local browser (install one with `npx playwright install chromium` to capture locally and keep it off the server)"
};
}
const session = await client.verifySession({
...args.hash ? { hash: args.hash } : {},
...args.room ? { room: args.room } : {},
...args.sandbox ? { sandbox: true } : {}
});
const sim = {
status: async (roomCode) => {
const s = await client.simControl(session).status(roomCode);
return s ?? null;
},
step: (roomCode, n) => client.simControl(session).step(roomCode, n),
setLatencyTicks: (roomCode, ticks) => client.simControl(session).setLatencyTicks(roomCode, ticks)
};
const browser = await chromium.launch({ headless: true, args: CHROMIUM_ARGS });
try {
const req = {
artifactHash: session.artifactHash,
roomCode: session.roomCode,
...session.isSim ? { expectSim: true } : {},
...args
};
const result = await runCapture(browser, req, {
// The artifact is served by antics; the local browser fetches it over the internet.
baseUrl: client.baseUrl,
maxAdvanceSeconds: session.maxAdvanceSeconds,
...session.isSim ? { sim } : {}
});
return {
result: {
...result,
note: `${result.note} Captured LOCALLY (your machine's browser) \u2014 the platform ran only the game's room.`
}
};
} finally {
await browser.close().catch(() => {
});
}
}
// src/tools.ts

@@ -41,4 +107,4 @@ var LOGIN_HELP = "Not logged in (this tool is owner-scoped). Ask the user to run `npx antics-cli login` in a terminal (GitHub sign-in), then retry. deploy_game works without login \u2014 it returns an ephemeral keyless URL.";

}
async function getDocsTool(client) {
const url = `${client.baseUrl}/llms.txt`;
async function getDocsTool(client, args = {}) {
const url = `${client.baseUrl}${args.topic === "sim" ? "/llms-sim.txt" : "/llms.txt"}`;
try {

@@ -74,10 +140,117 @@ const res = await fetch(url);

const keylessNote = args.projectId ? "" : `
Note: this is a keyless deploy, so the link stays live for 24h. To make it permanent (plus persistent leaderboards and 16-player rooms), run \`npx antics-cli login\` and redeploy under a project.`;
Note: this is a keyless deploy, so the link stays live for 24h AND is pinned to these exact bytes \u2014 redeploying produces a NEW link, so anyone holding the old one keeps playing the old build. To make it permanent and updatable (plus persistent leaderboards and 16-player rooms), run \`npx antics-cli login\` and redeploy under a project.`;
const shareLine = r.shareUrl ? `SHARE this link: ${r.shareUrl}
It always starts a room on your LATEST deploy \u2014 update the game and everyone holding it gets the new build.
Give the user THIS url for posting publicly; ${r.playUrl} is pinned to the bytes you just deployed.
` : `Play / start a session: ${r.playUrl}
`;
return {
text: `Deployed (${what}).
Play / start a session: ${r.playUrl}
It redirects to a /r/<code> URL \u2014 share THAT link so others join the same room.` + keylessNote,
` + shareLine + `Either link redirects to a /r/<code> URL \u2014 share THAT to put specific people in the same room.
` + (r.tier === "sim" ? `Deploy hash: ${r.hash} \u2014 this is a SIM deploy: verify with probe_sim { hash: "${r.hash}" } (fastest) or verify_game { hash: "${r.hash}" }; both run your logic on the platform's CPU and need Pro (get_account to check).` : `Deploy hash: ${r.hash} \u2014 verify it headlessly with verify_game { hash: "${r.hash}" } (screenshot + live state probes; no browser needed on your side; free for classic deploys).`) + (r.shareUrl ? `
Next: set_share_preview brands how the share link unfurls in chats (title, blurb, image).` : "") + keylessNote,
data: r
};
}
async function verifyGameTool(client, args) {
if (!args.hash && !args.room) {
return {
text: "verify_game needs a target: pass `hash` (the deploy hash from deploy_game \u2014 verifies a fresh room) or `room` (a live /r/<code> room code to inspect).",
data: { error: true }
};
}
try {
let r;
let pathNote = "";
try {
const local = await tryLocalCapture(client, args);
if (local.result) r = local.result;
else pathNote = ` (captured on the server \u2014 ${local.fellBack})`;
} catch (localErr) {
pathNote = ` (captured on the server \u2014 local capture failed: ${localErr.message})`;
}
if (!r) r = await client.verifyGame(args);
const lines = [`Verified room ${r.roomCode} (${r.players.length} player page${r.players.length > 1 ? "s" : ""})${pathNote}.`];
r.players.forEach((p, i) => {
const label = r.players.length > 1 ? `Player ${i + 1}: ` : "";
if (p.breadcrumb) lines.push(`${label}${p.breadcrumb}`);
if (p.state && Object.keys(p.state).length) {
lines.push(`${label}state: ${JSON.stringify(p.state)}`);
}
if (p.trace && Object.keys(p.trace).length) {
const rows = Object.entries(p.trace).map(([path, t]) => ` ${path}: min=${t.min} max=${t.max} first=${t.first} last=${t.last} (${t.samples} ticks)`).join("\n");
lines.push(`${label}trace (per-tick over the advance window):
${rows}`);
}
if (p.series && Object.keys(p.series).length) {
const n = Object.keys(p.series).length;
const pts = Object.values(p.series)[0]?.v.length ?? 0;
lines.push(`${label}series: ${n} path(s) \xD7 ~${pts} sampled points \u2014 full {t, v} arrays ride the structured result (players[${i}].series).`);
}
if (p.consoleErrors.length) {
lines.push(`${label}console ERRORS (${p.consoleErrors.length}):
${p.consoleErrors.slice(0, 8).join("\n ")}`);
}
if (p.consoleLogs.length) {
lines.push(`${label}console tail:
${p.consoleLogs.slice(-10).join("\n ")}`);
}
});
lines.push(r.note);
const images = [
...r.players.flatMap((p) => (p.screenshots ?? []).map((sh) => sh.base64)),
...r.players.map((p) => p.screenshotBase64).filter((s) => !!s)
];
return { text: lines.join("\n"), data: r, ...images.length ? { images } : {} };
} catch (err) {
if (err instanceof ApiError) {
return { text: `verify_game failed: ${err.message}${err.hint ? ` \u2014 ${err.hint}` : ""}`, data: { error: true } };
}
throw err;
}
}
async function probeSimTool(client, args) {
try {
const r = await client.probeSim(args);
const lines = [
`Probed ${r.seconds}s (${r.ticks} ticks at ${r.tickHz}Hz) \u2014 deterministic, no browser. schema ${r.schemaHash.slice(0, 8)}.`
];
if (Object.keys(r.state).length) lines.push(`final state: ${JSON.stringify(r.state)}`);
if (Object.keys(r.trace).length) {
const rows = Object.entries(r.trace).map(([path, t]) => ` ${path}: min=${t.min} max=${t.max} first=${t.first} last=${t.last} (${t.samples} ticks)`).join("\n");
lines.push(`trace (per-tick over the whole run):
${rows}`);
}
if (r.series && Object.keys(r.series).length) {
const n = Object.keys(r.series).length;
const pts = Object.values(r.series)[0]?.v.length ?? 0;
lines.push(`series: ${n} path(s) \xD7 ~${pts} sampled points \u2014 full {t, v} arrays ride the structured result (series).`);
}
if (r.appliedInputs && Object.keys(r.appliedInputs).length) {
const ids = Object.keys(r.appliedInputs);
const trunc = ids.some((id) => r.appliedInputs[id].truncated) ? " (truncated \u2014 run was longer than the echo cap)" : "";
lines.push(
`appliedInputs: per-tick applied input for ${ids.length} player(s) [${ids.join(", ")}]${trunc} \u2014 applied[k] is the input at tick k+1 (null = none, { input, held: true } = last input held). Full arrays in the structured result (appliedInputs).`
);
}
if (r.programs && Object.keys(r.programs).length) {
const rows = Object.entries(r.programs).map(
([id, phases]) => ` ${id}: ` + phases.map((ph) => `phase ${ph.phase} ${ph.endedBy}${ph.atTick !== null ? ` @tick ${ph.atTick}` : ""}${ph.until ? ` (${ph.until})` : ""}`).join("; ")
).join("\n");
lines.push(`programs (why each phase ended \u2014 "predicate" = its until HELD at that tick, "timeout" = the forSeconds ceiling expired without it, a whiff):
${rows}`);
}
if (r.simErrors.length) lines.push(`sim errors (non-fatal ticks):
${r.simErrors.slice(0, 8).join("\n ")}`);
if (r.warnings.length) lines.push(`WARNINGS:
${r.warnings.join("\n ")}`);
lines.push(r.note);
return { text: lines.join("\n"), data: r };
} catch (err) {
if (err instanceof ApiError) {
return { text: `probe_sim failed: ${err.message}${err.hint ? ` \u2014 ${err.hint}` : ""}`, data: { error: true } };
}
throw err;
}
}
async function createProjectTool(client, args) {

@@ -89,3 +262,4 @@ try {

publishableKey: ${r.publishableKey}
secretKey (shown once \u2014 store it now): ${r.secretKey}`,
secretKey (shown once \u2014 store it now): ${r.secretKey}
Next: deploy_game with projectId "${r.project.id}" \u2014 the result then carries shareUrl, the permanent link that always serves the latest deploy. Hand the user THAT link for sharing.`,
data: r

@@ -126,9 +300,13 @@ };

try {
if (args.name !== void 0 || args.description !== void 0) {
if (args.name !== void 0 || args.description !== void 0 || args.about !== void 0 || args.rules !== void 0) {
const patch = {};
if (args.name !== void 0) patch.name = args.name;
if (args.description !== void 0) patch.description = args.description.trim() || null;
if (args.about !== void 0) patch.about = args.about.trim() || null;
if (args.rules !== void 0) patch.rules = args.rules.trim() || null;
await client.updateProjectMeta(args.projectId, patch);
if (args.name !== void 0) applied.push("name");
if (args.description !== void 0) applied.push(patch.description === null ? "cleared description" : "description");
if (args.about !== void 0) applied.push(patch.about === null ? "cleared about" : "about");
if (args.rules !== void 0) applied.push(patch.rules === null ? "cleared rules" : "rules");
}

@@ -166,8 +344,36 @@ if (args.imageUrl !== void 0) {

}
async function getAccountTool(client) {
try {
const u = await client.getUsage();
const sim = u.metered.simRooms;
const lines = [
`Plan: ${u.plan}${u.authenticated ? "" : " (not logged in \u2014 keyless allowances shown; `npx antics-cli login` for your account's)"}.`,
sim.limit > 0 ? `Sim tier (server-authoritative, sim.js): ${sim.limit} concurrent sim room${sim.limit === 1 ? "" : "s"} on this plan, ${sim.inUse} in use right now.` : "Sim tier (server-authoritative, sim.js): NOT INCLUDED on this plan \u2014 it is a paid feature (your game logic runs on our CPU). You CAN still write and deploy a sim game (the deploy smoke validates it and reports errors), but running it server-side \u2014 live rooms, probe_sim, and sim captures \u2014 needs Pro.",
`Classic tier (no sim.js): UNLIMITED on every plan. ${u.unmetered.classicRooms.note}`
];
if (u.deniedLast24h) lines.push(`Heads up: ${u.deniedLast24h} player join(s) were turned away by plan caps in the last 24h.`);
if (u.simDeniedLast24h) lines.push(`You attempted the sim tier ${u.simDeniedLast24h} time(s) in the last 24h and were refused by the plan.`);
lines.push(
sim.limit > 0 ? "Building guidance: deploying is never blocked, and probe_sim/verify_game are yours to use \u2014 the plan meters how many sim rooms run AT ONCE." : u.metered.simRooms.upgradeable === false ? "Building guidance: BUILD CLASSIC. Pro is waitlist-only right now \u2014 there is no self-service upgrade \u2014 so this user cannot get sim access today even if they want to pay. Tell them the sim tier exists and needs Pro access, then build the game on the classic tier so they have something that runs." : "Building guidance: if the user is not on Pro, build on the CLASSIC tier unless they specifically want server authority (cheat-proof scores, authoritative physics) and are willing to upgrade. Say so up front rather than shipping a game they can neither verify nor run."
);
return { text: lines.join("\n"), data: u };
} catch (err) {
if (err instanceof ApiError) {
return { text: `get_account failed: ${err.message}${err.hint ? ` \u2014 ${err.hint}` : ""}`, data: { error: true } };
}
throw err;
}
}
// src/server.ts
var wrap = (r) => ({ content: [{ type: "text", text: r.text }] });
var wrap = (r) => ({
content: [
{ type: "text", text: r.text },
...(r.images ?? []).map((data) => ({ type: "image", data, mimeType: "image/png" }))
]
});
var INSTRUCTIONS = `antics deploys a web game \u2014 a single self-contained HTML file OR a multi-file project \u2014 as a LIVE multiplayer web game with rooms, real-time state sync, and leaderboards.
Flow: build the game against the antics browser SDK, then call deploy_game. For a one-file game pass it as \`html\`; for a real project (HTML + JS + CSS + assets) pass \`files\` (a path -> content map; binary assets as data: URIs; entry is index.html). You get back a /r/<code> link to share; opening it on two devices plays together.
LINKS: a keyless deploy's links die after 24h AND are pinned to those exact bytes \u2014 every redeploy is a new URL, so a link the user posts publicly goes stale on the first update. If the user is logged in (or willing to run \`npx antics-cli login\`), RECOMMEND deploying under a project (create_project, then pass projectId): the result then carries \`shareUrl\` (/p/<slug>) \u2014 a PERMANENT link that always serves the latest deploy, so they post once and keep shipping updates. Tell the user which link is which; hand them shareUrl for sharing.

@@ -181,3 +387,6 @@ Before writing anything non-trivial, call get_docs \u2014 it returns the complete SDK API plus working examples. The essentials:

- Leaderboard: await room.submitScore(n)
After deploying under a project, call set_share_preview to brand how its room links unfurl \u2014 set the title (name), description, and image (an https URL or uploaded image bytes).
After deploying under a project, call set_share_preview to brand how its room links unfurl \u2014 set the title (name), description, and image (an https URL or uploaded image bytes) \u2014 and to fill the game's public world page: write about (what the game is) and rules (how to play) for every game.
VERIFY WITHOUT A BROWSER: after deploy_game, call verify_game with the returned hash \u2014 it runs the game headlessly on the server (screenshot + live state probes via readState paths like "state.score" / "player.self.x", fast-forward with per-tick numeric traces, scripted key/pointer input, console output). Iterate on the numbers, not on screenshots: readState/trace catch bugs a final frame hides. Two-player sync check: verify_game { players: 2 } \u2014 per-page inputs: [{...}, {...}] lets both pages act.
SIM TIER (server-authoritative netcode, cheat-proof scores): ship a pure sim.js alongside index.html in the files map \u2014 export { schema, init, simulate(state, inputs, dt) } (+ optional onJoin/onLeave/score, and view(state, playerId) for hidden-information games \u2014 each player then receives only what their view returns) \u2014 and the platform runs ALL game logic server-side with client prediction handled for you: the client just calls room.sim.setInput({...}) and draws every frame from room.sim.renderState(). deploy_game's result says which tier the deploy landed in. Choose it for real-time movement/physics or validated leaderboards; classic host-authoritative stays the zero-config default. PLAN: the sim tier needs Pro \u2014 your logic runs on our CPU, whether a room is playing it or probe_sim/verify_game is. Writing and deploying a sim.js is free on any plan (the deploy smoke still validates it and reports errors), but RUNNING one server-side \u2014 live rooms, probe_sim, sim captures \u2014 needs Pro. The classic tier stays unlimited, verify_game included. Call get_account BEFORE choosing this tier \u2014 Pro is currently WAITLIST-ONLY, so a user who isn't already on it cannot upgrade today; if get_account reports 0 sim rooms, build classic and say so up front rather than shipping a game they can neither verify nor run.
For sim deploys, probe_sim is the FASTEST loop: it pumps sim.js server-side with scripted virtual players and per-tick traces \u2014 deterministic, no browser. Both it and sim captures run on our CPU, so both need Pro. Use verify_game for pixels, input feel, and 2-browser sync; probe_sim for logic.
Do NOT fetch the SDK source or invent a different API \u2014 call get_docs and follow it.`;

@@ -187,2 +396,3 @@ async function main() {

const client = new ApiClient({
clientName: "mcp",
...creds.baseUrl ? { baseUrl: creds.baseUrl } : {},

@@ -194,5 +404,5 @@ ...creds.token ? { token: creds.token } : {}

"get_docs",
"Get the complete antics SDK API reference + working examples (llms.txt). Call this BEFORE writing a game so it integrates correctly in one shot.",
{},
async () => wrap(await getDocsTool(client))
"Get the complete antics SDK API reference + working examples (llms.txt). Call this BEFORE writing a game so it integrates correctly in one shot. Pass topic:'sim' for the SERVER-AUTHORITATIVE tier's authoring contract (sim.js: schema/init/simulate, prediction via room.sim, physics/CDN imports, probe_sim workflow) \u2014 read it before writing any sim-tier game.",
{ topic: z.enum(["sim"]).optional().describe("Omit for the classic-tier SDK reference; 'sim' for the sim-tier (sim.js) authoring contract.") },
async (args) => wrap(await getDocsTool(client, args))
);

@@ -212,3 +422,3 @@ server.tool(

"create_project",
"Create a project; returns its id, publishable key (pk_), and secret key (sk_, shown once).",
"Create a project; returns its id, publishable key (pk_), and secret key (sk_, shown once). Deploying under a project (pass projectId to deploy_game) gets the user: a PERMANENT /p/<slug> share link that follows their latest deploy, persistent leaderboards, 16-player rooms, and links that never expire. Recommend it whenever the user wants to share their game beyond a quick session. Requires login (`npx antics-cli login`).",
{ name: z.string() },

@@ -229,5 +439,73 @@ async (args) => wrap(await createProjectTool(client, args))

);
const inputSpecSchema = z.object({
keys: z.array(z.string()).optional().describe("Keys held through the window ('w','space','up','ArrowLeft'\u2026)."),
pointer: z.union([z.boolean(), z.object({ x: z.number().optional(), y: z.number().optional() })]).optional().describe("HOLD a pointer press on the canvas through the capture. true = centre; {x, y} = canvas-relative pixels."),
sequence: z.array(
z.object({
keys: z.array(z.string()).optional(),
ms: z.number(),
click: z.union([z.boolean(), z.object({ x: z.number().optional(), y: z.number().optional() })]).optional().describe("Fire ONE full click (down+up) at canvas-relative {x, y} at the START of this phase \u2014 script multiple positioned clicks (tower placement, board games) as a sequence of click phases. true = centre.")
})
).optional().describe("Timed phases (virtual ms); keys shared between consecutive phases stay held; click fires a positioned click at phase start.")
});
server.tool(
"verify_game",
"See and MEASURE a deployed game without a browser: runs it headlessly on the server in a real room and returns a screenshot, console output, and \u2014 the reliable signal \u2014 live numeric probes of its synced state. Use after every deploy_game and to diagnose any reported bug. `readState` paths ('state.score', 'player.self.x', 'player.<id>.y') read the SDK's live state at capture; with `advanceSeconds` (a virtual clock that fast-forwards far faster than realtime \u2014 painting is skipped during the advance while ALL your JS still runs; heavy per-frame LOGIC still slows it, and the capture has a ~30s+0.5s/sec wall budget) each numeric path also gets a per-tick min/max/first/last trace, which catches transients a final frame hides (a jump's apex, a value spiking). Drive input with timed key phases; `players: 2` opens two pages in the SAME room to verify cross-client sync \u2014 `input` drives page 0, and per-page `inputs: [{...}, {...}]` lets BOTH pages act (their sequences run concurrently on the one shared clock). Prefer probes over eyeballing pixels. Free and unlimited for CLASSIC games; against a SIM (sim.js) deploy it runs your logic on our CPU and needs Pro, same as probe_sim.",
{
hash: z.string().optional().describe("Deployment hash from deploy_game \u2014 verifies a fresh room (sim deployments run in deterministic lockstep)."),
room: z.string().optional().describe("Live room code to inspect instead of minting a fresh room."),
readState: z.array(z.string()).optional().describe('Paths: "state.<key\u2026>" or "player.<id|self>.<key\u2026>"; in SIM rooms also "sim.<schema path>" (predicted state) and "render.<schema path>" (the smoothed view a correct game draws \u2014 use for motion/overshoot checks). "self" works as a player-id segment. Bad paths return the valid options.'),
advanceSeconds: z.number().optional().describe("Fast-forward this many game-seconds on a virtual clock (fast in wall time; capped at 300), tracing readState per tick."),
traceSeries: z.boolean().optional().describe("Also return the actual sampled time series per numeric readState path \u2014 parallel {t, v} arrays (virtual ms, value; <=600 points, uniformly thinned). The trace aggregates answer 'did it ever'; the series answers 'WHEN exactly' (where a run died, when a phase flipped)."),
screenshotAtMs: z.array(z.number()).optional().describe("Mid-capture screenshots at these VIRTUAL ms stamps (<=8; same clock as the response's virtualMs \u2014 settle counts). Action shots mid-flight without timing the whole capture to end there; each lands within a frame or two, under players[i].screenshots."),
sandbox: z.boolean().optional().describe("Score-safe verification: the capture room ranks submitScore calls on its own throwaway local board and never writes the project leaderboard \u2014 use when iterating on a game whose share link is already public. Fresh rooms only (with `hash`, not `room`)."),
input: inputSpecSchema.optional().describe("Scripted input for page 0 (the single-page shorthand)."),
inputs: z.array(inputSpecSchema).optional().describe("Per-page input for players: 2 \u2014 inputs[0] drives page 0, inputs[1] page 1 (page 0 falls back to `input`). Sequences run CONCURRENTLY on the one shared virtual clock, so two-player interactions (chase, collide, rally) are scriptable."),
players: z.number().optional().describe("1 (default) or 2 \u2014 two pages in one room for sync verification."),
screenshot: z.boolean().optional().describe("Default true. Set false to skip the image (faster, probes only)."),
urlParams: z.union([z.record(z.string()), z.array(z.record(z.string()))]).optional().describe('Extra query params for the page under test, e.g. { "seed": "42" } \u2014 make runs DETERMINISTIC if your game reads them (Math.random varies per run otherwise). A single object applies to every page; an ARRAY (one object per page, like `inputs`) gives each page its own \u2014 [{ name: "A", team: "red" }, { name: "B", team: "blue" }] with players: 2. Array length must match players.'),
settleMs: z.number().optional().describe("Virtual boot budget before probing (default 3000)."),
simulateLatencyMs: z.number().optional().describe("SIM rooms (fresh-minted via hash only): one-way network latency to simulate, in ms \u2014 verifies netcode feel honestly (prediction responsiveness, remote smoothing/no-overshoot). Converted to whole sim ticks."),
simulateLatencyTicks: z.number().optional().describe("Same as simulateLatencyMs but exact, in sim ticks.")
},
async (args) => wrap(await verifyGameTool(client, args))
);
server.tool(
"probe_sim",
"The FASTEST way to verify sim-tier game logic: pumps a deployed sim.js in the server sandbox with scripted VIRTUAL players \u2014 no browser anywhere, deterministic (same seed \u21D2 identical run). REQUIRES PRO (it runs your game logic on our CPU, like a live sim room); free/anonymous callers get PLAN_REQUIRED \u2014 call get_account first. Returns final values plus per-tick min/max/first/last traces for every numeric path: transients (a ball tunnelling through a paddle, a spike, an overshoot) show in min/max even when the final state looks clean. Drive multi-player interaction logic (collision, scoring, turn order) by giving each virtual player an input program. Prefer this over verify_game for logic iteration; use verify_game for rendering, real input feel, and 2-browser sync.",
{
hash: z.string().describe("Deployment hash of a SIM-tier deploy (from deploy_game \u2014 its sim.js is what gets pumped)."),
seconds: z.number().optional().describe("Sim-seconds to pump (default 5, capped at 300; wall-clock fast)."),
readState: z.array(z.string()).optional().describe('Schema paths: "ball.x", "players.p1.score", wildcard "players.*.score" traces every id. Bad paths return the full valid-path roster.'),
traceSeries: z.boolean().optional().describe("Also return the per-tick time series per numeric path \u2014 parallel {t, v} arrays (t in sim-ms; <=600 points, uniformly thinned). 'When exactly did the phase flip' in one call instead of bisecting."),
traceInputs: z.boolean().optional().describe("Also echo, per virtual player, WHICH input the runner applied each tick \u2014 appliedInputs[id].applied[k] is the input at tick k+1 (null = none, { input, held: true } = the last program input HELD after it ended). Debugs choreography: proves phase 1's input drove the early ticks and phase 2 the later ones. Opt-in (grows the response); capped at the first 600 ticks."),
players: z.array(
z.object({
id: z.string().describe("Virtual player id \u2014 becomes players.<id> via onJoin."),
joinAtSecond: z.number().optional().describe("When they join (default 0 = before tick 0)."),
program: z.array(
z.object({
forSeconds: z.number(),
input: z.unknown(),
until: z.record(z.string()).optional().describe(
'End the phase EARLY the tick this predicate first holds (evaluated server-side per tick against post-tick state): exactly ONE schema path mapped to one comparison "<op><value>" (ops > >= < <= == !=) \u2014 e.g. { "players.striker.x": ">=360" } or { "phase": "==playing" }. forSeconds becomes the timeout CEILING, and the result\'s `programs` marks each phase "predicate" (hit) vs "timeout" (whiff). "Walk until in range, then attack" with no duration bisection.'
)
})
).optional().describe("Input phases in order; the LAST input holds afterwards (level-state). A phase may carry `until` to end the moment a state predicate holds. Omit for an idle spectator.")
})
).optional().describe("Virtual players (up to 8) \u2014 verify 2-player logic with zero browsers."),
seed: z.number().optional().describe("Math.random seed (default 1). Same seed \u21D2 bit-identical run; vary to explore."),
viewAs: z.string().optional().describe("Probe AS this player id: readState resolves through the sim's view(state, playerId) export \u2014 verify a hidden-information game hides what it should. Requires a view() export.")
},
async (args) => wrap(await probeSimTool(client, args))
);
server.tool(
"get_account",
"Which plan is this user on, and what does it limit? Call this BEFORE building a server-authoritative (sim.js) game, and whenever a sim room is denied. Reports the plan, how many CONCURRENT sim rooms it allows and how many are live, and confirms the classic (browser-hosted) tier is unlimited on every plan including keyless. Writing and DEPLOYING a sim game is never blocked; RUNNING one server-side \u2014 live rooms, probe_sim, and verify_game against a sim deploy \u2014 needs Pro, because it runs game logic on our CPU.",
{},
async () => wrap(await getAccountTool(client))
);
server.tool(
"set_share_preview",
`Brand how a project's room links unfurl in chats/social (Discord, Slack, iMessage, X). Owner-scoped \u2014 needs login. Set any of: name (the link title), description (the blurb; pass "" to clear), and an image via imageUrl (https) OR imageData (base64 or data: URI \u2014 uploaded & hosted) OR clearImage:true to revert to the default. Only one image action per call.`,
`Brand how a project's room links unfurl in chats/social (Discord, Slack, iMessage, X) and fill its public world page. Owner-scoped \u2014 needs login. Set any of: name (the link title), description (the blurb; pass "" to clear), about + rules (the world page's crawlable content \u2014 write these for every game you deploy), and an image via imageUrl (https) OR imageData (base64 or data: URI \u2014 uploaded & hosted) OR clearImage:true to revert to the default. Only one image action per call.`,
{

@@ -237,2 +515,4 @@ projectId: z.string().describe("The project whose share preview to set."),

description: z.string().optional().describe('Unfurl description, \u2264 200 chars. Pass "" to clear it.'),
about: z.string().optional().describe('What the game is and what makes it fun \u2014 shown on its /world page + in-room info panel, \u2264 1200 chars. Pass "" to clear.'),
rules: z.string().optional().describe('How to play \u2014 controls, goal, rules. Shown under "How to play", \u2264 2000 chars. Pass "" to clear.'),
imageUrl: z.string().optional().describe("External https:// image URL (stored as-is; recommended 1200\xD7630)."),

@@ -239,0 +519,0 @@ imageData: z.string().optional().describe("Image as base64 or a data: URI (PNG/JPEG/WebP, \u2264 2 MB) \u2014 uploaded and hosted."),

+13
-4
{
"name": "antics-mcp",
"version": "0.1.9",
"version": "0.2.0",
"mcpName": "io.github.antics-gg/antics-mcp",
"description": "Multiplayer for your game, in one prompt — an MCP server that lets an AI agent deploy a web game to a playable multiplayer URL (rooms, state sync, leaderboards).",
"description": "Multiplayer for your game, in one prompt \u2014 an MCP server that lets an AI agent deploy a web game to a playable multiplayer URL (rooms, state sync, leaderboards).",
"license": "SEE LICENSE IN LICENSE",

@@ -41,9 +41,18 @@ "type": "module",

"dependencies": {
"antics-client": "^0.1.3",
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.1"
"antics-client": "^0.1.3 || ^0.2.0",
"zod": "^3.24.1",
"antics-harness": "^0.1.0"
},
"devDependencies": {
"tsx": "^4.19.2"
},
"peerDependencies": {
"playwright": ">=1.40"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
}
}
}