Add play again button for rematch after game ends

When a game finishes (win or draw), players see a "Play again" button.
Clicking it creates a new game and the opponent sees a "Join Rematch"
link to join the same game.
This commit is contained in:
Ryan Hamamura
2026-01-14 18:02:26 -10:00
parent 5f452914f8
commit 2cd5b1d289
9 changed files with 138 additions and 37 deletions

View File

@@ -13,7 +13,7 @@ import (
const createGame = `-- name: CreateGame :one
INSERT INTO games (id, board, current_turn, status)
VALUES (?, ?, ?, ?)
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id
`
type CreateGameParams struct {
@@ -40,6 +40,7 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
)
return i, err
}
@@ -80,7 +81,7 @@ func (q *Queries) DeleteGame(ctx context.Context, id string) error {
}
const getActiveGames = `-- name: GetActiveGames :many
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE status < 2
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id FROM games WHERE status < 2
`
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
@@ -101,6 +102,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
); err != nil {
return nil, err
}
@@ -116,7 +118,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
}
const getGame = `-- name: GetGame :one
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at FROM games WHERE id = ?
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id FROM games WHERE id = ?
`
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
@@ -131,6 +133,7 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
)
return i, err
}
@@ -171,7 +174,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
}
const getGamesByUserID = `-- name: GetGamesByUserID :many
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at FROM games g
SELECT g.id, g.board, g.current_turn, g.status, g.winner_user_id, g.winning_cells, g.created_at, g.updated_at, g.rematch_game_id FROM games g
JOIN game_players gp ON g.id = gp.game_id
WHERE gp.user_id = ?
ORDER BY g.updated_at DESC
@@ -195,6 +198,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
); err != nil {
return nil, err
}
@@ -265,7 +269,7 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
const updateGame = `-- name: UpdateGame :exec
UPDATE games
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
@@ -275,6 +279,7 @@ type UpdateGameParams struct {
Status int64
WinnerUserID sql.NullString
WinningCells sql.NullString
RematchGameID sql.NullString
ID string
}
@@ -285,6 +290,7 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
arg.Status,
arg.WinnerUserID,
arg.WinningCells,
arg.RematchGameID,
arg.ID,
)
return err

View File

@@ -17,6 +17,7 @@ type Game struct {
WinningCells sql.NullString
CreatedAt sql.NullTime
UpdatedAt sql.NullTime
RematchGameID sql.NullString
}
type GamePlayer struct {

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE games ADD COLUMN rematch_game_id TEXT;
-- +goose Down
ALTER TABLE games DROP COLUMN rematch_game_id;

View File

@@ -43,12 +43,18 @@ func (p *GamePersister) SaveGame(g *game.Game) error {
winningCells = sql.NullString{String: wc, Valid: true}
}
rematchGameID := sql.NullString{}
if g.RematchGameID != nil {
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
}
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
WinnerUserID: winnerUserID,
WinningCells: winningCells,
RematchGameID: rematchGameID,
ID: g.ID,
})
}
@@ -74,6 +80,10 @@ func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
g.WinningCellsFromJSON(row.WinningCells.String)
}
if row.RematchGameID.Valid {
g.RematchGameID = &row.RematchGameID.String
}
return g, nil
}

View File

@@ -8,7 +8,7 @@ SELECT * FROM games WHERE id = ?;
-- name: UpdateGame :exec
UPDATE games
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, updated_at = CURRENT_TIMESTAMP
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteGame :exec

View File

@@ -196,6 +196,30 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
return 0
}
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
return nil
}
newGI := gs.Create()
newID := newGI.ID()
gi.game.RematchGameID = &newID
if gi.persister != nil {
if err := gi.persister.SaveGame(gi.game); err != nil {
gs.Delete(newID)
gi.game.RematchGameID = nil
return nil
}
}
gi.dirty = true
return newGI
}
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()

View File

@@ -28,6 +28,7 @@ type Game struct {
Status GameStatus
Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
RematchGameID *string // ID of the rematch game, if one was created
}
func NewGame(id string) *Game {
@@ -39,6 +40,10 @@ func NewGame(id string) *Game {
}
}
func (g *Game) IsFinished() bool {
return g.Status == StatusWon || g.Status == StatusDraw
}
func (g *Game) BoardToJSON() string {
data, _ := json.Marshal(g.Board)
return string(data)

28
main.go
View File

@@ -281,6 +281,16 @@ func main() {
c.Sync()
})
createRematch := c.Action(func() {
if gi == nil {
return
}
newGI := gi.CreateRematch(store)
if newGI != nil {
c.Redirectf("/game/%s", newGI.ID())
}
})
// If nickname exists in session and game exists, join immediately
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
@@ -325,7 +335,7 @@ func main() {
content = append(content,
h.H1(h.Text("Connect 4")),
ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor),
ui.StatusBanner(g, myColor, createRematch.OnClick()),
ui.BoardComponent(g, columnClick, myColor),
)
@@ -444,6 +454,22 @@ const gameCSS = `
color: white;
}
.play-again-btn, .rematch-link {
margin-left: 1rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
background: white;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.play-again-btn:hover, .rematch-link:hover {
background: #eee;
}
.player-info {
display: flex;
gap: 2rem;

View File

@@ -5,7 +5,7 @@ import (
"github.com/ryanhamamura/via/h"
)
func StatusBanner(g *game.Game, myColor int) h.H {
func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
var message string
var class string
@@ -35,10 +35,34 @@ func StatusBanner(g *game.Game, myColor int) h.H {
class = "status draw"
}
return h.Div(
content := []h.H{
h.Class(class),
h.Text(message),
}
// Show rematch options for finished games
if g.IsFinished() {
if g.RematchGameID != nil {
content = append(content,
h.A(
h.Class("rematch-link"),
h.Href("/game/"+*g.RematchGameID),
h.Text("Join Rematch"),
),
)
} else if playAgainClick != nil {
content = append(content,
h.Button(
h.Class("play-again-btn"),
h.Type("button"),
h.Text("Play again"),
playAgainClick,
),
)
}
}
return h.Div(content...)
}
func getOpponentName(g *game.Game, myColor int) string {