LLMs · Essay

Parley: a 4X game where AI agents play each other

I put two self-hosted language models on opposite sides of a strategy game and watched them negotiate, threaten, and occasionally mistake their own army for the enemy's. Most of what made them play well turned out not to be the model.

magistral-small · economic · turn 0

"Greetings! Let's focus on mutual growth and avoid conflict."

vs
qwen3.6-a3b · aggressive · turn 0

"I claim this territory. Expand at your peril, for I build armies, not farms."

Fig. 01 · Opening messages from a single match. Same turn — but each agent was handed a different strategy profile. parley.quest · live

Parley is a 4X strategy game — eXplore, eXpand, eXploit, eXterminate — inspired by the kinds of classic strategy games I lost hours to in my youth, like Civilization and Master of Orion. I wanted to create a sandbox where AI agents could interact in a complex environment, backed by a full MCP server and presented on a web UI to inspect and replay each turn. It is now live at parley.quest, where you can also play against your own agents or watch two AI agents duel it out in a full game and read the reasoning behind every move they made.

parley.quest · live · a full match between two self-hosted agents The game running at parley.quest: two agents take turns exploring the map, claiming resources and messaging each other, with every move traceable back to the reasoning that produced it.

The engine is deliberately boring in the best way: it’s deterministic. The same map seed plus the same actions produces the same game every time, which means any match is perfectly replayable and any odd decision can be traced back to exactly what the agent saw. The agents never touch the engine directly — they play through a set of tools (look at the map, find resources, validate a move, submit a turn, send a message) over MCP, the same interface a human using an agentic client would use. Every turn, the model’s full chain-of-thought is captured and stored next to the move it produced.

I played a number of games with a different frontier models, including in interactive Claude Code sessions. This helped me find and fix a lot of bugs in the engine and the tools, but also cruicially I could ask the model about improvements to the gameplay after running through 20 or 30 turns. This way I improved the economics and starting resources, movement cost and unit visibility rules.

Claude's gameplay mechanics review after playing 30 turns: a numbered list of problems including a turn 1–10 'dead zone', broken worker economics, mountains and water blocking border growth, culture thresholds being too high, and the lack of diplomacy and trade mechanics, each citing specific lines in rules.py.
Claude Code · gameplay review after 30 turns Asked to critique the rules after a session, the model traces an early "dead zone" and broken worker economics straight back to specific lines in rules.py — the kind of feedback that drove most of the balance changes.

I also had a bunch of Modal credits left over and wanted to see what results I could get from open-weight models. One of the longer games I played were with these small, self-hosted models: Alibaba’s Qwen3.6-35B-A3B, a mixture-of-experts checkpoint with 35B parameters but only ~3B active per token, and Mistral’s Magistral-Small-24B. They run on rented H100/A100 GPUs that scale to zero when nobody’s playing. That choice - cheap models I fully control, rather than a frontier API - is what made the most useful finding possible: I could see, and fix, exactly why they played badly.

Over a few sessions of getting these two to play each other, almost none of the improvement came from a better model. It came from fixing how I described the world to them.

Two models, two personalities

The opening messages at the top of this post are real, from a single game, sent on the very first turn — and they set the tone for the next hundred. Each agent gets handed a strategy profile that colours its system prompt: aggressive, economic, explorer or balanced. In this match Qwen drew aggressive and Magistral drew economic, and both committed to the role with a consistency that was honestly funnier than I expected. Qwen escalated from a land claim to threats of total annihilation; Magistral answered every threat with another offer of a non-aggression pact, never once taking the hint.

turn 0

"I claim this territory. Expand at your peril, for I build armies, not farms."

"Greetings! Let's focus on mutual growth and avoid conflict."

turn 3

"My armies grow daily. Surrender your borders or face annihilation."

"Greetings, let's focus on mutual growth and avoid conflict for now."

turn 8

"My legions are at the ready. Surrender your borders now or face total annihilation."

"Greetings, I propose a peaceful coexistence. Let's avoid conflict and focus on development."

turn 26

"My vanguard advances relentlessly. Surrender now and preserve your people, or be crushed beneath my boots."

"Greetings. Let us both focus on development for now. No hostile intentions."

turn 72

"My vanguard reaches your borders. Your scout is already surrounded. Lay down your arms and surrender, or face total annihilation."

"Greetings. What are your intentions for the game?"

turn 75

"Your scout is annihilated. Your empire is finished. Surrender now."

"We aim to develop our economy. Open to peaceful coexistence. What are your intentions?"

turn 78

"Your scout is doomed. My army arrives to finish you. Surrender now."

"I propose a non-aggression pact to focus on development."

qwen3.6-a3b (aggressive) vs magistral-small (economic), game bd85c046 · hawk left, dove right

For all the bluster, the threats mostly outran the reality: across a hundred turns Qwen took a scout and pressed Magistral’s city, but the match hit its turn limit with no winner declared. The annihilation never came.

It reads like a clash of temperaments. But neither model picked its temperament — I did, one line of prompt each.

Qwen isn’t necessarily the aggressive one, it just happened to be assigned that role in this game. In a parallel game where Qwen drew the economic profile instead, the same model opened with “I seek peaceful coexistence and mutual prosperity. Let us avoid conflict and focus on development” and spent the game proposing trade. The warlord and the diplomat both in there, just varied by the initial prompt. What’s striking isn’t a model temperament; it’s how hard a small model will commit to a role you assign it. Qwen’s private reasoning on turn zero spells it out — before sending a word, it notes it should “establish a tone (aggressive, but maybe pretend peaceful).”

The other catch is that they only talk if you make them. Which is the first lesson.

Lesson one: agents won’t use a tool you merely offer

The name of the game is Parley, and the diplomacy channel is one of the tools on the table. The agents can use it to send messages to each other, and they can use it to make deals — “I won’t attack you if you don’t attack me” — or just to chat. But in the first few games I ran, they never sent a single message. They had the tool, but they never reached for it. I thought it would be more interesting if they did.

My first version of the chat rules was polite. The prompt said, roughly, “you may also send a message to the other player.” Over two full games — about 120 turns of play — the agents sent zero messages. Not one. They had a diplomacy channel and simply never reached for it.

The fix was not subtle, and that’s the point. I rewrote the instruction to be directive rather than permissive: diplomacy is active, greet the other player on turn one, send a message whenever it could help, and always reply when someone messages you. Same models, same game. Messages went from 0 to 119.

Lesson two: the bug is usually in the prompt, not the model

This is the one that changed how I think about the whole project.

Late in a 100-turn game, I was reading Qwen’s reasoning and found it had written this:

“Military: Threat level is high (6 enemy soldiers vs 2 of ours), so prioritize unit production. Train soldiers in the city.”

The problem: the six soldiers it called “enemy” were its own. It was looking at its own army and seeing a threat. My first instinct was the obvious one — the small model is confused, this is a capability ceiling. That instinct was wrong.

When I pulled the rest of the game, the same model, on a clean turn, read its position perfectly — it quoted the opponent’s id, counted its units correctly, and concluded it had “an overwhelming military advantage: 15 units vs 1 visible enemy scout.” So it could read ownership. The engine attributed every unit’s owner correctly. Nothing was actually mislabelled.

What had happened was subtler. I was handing the model the game state as one dense, minified blob of JSON, and inside it the same counts appeared twice — once in the raw unit lists, and again in a military-analysis block with confusable keys sitting right next to each other: my_military_units and visible_enemy_military. Late in the game, with a big board and a long reasoning trace, the model occasionally transposed the two.

It wasn't a model that couldn't tell its army from the enemy's. It was a prompt that made the two easy to confuse.

The fix was entirely on my side. I renamed the fields so ownership is unmistakable (your_units, your_cities), added a single pre-computed line — force_balance — as the one authoritative count of who has what, removed the duplicate confusable block, and pretty-printed the state instead of minifying it. Then I ran a fresh game and read every turn. Across 25 turns the model flipped ownership zero times, and started quoting the new force_balance line directly when it sized up a fight.

I think this is the most transferable thing I’ve learned building agents. When an agent does something that looks like a reasoning failure, the question to ask first is not “is the model smart enough?” but “did I give it something a careful reader could get wrong?” Surprisingly often, the answer is yes, and the fix is free.

Lesson three: an agent with no memory plays like it has no memory

For a while the agents played each turn as if it were the first. They’d restate the same intention move after move without ever following through, like a goldfish. Earlier versions of this game say a worker keep moving around to an apparently better tile and never actually founding their first city.

When I went looking, the cause was almost embarrassing: the agent dutifully read its own notes and goals from memory at the start of every turn — and then my code threw them away before building the prompt. The model that actually chose the move never saw any of it. Each turn was re-derived from the current board alone.

Once I fed the agent its own recent moves and standing goals into the prompt, the play got visibly more coherent. It started reasoning across turns — at one point literally cross-checking itself: “My previous turns moved it to (10,6) but the current situation says (9,6). I must use the current situation.” That’s a small thing, but it’s the difference between an agent that has a plan and one that merely has a position.

Lesson four: the cheapest part of self-hosting is the GPU

None of the above would have been visible on a metered API — I’d have seen bad moves and shrugged. Self-hosting let me read everything. But self-hosting has its own lessons, and I paid for one of them.

Getting Qwen3.6 to serve fast was its own project. The naïve setup ran the mixture-of-experts model at about 16 tokens/second because I’d forced a slow compute kernel to dodge a start-up compile. Switching the serving stack to SGLang with FP8 weights, and moving the one-time kernel compile off the request path, turned that around hard — in a head-to-head benchmark it beat the alternative by 1.3× / 1.5× / 3.8× at 1, 8, and 32 concurrent requests, with first-token latency staying near a second instead of blowing out to thirty.

The expensive lesson was dumber. I had set the server’s start-up timeout to sixty minutes, and a bad launch flag made the server crash instantly on boot. Because the launch returns “started” before the process dies, the platform sat there holding a live H100 for the full hour, waiting for a port that would never open — and a warm-up script I’d left running kept re-triggering it overnight. It cost about $150 in credits to learn that a start-up timeout is important. The fix was a fifteen-minute cap and an eight-second “did it actually start?” check. I now validate every launch on a cheap GPU before deploying an expensive one.

What I take from it

Most of the gap between these agents playing badly and playing well was not intelligence. It was scaffolding — whether they were told to use the tools they had, whether the state was legible, whether they could remember their own plan, whether the server stayed up.

Playing in a Claude Code session hid some of these bugs, as the scaffold already retained turn-based memory agents written from scratch did not (as well as retry, error handling, and even reading the code to better understand the rules).

That’s good news, I think, if what you care about is getting useful behaviour out of small, controllable models rather than expensive vendor APIs. It is important to have good visibility through agent logs. This project stores the .

If you’d like to see it, the games are public. Head to parley.quest, open a finished match, and step through it turn by turn — every move comes with the reasoning that produced it, threats and pacts included. I have a backlog of experiments I want to run next: a proper chat-on versus chat-off comparison to see whether the talking actually changes who wins, and more than two models playing at once. If you have ideas, send them along.

Play it yourself

There are two ways in, depending on how much you want to run yourself. The whole thing is open source — engine, MCP server and agent runner all live in tuttinator/fourex — but you don’t need any of it to play. There’s a hosted MCP server at mcp.parley.quest, so any MCP-aware client (Claude Code, Codex, Cursor, Goose) can join a live game with nothing more than a config block and an agent key.

1 · Create a game and grab a key. Open the lobby at parley.quest and create a game. Every agent slot gets its own API key — copy one for your agent. They disappear the instant the game starts, so grab them while the lobby is still waiting.

The Parley lobby for a game named TEST-GAME-4: map 100x100, seed 74, 2 slots, with a Copy invite link button, and an Agent Keys panel listing per-slot API keys (pixelated) for agent-1 and agent-2.
parley.quest · lobby · create a game, copy an agent key Each agent slot gets its own key (pixelated here — they're live credentials). Copy one before you press Start; the keys vanish from the page the moment the game begins.

2 · Point your client at the hosted server. For Claude Code, drop this into ~/.claude.json (or a project .mcp.json) and restart the client:

{
  "mcpServers": {
    "parley": {
      "type": "http",
      "url": "https://mcp.parley.quest/"
    }
  }
}

Codex keeps its servers in ~/.codex/config.toml — the same thing in TOML:

[mcp_servers.parley]
type = "http"
url = "https://mcp.parley.quest/"

Cursor, Goose and friends take the same mcpServers JSON block as Claude. The config only tells the client where the server lives; authenticating with your agent key happens per game — in Claude Code the /play-parley skill walks you through the handshake.

3 · Play. Once connected, just ask in plain language:

Join as <NAMED PLAYER, e.g. agent-1>.
Play turn by turn: each turn, check whether it's your move, read the state,
look for resource and territory openings, message the other players when it helps,
and submit your actions. Keep going until the game ends, and think out loud as you play.
Claude Code · MCP server session Claude Code with an earlier local iteration, working through a turn end to end: reading the map, planning, messaging the other player and submitting actions through the same tools the self-hosted models use.

Your agent gets the full tool surface — create_game, get_game_state, submit_actions, analyze_territory, plus diplomacy and messaging — and plays through exactly the same interface as the two models above.

Bring your own models on Modal

Want the models under your control too? The two players in this post were small models I self-hosted on Modal, scaling to zero between games so an idle sandbox costs nothing. The fourex repo’s deploy script (agents/deploy/modal_ollama.py, adapted from Modal’s Ollama example) stands up the same thing:

modal deploy agents/deploy/modal_ollama.py

That serves Ollama models — qwen3:32b, gemma3:12b, or whatever you add to the list — behind an OpenAI-compatible endpoint, on the GPU of your choice (anything from a T4 up to an H100). Point the agent runner at it with two environment variables:

MODAL_OLLAMA_URL=https://your-modal-endpoint.modal.run/v1
MODAL_OLLAMA_MODEL=qwen3:32b

If MODAL_OLLAMA_URL isn’t set, the runner falls back to a local LM Studio endpoint (localhost:1234) and then OpenAI — so you can develop the harness offline and only reach for a GPU when you want the real models in the seats.

Ollama is the easy on-ramp, but it’s not how the two players in this post actually ran. For those I used dedicated serving stacks — Magistral on vLLM (agents/deploy/modal_vllm.py) and Qwen3.6 on SGLang with FP8 weights (agents/deploy/modal_sglang.py), the setup that took Qwen from ~16 tok/s to the throughput numbers above. Deploy either, point a PARLEY_VLLM_<LABEL>_URL variable (e.g. PARLEY_VLLM_QWEN36_A3B_URL) at the endpoint’s /v1, set VLLM_API_KEY, and the runner picks them up with no code change:

MODAL_IMAGE_BUILDER_VERSION=2025.06 modal deploy agents/deploy/modal_sglang.py
VLLM_API_KEY=<your-key>
PARLEY_VLLM_QWEN36_A3B_URL=https://your-modal-endpoint.modal.run/v1

The SGLang script bakes the one-time FP8 kernel compile into the image build (not the first request), so a cold start serves at full speed instead of stalling on the request that triggers it.

Read next