Chat rooms and video calls that live in your terminal. The video is characters.
$ tc
termcall
1 chat room talk to up to 10 people
2 video call ASCII video, up to 4 people
3 camera check see yourself, try the render modes
One binary. Nothing to install alongside it — ffmpeg rides along inside.
# macOS / Linux
curl -fsSL https://call.dharun.dev/install.sh | sh
# Windows
irm https://call.dharun.dev/install.ps1 | iexOr take a binary from the releases page.
Run tc. Pick a chat room or a video call, then create one or join one.
Creating gives you a six-character code; anyone with the code and tc can
join. Codes avoid 0, O, 1, I and L, so one read down a phone line
survives the trip.
In a room:
| key | |
|---|---|
enter |
send a message |
↑ ↓ |
scroll back through the transcript |
m |
change the video render mode |
esc |
leave |
Rooms are not stored. When the last person leaves, the room is deleted and its code is free again.
m cycles them mid-call, and it applies to everyone on your screen.
blocks — the default. Each cell is ▀ carrying two colours, the
foreground painting the top half and the background the bottom, so one cell
shows two pixels in full 24-bit colour. The most picture per character.
ascii — one character per cell, chosen from .:-=+*#%@ by brightness and
tinted with the colour it stands for. The classic look, half the vertical
detail.
braille — 2×4 dots per cell, so four times the resolution, with colour per cell rather than per dot. Thresholded against each cell's own average, which makes it find edges. Good for outlines, weaker on flat colour.
All three run through auto-levels first. A webcam indoors puts almost everything it sees into a narrow band — measuring one gave a picture living entirely between luminance 125 and 140 — and a ten-step ramp maps that whole range onto a single character. Stretching what is actually there across the full range is the difference between a picture and a texture.
One Worker, one Durable Object per room, and a Go client. The server is workerd — the open-source runtime behind Cloudflare Workers — on a box you run. No database, no build step, no framework.
Chat goes through the Durable Object. Every message is relayed there and fanned out to the room. Text is small and rare, so this costs almost nothing.
Video does not. After the handshake, frames go directly between terminals over WebRTC, and the server carries nothing further.
That split is the whole design. Relaying ten frames a second from four people is 24,000 messages in a ten-minute call, every one of them through the server's uplink. The same call peer-to-peer costs the server about twenty messages, all of them handshake.
What crosses the wire is a JPEG, not characters. Each terminal renders locally, which is why you can change mode mid-call and why every tile fits whatever size your window happens to be. It is also smaller: ANSI colour codes are bulky enough that a screenful compresses no better than a photograph of the same scene. A frame is about 1.5 KB, so a full four-way call sends roughly 45 KB/s upstream.
Everyone connects to everyone. A mesh, not a server. At four people that is three connections each, which is exactly why video rooms stop at four: the cost of a mesh climbs as the square, and a fifth person makes it worse for everyone already there.
chat video
──── ─────
you ─┐ you ─────────────┐
├─→ Durable Object └─ handshake ─→ Worker
them ┘ │ │
└─→ everyone them ←─────────────────┘
↕
frames, direct
cd worker
npm install # workerd, the only dependency
npm start # workerd serve workerd.capnp → :8787worker/workerd.capnp is the whole deployment. Rooms
live in memory — one Durable Object per live room, wiped by the last person
out — so there is no disk to look after, and a restart simply ends the calls in
progress. Put it behind anything that terminates TLS and sends
X-Forwarded-Proto: https, so the install scripts it hands out say https://.
Keepalives are protocol-level WebSocket pings, which the runtime answers without
waking the Durable Object.
The one thing that may eventually need something else is TURN, the relay for
networks too strict for peer-to-peer to cross. /ice serves the list, so
adding one is three environment variables on the server and no client update:
TURN_URL=turn:host:3478 TURN_USER=… TURN_PASS=… npm startgo build -o tc ./cmd/tc # needs a system ffmpeg for video
go test ./... # unit tests; no server needed
cd worker && npm start # workerd on 127.0.0.1:8787
TC_HOST=http://127.0.0.1:8787 go test ./... # integration tests tooThe integration tests are skipped unless TC_HOST is set, because what they
check — that create refuses a live code, that a room fills up, that two peers
complete a WebRTC handshake — are decisions the real server makes. Testing
them against a mock would only prove the mock agrees with itself.
To build a release binary carrying ffmpeg:
tools/fetch-ffmpeg.sh linux amd64
GOOS=linux GOARCH=amd64 go build -tags embedffmpeg -ldflags "-s -w" ./cmd/tcThat takes the binary from about 7 MB to about 40 MB. It is worth it: a person
downloads one file and it works. A system ffmpeg on PATH is still preferred
at runtime when there is one, so the unpacking is skipped for anyone who
already has it.
| environment variable | |
|---|---|
TC_HOST |
the server to use, default call.dharun.dev |
TC_FFMPEG |
a specific ffmpeg to use instead of searching |
MIT — see LICENSE.
Release binaries bundle a GPLv3 build of FFmpeg, run as a separate program rather than linked. See NOTICE.