package game import ( "math/rand" "sync" tea "github.com/charmbracelet/bubbletea" ) const ( Width = 76 Height = 20 ) type Cell struct { Char rune Color string // lipgloss-compatible ANSI color string, "" = unset } // Cursor is another connected session's position, shown as a marker on // top of the canvas so drawing feels like a shared space, not a diff you // only notice after the fact. type Cursor struct { X, Y int Color string Label string } // canvasUpdatedMsg is broadcast to every connected session's bubbletea // program whenever the shared state changes — cells or cursors — so each // client's own Update/View loop redraws without polling. type canvasUpdatedMsg struct{} var cursorPalette = []string{"#ff6b6b", "#feca57", "#1dd1a1", "#54a0ff", "#ff9ff3", "#48dbfb", "#f368e0", "#00d2d3"} // Canvas is the single shared drawing surface plus the registry of // connected sessions' bubbletea programs, used purely to fan a redraw // signal out to everyone whenever anything changes -- there's no other // use of the registry (no per-session logic reaches back in through it). type Canvas struct { mu sync.Mutex cells [Height][Width]Cell cursors map[string]*Cursor programs map[string]*tea.Program nextColorIdx int } func NewCanvas() *Canvas { return &Canvas{ cursors: make(map[string]*Cursor), programs: make(map[string]*tea.Program), } } func (c *Canvas) Join(id string, label string, p *tea.Program) *Cursor { c.mu.Lock() defer c.mu.Unlock() color := cursorPalette[c.nextColorIdx%len(cursorPalette)] c.nextColorIdx++ cur := &Cursor{X: Width / 2, Y: Height / 2, Color: color, Label: label} c.cursors[id] = cur c.programs[id] = p c.broadcastLocked() return cur } func (c *Canvas) Leave(id string) { c.mu.Lock() defer c.mu.Unlock() delete(c.cursors, id) delete(c.programs, id) c.broadcastLocked() } func (c *Canvas) Paint(x, y int, char rune, color string) { c.mu.Lock() defer c.mu.Unlock() if x < 0 || x >= Width || y < 0 || y >= Height { return } c.cells[y][x] = Cell{Char: char, Color: color} c.broadcastLocked() } func (c *Canvas) Clear() { c.mu.Lock() defer c.mu.Unlock() c.cells = [Height][Width]Cell{} c.broadcastLocked() } func (c *Canvas) MoveCursor(id string, dx, dy int) { c.mu.Lock() defer c.mu.Unlock() cur, ok := c.cursors[id] if !ok { return } cur.X = clamp(cur.X+dx, 0, Width-1) cur.Y = clamp(cur.Y+dy, 0, Height-1) c.broadcastLocked() } // Snapshot returns a copy of the current cells and cursors, safe to read // from a View() call without holding the lock while rendering. func (c *Canvas) Snapshot() (cells [Height][Width]Cell, cursors map[string]Cursor) { c.mu.Lock() defer c.mu.Unlock() cursors = make(map[string]Cursor, len(c.cursors)) for id, cur := range c.cursors { cursors[id] = *cur } return c.cells, cursors } // CursorsSnapshot is Snapshot() without paying for a cell-array copy when // only cursor positions are needed (the common case for input handling). func (c *Canvas) CursorsSnapshot() map[string]Cursor { c.mu.Lock() defer c.mu.Unlock() cursors := make(map[string]Cursor, len(c.cursors)) for id, cur := range c.cursors { cursors[id] = *cur } return cursors } func (c *Canvas) PeerCount() int { c.mu.Lock() defer c.mu.Unlock() return len(c.cursors) } // broadcastLocked must be called with c.mu already held. Each Send runs in // its own goroutine rather than inline: a just-joined program's Run() // loop hasn't started reading its input channel yet at the moment Join() // calls this (Join registers the program, then broadcasts, before the // caller gets around to calling p.Run()) -- an inline, blocking Send to // that program would deadlock the whole session handler before it ever // reaches Run(). Confirmed by hitting exactly that deadlock in testing: // the very first session hung with nothing rendered. func (c *Canvas) broadcastLocked() { for _, p := range c.programs { if p == nil { continue } go p.Send(canvasUpdatedMsg{}) } } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } func RandomID() string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) for i := range b { b[i] = chars[rand.Intn(len(chars))] } return string(b) }