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,17 +269,18 @@ 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 = ?
` `
type UpdateGameParams struct { type UpdateGameParams struct {
Board string Board string
CurrentTurn int64 CurrentTurn int64
Status int64 Status int64
WinnerUserID sql.NullString WinnerUserID sql.NullString
WinningCells sql.NullString WinningCells sql.NullString
ID string RematchGameID sql.NullString
ID string
} }
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error { func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
@@ -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

@@ -9,14 +9,15 @@ import (
) )
type Game struct { type Game struct {
ID string ID string
Board string Board string
CurrentTurn int64 CurrentTurn int64
Status int64 Status int64
WinnerUserID sql.NullString WinnerUserID sql.NullString
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,13 +43,19 @@ 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,
ID: g.ID, RematchGameID: rematchGameID,
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

@@ -21,13 +21,14 @@ const (
) )
type Game struct { type Game struct {
ID string ID string
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow) Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
CurrentTurn int // 1 or 2 (matches player color) CurrentTurn int // 1 or 2 (matches player color)
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 {