From 2cd5b1d289dacca9667f6ade42197fe3689d3a34 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:02:26 -1000 Subject: [PATCH] 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. --- db/gen/games.sql.go | 28 +++++++++++++++++----------- db/gen/models.go | 17 +++++++++-------- db/migrations/002_add_rematch.sql | 5 +++++ db/persister.go | 22 ++++++++++++++++------ db/queries/games.sql | 2 +- game/store.go | 24 ++++++++++++++++++++++++ game/types.go | 19 ++++++++++++------- main.go | 28 +++++++++++++++++++++++++++- ui/status.go | 30 +++++++++++++++++++++++++++--- 9 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 db/migrations/002_add_rematch.sql diff --git a/db/gen/games.sql.go b/db/gen/games.sql.go index 9d4d9dc..4a23cf9 100644 --- a/db/gen/games.sql.go +++ b/db/gen/games.sql.go @@ -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,17 +269,18 @@ 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 = ? ` type UpdateGameParams struct { - Board string - CurrentTurn int64 - Status int64 - WinnerUserID sql.NullString - WinningCells sql.NullString - ID string + Board string + CurrentTurn int64 + Status int64 + WinnerUserID sql.NullString + WinningCells sql.NullString + RematchGameID sql.NullString + ID string } 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.WinnerUserID, arg.WinningCells, + arg.RematchGameID, arg.ID, ) return err diff --git a/db/gen/models.go b/db/gen/models.go index 6a5103d..cfe2ab0 100644 --- a/db/gen/models.go +++ b/db/gen/models.go @@ -9,14 +9,15 @@ import ( ) type Game struct { - ID string - Board string - CurrentTurn int64 - Status int64 - WinnerUserID sql.NullString - WinningCells sql.NullString - CreatedAt sql.NullTime - UpdatedAt sql.NullTime + ID string + Board string + CurrentTurn int64 + Status int64 + WinnerUserID sql.NullString + WinningCells sql.NullString + CreatedAt sql.NullTime + UpdatedAt sql.NullTime + RematchGameID sql.NullString } type GamePlayer struct { diff --git a/db/migrations/002_add_rematch.sql b/db/migrations/002_add_rematch.sql new file mode 100644 index 0000000..4069388 --- /dev/null +++ b/db/migrations/002_add_rematch.sql @@ -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; diff --git a/db/persister.go b/db/persister.go index 3894420..7e78c27 100644 --- a/db/persister.go +++ b/db/persister.go @@ -43,13 +43,19 @@ 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, - ID: g.ID, + 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 } diff --git a/db/queries/games.sql b/db/queries/games.sql index fa4c12f..479e00b 100644 --- a/db/queries/games.sql +++ b/db/queries/games.sql @@ -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 diff --git a/game/store.go b/game/store.go index a173385..e1c0e68 100644 --- a/game/store.go +++ b/game/store.go @@ -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() diff --git a/game/types.go b/game/types.go index 9827c84..73cd33e 100644 --- a/game/types.go +++ b/game/types.go @@ -21,13 +21,14 @@ const ( ) type Game struct { - ID string - 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) - CurrentTurn int // 1 or 2 (matches player color) - Status GameStatus - Winner *Player - WinningCells [][2]int // Coordinates of winning 4 cells for highlighting + ID string + 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) + CurrentTurn int // 1 or 2 (matches player color) + 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) diff --git a/main.go b/main.go index e88358c..474bc50 100644 --- a/main.go +++ b/main.go @@ -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; diff --git a/ui/status.go b/ui/status.go index 707e336..ff6fdb5 100644 --- a/ui/status.go +++ b/ui/status.go @@ -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 {