go · webrtc · cloudflare durable objects

termcall

chat rooms and video calls that live in your terminal. the video is characters.

go webrtc cloudflare workers durable objects tui ascii video ffmpeg mit

what it is

a terminal is a grid of characters, and a webcam frame is a grid of pixels. termcall is what happens when you take that correspondence seriously: a video call where every participant is drawn into your terminal, in colour, at whatever size your window happens to be.

one binary. nothing to install alongside it — ffmpeg rides along inside.

terminalcall.dharun.dev
$ 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
~1.5 kba video frame
4people on a video call
3render modes
mitlicense

how it works

one cloudflare worker, one durable object per room, and a go client. no database, no build step, no framework. the whole design is one split: chat goes through the durable object; video does not.

termcall architecture: chat relayed, video peer-to-peer chat messages travel from each terminal to a durable object and are fanned back out to the room. video only uses the worker for the webrtc handshake; after that, frames travel directly between terminals. CHAT — RELAYED you them durable object everyone, fanned out text is small and rare, so relaying it costs almost nothing VIDEO — PEER TO PEER your terminal their terminal worker handshake only frames, direct — cloudflare carries nothing relaying 10 fps from four people would be 24,000 requests in a ten-minute call. the same call peer-to-peer costs about twenty messages, all of them handshake.
the split is a billing decision as much as an architectural one — cloudflare charges a request per incoming websocket message.

a mesh, not a server

everyone connects to everyone. at four people that's 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.

the video mesh at two, three and four people two people need one connection, three need three, four need six. the count grows as the square of the room, which is why video rooms are capped at four. CONNECTIONS IN THE MESH 2 people 1 link 3 people 3 links 4 people 6 links · the cap a fifth person would need 10 links, and each of the four already there would pay for it
the cap isn't a limitation of the code — it's the point at which the mesh stops being kind to the people already in the room.

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's 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.

the frame pipeline a webcam frame is captured by ffmpeg, encoded as a jpeg, sent over webrtc, decoded on the far side, auto-levelled, and only then drawn into terminal cells in the mode that terminal has chosen. SENDING TERMINAL webcam ffmpeg jpeg ~1.5 kb webrtc, direct RECEIVING TERMINAL decode auto-levels your render mode cells on screen
rendering at the far end is what makes the mode a local choice, and the tile size a local one too.

the three render modes

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's actually there across the full range is the difference between a picture and a texture.

setup · install it

one command. release binaries carry ffmpeg inside, so there's nothing to install alongside.

  1. install the binary
    shmacos · linux
    curl -fsSL https://call.dharun.dev/install.sh | sh
    powershellwindows
    irm https://call.dharun.dev/install.ps1 | iex

    or take one from the releases page.

  2. run it, and try the camera first
    sh
    tc

    option 3 is a camera check — you see yourself and can cycle the render modes before anyone else does.

  3. create a room, 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. rooms aren't stored — when the last person leaves, the room is deleted and its code is free again.

keys, in a room

keywhat it does
entersend a message
↑ ↓scroll back through the transcript
mchange the video render mode
escleave

setup · build it yourself

sh
go build -o tc ./cmd/tc          # needs a system ffmpeg for video
go test ./...                    # unit tests; no server needed

cd worker && npm run dev         # wrangler dev on 127.0.0.1:8787
TC_HOST=http://127.0.0.1:8787 go test ./...   # integration too

the 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:

sh
tools/fetch-ffmpeg.sh linux amd64
GOOS=linux GOARCH=amd64 go build -tags embedffmpeg -ldflags "-s -w" ./cmd/tc

that takes the binary from about 7 mb to about 40 mb. it's 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 variablewhat it sets
TC_HOSTthe server to use, default call.dharun.dev
TC_FFMPEGa specific ffmpeg to use instead of searching

staying inside the free tier

everything runs on cloudflare's free plan, and it isn't close.

resourcefree allowancewhat termcall uses
worker requests100,000/day~1,000 for ten people chatting a hundred messages each
durable objectsfree plan, sqlite-backedone per live room, hibernating when idle
video relaynone; peer-to-peer after the handshake

idle rooms hibernate, so holding a room open bills no duration. keepalives are protocol-level websocket pings, which the runtime answers at the edge without waking the durable object — an application-level ping would bill a request every time.

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 secrets on the worker and no client update:

sh
npx wrangler secret put TURN_URL     # turn:host:3478
npx wrangler secret put TURN_USER
npx wrangler secret put TURN_PASS

mit licensed. release binaries bundle a gplv3 build of ffmpeg, run as a separate program rather than linked — see notice.


built by dharun ashokkumar · all projects