feat: add in-game chat to Connect 4
All checks were successful
Deploy c4 / deploy (push) Successful in 43s

Add real-time chat alongside the game board, mirroring the snake chat
implementation. Fix mobile layout for both C4 and snake chats — expand
chat to full width and reduce history height on small screens.
This commit is contained in:
Ryan Hamamura
2026-02-13 10:54:19 -10:00
parent e45559ecb3
commit 9069530e47
4 changed files with 250 additions and 1 deletions

View File

@@ -111,6 +111,8 @@
}
@media (max-width: 768px) {
.snake-game-area { flex-direction: column; align-items: center; }
.snake-game-area .snake-chat { width: 100%; max-width: 480px; }
.snake-chat-history { height: 150px; }
}
/* Snake chat */
@@ -152,3 +154,56 @@
font-size: 0.85rem;
}
.snake-chat-input button:hover { background: #667; }
/* C4 game area: board + chat side-by-side */
.c4-game-area {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: center;
}
@media (max-width: 768px) {
.c4-game-area { flex-direction: column; align-items: center; }
.c4-game-area .c4-chat { width: 100%; max-width: 480px; }
.c4-chat-history { height: 150px; }
}
/* C4 chat */
.c4-chat { width: 100%; max-width: 480px; }
.c4-game-area .c4-chat { width: 260px; max-width: none; flex-shrink: 0; }
.c4-chat-history {
height: 300px;
overflow-y: auto;
background: #334;
border-radius: 8px 8px 0 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.c4-chat-msg { font-size: 0.85rem; line-height: 1.3; }
.c4-chat-input {
display: flex;
gap: 0;
background: #445;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.c4-chat-input input {
flex: 1;
padding: 6px 10px;
background: transparent;
border: none;
color: inherit;
outline: none;
font-size: 0.85rem;
}
.c4-chat-input button {
padding: 6px 14px;
background: #556;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.85rem;
}
.c4-chat-input button:hover { background: #667; }

View File

@@ -1647,6 +1647,13 @@
flex-direction: column;
align-items: center;
}
.snake-game-area .snake-chat {
width: 100%;
max-width: 480px;
}
.snake-chat-history {
height: 150px;
}
}
.snake-chat {
width: 100%;
@@ -1698,6 +1705,75 @@
.snake-chat-input button:hover {
background: #667;
}
.c4-game-area {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: center;
}
@media (max-width: 768px) {
.c4-game-area {
flex-direction: column;
align-items: center;
}
.c4-game-area .c4-chat {
width: 100%;
max-width: 480px;
}
.c4-chat-history {
height: 150px;
}
}
.c4-chat {
width: 100%;
max-width: 480px;
}
.c4-game-area .c4-chat {
width: 260px;
max-width: none;
flex-shrink: 0;
}
.c4-chat-history {
height: 300px;
overflow-y: auto;
background: #334;
border-radius: 8px 8px 0 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.c4-chat-msg {
font-size: 0.85rem;
line-height: 1.3;
}
.c4-chat-input {
display: flex;
gap: 0;
background: #445;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.c4-chat-input input {
flex: 1;
padding: 6px 10px;
background: transparent;
border: none;
color: inherit;
outline: none;
font-size: 0.85rem;
}
.c4-chat-input button {
padding: 6px 14px;
background: #556;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.85rem;
}
.c4-chat-input button:hover {
background: #667;
}
@layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
color-scheme: light;

56
main.go
View File

@@ -353,6 +353,9 @@ func main() {
nickname := c.Signal(sessionNickname)
colSignal := c.Signal(0)
showGuestPrompt := c.Signal(false)
chatMsg := c.Signal("")
var chatMessages []ui.C4ChatMessage
var chatMu sync.Mutex
goToLogin := c.Action(func() {
c.Session().Set("return_url", "/game/"+gameID)
@@ -434,8 +437,53 @@ func main() {
}
})
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(),
}
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 {
@@ -480,13 +528,19 @@ func main() {
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()),
ui.BoardComponent(g, columnClick, myColor),
h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat),
)
if g.Status == game.StatusWaitingForPlayer {

64
ui/c4chat.go Normal file
View File

@@ -0,0 +1,64 @@
package ui
import (
"fmt"
"github.com/ryanhamamura/via/h"
)
type C4ChatMessage struct {
Nickname string `json:"nickname"`
Color int `json:"color"` // 1=Red, 2=Yellow
Message string `json:"message"`
Time int64 `json:"time"`
}
var c4ChatColors = map[int]string{
1: "#4a2a3a",
2: "#2a4545",
}
func C4Chat(messages []C4ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
var msgEls []h.H
for _, m := range messages {
color := "#666"
if c, ok := c4ChatColors[m.Color]; ok {
color = c
}
msgEls = append(msgEls, h.Div(h.Class("c4-chat-msg"),
h.Span(
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
h.Text(m.Nickname+": "),
),
h.Span(h.Text(m.Message)),
))
}
autoScroll := h.Script(h.Text(`
(function(){
var el = document.querySelector('.c4-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
`))
historyAttrs := []h.H{h.Class("c4-chat-history")}
historyAttrs = append(historyAttrs, msgEls...)
historyAttrs = append(historyAttrs, autoScroll)
return h.Div(h.Class("c4-chat"),
h.Div(historyAttrs...),
h.Div(h.Class("c4-chat-input"),
h.Input(
h.Type("text"),
h.Attr("placeholder", "Chat..."),
h.Attr("autocomplete", "off"),
msgBind,
sendKeyDown,
),
h.Button(h.Type("button"), h.Text("Send"), sendClick),
),
)
}