Driving Dungeondraft with an MCP server (and the bugs hiding in its save button) A developer's fork of dungeondraft-mcp, sammyrulez/dungeondraft-mcp, fixes three bugs that broke Dungeondraft maps once a human took over after an LLM session, including a NullReferenceException in Pathways.Save() that left Master.IsSaving stuck true and silently dropped every MCP-placed object. The bridge, a FastMCP Python server exposing about 40 tools over a localhost socket on port 8787 to a mod polling inside Dungeondraft, now makes its TCP listener process-global, sets the "preview" = false meta and registers created nodes in Objects.AddToSearchTable as Dungeondraft's own tools do, and rebuilds nodes on redo instead of re-attaching them. The fixes matter because the original bugs only surfaced after handoff: saves froze, objects could not be selected, and redone props were saved without textures and discarded on the next load. Dungeondraft https://dungeondraft.net/ is a map editor for tabletop RPG battlemaps. dungeondraft-mcp https://github.com/sammyrulez/dungeondraft-mcp my fork of brann-dev/dungeondraft-mcp https://github.com/brann-dev/dungeondraft-mcp lets an LLM such as Claude drive a running Dungeondraft: draw rooms, cut doors, paint terrain, place furniture, then take a screenshot and check its own work. How it works Dungeondraft has no API server, so the project comes in two halves that talk over a localhost socket: - The mod opens a TCP Server inside Dungeondraft and polls it on every frame from the mod's update delta hook. Each request is {"cmd": ..., params} . It is dispatched to a handler that calls Dungeondraft's own classes Objects.CreateObject , Walls.AddWall , PatternShapes.DrawPolygon , … and answers {"ok": true, "result": ...} . - The Python server FastMCP exposes about 40 tools: create, edit, terrain, caves, levels, camera, screenshot and export. Each tool is a thin bridge.request "cmd", ... call. Because the screenshot and export tools return images, the model can look at what it built and correct it. - Every element is referenced by Dungeondraft's integer node id, and the bridge keeps its own undo/redo stacks, separate from Ctrl+Z. What I fixed None of the three bugs showed up while the map was being built. They only appeared once a human took over the map. 1. "Port already in use" on every new map Dungeondraft calls the mod's start on a new script instance each time a map is opened or created, and there is no teardown hook. The previous instance's TCP Server kept port 8787 bound, so listen failed. lsof showed that Dungeondraft itself held the port, not the Python process. Fix: the listener is now process-global. It is stored in Engine metadata, and each new instance adopts it instead of rebinding. Ownership moves to the newest instance, and older instances stop polling. As a side effect, reloading the mod no longer drops the connection. 2. Saves that never finished, and objects you couldn't click The symptoms: after an MCP session, saving froze Dungeondraft, or the placed objects could not be selected, although walls and lights could. The modding docs say nothing on this, so I extracted Dungeondraft.dll from the game's .pck , decompiled it with ILSpy, and launched Dungeondraft from the terminal to capture its stdout. The log showed: System.NullReferenceException at Pathways.Save at Level.Save ... at Save.Serialize When Dungeondraft creates an object, path or light through its own tools, it does more than the bare Create calls the mod was using: - It sets a "preview" = false meta on each node. Pathways.Save and Lights.Save read it with a hard bool cast. Without it the cast throws, the background save task dies before writing the file, and Master.IsSaving stays true . From then on every save is refused as "busy" until you restart. Objects.Save wraps the same cast in a try/catch , which made it worse: maps with no paths or lights saved fine but silently dropped every MCP-placed object . - It registers each object in Objects.AddToSearchTable . SelectTool hit-tests objects only through that spatial index QuickSearchBuckets , so an object missing from it can never be clicked. Walls and lights are hit-tested another way, which is why they still worked. Fix: the bridge now registers the nodes it creates the same way Dungeondraft's tools do. 3. Redo produced texture-less ghosts The bridge undid a create by detaching the node and redid it by re-attaching the same node. For objects this breaks: Prop. EnterTree builds a fresh, empty Sprite every time the node enters the tree. The re-attached object still showed on screen through its old sprite, but it could not be selected and it was saved without a texture, so Dungeondraft would discard it on the next load. Fix: undo/redo for objects now works like Dungeondraft's own ObjectRecord . Undo snapshots the object with Prop.Save and deletes it; redo rebuilds it with Objects.LoadObject data , which gives it the same id, texture, tint and index entry. I verified it end to end: I built a test map over MCP, ran undo/redo cycles and deletes, selected each object by hand, saved, and parsed the saved .dungeondraft map JSON. Every object, path and light was there, with its texture. Teaching the agent to make maps: a skill The MCP server gives Claude the tools, but not the craft. Given forty tools and "make me a dungeon", the model draws a few rectangles, scatters some props and calls it done. So on top of the server I wrote a Claude Code skill https://docs.claude.com/en/docs/claude-code/skills , dungeondraft-mapmaking : a folder with a SKILL.md and a few scripts. Claude loads it only when a task mentions Dungeondraft or a battlemap. The workflow, from a mapmaking tutorial SKILL.md follows a beginner's Dungeondraft mapmaking tutorial https://www.youtube.com/watch?v=-X31R4sJ3gA , rewritten as tool calls: - Pre-flight. Run ping and get status first. If Dungeondraft is closed, ask the user to open it instead of retrying. If no map is open, ask them to create one, because the bridge cannot create or resize a map. - Plan before drawing. Start small about 20×20 tiles and keep a 2-tile margin. Write the floor plan as tile coordinates first. With a reference image a page from a module, a screenshot , count the tiles on the image instead of guessing. - Build order. Walls, doors, caves, terrain, floors, paths cliffs, stairs, fences , outside fill, objects, lights. Each step builds on the one before. For example, a door mounted on a wall cuts its own opening only if the wall already exists. - Check your own work. After each phase, call fit elements and take a screenshot . - Gotchas, collected in one place. 256 woxels per tile. The z-order values for caves, terrain, patterns, objects and roofs. Water is not supported by the bridge. Look assets up with list assets , because a given asset pack may not be installed. Inventing a map: dd layout.py Tracing a map from a book is mostly counting. Inventing one is a layout problem, and LLMs are bad at spatial layout over dozens of tool calls. So the skill comes with a small script Python standard library only that computes the layout. The model only reviews it and applies it. 1. plan: doesn't touch Dungeondraft; prints an ASCII preview and writes a JSON plan python3 dd layout.py plan --style dungeon --width 30 --height 30 --seed 42 --out plan.json 2. apply it one phase at a time, screenshotting in between python3 dd layout.py apply plan.json --phases walls,doors python3 dd layout.py apply plan.json --phases floors,outside,paths python3 dd layout.py apply plan.json --phases objects,lights It has three styles, dungeon , cave and outdoor , and the algorithms are standard procedural-generation techniques: | Problem | Algorithm | Source | |---|---|---| | Rooms | BSP partition, one room per leaf | RogueBasin | | Connections | Gabriel graph → minimum spanning tree → ~15% of the dropped edges added back as loops | TinyKeep https://www.gamedeveloper.com/programming/procedural-dungeon-generation-algorithm | | Room roles | entrance near the edge, "boss" room farthest in graph hops, dead ends become storage, shrine or crypt | Dormans 2010 | | Furniture | rules: against walls, keep a walking path between doors free, focal piece opposite the entrance, tables with chairs facing them | Merrell et al. 2011 https://graphics.stanford.edu/projects/furniture/ | | Trees, bushes, rocks | Poisson disk sampling filtered by fractal noise, so you get groves and clearings | Bridson 2007 https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph07-poissondisk.pdf | | Roads | A that avoids buildings and is cheaper on existing road, then Ramer-Douglas-Peucker | — | Some design choices: - The plan is data, not calls. plan writes a JSON list of bridge commands, grouped by phase. The model shows the ASCII preview to the user and changes the seed or parameters until the user likes it, and none of that touches the map. The same seed always produces the same plan. - Furniture is configured, not coded. Fourteen room types library, barracks, crypt, tavern, smithy… live in recipes.json as lists of rules. To add a new type of room, you edit JSON. - Assets are resolved at apply time. Each recipe lists search terms "bookshelf", "bookcase", "shelf" , and apply resolves each one once with list assets . It reports what it couldn't find and skips it, and an override file lets you pin exact assets. The created ids are saved next to the plan, so you can edit or delete them later. The script handles the floor plan and a first pass of furnishing. The polish overlapping props, extra lights, clutter is left to the model's hand calls, following the rest of the skill. It's tested against a fake bridge that speaks the same TCP protocol. I haven't yet checked against a live Dungeondraft that object rotations and wall-mounted doors look right, so the skill tells the model to check both with a screenshot on first use. Takeaways - When you integrate with closed software through a plugin API, the API docs describe the calls but not what the native tools do as well . Here the real contract was in the decompiled code. - Run the host app from a terminal and read its stdout. One stack trace settled what hours of guessing could not. - Test the human hand-off, not just the agent's own loop. Every one of these bugs was invisible to the model, which could still create, list and screenshot everything. They only broke when a person clicked, saved or reopened the map. - Tools aren't craft. An MCP server tells the model what it can do; a skill tells it what a good result looks like and in which order to get there. Spatial layout is better handled by a deterministic script that the model reviews and applies than by many tool calls the model improvises one at a time.