Summary
Once a chess game starts, the player has no way to:
- Resign the current game
- Start a new game without manually refreshing the page
Add two buttons to the in-game view: Resign and New Game. The first sends a new resign message over the WebSocket and shows the result. The second returns the user to the lobby.
This is a small feature that touches both the Go backend and the Next.js frontend.
Why
Currently packages/web/src/app/chess/page.tsx only has the lobby form. Once a game starts, the player is stuck until the game ends naturally. There's no resign handler on the server either (packages/server/handler/websocket.go:126-186 only handles join, move, ping).
Data Contract
New WebSocket message (frontend → server)
{ "type": "resign", "payload": { "player": "player" } }
The player field is the same identifier used in the join message.
New game state value
Game.Status (in packages/server/game/game.go:14) gets a new value: "resigned".
Existing game_over message is reused
packages/server/handler/websocket.go:256-278 already broadcasts:
{ "type": "game_over", "payload": { "status": "resigned", "winner": "black" } }
The winner is the opposite of the player who resigned (i.e. the bot wins if the human resigns).
Files to Change
Backend
1. packages/server/game/game.go (MODIFY)
Add a new method after MakeMove (around line 122). Place it next to the other state mutators.
func (g *Game) Resign(playerID string) (string, string, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.Status != "playing" {
return "", "", ErrGameNotPlaying
}
if g.BotMode {
g.Status = "resigned"
return g.Status, g.BotColor, nil
}
color, ok := g.findPlayerColor(playerID)
if !ok {
return "", "", &GameError{"player not in game"}
}
g.Status = "resigned"
winner := "black"
if color == "black" {
winner = "white"
}
return g.Status, winner, nil
}
func (g *Game) findPlayerColor(playerID string) (string, bool) {
for color, pid := range g.Players {
if pid == playerID {
return color, true
}
}
return "", false
}
The bot-mode shortcut is intentional: in a bot game the human is always the non-bot color, so the bot is the winner by definition.
2. packages/server/handler/websocket.go (MODIFY)
Add a new payload type near the existing ones (around line 41):
type ResignPayload struct {
Player string `json:"player"`
}
Then add a new case in the switch in readPump (after the move case, around line 183):
case "resign":
if client.PlayerID == "" {
h.sendError(client, "must join game first")
continue
}
var payload ResignPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
h.sendError(client, "invalid resign payload")
continue
}
status, winner, err := g.Resign(payload.Player)
if err != nil {
h.sendError(client, err.Error())
continue
}
h.sendGameUpdate(g)
h.sendGameOver(g, status, winner)
3. packages/server/handler/websocket.go (MODIFY — sendGameOver)
The current sendGameOver (line 256) infers the winner from g.Turn, which won't work for resignations. Change the signature so the caller passes the winner explicitly:
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)
}
Then update the two existing callers in the same file:
readPump move case (line 179): h.sendGameOver(g, status, inferWinner(g, status)) — see helper below
handleBotMove (line 210): same
Add a small helper near sendGameOver:
func inferWinner(g *game.Game, status string) string {
if status != "checkmate" {
return ""
}
if g.Turn == "white" {
return "black"
}
return "white"
}
The checkmate branch needs g.mu.RLock() to read g.Turn. Acquire the read lock inside the helper or pass the turn in.
Frontend
4. packages/web/src/components/chess/GameControls.tsx (NEW)
A small component for the two buttons.
interface GameControlsProps {
onResign: () => void;
onNewGame: () => void;
disabled: boolean;
gameOver: boolean;
}
export function GameControls({ onResign, onNewGame, disabled, gameOver }: GameControlsProps) {
const handleResign = () => {
if (window.confirm("Resign this game? You will lose.")) {
onResign();
}
};
return (
<div className="game-controls">
<button
type="button"
onClick={handleResign}
disabled={disabled || gameOver}
className="control-btn resign-btn"
>
Resign
</button>
<button
type="button"
onClick={onNewGame}
className="control-btn new-game-btn"
>
New Game
</button>
</div>
);
}
5. packages/web/src/app/chess/page.tsx (MODIFY)
-
Add import near the top:
import { GameControls } from "@/components/chess/GameControls";
-
Add a handleResign callback next to handleMove (around line 86):
const handleResign = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: "resign", payload: { player: "player" } }));
}, []);
-
Add a handleNewGame callback next to handleCreateGame (around line 73):
const handleNewGame = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
setGameId(null);
setGameState(null);
setError(null);
}, []);
This must close the existing WebSocket. The current useEffect cleanup (line 70) handles that on unmount, but we want to do it explicitly here so the next gameId change triggers a fresh connection.
-
Render the component in the game view, just after the .game-info block (around line 164):
<GameControls
onResign={handleResign}
onNewGame={handleNewGame}
disabled={!isMyTurn || !connected}
gameOver={currentGameState.status !== "playing"}
/>
-
Update the game_over handler (lines 50-58) to handle resigned:
} else if (msg.type === "game_over") {
const result = msg.payload;
if (result.status === "checkmate") {
setError(`Checkmate! ${result.winner === playerColor ? "You win!" : "You lose!"}`);
} else if (result.status === "stalemate") {
setError("Stalemate! It's a draw.");
} else if (result.status === "resigned") {
setError(`${result.winner === playerColor ? "Opponent resigned. You win!" : "You resigned."}`);
} else {
setError(`Game over: ${result.status}`);
}
}
6. packages/web/src/app/globals.css (MODIFY)
Append a new block at the end of the file (after line 276):
/* Game Controls */
.game-controls {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
justify-content: center;
}
.control-btn {
padding: 0.5rem 1.25rem;
font-size: 0.9rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: inherit;
cursor: pointer;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
}
.control-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.resign-btn:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.3);
border-color: rgba(255, 80, 80, 0.6);
}
.new-game-btn:hover:not(:disabled) {
background: rgba(80, 160, 255, 0.25);
border-color: rgba(80, 160, 255, 0.6);
}
Acceptance Criteria
Backend
Frontend
Out of Scope
- Draw offers / acceptance
- Time controls / clocks
- Multiplayer (non-bot) resign UI — the backend supports it but the lobby doesn't yet expose a way to start a multiplayer game
- Reconnecting to an in-progress game
Hints
- Branch from
main and name it issue/<this-number>-resign-new-game (per CONTRIBUTING.md)
- The WebSocket message shape lives in
packages/web/src/app/chess/page.tsx:39-65 — keep the new message consistent
- The Go
Game struct uses a sync.RWMutex (packages/server/game/game.go:20). Hold the write lock for any state mutation
- Run both servers locally (
go run main.go + pnpm dev:web) to manually test
gofmt your Go changes before committing
Summary
Once a chess game starts, the player has no way to:
Add two buttons to the in-game view: Resign and New Game. The first sends a new
resignmessage over the WebSocket and shows the result. The second returns the user to the lobby.This is a small feature that touches both the Go backend and the Next.js frontend.
Why
Currently
packages/web/src/app/chess/page.tsxonly has the lobby form. Once a game starts, the player is stuck until the game ends naturally. There's no resign handler on the server either (packages/server/handler/websocket.go:126-186only handlesjoin,move,ping).Data Contract
New WebSocket message (frontend → server)
{ "type": "resign", "payload": { "player": "player" } }The
playerfield is the same identifier used in thejoinmessage.New game state value
Game.Status(inpackages/server/game/game.go:14) gets a new value:"resigned".Existing game_over message is reused
packages/server/handler/websocket.go:256-278already broadcasts:{ "type": "game_over", "payload": { "status": "resigned", "winner": "black" } }The winner is the opposite of the player who resigned (i.e. the bot wins if the human resigns).
Files to Change
Backend
1.
packages/server/game/game.go(MODIFY)Add a new method after
MakeMove(around line 122). Place it next to the other state mutators.2.
packages/server/handler/websocket.go(MODIFY)Add a new payload type near the existing ones (around line 41):
Then add a new
casein the switch inreadPump(after themovecase, around line 183):3.
packages/server/handler/websocket.go(MODIFY —sendGameOver)The current
sendGameOver(line 256) infers the winner fromg.Turn, which won't work for resignations. Change the signature so the caller passes the winner explicitly:Then update the two existing callers in the same file:
readPumpmovecase (line 179):h.sendGameOver(g, status, inferWinner(g, status))— see helper belowhandleBotMove(line 210): sameAdd a small helper near
sendGameOver:Frontend
4.
packages/web/src/components/chess/GameControls.tsx(NEW)A small component for the two buttons.
5.
packages/web/src/app/chess/page.tsx(MODIFY)Add import near the top:
Add a
handleResigncallback next tohandleMove(around line 86):Add a
handleNewGamecallback next tohandleCreateGame(around line 73):Render the component in the game view, just after the
.game-infoblock (around line 164):Update the
game_overhandler (lines 50-58) to handleresigned:6.
packages/web/src/app/globals.css(MODIFY)Append a new block at the end of the file (after line 276):
Acceptance Criteria
Backend
go build ./...passes (cd packages/server && go build ./...){ "type": "resign", "payload": { "player": "player" } }over the WebSocket ends the gamegame_updatewithstatus: "resigned"andgame_overwith the correct winnermoveand bot move flows still work (regression check)Frontend
next buildpasses (cd packages/web && npx next build)Out of Scope
Hints
mainand name itissue/<this-number>-resign-new-game(perCONTRIBUTING.md)packages/web/src/app/chess/page.tsx:39-65— keep the new message consistentGamestruct uses async.RWMutex(packages/server/game/game.go:20). Hold the write lock for any state mutationgo run main.go+pnpm dev:web) to manually testgofmtyour Go changes before committing