package main import ( "context" "database/sql" "log" "github.com/google/uuid" "github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db/gen" "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/ui" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" ) var store = game.NewGameStore() var queries *gen.Queries func main() { if err := db.Init("c4.db"); err != nil { log.Fatal(err) } queries = gen.New(db.DB) store.SetPersister(db.NewGamePersister(queries)) sessionManager, err := via.NewSQLiteSessionManager(db.DB) if err != nil { log.Fatal(err) } v := via.New() v.Config(via.Options{ LogLvl: via.LogLevelDebug, DocumentTitle: "Connect 4", ServerAddress: ":7331", SessionManager: sessionManager, }) 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) { userID := c.Session().GetString("user_id") username := c.Session().GetString("username") isLoggedIn := userID != "" var userGames []ui.GameListItem if isLoggedIn { ctx := context.Background() games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true}) if err == nil { for _, g := range games { isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor userGames = append(userGames, ui.GameListItem{ ID: g.ID, Status: int(g.Status), OpponentName: g.OpponentNickname.String, IsMyTurn: isMyTurn, LastPlayed: g.UpdatedAt.Time, }) } } } nickname := c.Signal("") if isLoggedIn { nickname = c.Signal(username) } logout := c.Action(func() { c.Session().Clear() c.Redirect("/") }) createGame := c.Action(func() { name := nickname.String() if name == "" { return } c.Session().Set("nickname", name) gi := store.Create() c.Redirectf("/game/%s", gi.ID()) }) deleteGame := func(id string) h.H { return c.Action(func() { for _, g := range userGames { if g.ID == id { store.Delete(id) break } } c.Redirect("/") }).OnClick() } c.View(func() h.H { return ui.LobbyView( nickname.Bind(), createGame.OnKeyDown("Enter"), createGame.OnClick(), isLoggedIn, username, logout.OnClick(), userGames, deleteGame, ) }) }) // Login page v.Page("/login", func(c *via.Context) { username := c.Signal("") password := c.Signal("") errorMsg := c.Signal("") login := c.Action(func() { ctx := context.Background() user, err := queries.GetUserByUsername(ctx, username.String()) if err == sql.ErrNoRows { errorMsg.SetValue("Invalid username or password") c.Sync() return } if err != nil { errorMsg.SetValue("An error occurred") c.Sync() return } if !auth.CheckPassword(password.String(), user.PasswordHash) { errorMsg.SetValue("Invalid username or password") c.Sync() return } c.Session().Set("user_id", user.ID) c.Session().Set("username", user.Username) c.Session().Set("nickname", user.Username) c.Redirect("/") }) c.View(func() h.H { return ui.LoginView( username.Bind(), password.Bind(), login.OnKeyDown("Enter"), login.OnClick(), errorMsg.String(), ) }) }) // Register page v.Page("/register", func(c *via.Context) { username := c.Signal("") password := c.Signal("") confirm := c.Signal("") errorMsg := c.Signal("") register := c.Action(func() { if err := auth.ValidateUsername(username.String()); err != nil { errorMsg.SetValue(err.Error()) c.Sync() return } if err := auth.ValidatePassword(password.String()); err != nil { errorMsg.SetValue(err.Error()) c.Sync() return } if password.String() != confirm.String() { errorMsg.SetValue("Passwords do not match") c.Sync() return } hash, err := auth.HashPassword(password.String()) if err != nil { errorMsg.SetValue("An error occurred") c.Sync() return } ctx := context.Background() id := uuid.New().String() user, err := queries.CreateUser(ctx, gen.CreateUserParams{ ID: id, Username: username.String(), PasswordHash: hash, }) if err != nil { errorMsg.SetValue("Username already taken") c.Sync() return } c.Session().Set("user_id", user.ID) c.Session().Set("username", user.Username) c.Session().Set("nickname", user.Username) c.Redirect("/") }) c.View(func() h.H { return ui.RegisterView( username.Bind(), password.Bind(), confirm.Bind(), register.OnKeyDown("Enter"), register.OnClick(), errorMsg.String(), ) }) }) // Game page v.Page("/game/{game_id}", func(c *via.Context) { gameID := c.GetPathParam("game_id") sessionNickname := c.Session().GetString("nickname") sessionUserID := c.Session().GetString("user_id") 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)) } // Use user_id as player_id if logged in if sessionUserID != "" { playerID = game.PlayerID(sessionUserID) } 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, } if sessionUserID != "" { player.UserID = &sessionUserID } 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() }) 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{ ID: playerID, Nickname: sessionNickname, } if sessionUserID != "" { player.UserID = &sessionUserID } gi.Join(&game.PlayerSession{ Player: player, Sync: c, }) } else if gameExists && gi.GetPlayerColor(playerID) != 0 { // Re-register sync context for existing players on reconnect gi.RegisterSync(playerID, 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, createRematch.OnClick()), 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; } .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; 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; } .auth-header { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-bottom: 1rem; padding: 0.5rem; background: var(--pico-muted-background); border-radius: 8px; } .auth-header button { margin: 0; padding: 0.25rem 0.5rem; font-size: 0.875rem; } .guest-banner { margin-bottom: 1rem; padding: 0.5rem; background: var(--pico-muted-background); border-radius: 8px; font-size: 0.875rem; } .error { color: #dc2626; background: #fef2f2; padding: 0.5rem 1rem; border-radius: 8px; margin-bottom: 1rem; } .game-list { margin-top: 2rem; text-align: left; } .game-list h3 { margin-bottom: 1rem; text-align: center; } .game-list-items { display: flex; flex-direction: column; gap: 0.5rem; } .game-entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--pico-muted-background); border-radius: 8px; transition: background 0.2s; } .game-entry:hover { background: var(--pico-secondary-background); } .game-entry-link { flex: 1; display: flex; justify-content: space-between; align-items: center; padding: 0.25rem 0.5rem; text-decoration: none; color: inherit; } .game-entry-main { display: flex; flex-direction: column; gap: 0.25rem; } .game-delete-btn { width: 2rem; height: 2rem; padding: 0; margin: 0; border: none; background: transparent; color: var(--pico-muted-color); font-size: 1.25rem; cursor: pointer; border-radius: 4px; transition: background 0.2s, color 0.2s; } .game-delete-btn:hover { background: #dc2626; color: white; } .opponent-name { font-weight: bold; } .game-status { font-size: 0.875rem; } .game-status.your-turn { color: #22c55e; font-weight: bold; } .game-status.waiting { color: var(--pico-muted-color); } .time-ago { font-size: 0.75rem; color: var(--pico-muted-color); } `