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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions