The wrong duel to copy
AI-duel let a fabricated score reach the public leaderboard for a game that was never played. The obvious model to copy was PvP, the other duel mode. The one that actually fit was Daily Challenge instead.
A security review pass on an unrelated post turned up something real: AI-duel’s end-game and leaderboard submission trusted whatever score, winner, and best word the client sent, with nothing checking any of it against what actually happened in the game. Anyone could start a game and post any result for it directly, no play required, and it would land on the public leaderboard.
PvP looked like the obvious model to copy first, since it already runs server-authoritative and shares the “duel” name. That instinct didn’t survive contact with the details. PvP’s whole design tolerates a server round trip on every single word because there’s a real human on the other end who might take anywhere from a few seconds to a full minute anyway; the timeout and forfeit machinery exists specifically for an opponent who might not respond at all. An AI opponent never stalls and never waits to be waited for, so none of the reasoning that makes PvP’s model worth its cost actually applies here.
Daily Challenge turned out to be the right shape, despite not being a duel at all. It already does exactly this: play the whole thing instantly and locally, then at submission time replay the full history through the real game engine server-side and use whatever that replay computes, never what the client claims. The same pattern extends cleanly to two actors instead of one, with the same amount of added latency during actual play: none.
export type ReplayResult =
| {
ok: true;
playerScore: number;
opponentScore: number;
winner: 'player' | 'opponent' | 'draw';
totalTurns: number;
bestWord: string | null;
bestWordScore: number | null;
bestWordMultiplier: number | null;
}
| { ok: false; reason: string; failingWord?: string; failingIndex?: number; actor?: DuelTurn };
One more thing turned up while working out what the replay needed to read from. The AI opponent’s own word choice isn’t something that can be recomputed after the fact either: it comes from a live query against the dictionary and reacts to the player’s own recent play, not from anything derivable purely from the game’s stored seed. Both sides of the match needed to become a reliably logged sequence, actor and word, in the order they actually happened, not just the player’s.
Two existing end-to-end tests turned out to already be doing exactly what the gap allowed: calling the submission endpoint directly with a made-up score and no recorded moves at all, because nothing had ever required otherwise. Both needed rewriting to actually play real words through first before the rest of the suite would pass again, which is as good a confirmation as any that the gap was real and that closing it changed something.