DEV Community

arif
arif

Posted on • Originally published at arif.sh

Your SSH key is already an account: building multiplayer apps over SSH

Almost every app starts with the same chore. Before a user can do anything, you build a way to know who they are: an email field, a password hash, an OAuth redirect, a confirmation link, a session cookie. It is real work, and none of it is the thing you set out to build.

SSH has handled this since the 1990s, and almost nobody builds on it.

When a client connects over SSH, the handshake cryptographically proves the client holds the private key for the public key it presents. By the time your code runs, the connection has already answered the question every login form exists to answer. The key fingerprint is a stable, unique, verified id for that user. No email, no password, no signup, no third party. You have an account before the first byte of your app runs.

So I pulled the common parts into a small Go framework called wharf, built on Charm's wish and bubbletea.

TL;DR

  • The SSH key fingerprint is a free, verified, zero-signup user id.
  • wharf gives you identity, presence, multiplayer rooms, and per-user persistence. You write a Bubble Tea model, it does the rest.
  • Each room is a single goroutine, so broadcasting is one line and there are no locks.
  • Storage is a tiny interface with adapters for memory, SQLite, Postgres, Redis, and bbolt, each its own module so the core has no database dependency.
  • Go, MIT. Repo: github.com/doganarif/wharf

The smallest app

An app is a function from a session to a Bubble Tea model. That is the whole contract.

func main() {
    wharf.New(":2222").
        App("shout", shout).
        Run()
}

func shout(s *wharf.Session) tea.Model {
    return model{
        me:   s.User,          // verified identity, for free
        room: s.Join("shout"), // a shared room
    }
}
Enter fullscreen mode Exit fullscreen mode

ssh shout@your.host and you are in. The room traffic shows up as normal messages in your Update:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.Type == tea.KeyEnter {
            m.room.Broadcast(line{Who: m.me.ShortID, Text: m.input})
            m.input = ""
        }
    case wharf.Event:    // someone broadcast to the room
        m.lines = append(m.lines, msg.Payload.(line))
    case wharf.Presence: // the roster changed (join or leave)
        m.roster = msg.Roster
    }
    return m, nil
}
Enter fullscreen mode Exit fullscreen mode

That is multiplayer. No socket handling, no membership bookkeeping, no auth.

Identity is free, and persistent

s.User is derived from the public key. The fingerprint is the real id, and wharf turns it into a stable handle and color so the same key is always the same brave-otter in the same shade. Two strangers who never signed up now have distinct, durable identities.

Use the fingerprint as a key into a store and you get per-user state with no login:

b := s.Bucket("visits") // namespaced to this app
raw, _, _ := b.Get(s.User.Fingerprint)
// read/unread, bookmarks, "continue where you left off", a history
// that is simply there when they reconnect tomorrow.
Enter fullscreen mode Exit fullscreen mode

It is anonymous and persistent at once. You never learn anyone's email, so you are not holding data you have to protect, but you recognize them perfectly across visits.

A room is a single goroutine

The naive multiplayer design is a shared map of members behind a mutex, and it races the moment people join and leave while messages fly.

wharf models each room as an actor. One goroutine owns the member set. Join, leave, and broadcast are messages sent to it over channels. Because exactly one goroutine ever touches that map, there are no locks and no data races by construction. Concurrency is message passing, not shared memory.

A nice second-order effect: since the room already serializes every mutation, it is the natural home for shared state. wharf has stateful rooms that hold a value and a reducer, hand each newcomer a snapshot on join, and broadcast the new state after every action. A live poll or a small game board becomes a reducer and a render, with the hard part solved one layer down.

Persistence without forcing a database on you

It would have been easy to import a Postgres driver into the core and make everyone carry it. Instead, persistence is a five-method interface. The in-memory implementation lives in the core and nothing else. Every real backend is its own Go module:

import "github.com/doganarif/wharf/store/sqlite"

st, _ := sqlite.Open("wharf.db")
wharf.New(":2222").Store(st).App("shout", shout).Run()
Enter fullscreen mode Exit fullscreen mode

Importing the core pulls in no database. Importing an adapter pulls in exactly one. SQLite, Postgres, Redis, and an embedded single-file option all pass one shared conformance suite, so swappable is something I can prove rather than claim. The Postgres and Redis adapters are verified against live servers, and per-key history that works in memory survives a restart unchanged when you point it at a file or a server.

A bug that made the design click

Early on, presence was sometimes wrong: one of two connected users would not see the other arrive. Broadcasts worked, identity worked, only the join ordering was off, intermittently.

The cause was timing. Before rendering, the terminal layer asks the client about its background color and waits for a reply. Real terminals answer in milliseconds. A client that never answers makes the code wait for the timeout, and in my first version that wait happened before the session joined its room. A slow client would join late, after someone who connected after it, and events arrived out of order.

The fix was to join the room before negotiating anything about rendering. The lesson generalized: the moment you stop assuming "connected" and "in the room" happen at the same instant, a class of ordering bugs disappears.

What you can build, and the one catch

wharf is not a chat library, even though chat is the obvious demo. The primitives (identity, presence, rooms, shared state, storage) fit a lot of things. The examples I shipped are a collaborative drawing canvas where you watch other cursors move, a keyless chat room, and a live poll. The same shapes cover guestbooks, small multiplayer games, status boards, internal tools your team reaches with keys they already have, and an assistant you talk to in your terminal.

The honest catch: SSH is pull-only. Nobody re-runs a command on a Tuesday for no reason, so an SSH app cannot tap someone on the shoulder. The experience is great and the retention is near zero unless you add a way back in, usually a small web mirror for discovery or a notification when there is an actual reason to return. The terminal is the experience, not the reminder.

Try it

go run github.com/doganarif/wharf/cmd/wharf@latest
# then, in two terminals:
ssh -p 2222 canvas@localhost
ssh -p 2222 chat@localhost
Enter fullscreen mode Exit fullscreen mode

Repo (Go, MIT): https://github.com/doganarif/wharf

If you build something odd with it, I would like to see it.

Top comments (0)