package main import ( "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("") 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(), createGame.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 gameExists bool // Look up game (may not exist during warmup or invalid ID) if gameID != "" { gi, gameExists = store.Get(gameID) } // Generate a stable player ID for this session playerID := game.PlayerID(c.Session().GetString("player_id")) if playerID == "" { playerID = game.PlayerID(game.GenerateID(8)) c.Session().Set("player_id", string(playerID)) } setNickname := c.Action(func() { if gi == nil { return } name := nickname.String() if name == "" { return } c.Session().Set("nickname", name) // Try to join if not already in game if gi.GetPlayerColor(playerID) == 0 { player := &game.Player{ ID: playerID, Nickname: name, } gi.Join(&game.PlayerSession{ Player: player, Sync: c, }) } c.Sync() }) dropPiece := c.Action(func() { if gi == nil { return } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { return } col := colSignal.Int() gi.DropPiece(col, myColor) c.Sync() }) // Auto-join logic if gameExists && gi.GetPlayerColor(playerID) == 0 { g := gi.GetGame() // Invitee: game is waiting for second player - auto-generate nickname and join if g.Status == game.StatusWaitingForPlayer { name := sessionNickname if name == "" { name = game.GenerateNickname() c.Session().Set("nickname", name) } player := &game.Player{ ID: playerID, Nickname: name, } gi.Join(&game.PlayerSession{ Player: player, Sync: c, }) } else if sessionNickname != "" { // Reconnecting player with existing nickname player := &game.Player{ ID: playerID, Nickname: sessionNickname, } 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() } myColor := gi.GetPlayerColor(playerID) // Need nickname first / not joined yet if myColor == 0 { return ui.NicknamePrompt( nickname.Bind(), setNickname.OnKeyDown("Enter"), setNickname.OnClick(), ) } g := gi.GetGame() // 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() } 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; } `