Checkmate winner incorrectly reported as a loss
Summary
When a player delivers checkmate, the result message incorrectly tells them "You lose!" even though they won.
Concrete scenario: the player is white. They play a Scholar's Mate (1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6?? 4.Qxf7#) and checkmate the black king. The UI then shows "Checkmate! You lose!" — it should say "You win!".
The same bug shows up in reverse: if the player is black and the bot delivers checkmate, the UI currently says "You win!" when it should say "You lose!".
Why
The bug is on the server. g.Turn is not updated when the game ends, so the sendGameOver helper reads the winner's side and flips it to the loser.
packages/server/game/game.go:113-119:
if outcome != chess.NoOutcome {
// game over
} else {
if g.Board.Position().Turn() == chess.White {
g.Turn = "white"
} else {
g.Turn = "black"
}
}
g.Turn is only updated on the non-terminal branch. After a checkmate move, g.Turn retains the side that just moved (the winner), not the side that needs to respond (the loser).
packages/server/handler/websocket.go:256-265:
winner := g.Turn
if status == "checkmate" {
if g.Turn == "white" {
winner = "black"
} else {
winner = "white"
}
}
The inference assumes g.Turn is the checkmated side. It is actually the winning side. The rule flips the winner to the loser.
Proposed Fix
Stop inferring the winner from g.Turn. Compute the winner from the position at the moment the game ends, and pass it explicitly to sendGameOver. Three changes:
1. packages/server/game/game.go
Change MakeMove and GetBotMove to also return the winner. Compute the winner inside the write lock so there is no race.
// MakeMove now returns (status, winner, error).
func (g *Game) MakeMove(moveStr string) (string, string, error) {
// ... existing logic through Move + outcome detection ...
if outcome != chess.NoOutcome {
switch {
case method == chess.Checkmate:
g.Status = "checkmate"
// Side to move is the one in checkmate (the loser).
if g.Board.Position().Turn() == chess.White {
return g.Status, "black", nil
}
return g.Status, "white", nil
case method == chess.Stalemate:
g.Status = "stalemate"
return g.Status, "draw", nil
default:
g.Status = "draw"
return g.Status, "draw", nil
}
}
// Game continues: keep the existing g.Turn update.
if g.Board.Position().Turn() == chess.White {
g.Turn = "white"
} else {
g.Turn = "black"
}
return g.Status, "", nil
}
Apply the same shape to GetBotMove. winner is "" while the game is still in progress, "white" or "black" for checkmate, and "draw" for stalemate and other draws.
2. packages/server/handler/websocket.go
Change sendGameOver to take the winner as a parameter:
func (h *WSHandler) sendGameOver(g *game.Game, status string, winner string) {
data, _ := json.Marshal(map[string]string{
"status": status,
"winner": winner,
})
msg := WSMessage{
Type: "game_over",
Payload: data,
}
msgData, _ := json.Marshal(msg)
h.Hub.Broadcast(g.ID, msgData)
}
Update both call sites:
readPump move case (around line 179): h.sendGameOver(g, status, winner) using the value returned by the new MakeMove.
handleBotMove (around line 210): h.sendGameOver(g, g.Status, winner) using the value returned by the new GetBotMove.
3. packages/server/handler/handler.go
The REST handler at line 137 (MakeMove) also calls g.MakeMove. Update it to consume the new (status, winner, err) triple. The existing response shape (which only returns status) can stay as-is, or include winner.
Frontend
No changes required. packages/web/src/app/chess/page.tsx:50-58 already uses result.winner === playerColor to decide between "win" and "lose". Once the server sends the correct winner, the existing handler renders the right message.
Steps to reproduce
- Start a bot game as white on
http://localhost:3000/chess.
- Play a Scholar's Mate:
1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#.
- Observe the result message: "Checkmate! You lose!" (it should say "You win!").
The same scenario in reverse: as black, let the bot checkmate you, the UI says "You win!" instead of "You lose!".
Acceptance Criteria
Out of Scope
- Resign handling (tracked separately).
- Draw offers.
- Time controls.
- Multi-game sessions / rematch UI.
Checkmate winner incorrectly reported as a loss
Summary
When a player delivers checkmate, the result message incorrectly tells them "You lose!" even though they won.
Concrete scenario: the player is white. They play a Scholar's Mate (
1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6?? 4.Qxf7#) and checkmate the black king. The UI then shows "Checkmate! You lose!" — it should say "You win!".The same bug shows up in reverse: if the player is black and the bot delivers checkmate, the UI currently says "You win!" when it should say "You lose!".
Why
The bug is on the server.
g.Turnis not updated when the game ends, so thesendGameOverhelper reads the winner's side and flips it to the loser.packages/server/game/game.go:113-119:g.Turnis only updated on the non-terminal branch. After a checkmate move,g.Turnretains the side that just moved (the winner), not the side that needs to respond (the loser).packages/server/handler/websocket.go:256-265:The inference assumes
g.Turnis the checkmated side. It is actually the winning side. The rule flips the winner to the loser.Proposed Fix
Stop inferring the winner from
g.Turn. Compute the winner from the position at the moment the game ends, and pass it explicitly tosendGameOver. Three changes:1.
packages/server/game/game.goChange
MakeMoveandGetBotMoveto also return the winner. Compute the winner inside the write lock so there is no race.Apply the same shape to
GetBotMove.winneris""while the game is still in progress,"white"or"black"for checkmate, and"draw"for stalemate and other draws.2.
packages/server/handler/websocket.goChange
sendGameOverto take the winner as a parameter:Update both call sites:
readPumpmove case (around line 179):h.sendGameOver(g, status, winner)using the value returned by the newMakeMove.handleBotMove(around line 210):h.sendGameOver(g, g.Status, winner)using the value returned by the newGetBotMove.3.
packages/server/handler/handler.goThe REST handler at line 137 (
MakeMove) also callsg.MakeMove. Update it to consume the new(status, winner, err)triple. The existing response shape (which only returnsstatus) can stay as-is, or includewinner.Frontend
No changes required.
packages/web/src/app/chess/page.tsx:50-58already usesresult.winner === playerColorto decide between "win" and "lose". Once the server sends the correct winner, the existing handler renders the right message.Steps to reproduce
http://localhost:3000/chess.1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#.The same scenario in reverse: as black, let the bot checkmate you, the UI says "You win!" instead of "You lose!".
Acceptance Criteria
go build ./...passes (cd packages/server && go build ./...).go test ./...passes.pnpm typecheck:webandpnpm lint:webpass.packages/web/tests/still pass).POST /api/games/{id}/movehandler.Out of Scope