Fredrik Johansson 2b8c3ae6ee 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

scrawl

A shared ASCII doodle wall you connect to over SSH. Everyone connected right now is drawing on the same canvas, live.

ssh scrawl.goonk.se -p 23235

Not aiming to be useful to anyone but me — built purely because a multiplayer canvas over raw SSH is a genuinely fun, weird shape for a toy, and the wish+bubbletea pattern from delve-term was still fresh enough to reuse without re-learning it.

Controls

  • Arrow keys or hjkl — move your cursor
  • space / enter — paint
  • backspace / delete — erase
  • [ / ] — cycle the ink character
  • 1-8 — pick a color
  • c — clear the whole canvas (yes, for everyone — it's a shared wall)
  • q / ctrl+c — quit

Architecture

Same security model as delve-term: the SSH server (charmbracelet/wish) hands each connection straight to a bubbletea program — no shell, no exec, structurally can't reach a real shell on the host.

The one thing this app needed beyond delve-term's pattern: multiple sessions sharing live state. wish's bm.Middleware helper hides the *tea.Program it creates behind its own closure, so this app builds the program directly instead, keeping a reference in a small registry (game.Canvas) that broadcasts a redraw signal to every other connected session the instant one of them paints.

A real bug worth knowing about if you're extending this: Program.Send() blocks until that program's Run() event loop is actively reading from it. Broadcasting synchronously from inside Join()/Paint() deadlocked every session before it ever reached Run() — fixed by sending asynchronously (go p.Send(...)) everywhere the canvas notifies sessions of a change.

A second, subtler bug: lipgloss's default 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, a systemd journal), so every session silently lost all color/background styling at once. Fixed with a lipgloss.Renderer created per-session, bound to that session's actual output, forced to TrueColor.

Local development

./run-local.sh                                        # starts on :23235
ssh -p 23235 localhost -o StrictHostKeyChecking=no     # in another terminal

Status

Verified with two simultaneous SSH sessions (scripted, not just live poking) — one paints, the other sees it via the broadcast, peer count stays in sync. No persistence: canvas state lives in memory only and resets on restart, which is fine for what this is.

License

MIT.

Description
Silly little toy.
Readme 47 KiB
Languages
Go 95.4%
Dockerfile 2.3%
Shell 2.3%