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

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+"')"),
),
)
}