Skip to content

[Bug] Fix checkmate winner inference #22

Description

@dev-rkb

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

  1. Start a bot game as white on http://localhost:3000/chess.
  2. Play a Scholar's Mate: 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#.
  3. 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

  • go build ./... passes (cd packages/server && go build ./...).
  • go test ./... passes.
  • Manual: as white, deliver checkmate against the bot — UI shows "You win!".
  • Manual: as black, the bot delivers checkmate — UI shows "You lose!".
  • Stalemate still says "draw" / "It's a draw."
  • pnpm typecheck:web and pnpm lint:web pass.
  • E2E test added: simulate a Scholar's Mate sequence and assert the result message contains "win" (case-insensitive) for the winning side.
  • No regression in the move and bot-move flows (existing e2e tests in packages/web/tests/ still pass).
  • No regression in the REST POST /api/games/{id}/move handler.

Out of Scope

  • Resign handling (tracked separately).
  • Draw offers.
  • Time controls.
  • Multi-game sessions / rematch UI.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions