cd /news/ai-agents/two-ai-chats-one-game-board-and-a-se… · home › topics › ai-agents › article
[ARTICLE · art-159806] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Two AI chats, one game board, and a server that trusts neither

A developer running the random chat and gaming site Fyreflock built a server-side referee, GameSession, to let language models play two-player games through an MCP connector without leaking opponents' secrets. Because a single server process now holds both seats, each seat receives only a rendered text board and a per-game redacted state view instead of the raw game state, with moves validated through the same ingestMove reducer used between browsers. Games are kept in memory and discarded with the conversation, and each game defines its own redaction rather than relying on a generic field-level redactor.

by read11 min views1 publishedOct 28, 2026

Every game in Fyreflock was written for two browsers in lockstep, with no referee anywhere. That held up right until I let a language model take a seat, because a model reads everything it's handed, and the thing handing it over was now a server holding both halves of the board.

Some context. Fyreflock is a random text and video chat I run, with two-player games you can open against the stranger mid-conversation. It also has an MCP connector for Claude, ChatGPT and Claude Code (github.com/FuloxDev/fyreflock-claude, which holds the plugin and setup; the server code quoted below lives in the main app). So the person you're playing Connect 4 against might be in a browser tab, or typing "4" into their own Claude.

This is how the games survived that.

Between two browsers, both sides run the same pure reducer from the same seed. A move crosses the WebRTC data channel and the receiving side runs it through one function before applying it:

// packages/shared/src/games/sync.ts (trimmed)
export async function ingestMove<S, M>(def: GameDef<S, M>, state: S, move: M, seat: Seat, env: MoveHashes) {
  if (!def.isLegal(state, move, seat)) return { ok: false, reason: 'ignored' };

  if (def.verifyIncoming) {
    const honest = await def.verifyIncoming(state, move, seat).catch(() => false);
    if (!honest) return { ok: false, reason: 'cheat' };
  }

  const next = def.reduce(state, move, seat);
  // ...plus a state-hash comparison that only means something between lockstep peers
  return { ok: true, state: next };
}

Secrets are handled by omission. In Rock Paper Scissors you send a hash of your throw first and the throw plus its salt later, and the other side checks the reveal against the hash. A Battleship fleet never leaves the device that placed it. Nobody needs a referee, because neither peer ever holds the other one's secret.

The connector lets an assistant play through tools: fyreflock_game_invite, fyreflock_game_move, fyreflock_game_board and a few more. The person says "4", the model calls the move tool, and the server applies it.

Which means one process now holds both seats. If it hands Claude the raw Would You Rather state, Claude knows the other person's pick before it asks you for yours. There's no clever attack in that. The model just reads the JSON.

So the server became the referee. GameSession owns the one true state per conversation, applies moves through the same ingestMove the browsers use, and gives each seat its own view and nothing else:

// apps/server/src/gamesession.ts
/** What this seat is allowed to know. Never the raw state. */
view(seat: Seat): { text: string; data: Record<string, unknown> } {
  const g = this.game as unknown as AgentGame<unknown, unknown>;
  return { text: g.render ? g.render(this.state, seat) : '', data: g.redact(this.state, seat) };
}

Games live in memory and die with the conversation. A restart can't leave a half-played board that nobody recalls starting, which I think is the correct way to lose data.

Here's one move from a text assistant:

sequenceDiagram
    participant P as Person A
    participant M as A's assistant
    participant S as Fyreflock server
    participant G as GameSession
    participant B as Person B
    P->>M: 4
    Note over M: Claude Code only, a hook checks the move is what P typed
    M->>S: fyreflock_game_move with move 4
    S->>S: screen the text, rate limit
    S->>G: play seat 0, input 4
    G->>G: parse, then ingestMove inside the queue
    G-->>S: ok, new state
    S->>B: game.moved, or game.state if B has a box
    S-->>M: text board and redacted state for seat 0, in an envelope
    M-->>P: the board

Nothing in my state shapes says which fields are private. Between browsers nothing had to, since a peer only ever received what the other peer chose to send. A generic redactor would have to guess, and guessing wrong here means telling somebody the answer.

So each text-playable game got its own. Would You Rather shows the whole idea:

// packages/shared/src/games/agent.ts
redact(s, seat) {
  const both = s.picks[0] !== null && s.picks[1] !== null;
  return {
    round: s.round + 1,
    your_pick: s.picks[seat] === null ? null : (s.picks[seat] as number) + 1,
    // Only once the card is face up. Before that it is the answer.
    their_pick: both ? (s.picks[other(seat)] as number) + 1 : null,
    agreed: s.agreed,
    done: s.done,
  };
},

Two Truths and a Lie hides the lie from the setter's own payload too. You obviously know which of your statements is false. Your model would also know, and it would happily repeat it into a conversation the other person is reading.

Pig was a fun one. Its dice come from the seed, so whoever holds the seed can read the next roll before deciding whether to hold. Between browsers that was harmless. Once the server rolls and the client only watches, the seed has to go:

// packages/shared/src/games/agent.ts
board: (s) => {
  const { seed: _seed, ...rest } = s;
  return rest;
},

Two more MCP details bit here. Hosts pass structuredContent to the model, so the redacted object is the only thing that ever goes in it, and the readable board goes in content. And that board gets wrapped in the same envelope as a chat line, because Two Truths statements and Word Chain words are text a stranger typed, which makes them a prompt injection channel with a game skin on it. For the same reason the 400-character move argument goes through the outbound screen like any message. 400 characters is plenty of room for a phone number or an API key.

Two Truths commits to which statement is false and reveals it after the guess, so a modified client can't pick whichever one it wasn't caught on. Between browsers each side keeps its own salt.

A model can't keep a secret from its own transcript. The salt would sit right next to the answer it's meant to hide. So the server keeps the salt, makes the commitment for the player, and produces the reveal when the game's phase asks for it. The hash check still runs and still rejects a reveal that doesn't match. Only the custody moved.

The lie itself arrives as its own tool argument, never inside the move text:

// apps/server/src/mcp/tools.ts
inputSchema: {
  conversation_id: z.string().min(1).optional(),
  move: z.string().min(1).max(400),
  // Two Truths only. A separate argument rather than part of the move text because
  // it must never travel back to the model, and anything inside `move` is echoed.
  lie_index: z.number().int().min(1).max(3).optional(),
  person_asked: personAsked,
},

ingestMove is async. Two moves landing close together could both read the same state, both pass the checks against it, and both reduce from it, with the second quietly overwriting the first. With people clicking boards that's rare. With two models firing tool calls it's the normal case, since neither of them is waiting to watch a board redraw.

So every state change goes through a promise chain:

// apps/server/src/gamesession.ts
private serialise<T>(work: () => Promise<T>): Promise<T> {
  const run = this.queue.then(work);
  // Chained on the settled result so one rejection cannot wedge the queue.
  this.queue = run.then(() => undefined, () => undefined);
  return run;
}

The first version only routed play through it. The bot's turn and the Two Truths setter wrote this.state on their own, after their own awaits, while the queue's comment promised it protected the state. Good comment. Wrong code.

Later the connector grew an MCP Apps widget, a small chat box drawn inside the conversation. Hosts unload that iframe whenever you look at another chat, and a WebRTC connection dies with it, so since commit ac0a5aa every match with a box in it is relayed through the server:

// apps/server/src/hub.ts
const relay = kindA === 'agent' || kindB === 'agent' || boxed(aId, kindA) || boxed(bId, kindB);

No data channel means the lockstep engine had nothing to ride. But the referee already existed, so it moved onto the box's WebSocket: five socket messages that mirror the tools, each ending in a game.state to every seat holding a box. In the box, the model never touches a move. It only gets a one-line note about the game, so "how's the game going?" still has an answer.

Four ways in, one referee:

flowchart LR
    T[Text assistant, no widget] -->|fyreflock_game_move| D
    W[Chat box on its WebSocket] -->|game.move| D
    F[Chat box whose host refused the socket] -->|fyreflock_app_game| D
    R[fyreflock.com tab in a relayed chat] -->|game.move| D
    D[GameDesk finds the session] --> Q[GameSession queue]
    Q --> I[ingestMove checks legality and commits, then reduces]
    I --> V[render and redact for a model]
    I --> X[board for a box]

The box draws your own move before the server answers, keyed on the state it was made from. The next board from the server is a new object, so the guess drops away by itself:

// apps/web/src/lib/relayGame.ts
const shown = live && local && local.base === live.state ? local.next : live?.state;
// ...
play: (move) => {
  if (def && live && shown && def.isLegal(shown, move, live.seat)) {
    setLocal({ base: live.state, next: def.reduce(shown, move, live.seat) });
  }
  send({ t: 'game.move', move });
},

And when the referee says no, the hub sends the true board straight after the refusal, so a box that drew the move early takes it back. The socket also got the rate limit the tool path already had: 60 moves per 10 seconds per person.

Why bother with the socket when a tool call works? The host probe I ran on 2026-10-19 measured both from inside the iframe:

On Claude and ChatGPT the socket answers in 270 to 283 ms and the host-proxied tool call takes 700 to 1116 ms. VS Code is the odd one out at about 100 ms, because its proxy runs locally. On loopback, a move reached the other box in 4 ms. Your own move costs nothing to see either way, thanks to the optimistic draw.

The box plays all 25 turn-based games, while the text tools offer 5, because only those have a board you can read as characters. The three realtime games need a data channel and stay on the website. The games with a secret play in the box exactly as they do in a browser, salt held on the client. One catch: that secret lives in the box instance, so if the host reloads the box mid-game, the salt is gone and the game ends with a note. A perfect-information game just gets its board handed back.

The first commit of all this had twenty-one passing tests. A review the same morning found that nothing about two-player games actually worked, and the review was right.

The invitation reached nobody. The function that drains a player's mailbox handled seven message types and silently threw away game.invite and game.moved, under a comment saying anything else was reported rather than dropped.

Two Truths was registered, listed and invitable, and its parser returned null for every input, so each move came back "illegal" with a hint telling the model to retry the one thing that could never work. The loser never saw the end, because the session was torn down inside the winner's call, so their next move was answered with "there is no game running". A person playing the computer while they waited, who then matched and accepted a real game, had their next move land on the bot's board while the stranger stared at an empty one.

And any third account could end two other people's game, because quitting checked that a game existed instead of checking that you were in it.

My favourite: the bot test asserted solo === true on a session it had just built with a null second seat. It tested that I can type.

The fix was 657 inserted lines across 10 files, including a new 263-line test file covering the layer where four of these were hiding: the mailbox, the board, move routing, and who's allowed to end a game. The units were fine. The thing between them was broken.

I'd mark private fields in the state types from day one. "Secret by not sending it" was a perfectly good rule for two peers, and it left me with zero information about what to hide once one process held everything. Every redactor was written by reading a reducer and asking "would this spoil the game?"

I'd also write the first test as a whole conversation: two accounts, an invite, an accept, some moves, a finish both sides can see. That's roughly what the 263-line file became, written after the review instead of before it.

The server stops illegal moves and hides secrets. What it can't do on its own is stop a model from playing a perfectly legal move its person never chose.

In Claude Code it's enforced. The plugin has a PreToolUse hook on the move tool that compares the move with what you typed, and the rule is equality. Its first rule was "is the sent text somewhere in what they typed", and a review found fifteen ways through that in one sitting. Typing 4 works, tell them 4 works, and column 4 please gets refused. On claude.ai and ChatGPT there are no hooks, so the rule is one sentence in the tool description: "they are playing, not you". The box sidesteps the whole thing by keeping the model out of the move path, and the text tools are still there for hosts that can't draw it.

So here's what I keep going back and forth on. If your MCP tool acts for one person against another, would you enforce the exact words and annoy everyone who types like a human, or let the model paraphrase and accept that some day it plays a move nobody picked?

── more in #ai-agents 4 stories · sorted by recency
── more on @fyreflock 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/two-ai-chats-one-gam…] indexed:0 read:11min 2026-10-28 · —