Initial build: scrawl, a shared ASCII canvas over SSH

ssh in and draw on a live, shared doodle wall -- everyone connected
sees everyone else's edits in real time. Same wish+bubbletea security
model as delve-term (no shell, no exec, structurally can't reach a
real shell on the host), extended with the one thing delve-term didn't
need: multiple sessions sharing live state. wish's bm.Middleware helper
hides the *tea.Program it creates, so this builds the program directly
instead, keeping a registry (game.Canvas) that broadcasts a redraw
signal to every other connected session the instant one of them paints.

Banner generated via `figlet -f slant SCRAWL` rather than hand-drawn --
slant felt right for a doodle/scribble tool.

Two real bugs caught by actually running this with real SSH sessions,
not just unit-testing the game logic in isolation:

1. Program.Send() blocks until that program's Run() event loop is
   actively reading from it. Broadcasting synchronously from inside
   Join()/Paint() (including a session broadcasting to its own,
   not-yet-running program on join) deadlocked every session before it
   ever reached Run() -- the very first connection just hung with
   nothing rendered. Fixed by sending asynchronously (go p.Send(...))
   everywhere the canvas notifies sessions of a change.

2. Subtler: lipgloss's default package-level styles detect color
   support from the *server process's* os.Stdout, not any given
   session's actual terminal -- and a server's stdout is typically
   redirected (a log file, systemd journal), so every connected session
   silently lost all color/background styling at once, server-wide.
   Manifested as painting a cell your own cursor already sat on being
   invisible (the cursor glyph before/after looked identical, so
   bubbletea's diffing renderer correctly sent zero bytes for a change
   that produced no visual diff) -- confirmed via server-side debug
   logging that painting itself worked correctly every time, isolating
   the bug to rendering, then confirmed via a Go test that forcing a
   real color profile was the difference between 0 and 1327 runes of
   diff between two frames that should look different. Fixed with a
   lipgloss.Renderer created per-session, bound to that session's
   actual output, forced to TrueColor; cursors now highlight whatever's
   already painted at that cell (background tint) rather than
   replacing the character, so a session's own paint is never masked
   by its own cursor marker sitting on top of it.

Verified end-to-end with two real, simultaneous SSH sessions (scripted
via pexpect): peer count syncs correctly, and one session's paint
genuinely arrives at the other via the live broadcast -- not just
unit-tested in isolation. Also verified the actual Docker image builds
and serves correctly over real SSH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-15 19:28:17 +02:00
commit 2b8c3ae6ee
14 changed files with 913 additions and 0 deletions

168
game/canvas.go Normal file
View File

@@ -0,0 +1,168 @@
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)
}