Turn-based multiplayer, play-by-mail style
3 min read
Not every multiplayer game needs everyone online at once. Chess, Words With Friends, async card duels are all play-by-mail: I move, you get notified, you move back tonight. The hard part was never the game rules. It's the plumbing: whose turn is it, what if two moves race, how does the other player even know it's their move?
Triggair's async matches handle exactly that plumbing. The server enforces turn order and uses optimistic concurrency (a version number) to reject stale moves, then notifies the next player through their inbox. Your game owns the state. Triggair never inspects it, so any board, hand, or word grid fits.
Create a match
Hand create the list of players and an initial state. The shape of state is entirely yours; here's a tic-tac-toe board.
const { match } = await tg.asyncMatch.create(
[me.id, opponent.id],
{
type: 'tictactoe',
state: { board: Array(9).fill(null), marks: { [me.id]: 'X', [opponent.id]: 'O' } },
},
);
// match = { id, turn_order, current_turn, state, version, status, winner }
turn_order and current_turn come back decided. You never track "whose turn" in your own database. The match is the source of truth.
Take a turn
To move, send the match id, the version you last saw, and the new state. The server accepts the move only if it's your turn and your version matches, then it advances current_turn and drops a notification in the next player's inbox.
const { match: m } = await tg.asyncMatch.get(matchId);
const board = [...m.state.board];
board[cellIndex] = m.state.marks[me.id];
const updated = await tg.asyncMatch.turn(m.id, {
version: m.version,
state: { ...m.state, board },
// if this move ends the game:
// end: { winner: me.id },
});
When someone wins, include end: { winner } on the same turn. The server sets status and winner, closing the match atomically with the final move, so there's no separate "declare winner" call to get out of sync.
Handle version conflicts
Optimistic concurrency means you don't lock anything. You just try, and handle the rare rejection. If the opponent moved between your get and your turn, your version is stale and the call fails. Re-fetch and let the player re-decide.
try {
await tg.asyncMatch.turn(m.id, { version: m.version, state: nextState });
} catch (err) {
if (err.code === 'async_conflict') {
const { match: fresh } = await tg.asyncMatch.get(m.id); // pull the new board
rerenderBoard(fresh);
return; // don't blindly resubmit - the situation changed
}
console.warn(err.agentHint); // e.g. "not this player's turn"
}
This is the whole reason the server holds version: two clients can never clobber each other's move, even if both players tap at the same instant.
Manage the player's games
tg.asyncMatch.mine() lists every match the player is in, so you can build your "your move" lobby from it. And forfeit gives a graceful exit when someone abandons a game.
const matches = await tg.asyncMatch.mine();
const yourMove = matches.filter(m => m.current_turn === me.id && m.status === 'active');
// bail out of a game:
await tg.asyncMatch.forfeit(staleMatchId);
Gotchas
- Always send the
versionfrom the state you rendered, not a cached one. That's what makes conflict detection work. - The inbox is the notification channel. Poll
tg.inbox.list()(or check on app open) to surface "it's your turn"; the next player is nudged there automatically. - Validate legal moves client-side for UX, but remember the server enforces turn order and concurrency, not your rules; keep authoritative game logic deterministic so both players compute the same outcome from
state.
Going further
Because state is opaque to Triggair, the same three calls run chess, a card game, or a co-op puzzle. Model your board, respect the version, and let the inbox tap players on the shoulder.