Add Connect 4 multiplayer game server

Real-time two-player Connect 4 using Via framework with:
- Game creation and invite links
- SSE-based live updates for both players
- Win detection with animated highlighting
- Session-based nickname persistence
This commit is contained in:
Ryan Hamamura
2026-01-14 12:57:57 -10:00
commit 389fc12bf2
10 changed files with 947 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
c4

97
game/logic.go Normal file
View File

@@ -0,0 +1,97 @@
package game
// DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success).
func (g *Game) DropPiece(col, playerColor int) (int, bool) {
if col < 0 || col > 6 {
return -1, false
}
if g.CurrentTurn != playerColor {
return -1, false
}
if g.Status != StatusInProgress {
return -1, false
}
// Find lowest empty row in column
for row := 5; row >= 0; row-- {
if g.Board[row][col] == 0 {
g.Board[row][col] = playerColor
return row, true
}
}
return -1, false // Column full
}
// CheckWin checks if the last move at (row, col) created a win.
func (g *Game) CheckWin(row, col int) bool {
color := g.Board[row][col]
directions := [][2]int{
{0, 1}, // Horizontal
{1, 0}, // Vertical
{1, 1}, // Diagonal down-right
{1, -1}, // Diagonal down-left
}
for _, dir := range directions {
cells := g.countLine(row, col, dir[0], dir[1], color)
if len(cells) >= 4 {
g.Status = StatusWon
g.WinningCells = cells[:4]
return true
}
}
return false
}
func (g *Game) countLine(row, col, dr, dc, color int) [][2]int {
cells := [][2]int{{row, col}}
// Count in positive direction
for r, c := row+dr, col+dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r+dr, c+dc {
if g.Board[r][c] != color {
break
}
cells = append(cells, [2]int{r, c})
}
// Count in negative direction
for r, c := row-dr, col-dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r-dr, c-dc {
if g.Board[r][c] != color {
break
}
cells = append(cells, [2]int{r, c})
}
return cells
}
// CheckDraw checks if board is full with no winner.
func (g *Game) CheckDraw() bool {
for col := 0; col < 7; col++ {
if g.Board[0][col] == 0 {
return false
}
}
g.Status = StatusDraw
return true
}
// SwitchTurn alternates the current turn.
func (g *Game) SwitchTurn() {
if g.CurrentTurn == 1 {
g.CurrentTurn = 2
} else {
g.CurrentTurn = 1
}
}
// IsWinningCell checks if a cell is part of the winning line.
func (g *Game) IsWinningCell(row, col int) bool {
for _, cell := range g.WinningCells {
if cell[0] == row && cell[1] == col {
return true
}
}
return false
}

192
game/store.go Normal file
View File

@@ -0,0 +1,192 @@
package game
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
type Syncable interface {
Sync()
}
type PlayerSession struct {
Player *Player
Sync Syncable
}
type GameStore struct {
games map[string]*GameInstance
gamesMu sync.RWMutex
}
func NewGameStore() *GameStore {
return &GameStore{
games: make(map[string]*GameInstance),
}
}
func (gs *GameStore) Create() *GameInstance {
id := generateGameID()
gi := NewGameInstance(id)
gs.gamesMu.Lock()
gs.games[id] = gi
gs.gamesMu.Unlock()
go gi.run()
return gi
}
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
gs.gamesMu.RLock()
defer gs.gamesMu.RUnlock()
gi, ok := gs.games[id]
return gi, ok
}
func generateGameID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
type GameInstance struct {
game *Game
gameMu sync.RWMutex
players map[PlayerID]Syncable
playersMu sync.RWMutex
join chan *PlayerSession
leave chan PlayerID
done chan struct{}
dirty bool
}
func NewGameInstance(id string) *GameInstance {
return &GameInstance{
game: NewGame(id),
players: make(map[PlayerID]Syncable),
join: make(chan *PlayerSession, 5),
leave: make(chan PlayerID, 5),
done: make(chan struct{}),
}
}
func (gi *GameInstance) ID() string {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game.ID
}
func (gi *GameInstance) Join(ps *PlayerSession) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
// Assign player to an open slot
if gi.game.Players[0] == nil {
ps.Player.Color = 1 // Red
gi.game.Players[0] = ps.Player
} else if gi.game.Players[1] == nil {
ps.Player.Color = 2 // Yellow
gi.game.Players[1] = ps.Player
gi.game.Status = StatusInProgress
} else {
return false // Game is full
}
gi.playersMu.Lock()
gi.players[ps.Player.ID] = ps.Sync
gi.playersMu.Unlock()
gi.dirty = true
return true
}
func (gi *GameInstance) Leave(pid PlayerID) {
gi.leave <- pid
}
func (gi *GameInstance) GetGame() *Game {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game
}
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players {
if p != nil && p.ID == pid {
return p.Color
}
}
return 0
}
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
row, ok := gi.game.DropPiece(col, playerColor)
if !ok {
return false
}
if gi.game.CheckWin(row, col) {
for _, p := range gi.game.Players {
if p != nil && p.Color == playerColor {
gi.game.Winner = p
break
}
}
} else if gi.game.CheckDraw() {
// Status already set by CheckDraw
} else {
gi.game.SwitchTurn()
}
gi.dirty = true
return true
}
func (gi *GameInstance) run() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case pid := <-gi.leave:
gi.playersMu.Lock()
delete(gi.players, pid)
gi.playersMu.Unlock()
case <-ticker.C:
gi.publish()
case <-gi.done:
return
}
}
}
func (gi *GameInstance) publish() {
gi.gameMu.Lock()
if !gi.dirty {
gi.gameMu.Unlock()
return
}
gi.dirty = false
gi.playersMu.RLock()
syncers := make([]Syncable, 0, len(gi.players))
for _, sync := range gi.players {
syncers = append(syncers, sync)
}
gi.playersMu.RUnlock()
gi.gameMu.Unlock()
for _, sync := range syncers {
sync.Sync()
}
}
func (gi *GameInstance) Stop() {
close(gi.done)
}

41
game/types.go Normal file
View File

@@ -0,0 +1,41 @@
package game
import "time"
type PlayerID string
type Player struct {
ID PlayerID
Nickname string
Color int // 1 = Red, 2 = Yellow
}
type GameStatus int
const (
StatusWaitingForPlayer GameStatus = iota
StatusInProgress
StatusWon
StatusDraw
)
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
CreatedAt time.Time
}
func NewGame(id string) *Game {
return &Game{
ID: id,
Board: [6][7]int{},
CurrentTurn: 1, // Red goes first
Status: StatusWaitingForPlayer,
CreatedAt: time.Now(),
}
}

19
go.mod Normal file
View File

@@ -0,0 +1,19 @@
module github.com/ryanhamamura/c4
go 1.25.4
require (
github.com/go-via/via-plugin-picocss v0.1.1
github.com/ryanhamamura/via v0.2.3
)
require (
github.com/CAFxX/httpcompression v0.0.9 // indirect
github.com/alexedwards/scs/v2 v2.9.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/go-via/via v0.1.4 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/starfederation/datastar-go v1.0.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
maragu.dev/gomponents v1.2.0 // indirect
)

49
go.sum Normal file
View File

@@ -0,0 +1,49 @@
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-via/via v0.1.4 h1:Fz9fwaT5+TBqcetiVM33SxkuysAeFDOiiASFu3GW7WY=
github.com/go-via/via v0.1.4/go.mod h1:Y8oddRwP6SWX15Xb6UQj4HtLZwxTYI1HbWBmELtB/f8=
github.com/go-via/via-plugin-picocss v0.1.1 h1:rbA9wL9eEanT8HOOfX1b4Mr2L2VjaDrsIrUECDxV73k=
github.com/go-via/via-plugin-picocss v0.1.1/go.mod h1:npvsvG2FWeIPkzHzSSzW+uBGE0m5gnIAdlePqKcfuAQ=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ryanhamamura/via v0.2.3 h1:Fmq2Gws9Ph7njZxYI3O03PhTyfFTOBv5xm+4s053c3E=
github.com/ryanhamamura/via v0.2.3/go.mod h1:z1f0pajcta/pD2LEBMVmuBXf/J2yF0obMVKA8FshR9I=
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc=
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=

318
main.go Normal file
View File

@@ -0,0 +1,318 @@
package main
import (
"crypto/rand"
"encoding/hex"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/ui"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var store = game.NewGameStore()
func main() {
v := via.New()
v.Config(via.Options{
LogLvl: via.LogLevelDebug,
DocumentTitle: "Connect 4",
ServerAddress: ":7331",
})
v.AppendToHead(
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.StyleEl(h.Raw(gameCSS)),
)
// Home page - enter nickname and create game
v.Page("/", func(c *via.Context) {
nickname := c.Signal("")
setNickname := c.Action(func() {
name := nickname.String()
if name != "" {
c.Session().Set("nickname", name)
c.Sync()
}
})
createGame := c.Action(func() {
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
gi := store.Create()
c.Redirectf("/game/%s", gi.ID())
})
c.View(func() h.H {
return ui.LobbyView(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
createGame.OnClick(),
)
})
})
// Game page
v.Page("/game/{game_id}", func(c *via.Context) {
gameID := c.GetPathParam("game_id")
sessionNickname := c.Session().GetString("nickname")
nickname := c.Signal(sessionNickname)
colSignal := c.Signal(0)
var gi *game.GameInstance
var player *game.Player
var playerJoined bool
var gameExists bool
// Look up game (may not exist during warmup or invalid ID)
if gameID != "" {
gi, gameExists = store.Get(gameID)
}
setNickname := c.Action(func() {
if gi == nil {
return
}
name := nickname.String()
if name == "" {
return
}
c.Session().Set("nickname", name)
if !playerJoined {
player = &game.Player{
ID: game.PlayerID(generatePlayerID()),
Nickname: name,
}
playerJoined = gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
}
c.Sync()
})
dropPiece := c.Action(func() {
if gi == nil || player == nil {
return
}
col := colSignal.Int()
gi.DropPiece(col, player.Color)
})
// If nickname exists in session and game exists, join immediately
if gameExists && sessionNickname != "" {
player = &game.Player{
ID: game.PlayerID(generatePlayerID()),
Nickname: sessionNickname,
}
playerJoined = gi.Join(&game.PlayerSession{
Player: player,
Sync: c,
})
}
c.View(func() h.H {
// Game not found - redirect to home
if !gameExists {
c.Redirect("/")
return h.Div()
}
// Need nickname first
if !playerJoined {
return ui.NicknamePrompt(
nickname.Bind(),
setNickname.OnKeyDown("Enter"),
setNickname.OnClick(),
)
}
g := gi.GetGame()
myColor := player.Color
// Create column click function
columnClick := func(col int) h.H {
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
}
var content []h.H
content = append(content,
h.H1(h.Text("Connect 4")),
ui.PlayerInfo(g, myColor),
ui.StatusBanner(g, myColor),
ui.BoardComponent(g, columnClick, myColor),
)
// Show invite link when waiting for opponent
if g.Status == game.StatusWaitingForPlayer {
content = append(content, ui.InviteLink(g.ID))
}
mainAttrs := []h.H{h.Class("container game-container")}
mainAttrs = append(mainAttrs, content...)
return h.Main(mainAttrs...)
})
})
v.Start()
}
func generatePlayerID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
const gameCSS = `
body { margin: 0; }
.lobby {
max-width: 400px;
margin: 2rem auto;
text-align: center;
}
.game-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
}
.board {
display: flex;
gap: 8px;
background: #2563eb;
padding: 16px;
border-radius: 12px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
border-radius: 8px;
}
.column.clickable {
cursor: pointer;
}
.column.clickable:hover {
background: rgba(255,255,255,0.15);
}
.cell {
width: 48px;
height: 48px;
border-radius: 50%;
background: #1e40af;
transition: background 0.2s;
}
.cell.red {
background: #dc2626;
}
.cell.yellow {
background: #facc15;
}
.cell.winning {
animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
to { transform: scale(1.1); box-shadow: 0 0 20px rgba(255,255,255,0.8); }
}
.status {
font-size: 1.25rem;
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 8px;
}
.status.waiting {
background: var(--pico-muted-background);
}
.status.your-turn {
background: #22c55e;
color: white;
}
.status.opponent-turn {
background: var(--pico-muted-background);
}
.status.winner {
background: #22c55e;
color: white;
}
.status.loser {
background: #dc2626;
color: white;
}
.status.draw {
background: #f59e0b;
color: white;
}
.player-info {
display: flex;
gap: 2rem;
margin-bottom: 0.5rem;
}
.player {
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-chip {
width: 20px;
height: 20px;
border-radius: 50%;
background: #666;
}
.player-chip.red {
background: #dc2626;
}
.player-chip.yellow {
background: #facc15;
}
.invite-section {
margin-top: 1rem;
text-align: center;
}
.invite-link {
background: var(--pico-muted-background);
padding: 1rem;
border-radius: 8px;
font-family: monospace;
word-break: break-all;
margin: 0.5rem 0;
}
.copy-btn {
margin-top: 0.5rem;
}
`

60
ui/board.go Normal file
View File

@@ -0,0 +1,60 @@
package ui
import (
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/via/h"
)
// ColumnClickFn returns an h.H onClick attribute for a given column index
type ColumnClickFn func(col int) h.H
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
var cols []h.H
for col := 0; col < 7; col++ {
var cells []h.H
for row := 0; row < 6; row++ {
cellColor := g.Board[row][col]
isWinning := g.IsWinningCell(row, col)
cells = append(cells, Cell(cellColor, isWinning))
}
// Column is clickable only if it's player's turn and game is in progress
canClick := g.Status == game.StatusInProgress && g.CurrentTurn == myColor
cols = append(cols, Column(col, cells, columnClick, canClick))
}
boardAttrs := []h.H{h.Class("board")}
boardAttrs = append(boardAttrs, cols...)
return h.Div(boardAttrs...)
}
func Column(colIdx int, cells []h.H, columnClick ColumnClickFn, canClick bool) h.H {
class := "column"
if canClick {
class += " clickable"
}
attrs := []h.H{h.Class(class)}
if canClick && columnClick != nil {
attrs = append(attrs, columnClick(colIdx))
}
attrs = append(attrs, cells...)
return h.Div(attrs...)
}
func Cell(color int, isWinning bool) h.H {
class := "cell"
switch color {
case 1:
class += " red"
case 2:
class += " yellow"
}
if isWinning {
class += " winning"
}
return h.Div(h.Class(class))
}

60
ui/lobby.go Normal file
View File

@@ -0,0 +1,60 @@
package ui
import (
"github.com/ryanhamamura/via/h"
)
func LobbyView(nicknameBind, setNicknameKeyDown, createGameClick h.H) h.H {
return h.Main(h.Class("container"),
h.Div(h.Class("lobby"),
h.H1(h.Text("Connect 4")),
h.P(h.Text("Challenge a friend to a game of Connect 4!")),
h.Form(
h.FieldSet(
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")),
h.Input(
h.ID("nickname"),
h.Type("text"),
h.Placeholder("Enter your nickname"),
nicknameBind,
h.Attr("required"),
setNicknameKeyDown,
),
),
h.Button(
h.Type("button"),
h.Text("Create Game"),
createGameClick,
),
),
),
)
}
func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H {
return h.Main(h.Class("container"),
h.Div(h.Class("lobby"),
h.H1(h.Text("Join Game")),
h.P(h.Text("Enter your nickname to join the game.")),
h.Form(
h.FieldSet(
h.Label(h.Text("Your Nickname"), h.Attr("for", "nickname")),
h.Input(
h.ID("nickname"),
h.Type("text"),
h.Placeholder("Enter your nickname"),
nicknameBind,
h.Attr("required"),
h.Attr("autofocus"),
setNicknameKeyDown,
),
),
h.Button(
h.Type("button"),
h.Text("Join"),
setNicknameClick,
),
),
),
)
}

110
ui/status.go Normal file
View File

@@ -0,0 +1,110 @@
package ui
import (
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/via/h"
)
func StatusBanner(g *game.Game, myColor int) h.H {
var message string
var class string
switch g.Status {
case game.StatusWaitingForPlayer:
message = "Waiting for opponent..."
class = "status waiting"
case game.StatusInProgress:
if g.CurrentTurn == myColor {
message = "Your turn!"
class = "status your-turn"
} else {
opponentName := getOpponentName(g, myColor)
message = opponentName + "'s turn"
class = "status opponent-turn"
}
case game.StatusWon:
if g.Winner != nil && g.Winner.Color == myColor {
message = "You win!"
class = "status winner"
} else if g.Winner != nil {
message = g.Winner.Nickname + " wins!"
class = "status loser"
}
case game.StatusDraw:
message = "It's a draw!"
class = "status draw"
}
return h.Div(
h.Class(class),
h.Text(message),
)
}
func getOpponentName(g *game.Game, myColor int) string {
for _, p := range g.Players {
if p != nil && p.Color != myColor {
return p.Nickname
}
}
return "Opponent"
}
func PlayerInfo(g *game.Game, myColor int) h.H {
var myName, opponentName string
var myColorClass, opponentColorClass string
for _, p := range g.Players {
if p == nil {
continue
}
if p.Color == myColor {
myName = p.Nickname
if p.Color == 1 {
myColorClass = "red"
} else {
myColorClass = "yellow"
}
} else {
opponentName = p.Nickname
if p.Color == 1 {
opponentColorClass = "red"
} else {
opponentColorClass = "yellow"
}
}
}
if opponentName == "" {
opponentName = "Waiting..."
}
return h.Div(h.Class("player-info"),
h.Div(h.Class("player you"),
h.Span(h.Class("player-chip "+myColorClass)),
h.Span(h.Text(myName+" (You)")),
),
h.Div(h.Class("player opponent"),
h.Span(h.Class("player-chip "+opponentColorClass)),
h.Span(h.Text(opponentName)),
),
)
}
const baseURL = "https://demo.adriatica.io"
func InviteLink(gameID string) h.H {
fullURL := baseURL + "/game/" + gameID
return h.Div(h.Class("invite-section"),
h.P(h.Text("Share this link with your opponent:")),
h.Div(h.Class("invite-link"),
h.Text(fullURL),
),
h.Button(
h.Class("copy-btn"),
h.Type("button"),
h.Text("Copy Link"),
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
),
)
}