Tank Fight MCP
by LarryAtGU
README.md
# Tank Fight MCP — the game spec as a service
An MCP server that serves the **canonical specification** for Tank Fight: field
and window size, tank and bullet stats, spawn rules, scoring, controls, menu
behaviour, AI strategies, the difficulty ramp, and a destructible maze per level.
It runs as a local service on a port. Any MCP client can connect, ask what tools
it offers, and query the specification — while building the game in Java, in
TypeScript, or in anything else.
## What it's for
Two jobs, and the second matters more than it first looks.
**1. Keep independent implementations in agreement.** Two builds of the same
game, written separately, drift the moment they need a number nobody wrote down.
Both asking the same service how big a tank is removes the guesswork.
**2. Stop the model inventing requirements.** Every detail a spec leaves out is
a place where an AI coding assistant quietly picks something plausible and moves
on — *do bullets stop at walls? can allies shoot each other? what happens when a
player is destroyed mid-round?* It rarely mentions that it decided. Each of
those is answered here, so the question becomes a lookup instead of a guess, and
`open_questions` names what genuinely **isn't** decided, so the honest answer is
"ask a human" rather than a confident invention.
## Run it
Requires [uv](https://docs.astral.sh/uv/).
```bash
uv sync
```
```bash
uv run server.py
```
That serves the specification at `http://127.0.0.1:8082/mcp`. Use `--port` to
listen elsewhere, `--host` to bind another interface, and `--stdio` for clients
that only speak stdio.
## Connect a client
Any MCP client, any number of them at once. For Claude Code:
```bash
claude mcp add --transport http tank-spec http://localhost:8082/mcp
```
Add `--scope project` to record it in the repository you're building in, so
everyone working on that build gets the same specification. Then `/mcp` to
confirm the connection, and the client can ask the server what tools exist and
how to call them — nothing here needs configuring per client.
## Tools
| Tool | Answers |
|---|---|
| `list_sections()` | What's in the spec, one line each — start here |
| `get_spec(section)` | One section, or a dotted path like `entities.bullet.damage` |
| `search_spec(query)` | "Does X happen?" — finds the rule that decides it |
| `resolve_level(level)` | What level N means: total enemies, max concurrent, spawn interval, which maze |
| `get_maze(level)` | That level's brick layout, expanded into tiles, with an ASCII picture |
| `propose_spec(path, question, …)` | Files a gap a client hit — a question for a human, not an answer |
| `list_proposals(status)` | What's been raised and not yet ruled on |
| `open_questions()` | What the spec deliberately hasn't decided, plus the pending queue |
## What this server deliberately does not do
It has no idea who is connected or what they are building, and that is on
purpose. It knows about the specification; clients know about the server.
Nothing points the other way.
So checking whether an implementation still matches the spec is **the client's
job**. A client can read the values out of its own code and compare them against
what these tools return — it is sitting in that repository and the server is
not. A server that reached into a checkout to inspect it would have to know the
language, the file layout and the path, and would need updating every time
another implementation appeared.
The same reasoning is why the spec is served rather than shipped as a file:
- **It's outside every implementation.** A session working in one repository
can't read another's files, but it can call a service.
- **It's queried, not dumped.** `search_spec("friendly fire")` returns one rule.
Loading the whole specification into a model's context to answer one question
is exactly the waste this avoids.
- **Some answers are computed, not stored.** Levels are generated from a ramp
formula — level 6 exists in no file. `resolve_level` is the only correct way
to ask, and its integer truncation is load-bearing: a floating-point version
disagrees at four of the eight levels.
## When the spec doesn't cover it
A specification is never finished, and the gaps are where an assistant quietly
invents something. `propose_spec` gives that impulse somewhere to go that isn't
the source of truth:
```
propose_spec(
path = "rules.tank_reverse",
question = "Can a tank reverse without turning to face the new direction?",
suggestion= "It cannot — movement always turns the tank first",
rationale = "A build with free rotation would answer this differently",
)
```
The proposal lands in `proposals/` as its own file, marked `pending`. It does
**not** become part of the specification, and the tool says so in its result, in
its description, and in the server's connection instructions — a client that
files one is expected to tell the user and ask, not to build on it.
Deliberate limits, all of them the same limit:
- **Clients propose; only a person ratifies.** Accepting means a human editing
`spec/game-spec.yaml`. If clients could answer their own questions, two of
them would answer differently and you would be back to the drift the spec
exists to prevent — except now it would look authoritative.
- **Proposing against something already specified** is recorded as a *challenge*
and returns the current value. Until a human agrees, the current value stands.
- **A second proposal on the same path is refused**, and returns the first, so a
question gets asked once rather than by every client that trips over it.
- **Pending proposals surface in `open_questions`** so other clients can see the
question has been raised — clearly marked as carrying no authority.
`proposals/README.md` covers the human side: accept, decline, or leave it
pending, which is itself an answer.
## Destructible brick and per-level mazes
Everything inside the border is **brick**, and brick can be shot away — by both
sides, since sides matter for tanks and never for walls. A tile is 20x20 and
takes 40 damage, so two standard bullets open a hole that tanks then drive
through. The border itself is immune.
Each level has its own maze, so the level number is the only difficulty knob:
it sets how many enemies arrive, how fast, *and* the terrain they arrive into.
Level 1 is nearly open; level 8 is dense enough that shooting a path is usually
faster than finding one.
`get_maze(level)` returns the rectangles, the expanded tile list, and a picture:
```
########################################
#EE................EE................EE#
#EE................EE................EE#
#......................................#
#......BBBBBBBBBBB....BBBBBBBBBBB......#
...
#.............AA...AA...AA.............#
########################################
```
Use the expanded tiles rather than expanding the rectangles yourself — that
step, and the rule for which tile a bullet damages when it straddles two, are
where two builds most easily end up digging different holes from the same shots.
Adding a maze is just adding rectangles under `mazes.levels` in the YAML. The
test suite then holds it to the invariants that make a level playable:
tile-aligned, inside the area, non-overlapping, spawn and ally boxes clear, and
every entry point reachable on foot without destroying anything — digging is a
shortcut, never a requirement.
## Editing the spec
`spec/game-spec.yaml` is the whole thing, and it's meant to be edited by hand —
that's the point. The server re-reads it on every call, so a change takes effect
immediately without restarting anything.
The specification was seeded from a working implementation and is independent of
it from that point on. It leads: change a value here first, then make the
implementations follow.
## Tests
```bash
uv run pytest
```
`tests/test_spec.py` pins the spec's values and holds every maze to the
playability invariants. `tests/test_server.py` drives the server through an MCP
client — discover the tools, read their descriptions, call them.
`tests/test_http.py` starts the real process on a port and connects to it over
HTTP, the way a client does. `tests/test_proposals.py` covers the queue,
including the property that matters most: a proposal never changes what the
specification says.
## Layout
```
spec/game-spec.yaml the specification — the actual source of truth
server.py MCP server: tool definitions, their descriptions, and the transport
tank_spec/spec.py loading, searching, level maths, maze expansion
tank_spec/proposals.py the proposal queue — questions clients raised, never answers
proposals/ one file per proposal, awaiting a human decision
tests/ spec and maze invariants, proposals, MCP integration, HTTP transport
```
TDQS
A4.7/5.0
Scored across 6 tools
Disambiguation5/5
Each tool has a distinct purpose: listing sections, getting a section, searching the spec, resolving level semantics, retrieving maze layouts, and listing open questions. No overlaps or ambiguity.
Naming Consistency5/5
All tool names follow a consistent verb_noun pattern (list_, get_, search_, resolve_, open_), making the API predictable and easy to navigate.
Tool Count5/5
Six tools is well-scoped for a specification server, covering all necessary access patterns without unnecessary redundancy or bloat.
Completeness5/5
The tool set fully covers the domain: discovering, retrieving, searching, interpreting, and visualizing spec details, plus handling open questions. No obvious gaps.
Maintenance
ActivityMaintained
ResponsivenessNo issues