emptysock-mcp
An MCP server exposing EmptySock game-engine tooling to AI agents: live queries to a running game over a local WebSocket bridge, plus static file/docs tools.
Live bridge tools (relay to a connected game on
127.0.0.1:7777; returnno-live-instanceetc. when nothing is attached):physics_*(2D raycast, overlap circle, body state),scene_*(list entities, entity info, get component, create entity),navmesh_*(find path, nearest node),actor_*(send message, broadcast, inbox size).Static file tools (no live game needed):
save_read/save_write/save_delete/save_list(sandboxed toSAVE_BASE_DIR) andstory_graph_export(reads{ASSET_BASE_DIR}/{sceneId}/{graphId}.storyGraph.json).Docs tool:
emptysock_layer_inforeturns staticLayerSystemAPI reference.Stub:
particle_emitter_configvalidates input but persists nothing.Schema vs README mismatches: the schema also exposes
gms2_inspect_project(reads a GameMaker Studio 2.yyp, undocumented in the README) and aphysics_raycast_3dtool the README explicitly says doesn't exist; conversely the README'sbattle_estimate_damage,visualscript_validate, andactor_listare absent from this schema.Safety/ops: Zod input validation, path-containment checks, per-tool sliding-window rate limiting, and stderr JSON audit logging (stdout is the MCP wire).
Provides read-only integration with GameMaker Studio 2, allowing inspection of .yyp project files and returning a JSON summary with project name, asset counts, and lists of object and script names.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@emptysock-mcp Find a path from (10,10) to (50,50) on map level1."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
emptysock-mcp
Deprecated and archived. EmptySock development has stopped. This server is kept as a working reference for how to expose a game engine to AI agents over MCP; it is no longer maintained, and issues and pull requests are not being reviewed.
A Model Context Protocol server for the EmptySock game engine. It hands Claude Desktop, AI agents, and the Claude API a set of tools for poking at an EmptySock project: reading save files, validating Story Graphs and VisualScript graphs, estimating battle damage, and querying a live running game's physics, scene, actor and navmesh state over a local WebSocket bridge.
The server exposes 22 tools: 20 are live (they do real work on files on disk, or relay to a connected live game), 1 is a documentation tool (emptysock_layer_info), and 1 is a stub (particle_emitter_config). The table below tells you which is which — no tool here pretends to be more finished than it is.
Requirements
Node.js 20+
npm 9+
Related MCP server: Hayba
Installation
git clone https://github.com/eleferrets/emptysock-mcp.git
cd emptysock-mcp
npm ci # or `npm install`
npm run build # compiles to dist/ (dist/server.js is the entry point)Optional checks: npm run lint (type-check), npm test (Vitest, 80 tests).
Configuration
Every variable is optional and read from the process environment (src/env.ts). The server does not load a .env file by itself, so copying .env.example to .env has no effect unless you pass it in. Either export the variables, set them in your MCP host's env block (see Claude Desktop below), or let Node load the file:
cp .env.example .env # then edit the paths
node --env-file=.env dist/server.js # Node 20.6+
npx tsx --env-file=.env src/server.ts # development, no build stepVariable | Required | What it does |
| No | The one directory the save tools are allowed to touch. Everything |
| No | The directory project asset files live under. |
| No | How many calls a single tool can take in one rate-limit window before it starts saying no. Default |
| No | The length of that window, in milliseconds. Default |
| No | Port the live-bridge WebSocket server binds to on |
Never commit
.env. It's gitignored for a reason — keep secrets in your CI/CD secret manager instead.
Running the server
stdio (recommended for local use and Claude Desktop)
npm run dev # development — tsx, no build step
# or after building:
node dist/server.jsThe MCP protocol itself runs over stdin/stdout, so there is no MCP network port and no MCP auth layer. The one network listener is the optional live bridge: a WebSocket server bound to 127.0.0.1:EMPTYSOCK_BRIDGE_PORT (default 7777) that a live game dials into (see below). If that port is already taken (for example by a second copy of this server), the bind error is written to the audit log on stderr and the server keeps running; the bridge-backed tools then report no-live-instance.
To check that the server starts and lists its tools, send it an MCP handshake on stdin, for example {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}} followed by {"jsonrpc":"2.0","method":"notifications/initialized"} and {"jsonrpc":"2.0","id":2,"method":"tools/list"} (one JSON message per line). tools/list returns the 22 tools described below.
Claude Desktop
Add the server to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"emptysock": {
"command": "node",
"args": ["/absolute/path/to/emptysock-mcp/dist/server.js"],
"env": {
"SAVE_BASE_DIR": "/absolute/path/to/your/saves"
}
}
}
}Restart Claude Desktop and the EmptySock tools show up in the tool picker.
What this server actually talks to (read this before assuming a tool does more than it does)
This server hosts a plain WebSocket bridge on 127.0.0.1:EMPTYSOCK_BRIDGE_PORT (default 7777, see lib/bridge.ts) that a live game or the IDE preview dials into as the client. Once connected, tool calls relay real EngineQuery/EngineQueryResult envelopes to @emptysock/engine's QueryChannel (packages/engine/src/bridge/QueryChannel.ts) running inside that live process and return its real answer — not a guess. No auth token: this is a localhost-only trust model, matching how this server already treats local file I/O. Only one live game is expected connected at a time; a newer connection replaces an older one rather than being queued.
What that means in practice, split by domain:
Physics, Scene, NavMesh, and Actor tools (
physics_*,scene_*,navmesh_*,actor_*) are all live: they relay real queries to the connected live game'sQueryChanneland return its real answer. With no live game connected, they return{ ok: false, error: { code: "no-live-instance", ... } }. Beyond that, each domain has its own honest "attached but not that system" error: physics queries against a scene with no initializedPhysicsSystemget"no-physics-world";actor_*against a scene with noActorSystemattached gets"no-actor-system";navmesh_*against a scene with no navmesh attached gets"no-navmesh". None of these ever collapse into a fabricated empty result — anavmesh_find_pathcall that finds no route reportspath: null, not an error, and is distinguishable from every one of the three "nothing to even ask" cases above.Save, Story Graph export, and VisualScript validation are all real, working tools that operate on static project files on disk (save JSON,
.storyGraph.jsonfiles, aVisualScriptGraphpayload you hand it directly). No live game required, because none of these ever needed one.Battle damage estimation is a real, working, pure calculation — it reimplements
BattleSystem's default physical damage formula rather than driving a liveBattleSysteminstance, because that instance is a stateful turn machine meant to run inside a real game loop, not something this server has any business owning.emptysock_layer_infois reference documentation served as a tool response, not a stub — there's nothing to fake here, it's just handing back API docs.
One more note on terminology: the engine's recent module-package split moved VN, battle, and tilemap logic out of core @emptysock/engine into their own packages (@emptysock/vn, @emptysock/battle, @emptysock/tilemap). This server is a separate Node process and doesn't import the engine at all, so none of that split changes any code here — but the story_graph_export, battle_estimate_damage, and navmesh_* tools below are documented against the current package names (VNSystem now lives in @emptysock/vn, BattleSystem in @emptysock/battle, NavMeshSystem in @emptysock/tilemap) so you know which package's shape you're actually matching.
Security model, in plain terms
Path safety. Every filesystem path a caller supplies goes through a
SafeRelPathZod schema (no.., no leading/or\) and then gets resolved and checked again against the allowed base directory before any file touches disk. Two independent checks, because "the schema already rejected it" is a bad place to stop trusting yourself.Rate limiting. A sliding-window limiter sits in front of every tool call, keyed by tool name, tuned by
RATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MS. It exists to stop a runaway agent loop from calling the same tool a thousand times in ten seconds, not to defend against a hostile network attacker (there is no network surface to attack).Audit logging. Every tool call gets logged as one line of JSON to stderr — tool name, status, duration. Never to stdout, because stdout is the actual MCP wire protocol and mixing log lines into it would corrupt every message after.
Input validation. Every tool argument is parsed with Zod before anything happens. Malformed input gets a clean
InvalidParamserror, not a stack trace or a half-executed side effect.No credentials, no secrets. There's no auth surface to configure because stdio has exactly one caller: the MCP host process that spawned this one.
Available tools
Status key: live — does real work (either standalone, or by relaying to a connected live game over the bridge). stub — validates input correctly but has no live-bridge query kind to call yet, so it always returns an honest ok: false error. docs — returns static reference information by design, not a stub.
NavMesh
Tool | Status | Parameters | Returns |
| live |
|
|
| live |
|
|
Relays to QueryChannel's navmeshFindPath/navmeshNearestNode query kinds, which reach an optional NavMeshQuerySource (@emptysock/tilemap's NavMeshSystem, wired in only if the live game's scene actually attached one) — a scene with no navmesh loaded is normal, not an error, and reports "no-navmesh" rather than "not-found".
Example call — find path:
{ "from": { "x": 0, "y": 0 }, "to": { "x": 100, "y": 50 }, "mapId": "level1" }Physics
Tool | Status | Parameters | Returns |
| live |
|
|
| live |
|
|
| live |
|
|
There's no physics_raycast_3d tool in this registry. 3D raycasting isn't offered at all right now, not even as a stub — implementing it needs a Rapier3D WASM build available server-side, which this server doesn't have (the engine's 3D physics runs in the browser's WASM context, not in Node). Don't call it expecting an error message with useful detail; you'll just get an unknown-tool error like any other made-up tool name.
Example call — overlap circle:
{ "center": { "x": 50, "y": 50 }, "radius": 20, "layerMask": 3 }Scene
Tool | Status | Parameters | Returns |
| live |
|
|
| live |
|
|
| live |
|
|
| live |
|
|
Example call — get component:
{ "sceneId": "gameplay", "entityId": "1", "componentType": "Transform" }Save
All save tools are sandboxed to SAVE_BASE_DIR. Path traversal (.., absolute paths) is rejected both at the schema layer and again when the path is resolved.
Each slot is one {slot}.json file holding this server's own envelope: { id, scene, data, timestamp, playtime }. This is not the format the engine's current SaveSystem writes (that one is component-based and asynchronous: a StorageAdapter-backed blob with a formatVersion, per-component versions and entity data, bound to a live Scene). Treat these tools as sandboxed JSON file storage with a fixed envelope; a game that wants them to read its saves has to write the same envelope itself.
Tool | Status | Parameters | Returns |
| live |
| The slot envelope read from disk, or |
| live |
|
|
| live |
|
|
| live |
|
|
Example call — write:
{ "slot": "autosave", "scene": "bridge-level", "data": { "level": 3, "score": 4200, "checkpoint": "bridge" } }Slot names are alphanumeric plus dashes/underscores only (slot1, autosave, new-game-plus).
Actor
Tool | Status | Parameters | Returns |
| live |
|
|
| live |
|
|
| live |
|
|
| live | none |
|
Relays to QueryChannel's actorSendMessage/actorBroadcast/actorInboxSize/actorList query kinds, which reach the live game's real ActorSystem directly. A scene can legitimately be live with zero actors running, so a missing ActorSystem is its own error, "no-actor-system", never "no-live-instance". The same ordering guarantee ActorSystem uses everywhere else applies: it drains every actor's inbox before calling update() on any actor, so a message sent during frame N is fully processed before frame N's update() logic runs.
Example call — send message:
{ "actorId": "enemy-spawner", "message": { "type": "SPAWN_WAVE", "payload": { "wave": 3 } } }Layers
Tool | Status | Parameters | Returns |
| docs | none | A reference document for the |
Particles
Tool | Status | Parameters | Returns |
| stub |
| Reading returns a fixed default config merged with nothing real; writing returns |
Example call — read config:
{ "emitterId": "dust" }Example call — write config:
{
"emitterId": "dust",
"config": {
"emissionRate": 60,
"lifetime": { "min": 0.5, "max": 1.2 },
"shape": "circle",
"shapeRadius": 20,
"colorGradient": [16766464, 16711680]
}
}Story Graph
Tool | Status | Parameters | Returns |
| live |
| The parsed Story Graph — |
This is the Story Graph that @emptysock/vn's VNSystem runs at play time. Node types are "dialogue", "choice", and the VariableStore-driven "condition" type. Choice nodes carry their option labels in a plain options string array and, when any option is gated, a parallel optionWhens array of VariableCondition | null. A condition node carries a single condition. Branch destinations — a choice option's target, or a condition node's true/false targets — live on the graph's edges (keyed by fromPort), never on the node itself.
Example call:
{ "sceneId": "chapter1", "graphId": "intro" }Battle
Tool | Status | Parameters | Returns |
| live |
|
|
This genuinely reimplements @emptysock/battle's BattleSystem default physical damage formula (max(1, floor((effectiveAttack - effectiveDefense / 2) * power * (isCrit ? critMultiplier : 1)))) rather than driving a live BattleSystem turn machine — that machine's real job (turn order, status effects, subscriptions) is meant to run inside an actual game, and reimplementing all of that here risks drifting out of sync with the real thing. Use this one for sanity-checking combat balance numbers, not for simulating an actual fight.
Example call:
{ "effectiveAttack": 50, "effectiveDefense": 20, "power": 1.2 }VisualScript
Tool | Status | Parameters | Returns |
| live |
|
|
This runs a real, complete structural check against a VisualScriptGraph (the node graph @emptysock/engine's VisualScriptSystem compiles and runs): duplicate node ids, dangling next/connection targets, nodes unreachable from an onUpdate/onEvent entry node, and branch nodes missing a false branch. It does not execute the graph against a live VariableStore or ActorSystem — that's a different, much bigger job — but everything it does check, it checks for real.
Example call:
{
"graph": {
"nodes": [
{ "id": "update-1", "kind": "onUpdate", "next": ["branch-1"] },
{ "id": "branch-1", "kind": "branch", "next": ["set-1"], "variableIndex": 0, "comparator": "gt", "value": 5 },
{ "id": "set-1", "kind": "setSwitch", "next": [], "switchIndex": 0, "value": true }
],
"connections": [
{ "id": "c1", "from": "update-1", "to": "branch-1", "fromPort": 0 },
{ "id": "c2", "from": "branch-1", "to": "set-1", "fromPort": 0 }
]
}
}Development
npm run lint # TypeScript type-check (no emit)
npm test # run Vitest suite
npm run test:watch # watch mode
npm run build # tsc -> dist/Tests live in src/tests/. They cover input validation, tool dispatch, and the security invariants that actually matter here (path traversal, unknown tool names, and confirming the stubs stay honest stubs).
Adding a tool
Create
src/tools/<domain>.ts— export atoolDefarray entry and ahandlerfunction.Register both in
src/tools/index.tsvia theregister()call inbuildRegistry().(Optional, both companion repos are archived) Mirror the tool in the
emptysock-ai-skillspack.Update this README's tools table and the tool count in the intro. If it's a stub, say so plainly — don't let it look more finished than it is.
Shared helpers live in src/lib/:
parse(schema, raw)— Zod parse that throwsMcpError(InvalidParams)on failureSafeRelPath,SafeId,Vec2,Vec3,GameNum— reusable Zod schemastextResponse(data)— builds the standard MCP text content responsewrapError(err)— logs to stderr and re-throws asMcpError(InternalError)queryLiveGame(query)(lib/bridge.ts) — relays anEngineQueryto the connected live game
Security model
Concern | Mitigation |
Malformed arguments | Zod |
Path traversal |
|
Shell injection | No |
Credential leakage | Secrets come from |
Oversized inputs | String lengths bounded on every schema field |
Unknown tools |
|
Available Tools
21 toolsactor_broadcastB
Broadcast a message to all registered actors in the current ActorSystem.
Inputs: message (required).
actor_inbox_sizeB
Return the number of pending messages in an actor's inbox.
Inputs: actorId (required).
actor_send_messageA
Enqueue a message in a specific actor's inbox. The actor processes it on the next flush pass.
Inputs: actorId (required), message (required).
emptysock_layer_infoA
Return reference documentation for the EmptySock LayerSystem API: defineLayer, addToLayer, setVisible, setParallax, and RenderSystem integration.
Inputs: no named parameters.
gms2_inspect_projectA
Read a GameMaker Studio 2 .yyp project file and return a JSON summary: project name, asset counts by type, and lists of object and script names. Read-only — does not modify any files.
Inputs: yypPath (required).
particle_emitter_configA
Get or set a ParticleSystem emitter configuration by emitter ID. Omit config to read; provide config to write.
Inputs: emitterId (required), config.
physics_body_stateA
Return the current position, velocity, and angular velocity of a physics body by entity ID.
Inputs: entityId (required).
physics_overlap_circleB
Return all entity IDs whose 2D colliders overlap a circle.
Inputs: center (required), radius (required), layerMask.
physics_raycast_2dB
Cast a ray in 2D physics space and return the first hit entity, hit point, and normal.
Inputs: origin (required), direction (required), maxDistance (required), layerMask.
physics_raycast_3dC
Cast a ray in 3D physics space (Rapier3D) and return the first hit.
Inputs: origin (required), direction (required), maxDistance (required), layerMask.
save_deleteA
Delete a save slot from disk.
Inputs: slot (required).
save_listA
List all available save slots.
Inputs: subdir.
save_readA
Read a save slot from disk and return its JSON data.
Inputs: slot (required).
save_writeA
Write JSON data to a save slot on disk.
Inputs: data (required), slot (required).
scene_create_entityC
Add a new entity to a scene. Returns the new entity's ID.
Inputs: sceneId (required), tag, components.
scene_entity_infoC
Return the tag, active state, and component list for a specific entity.
Inputs: sceneId (required), entityId (required).
scene_get_componentB
Retrieve the serialised state of a specific component on an entity.
Inputs: sceneId (required), entityId (required), componentType (required).
scene_list_entitiesB
List all entity IDs currently active in a scene.
Inputs: sceneId (required).
story_graph_exportA
Export the Story Graph (VNSystem) for a named scene as a JSON object containing nodes and edges.
Inputs: sceneId (required), graphId.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 21 tools
Tools are clearly separated by domain (navmesh, physics, scene, save, actor, etc.) and each tool targets a distinct action or query. Even within physics, 2D and 3D raycasts are explicitly differentiated. No two tools appear to do the same thing.
All tools follow a consistent domain_verb_noun pattern (e.g., scene_create_entity, save_list, actor_send_message). Suffixes like _2d and _3d are uniform, and the one odd name (emptysock_layer_info) still adheres to the same style. Naming is predictable and coherent.
With 21 tools, the count falls in the 16-25 range, which is borderline heavy. However, the server covers many distinct subsystems, so each tool arguably earns its place, but the overall number feels slightly excessive for a single MCP server.
The set covers core read/query operations across multiple domains, but there are notable gaps: scene entities can be created and inspected but not updated or deleted, physics only offers queries (no manipulation), and particle control is limited to configuration. This leaves some lifecycle operations incomplete.
Maintenance
Related MCP Connectors
Make games with AI: one MCP server that connects your agent to Unity, Unreal and Godot editors.
A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal E…
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Persistent memory, knowledge graph, task management and more for AI agents via MCP.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables LLM-driven text game state management by exposing MCP tools for managing players, locations, items, entities, and abstract concepts.MIT
- AlicenseNot gradedqualityBmaintenanceAn MCP server enabling AI agents to author Unreal Engine 5 scenes directly, with tools for spawning actors, building PCG graphs, validating physics, generating terrain, and more through a single MCP connection.13MIT
- AlicenseNot gradedqualityDmaintenanceConnects Claude Code to the Unity Editor via MCP, enabling AI-driven control of scenes, assets, components, UI, animations, and more through 91 tools.2MIT
- AlicenseCqualityCmaintenanceEnables AI-driven game development by providing MCP tools to interact with the Godot editor, including scene editing, node manipulation, script attachment, and scene execution.2814 npmMIT