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>
2026-07-15 19:28:17 +02:00
|
|
|
package game
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Generated via `figlet -f slant SCRAWL` — the slant felt right for a
|
|
|
|
|
// doodle/scribble tool, not hand-tuned character by character.
|
|
|
|
|
const banner = ` _____ __________ ___ _ ____
|
|
|
|
|
/ ___// ____/ __ \/ | | / / /
|
|
|
|
|
\__ \/ / / /_/ / /| | | /| / / /
|
|
|
|
|
___/ / /___/ _, _/ ___ | |/ |/ / /___
|
|
|
|
|
/____/\____/_/ |_/_/ |_|__/|__/_____/`
|
|
|
|
|
|
|
|
|
|
func (m Model) View() string {
|
|
|
|
|
if m.state == stateIntro {
|
|
|
|
|
return m.renderIntro()
|
|
|
|
|
}
|
|
|
|
|
return m.renderCanvas()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// style is a small accessor so every render call goes through m.renderer
|
|
|
|
|
// (bound to this specific SSH session's actual output) rather than
|
|
|
|
|
// lipgloss's package-level default styles, which detect color support
|
|
|
|
|
// from the *server process's* os.Stdout -- not any given session's
|
|
|
|
|
// terminal. That's a real bug this app hit: the server's stdout is
|
|
|
|
|
// typically redirected (a log file, a systemd journal, anything that
|
|
|
|
|
// isn't a TTY), so the default global renderer silently disabled all
|
|
|
|
|
// color/background styling for every connected session at once,
|
|
|
|
|
// regardless of what terminal they were actually using.
|
|
|
|
|
func (m Model) style() lipgloss.Style {
|
|
|
|
|
return m.renderer.NewStyle()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m Model) renderIntro() string {
|
|
|
|
|
title := m.style().Bold(true).Foreground(lipgloss.Color("#ff6b6b")).Render(banner)
|
|
|
|
|
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
|
|
|
|
body := title + "\n" +
|
|
|
|
|
dim.Render("a shared doodle wall, one keystroke at a time") + "\n\n" +
|
|
|
|
|
"Everyone connected right now is drawing on the same canvas.\n" +
|
|
|
|
|
"Move with arrow keys or hjkl, space/enter to paint, backspace to\n" +
|
2026-07-15 19:55:59 +02:00
|
|
|
"erase. , and . cycle the ink character, 1-8 pick a color, c clears\n" +
|
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>
2026-07-15 19:28:17 +02:00
|
|
|
"everything for everyone (yes, really — it's a shared wall).\n\n" +
|
|
|
|
|
dim.Render("press any key to start")
|
|
|
|
|
return m.boxStyle().Render(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m Model) boxStyle() lipgloss.Style {
|
|
|
|
|
return m.style().Border(lipgloss.RoundedBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("#555555")).Padding(1, 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m Model) renderCanvas() string {
|
|
|
|
|
cells, cursors := m.canvas.Snapshot()
|
|
|
|
|
|
|
|
|
|
// Cursors are a background highlight on whatever's actually painted at
|
|
|
|
|
// that cell, not a glyph that replaces it -- a glyph-replace bug meant
|
|
|
|
|
// painting a cell your own cursor already sat on was invisible: the
|
|
|
|
|
// cursor marker before and after painting rendered as the exact same
|
|
|
|
|
// character in the exact same color, so bubbletea's diffing renderer
|
|
|
|
|
// (correctly) sent zero bytes, since nothing had visually changed.
|
|
|
|
|
type painted struct {
|
|
|
|
|
ch rune
|
|
|
|
|
color string
|
|
|
|
|
bg string
|
|
|
|
|
}
|
|
|
|
|
grid := make([][]painted, Height)
|
|
|
|
|
for y := 0; y < Height; y++ {
|
|
|
|
|
grid[y] = make([]painted, Width)
|
|
|
|
|
for x := 0; x < Width; x++ {
|
|
|
|
|
c := cells[y][x]
|
|
|
|
|
if c.Char == 0 {
|
|
|
|
|
grid[y][x] = painted{ch: ' '}
|
|
|
|
|
} else {
|
|
|
|
|
grid[y][x] = painted{ch: c.Char, color: c.Color}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for id, cur := range cursors {
|
|
|
|
|
if id == m.id {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
grid[cur.Y][cur.X].bg = cur.Color
|
|
|
|
|
}
|
|
|
|
|
if self, ok := cursors[m.id]; ok {
|
|
|
|
|
grid[self.Y][self.X].bg = inkPalette[m.inkColorIdx]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for y := 0; y < Height; y++ {
|
|
|
|
|
for x := 0; x < Width; x++ {
|
|
|
|
|
p := grid[y][x]
|
|
|
|
|
style := m.style()
|
|
|
|
|
if p.color != "" {
|
|
|
|
|
style = style.Foreground(lipgloss.Color(p.color))
|
|
|
|
|
}
|
|
|
|
|
if p.bg != "" {
|
|
|
|
|
style = style.Background(lipgloss.Color(p.bg)).Foreground(lipgloss.Color("#000000"))
|
|
|
|
|
}
|
|
|
|
|
ch := p.ch
|
|
|
|
|
if ch == 0 || ch == ' ' {
|
|
|
|
|
ch = ' '
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(style.Render(" "))
|
|
|
|
|
b.WriteString(style.Render(string(ch)))
|
|
|
|
|
}
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inkSwatch := m.style().Foreground(lipgloss.Color(inkPalette[m.inkColorIdx])).Render(string(charPalette[m.charIdx]))
|
|
|
|
|
dim := m.style().Foreground(lipgloss.Color("#777777"))
|
2026-07-15 19:55:59 +02:00
|
|
|
status := fmt.Sprintf("ink %s (1-8 color, , . char) peers: %d %s",
|
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>
2026-07-15 19:28:17 +02:00
|
|
|
inkSwatch, m.canvas.PeerCount(), dim.Render("space paint · c clear · q quit"))
|
|
|
|
|
|
|
|
|
|
return m.boxStyle().Render(b.String() + "\n" + status)
|
|
|
|
|
}
|