package main import ( "context" "crypto/md5" "database/sql" "embed" "encoding/hex" "encoding/json" "io/fs" "log" "os" "sync" "time" "github.com/google/uuid" "github.com/joho/godotenv" "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/snake" "github.com/ryanhamamura/c4/ui" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" ) var ( store = game.NewGameStore() snakeStore = snake.NewSnakeStore() queries *gen.Queries chatPersister *db.ChatPersister ) //go:embed assets var assets embed.FS func DaisyUIPlugin(v *via.V) { css, _ := fs.ReadFile(assets, "assets/css/output.css") sum := md5.Sum(css) version := hex.EncodeToString(sum[:4]) v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version))) } func port() string { if p := os.Getenv("PORT"); p != "" { return p } return "7331" } func main() { _ = godotenv.Load() if err := os.MkdirAll("data", 0o755); err != nil { log.Fatal(err) } if err := db.Init("data/c4.db"); err != nil { log.Fatal(err) } queries = gen.New(db.DB) store.SetPersister(db.NewGamePersister(queries)) snakeStore.SetPersister(db.NewSnakePersister(queries)) chatPersister = db.NewChatPersister(queries) sessionManager, err := via.NewSQLiteSessionManager(db.DB) if err != nil { log.Fatal(err) } v := via.New() v.Config(via.Options{ LogLevel: via.LogLevelDebug, DocumentTitle: "Game Lobby", ServerAddress: ":" + port(), SessionManager: sessionManager, Plugins: []via.Plugin{DaisyUIPlugin}, ContextSuspendAfter: 5 * time.Minute, ContextTTL: 30 * time.Minute, }) subFS, _ := fs.Sub(assets, "assets") v.StaticFS("/assets/", subFS) store.SetPubSub(v.PubSub()) snakeStore.SetPubSub(v.PubSub()) // Home page - tabbed lobby 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) } activeTab := c.Signal("connect4") 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() } tabClickConnect4 := c.Action(func() { activeTab.SetValue("connect4") c.Sync() }) tabClickSnake := c.Action(func() { activeTab.SetValue("snake") c.Sync() }) snakeNickname := c.Signal("") if isLoggedIn { snakeNickname = c.Signal(username) } // Speed selection signal (index into SpeedPresets, default to Normal which is index 1) selectedSpeedIndex := c.Signal(1) // Speed selector actions var speedSelectClicks []h.H for i := range snake.SpeedPresets { idx := i speedSelectClicks = append(speedSelectClicks, c.Action(func() { selectedSpeedIndex.SetValue(idx) c.Sync() }).OnClick()) } // Snake create game actions — one per preset for solo and multiplayer var snakeSoloClicks []h.H var snakeMultiClicks []h.H for _, preset := range snake.GridPresets { w, ht := preset.Width, preset.Height snakeSoloClicks = append(snakeSoloClicks, c.Action(func() { name := snakeNickname.String() if name == "" { return } c.Session().Set("nickname", name) speedIdx := selectedSpeedIndex.Int() speed := snake.DefaultSpeed if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) { speed = snake.SpeedPresets[speedIdx].Speed } si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed) c.Redirectf("/snake/%s", si.ID()) }).OnClick()) snakeMultiClicks = append(snakeMultiClicks, c.Action(func() { name := snakeNickname.String() if name == "" { return } c.Session().Set("nickname", name) speedIdx := selectedSpeedIndex.Int() speed := snake.DefaultSpeed if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) { speed = snake.SpeedPresets[speedIdx].Speed } si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed) c.Redirectf("/snake/%s", si.ID()) }).OnClick()) } c.View(func() h.H { return ui.LobbyView(ui.LobbyProps{ NicknameBind: nickname.Bind(), CreateGameKeyDown: createGame.OnKeyDown("Enter"), CreateGameClick: createGame.OnClick(), IsLoggedIn: isLoggedIn, Username: username, LogoutClick: logout.OnClick(), UserGames: userGames, DeleteGameClick: deleteGame, ActiveTab: activeTab.String(), TabClickConnect4: tabClickConnect4.OnClick(), TabClickSnake: tabClickSnake.OnClick(), SnakeNicknameBind: snakeNickname.Bind(), SnakeSoloClicks: snakeSoloClicks, SnakeMultiClicks: snakeMultiClicks, ActiveSnakeGames: snakeStore.ActiveGames(), SelectedSpeedIndex: selectedSpeedIndex.Int(), SpeedSelectClicks: speedSelectClicks, }) }) }) // 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().RenewToken() c.Session().Set("user_id", user.ID) c.Session().Set("username", user.Username) c.Session().Set("nickname", user.Username) returnURL := c.Session().GetString("return_url") if returnURL != "" { c.Session().Set("return_url", "") c.Redirect(returnURL) } else { 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().RenewToken() c.Session().Set("user_id", user.ID) c.Session().Set("username", user.Username) c.Session().Set("nickname", user.Username) returnURL := c.Session().GetString("return_url") if returnURL != "" { c.Session().Set("return_url", "") c.Redirect(returnURL) } else { c.Redirect("/") } }) c.View(func() h.H { return ui.RegisterView( username.Bind(), password.Bind(), confirm.Bind(), register.OnKeyDown("Enter"), register.OnClick(), errorMsg.String(), ) }) }) // Connect 4 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) showGuestPrompt := c.Signal(false) chatMsg := c.Signal("") chatMessages, _ := chatPersister.LoadChatMessages(gameID) var chatMu sync.Mutex goToLogin := c.Action(func() { c.Session().Set("return_url", "/game/"+gameID) c.Redirect("/login") }) goToRegister := c.Action(func() { c.Session().Set("return_url", "/game/"+gameID) c.Redirect("/register") }) continueAsGuest := c.Action(func() { showGuestPrompt.SetValue(true) c.Sync() }) var gi *game.GameInstance var gameExists bool if gameID != "" { gi, gameExists = store.Get(gameID) } playerID := game.PlayerID(c.Session().GetString("player_id")) if playerID == "" { playerID = game.PlayerID(game.GenerateID(8)) c.Session().Set("player_id", string(playerID)) } 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) if gi.GetPlayerColor(playerID) == 0 { player := &game.Player{ ID: playerID, Nickname: name, } if sessionUserID != "" { player.UserID = &sessionUserID } gi.Join(&game.PlayerSession{ Player: player, }) } 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()) } }) sendChat := c.Action(func() { msg := chatMsg.String() if msg == "" || gi == nil { return } color := gi.GetPlayerColor(playerID) if color == 0 { return } g := gi.GetGame() nick := "" for _, p := range g.Players { if p != nil && p.ID == playerID { nick = p.Nickname break } } cm := ui.C4ChatMessage{ Nickname: nick, Color: color, Message: msg, Time: time.Now().UnixMilli(), } chatPersister.SaveChatMessage(gameID, cm) data, err := json.Marshal(cm) if err != nil { return } c.Publish("game.chat."+gameID, data) chatMsg.SetValue("") }) if gameExists { c.Subscribe("game."+gameID, func(data []byte) { c.Sync() }) c.Subscribe("game.chat."+gameID, func(data []byte) { var cm ui.C4ChatMessage if err := json.Unmarshal(data, &cm); err != nil { return } chatMu.Lock() chatMessages = append(chatMessages, cm) if len(chatMessages) > 50 { chatMessages = chatMessages[len(chatMessages)-50:] } chatMu.Unlock() c.Sync() }) } 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, }) } c.View(func() h.H { if !gameExists { c.Redirect("/") return h.Div() } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { if sessionUserID == "" && !showGuestPrompt.Bool() { return ui.GameJoinPrompt( goToLogin.OnClick(), continueAsGuest.OnClick(), goToRegister.OnClick(), ) } return ui.NicknamePrompt( nickname.Bind(), setNickname.OnKeyDown("Enter"), setNickname.OnClick(), ) } g := gi.GetGame() columnClick := func(col int) h.H { return dropPiece.OnClick(via.WithSignalInt(colSignal, col)) } chatMu.Lock() msgs := make([]ui.C4ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() chat := ui.C4Chat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")) var content []h.H content = append(content, ui.BackToLobby(), ui.StealthTitle("text-3xl font-bold"), ui.PlayerInfo(g, myColor), ui.StatusBanner(g, myColor, createRematch.OnClick()), h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat), ) if g.Status == game.StatusWaitingForPlayer { content = append(content, ui.InviteLink(g.ID)) } mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")} mainAttrs = append(mainAttrs, content...) return h.Main(mainAttrs...) }) }) // Snake game page v.Page("/snake/{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) showGuestPrompt := c.Signal(false) goToLogin := c.Action(func() { c.Session().Set("return_url", "/snake/"+gameID) c.Redirect("/login") }) goToRegister := c.Action(func() { c.Session().Set("return_url", "/snake/"+gameID) c.Redirect("/register") }) continueAsGuest := c.Action(func() { showGuestPrompt.SetValue(true) c.Sync() }) var si *snake.SnakeGameInstance var gameExists bool if gameID != "" { si, gameExists = snakeStore.Get(gameID) } playerID := snake.PlayerID(c.Session().GetString("player_id")) if playerID == "" { pid := game.GenerateID(8) playerID = snake.PlayerID(pid) c.Session().Set("player_id", pid) } if sessionUserID != "" { playerID = snake.PlayerID(sessionUserID) } setNickname := c.Action(func() { if si == nil { return } name := nickname.String() if name == "" { return } c.Session().Set("nickname", name) if si.GetPlayerSlot(playerID) < 0 { player := &snake.Player{ ID: playerID, Nickname: name, } if sessionUserID != "" { player.UserID = &sessionUserID } si.Join(player) } c.Sync() }) // Direction input: single action with a direction signal dirSignal := c.Signal(-1) handleDir := c.Action(func() { if si == nil { return } slot := si.GetPlayerSlot(playerID) if slot < 0 { return } dir := snake.Direction(dirSignal.Int()) si.SetDirection(slot, dir) }) createRematch := c.Action(func() { if si == nil { return } newSI := si.CreateRematch() if newSI != nil { c.Redirectf("/snake/%s", newSI.ID()) } }) chatMsg := c.Signal("") var chatMessages []ui.ChatMessage var chatMu sync.Mutex sendChat := c.Action(func() { msg := chatMsg.String() if msg == "" || si == nil { return } slot := si.GetPlayerSlot(playerID) if slot < 0 { return } cm := ui.ChatMessage{ Nickname: si.GetGame().Players[slot].Nickname, Slot: slot, Message: msg, Time: time.Now().UnixMilli(), } data, err := json.Marshal(cm) if err != nil { return } c.Publish("snake.chat."+gameID, data) chatMsg.SetValue("") }) if gameExists { c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() }) if si.GetGame().Mode == snake.ModeMultiplayer { c.Subscribe("snake.chat."+gameID, func(data []byte) { var cm ui.ChatMessage if err := json.Unmarshal(data, &cm); err != nil { return } chatMu.Lock() chatMessages = append(chatMessages, cm) if len(chatMessages) > 50 { chatMessages = chatMessages[len(chatMessages)-50:] } chatMu.Unlock() c.Sync() }) } } // Auto-join if nickname exists if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 { player := &snake.Player{ ID: playerID, Nickname: sessionNickname, } if sessionUserID != "" { player.UserID = &sessionUserID } si.Join(player) } c.View(func() h.H { if !gameExists { c.Redirect("/") return h.Div() } mySlot := si.GetPlayerSlot(playerID) if mySlot < 0 { if sessionUserID == "" && !showGuestPrompt.Bool() { return ui.GameJoinPrompt( goToLogin.OnClick(), continueAsGuest.OnClick(), goToRegister.OnClick(), ) } return ui.NicknamePrompt( nickname.Bind(), setNickname.OnKeyDown("Enter"), setNickname.OnClick(), ) } sg := si.GetGame() var content []h.H content = append(content, ui.BackToLobby(), h.H1(h.Class("text-3xl font-bold"), h.Text("~~~~")), ui.SnakePlayerList(sg, mySlot), ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()), ) if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { board := ui.SnakeBoard(sg) if sg.Mode == snake.ModeMultiplayer { chatMu.Lock() msgs := make([]ui.ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")) content = append(content, h.Div(h.Class("snake-game-area"), board, chat)) } else { content = append(content, board) } } else if sg.Mode == snake.ModeMultiplayer { // Show chat even before game starts (waiting/countdown) chatMu.Lock() msgs := make([]ui.ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))) } // Only show invite link for multiplayer games if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { content = append(content, ui.SnakeInviteLink(sg.ID)) } wrapperAttrs := []h.H{ h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"), via.OnKeyDownMap( via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithThrottle(100*time.Millisecond)), via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithThrottle(100*time.Millisecond)), via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithThrottle(100*time.Millisecond)), via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithThrottle(100*time.Millisecond)), via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)), via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)), via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)), via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)), ), } wrapperAttrs = append(wrapperAttrs, content...) return h.Main(wrapperAttrs...) }) }) v.Start() }