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

View File

@@ -17,6 +17,7 @@ type Game struct {
WinningCells sql.NullString WinningCells sql.NullString
CreatedAt sql.NullTime CreatedAt sql.NullTime
UpdatedAt sql.NullTime UpdatedAt sql.NullTime
RematchGameID sql.NullString
} }
type GamePlayer struct { 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} 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{ return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
Board: g.BoardToJSON(), Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn), CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status), Status: int64(g.Status),
WinnerUserID: winnerUserID, WinnerUserID: winnerUserID,
WinningCells: winningCells, WinningCells: winningCells,
RematchGameID: rematchGameID,
ID: g.ID, ID: g.ID,
}) })
} }
@@ -74,6 +80,10 @@ func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
g.WinningCellsFromJSON(row.WinningCells.String) g.WinningCellsFromJSON(row.WinningCells.String)
} }
if row.RematchGameID.Valid {
g.RematchGameID = &row.RematchGameID.String
}
return g, nil return g, nil
} }

View File

@@ -8,7 +8,7 @@ SELECT * FROM games WHERE id = ?;
-- name: UpdateGame :exec -- name: UpdateGame :exec
UPDATE games 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 = ?; WHERE id = ?;
-- name: DeleteGame :exec -- name: DeleteGame :exec

View File

@@ -196,6 +196,30 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
return 0 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 { func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock() gi.gameMu.Lock()
defer gi.gameMu.Unlock() defer gi.gameMu.Unlock()

View File

@@ -28,6 +28,7 @@ type Game struct {
Status GameStatus Status GameStatus
Winner *Player Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting 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 { 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 { func (g *Game) BoardToJSON() string {
data, _ := json.Marshal(g.Board) data, _ := json.Marshal(g.Board)
return string(data) return string(data)

28
main.go
View File

@@ -281,6 +281,16 @@ func main() {
c.Sync() 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 nickname exists in session and game exists, join immediately
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 { if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ player := &game.Player{
@@ -325,7 +335,7 @@ func main() {
content = append(content, content = append(content,
h.H1(h.Text("Connect 4")), h.H1(h.Text("Connect 4")),
ui.PlayerInfo(g, myColor), ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor), ui.StatusBanner(g, myColor, createRematch.OnClick()),
ui.BoardComponent(g, columnClick, myColor), ui.BoardComponent(g, columnClick, myColor),
) )
@@ -444,6 +454,22 @@ const gameCSS = `
color: white; 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 { .player-info {
display: flex; display: flex;
gap: 2rem; gap: 2rem;

View File

@@ -5,7 +5,7 @@ import (
"github.com/ryanhamamura/via/h" "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 message string
var class string var class string
@@ -35,10 +35,34 @@ func StatusBanner(g *game.Game, myColor int) h.H {
class = "status draw" class = "status draw"
} }
return h.Div( content := []h.H{
h.Class(class), h.Class(class),
h.Text(message), 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 { func getOpponentName(g *game.Game, myColor int) string {