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](https://repo.explewd.com/explewd/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
|
2026-07-15 19:55:59 +02:00
|
|
|
- `,` / `.` — cycle the ink character (`[` / `]` also work)
|
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
|
|
|
- `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`](https://github.com/charmbracelet/wish)) hands
|
|
|
|
|
each connection straight to a [bubbletea](https://github.com/charmbracelet/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
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
./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.
|