Skip to content

[good first issue] Add New Game and Resign buttons to chess game #12

Description

@dev-rkb

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

  • go build ./... passes (cd packages/server && go build ./...)
  • Sending { "type": "resign", "payload": { "player": "player" } } over the WebSocket ends the game
  • After resign, the server broadcasts game_update with status: "resigned" and game_over with the correct winner
  • Resigning a finished game returns an error
  • Existing move and bot move flows still work (regression check)

Frontend

  • Two buttons appear in the game view: "Resign" and "New Game"
  • "Resign" shows a confirmation dialog before sending
  • After resigning, the player sees a "You resigned." message
  • "New Game" returns to the lobby and a fresh game can be started
  • Both buttons are disabled when the WebSocket is disconnected
  • "Resign" is disabled when the game is already over
  • next build passes (cd packages/web && npx next build)

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions