shooting-game
# shooting-game MCP server
Lets Claude (via Claude Desktop or Claude Code) join a real match of the
shooting game as the second player and actually play it — reading the
arena through a real, live browser and moving/shooting through the same
inputs a human would use.
It does **not** re-implement any game logic. Each tool call drives a
headless (or visible) Chromium instance pointed at the real Angular app
via [Playwright](https://playwright.dev/), reading state out of the DOM
and sending real keyboard input — the same trick used to test the
`vs computer` bot in this repo. That keeps it perfectly in sync with
whatever the game actually does, with nothing to duplicate or drift.
## How a match works
1. A human opens the game and clicks **"vs claude (mcp)"** on the home
screen. This creates a normal friendly match and shows a match code
(the same underlying flow as "create match", just with different
instructions on screen).
2. The human gives Claude that code.
3. Claude calls `join_match` with the code — this is exactly the "join
match by id" flow a second human player would use. Claude is always
the second player to join (`PLAYER_B`).
4. Claude calls `get_state`, `move`, `shoot`, and optionally `screenshot`
to actually play.
## Setup
```bash
npm install # also downloads a Chromium build for Playwright
npm run test:smoke # optional: verifies the whole pipeline against a live game
```
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `GAME_CLIENT_URL` | `https://shooting-game-eight.vercel.app` | Base URL of the Angular client to connect to. Point this at `http://localhost:4200` to play against a local `ng serve`. |
| `MCP_HEADLESS` | `false` | Set to `true` to run the browser headless. Leave it `false` if you want to actually watch Claude play. |
### Register with Claude Code
```bash
claude mcp add shooting-game -- node /absolute/path/to/mcp-server/index.js
```
(or add it to a project's `.mcp.json` with the same command/args.)
### Register with Claude Desktop
Add an entry to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"shooting-game": {
"command": "node",
"args": ["/absolute/path/to/mcp-server/index.js"],
"env": {
"GAME_CLIENT_URL": "https://shooting-game-eight.vercel.app",
"MCP_HEADLESS": "false"
}
}
}
}
```
### Hosted connector (claude.ai)
There's a second entry point, `http-server.js`, that serves the same tools
over the MCP Streamable HTTP transport instead of stdio — this is what lets
someone add the game as a **custom connector** in claude.ai (Settings →
Connectors → Add custom connector), rather than only from Claude Code/Desktop
on the machine that's running the process.
Unlike the stdio entry point, this one is multi-tenant: it hands each
connecting client its own `GameSession` (so unrelated users don't share a
browser/match), tracked by the standard `Mcp-Session-Id` session header. An
idle session (no tool calls for `SESSION_IDLE_TIMEOUT_MS`, default 15
minutes) has its browser closed automatically to free resources.
```bash
npm run start:http # listens on $PORT (default 3000), endpoint is POST /mcp
```
**Deploying (Render):** this needs a persistent Node process with Chromium
installed, not a serverless function — build the included `Dockerfile`
(based on the official `mcr.microsoft.com/playwright` image, pinned to the
same Playwright version as `package.json`) as a Render **Web Service**. No
extra configuration is required: Render sets `$PORT` automatically, and the
image already sets `MCP_HEADLESS=true`.
Then in claude.ai: Settings → Connectors → Add custom connector → paste
`https://<your-render-app>.onrender.com/mcp` as the URL, no authentication.
**Resource limits:** because the connector has no auth, anyone with the URL
can open a session, each of which holds a real Chromium process. Two knobs
guard against that:
| Variable | Default | Purpose |
|---|---|---|
| `MAX_CONCURRENT_SESSIONS` | `6` | Hard cap on simultaneous browser sessions across all connected clients; `join_match` fails with a "Server busy" error past this. |
| `SESSION_IDLE_TIMEOUT_MS` | `900000` (15 min) | How long a session can go without a tool call before its browser is closed and the slot freed. |
## Tools
| Tool | Purpose |
|---|---|
| `join_match({ code? })` | Join a friendly match by code (or random matchmaking if omitted). Launches the browser session. |
| `get_state()` | Round, score, your position/health, opponent's position/health, arena size, any round/match-end announcement. |
| `move({ direction, holdMs? })` | Hold `ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight`. Also sets facing — see below. |
| `shoot({ holdMs? })` | Fire in the direction you last moved. |
| `screenshot()` | PNG of the current arena, if Claude would rather look than read coordinates. |
| `leave_match()` | Disconnects and closes the browser. |
**There is no separate aim.** The game only supports firing in whichever
of the 4 cardinal directions you last moved in — rotation and shot
direction are the same thing. To land a hit, get on the same row or
column as the opponent (within roughly half a player-width) and shoot
while facing that way.
## Notes
- The stdio entry point (`index.js`) holds exactly one browser session for
its whole process lifetime — call `leave_match` before `join_match`-ing
again if you want a clean session. The HTTP entry point (`http-server.js`)
gives each connected client its own session instead (see above).
- Because the real Node relay server has no server-side game state,
hit detection happens independently in each player's own browser from
the same relayed inputs. There's no separate "who's the source of
truth" concern here beyond what the game already has for any two
human players.
TDQS
Scored across 6 tools
Each tool targets a distinct action: joining, reading structured state, moving, shooting, screenshotting, and leaving. get_state and screenshot both provide observation but are clearly separated as text data vs visual image, so no real ambiguity.
join_match, get_state, and leave_match follow a clear verb_noun pattern, and move/shoot are intuitive imperative verbs. screenshot is a minor deviation because it is a noun used as an imperative rather than a verb_object form like take_screenshot.
Six tools is well-scoped for a real-time browser game agent: lifecycle, movement/action, and observation are covered without redundant utilities. Every tool earns its place.
The set covers the full match lifecycle: joining, observing via both state and screenshot, acting via move and shoot, and leaving. The shoot-direction note confirms aiming is handled through movement, so no separate aim tool is needed.