[ and ] needed a reach/shift on most layouts and felt awkward next to the otherwise all-unshifted control scheme (hjkl, space, digits). , and . are adjacent, unshifted, easy to hit without looking away from the canvas -- a natural prev/next pair. Old bindings kept as a fallback rather than removed. Verified both in isolation (Update() with a KeyMsg) and live over a real SSH session -- the status-line ink swatch visibly cycles from █ to ▓ on a fresh connection. An initial live test after the rebind appeared to fail, but that traced to a stale session left over from the server restart, not an actual bug -- confirmed working correctly on a fresh connection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2.7 KiB
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— paintbackspace/delete— erase,/.— cycle the ink character ([/]also work)1-8— pick a colorc— 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.