Last Words: Building an Online Table for Inventing (and Losing) a Language

An open-source, containerised web app for Dialect-style games: 3–5 friends invent a cut-off community's words, then decide which ones get lost.

Share
The words Last Words beside a small family tree of invented words, some surviving, some struck out as lost
📜
TL;DR
• Last Words is a self-hostable web app for playing Dialect-style language games: 3–5 friends invent the words a cut-off community would need, then decide which ones get lost.
• The whole thing starts with one docker compose up: a React client, a TypeScript game engine, Postgres, a Python scoring service and a voice server.
• Every move is saved as an event in Postgres, and the game state is rebuilt from that log. Replays, reconnects, exports and scores all come from the same list.
• It runs in a browser, installs on your home screen, and comes as an Android app.

A game where losing is the point

Most games end with a winner. This one ends with a funeral for words.

Picture it: you and four friends play the keepers of a chain of foggy signal towers. A supply boat comes once a year. Over the evening you invent the slang only tower keepers would need, like a word for the first hour after the fog lifts, or for a colleague who has stopped answering the signal lamp. Then the mainland sends a crew to take the towers down. You go through your new language one word at a time and decide what happens to each one.

That idea comes from Dialect, a lovely tabletop game by Thorny Games about language, isolation and loss. If you haven't played it, go and buy it. It's one of the most thoughtful games I know.

The push to build this came from my wife. She told me she'd love to have a game like this, one we could actually play together with friends, whenever we wanted. The catch: Dialect is built for a real table with real cards, and our friends live in different cities.

So I built a table we could all sit at from anywhere.

🙏
Credit where it's due. Last Words borrows the shape of a Dialect-style game. It doesn't reproduce Thorny Games' backdrops, archetype write-ups or card text. All nine communities, 108 scene prompts and 45 archetypes in the app are original writing, released as CC0.

What I built

Last Words is a self-hostable web platform, built entirely on open-source components, for this kind of game. One person starts a table and copies an invite link. Up to four friends open it, type a name, and sit down. The app walks the group through every phase, keeps the growing dictionary, draws a family tree of how each word grew from the last, and hands you the finished dictionary at the end.

Diagram: 3 to 5 friends join one shared table, play through the phases, and keep a lexicon, a family tree, richness scores and a JSON dictionary export
Friends go in, a whole small language comes out.

There's no winner. The scoring service doesn't rank players. It holds up a mirror, with numbers like "how many words did you actually say again later?"

What a game feels like

A table moves through nine phases. They're defined once, in the shared contracts file, so the server, the client and the tests all agree on them:

State diagram of nine phases: LOBBY, ISOLATION, ASPECTS, ARCHETYPES, AGE_1, AGE_2, AGE_3, LEGACY, ARCHIVED, with the condition needed to leave each one
The phase machine. The orange lines are the rules the engine checks before it lets the host move on.

In plain words:

  1. Lobby. People arrive through the invite link.
  2. Isolation. Pick the cut-off community you'll play, such as fog-bound tower keepers, a mining seam, or an Antarctic station.
  3. Aspects. Together, write three truths about the community. Every word you coin later should grow from this soil.
  4. Archetypes. Each player claims a role, like "The Keeper" or "The One Who Leaves".
  5. Three Ages. Draw a scene prompt ("Name what you call the boat when it is late"), argue it out loud, and propose a word. The table adopts the words that stick. Each new Age brings a change that shakes the community.
  6. Legacy. The ending arrives. Take each adopted word in turn: does it survive, get misremembered, or become lost?
  7. Archived. The dictionary stays readable forever.

Nine communities ship with the app, picked for range. A few of them:

CommunityCut off byEnds when
The Long WatchFog and distanceThe towers come down
The SeamDepthThe pit closes
Kestrel StationIce and darknessThe station is automated
The Night ShiftTime, not distanceThe site is automated
The Rule of SilenceA vow that forbids speechThe house is dispersed

(Plus four more: Ward Nine, Saturation, The Last Year and The Dig.) Each ending is chosen so the Legacy has teeth. The community doesn't just drift apart. Something specific closes it.

How it works: someone coins a word
From the built-in "How it works" tour: coining a word.

How it works

Architecture diagram: React web client on 5174, TypeScript server on 8080 with REST and /ws, Postgres on 5433, Python FastAPI scoring on 8000, LiveKit voice server, with Valkey and Ollama provisioned but unused
Everything in the dev stack, with the real ports from docker-compose.yml.

Here's each box in one or two sentences:

  • contracts/ is a tiny shared TypeScript package. It defines the phases, the intents, the Word and SessionState shapes, and the WebSocket messages. Client and server import the same file, so they can't drift apart.
  • server/ is the game engine and the only authority. It decides whether a move is legal and writes the event log.
  • Postgres holds the log, plus snapshots and the scoring service's bookkeeping.
  • scoring/ is a small Python FastAPI service. It reads the log and computes "richness" metrics. It never changes the game.
  • web/ is the React client: dark-first, mobile-first, one main action per screen.
  • livekit is an open-source voice server (an SFU, or Selective Forwarding Unit). It powers the built-in voice call.
  • Valkey and Ollama are set up but not used yet. Valkey (a Redis-compatible store) is there for later scaling. Ollama comes up only with --profile ai, and nothing is wired to it yet.

One shared vocabulary

The heart of the contracts file is the list of things a player can ask for. I call them intents:

export type Intent =
  | { kind: 'chooseIsolation'; isolationId: string }
  | { kind: 'draftAspect'; slot: 0 | 1 | 2; text: string }
  | { kind: 'claimArchetype'; archetypeId: string }
  | { kind: 'drawPrompt' }
  | { kind: 'proposeWord'; term: string; gloss: string; derivesFrom: string[] }
  | { kind: 'commitWord'; wordId: string }
  | { kind: 'decideLegacy'; wordId: string; fate: WordFate }
  | { kind: 'advancePhase' }
  | { kind: 'setPaused'; paused: boolean }
  // ...plus markWord, recordUsage and postChat

A Word carries its term, its meaning (the "gloss"), who coined it, which phase it was born in, a status (proposed → canon → archaic → lost), its fate, and quotes of people using it. Its derivesFrom list points at parent words or at one of the three truths (written aspect:0, aspect:1, aspect:2). That's how the family tree gets its roots.

Intents, events, state: the chess scoresheet

This is the one idea worth slowing down for. Think of a chess tournament.

  • A player wants to move a knight. That's an intent. It's a request, and it might be illegal.
  • The arbiter checks the rules. If the move is fine, it gets written on the scoresheet. That line is an event, a fact that happened and will never be erased.
  • The board is the current state. You never need to save the board, because anyone can replay the scoresheet from move one and arrive at the same position.

Last Words works exactly like that. The engine has two pure functions. decide(state, intent) is the arbiter: it either throws a friendly IllegalMove ("Only the host can do that.") or returns events like WordProposed. fold(events) replays the scoresheet: it starts from an empty table and applies every event in order to produce SessionState.

💡
Key idea: the event log is the truth, and everything else is a view of it. Reconnecting, a late joiner catching up, the JSON export and the scores are all just different ways of reading the same scoresheet.

Follow one word end-to-end

Let's say Ana proposes a new word. Here's what actually happens in the code:

Sequence diagram: Ana's browser sends a proposeWord intent; the server queues it, takes a Postgres advisory lock, reads all events, folds state, decides, appends WordProposed, commits, saves a snapshot and broadcasts the whole state to every socket; the scoring service polls every 3 seconds and recomputes
From Ana's click to every screen updating, then the scoring service catching up a few seconds later.
  1. Ana's browser sends { type: 'intent', intent: { kind: 'proposeWord', ... } } down the WebSocket.
  2. The server puts the work in a small per-table queue, so two clicks from the same table never trip over each other inside one process.
  3. It opens a Postgres transaction and takes an advisory lock on the table's id. Think of it as a "talking stick" for that table.
  4. It reads every event for the table and folds them into the current state.
  5. decide() checks the rules: right phase, has a form and a meaning, not a duplicate.
  6. The new WordProposed event is appended with the next sequence number, then the transaction commits.
  7. A snapshot of the state is saved alongside it.
  8. The server sends the whole new state to every socket at the table, Ana's included.
  9. Within about three seconds, the Python service notices new events past its cursor, recomputes that table's scores, and moves its cursor forward.

The real-time protocol, on one card

The WebSocket protocol is small enough to fit in a table:

DirectionMessageWhat it means
Client → serverjoin"Seat me at this table." Carries the table id, a name and (if you have one) your saved player id
Client → serverintent"I'd like to do this." Any of the intents above
Server → clientwelcome"You're seated." Your player id plus the full state
Server → clientstate"Something changed." The full new state
Server → clienterror"Can't do that," with a human-readable reason

Both client messages also have plain HTTP twins (POST /api/sessions/:id/join and /intent). On networks that quietly drop WebSocket frames, the client falls back to HTTP and polls every three seconds, so the game stays playable.

Four design decisions worth stealing

1. Broadcast the whole state, not the diff

The choice: after every successful move, every player gets the entire SessionState.
Why: with five players and a few hundred events, that's cheap. It also wipes out a whole family of "my screen disagrees with yours" bugs, because there's nothing to merge.
Trade-off: the message grows with the game. Chat is the one thing that could grow without limit, so only the last 200 messages ride along in state. The rest stay in the log.

2. A database lock as the "room server"

The choice: no sticky sessions and no in-memory room objects. A Postgres advisory lock on the table id is the whole room-affinity mechanism.
Why: any number of server processes could serve the same table, but only one at a time can decide and append. Idle tables use no memory at all. They're rebuilt from the log when someone speaks.
Trade-off: every move replays the table's log. At this game's size that doesn't matter, and snapshots are already being written for when it does.

3. TypeScript owns authority, Python owns derivation

The choice: scoring lives in a separate Python FastAPI service that can only read the event log. It keeps its own session_scores table and a durable cursor in consumer_cursors.
Why: scores are derived, recomputable and disposable. If I change a formula, I can throw the table away and rebuild it from the log. And Python is a natural home for future analysis work.
Trade-off: the two sides share no code, only event names. The types are hand-written for now, and generating both sides from one schema is on the roadmap.

The metrics are deliberately gentle:

Shown asWhat it measures
Words adoptedLexicon size, words that made it past "proposed"
Said again laterShare of adopted words that someone quoted in use
Generations deepLongest chain of words derived from words
Truths that bore fruitHow many of the three truths grew at least one word
Survived the endingShare of decided words whose fate was "survives"

4. The host drives, anyone can stop

The choice: only the host can send advancePhase. Everything else is open to everyone: choosing the community, writing truths, coining and adopting words. And anyone can pause the table at any time, no explanation needed.
Why: someone has to keep the evening moving, but the content belongs to the group. The engine also guards each exit. You can't leave Aspects with two truths, or finish the Legacy with a word still undecided.
Trade-off: a slow host slows the table. Social problem, social fix: tell them.

The invite is just the page URL with the table id in the hash: #/t/<uuid>. Your identity is a random id kept in the browser's localStorage. Reload or lose wifi, and you come back to the same seat with the same archetype. The socket reconnects on its own, backing off up to eight seconds between tries.

Latecomers are welcome all the way to the end of Age 3. They get a catch-up card showing the community's premise, the three truths and the free archetypes, and they're allowed to claim a role once, late. The Legacy, though, is deliberately closed. Deciding the fate of words you never coined isn't something to hand a stranger. The fifth seat is the last one.

How it works: the ending, where words are lost or survive
At the end, each word gets a fate. Most are lost.

Phones, and a real Android app

The client is mobile-first and installable as a home-screen app: a bottom tab bar on phones, prompt and lexicon side by side on wider screens.

There's also a proper Android APK, built with Capacitor, and the game itself didn't change to make it. The build runs entirely in Docker, so no Android Studio is needed. One setting, androidScheme: 'https', makes the app count as a "secure context", which is what lets the microphone (and so voice) work inside it. The only casualty is dictation, because Android's built-in WebView has no speech recognition. The chat box explains that instead of showing a dead button.

Family tree of words growing from the three truths
The family tree: words grow from the three truths, and later words grow from earlier ones.

What was hard (and what I learned)

  • The bug that ate the join message. Played over a LAN address like http://192.168…, the page isn't a secure context, and crypto.randomUUID() simply doesn't exist there. It was being called inside the socket's onopen handler, so the error silently swallowed the join frame. The socket stayed open and the screen said "Finding the table…" forever. The fix was a small UUID helper built on getRandomValues. The clipboard API has the same restriction, so "Copy invite" now falls back gracefully.
  • Peer-to-peer voice works until it doesn't. The first voice version was a mesh: everyone sent their microphone to everyone. With five people, that's four uploads per phone, and a phone switching from wifi to mobile data just dropped. Moving to a LiveKit SFU means one upload per person, built-in TURN for awkward networks, and automatic reconnects. The game server mints short-lived voice tokens (two hours) only for players already seated at that table.
  • Prompt decks can stall. Drawing "the next unseen prompt" by counting history broke once the deck ran out, because a repeat never makes the history longer. The engine now rotates on a separate draw counter that only ever goes up, and it never hands back the prompt already on the table.

Try it yourself

The easiest way to play is the live version at play.sanyal.net/last-word. Enter a name, start a table, and send the invite link to two to four friends. No install and no sign-up.

To run your own copy instead, you need Docker. Then:

docker compose up --build
# open http://localhost:5174

Enter a name, hit Start a new table, then use Copy invite and send the link to up to four friends. Want the same shape as a deployment, with everything behind one proxy and one path? Use this:

docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d --build
open http://localhost:8081/last-word/

Curious about the optional AI profile (Ollama on port 11434, not wired up yet)?

docker compose --profile ai up

A full end-to-end suite drives players through every phase, including with no WebSocket at all:

./e2e/all.sh

New to the game? #/how-to-play is a one-minute guided demo that ends, fittingly, on the Legacy.

What's next

Straight from the project's to-do list, roughly in priority order:

  • Async mode. mode: 'async' is already accepted and stored, but it still needs durable timers and a notifier so a game can stretch over a week.
  • Accounts. Right now identity is a browser-stored id that the server trusts.
  • Bots as players. Intents are the only way to change the game, so a bot is just another client. None exists yet.
  • An LLM gateway for the Ollama container that's already sitting there.
  • Contract codegen: JSON Schema as the single source, generating both TypeScript and Python types.
  • Event upcasters, so old events can be migrated before the first breaking change to their payloads.
  • Scaling past one server, using Valkey pub/sub so broadcasts reach sockets on other processes.

Last words

This started with my wife saying she'd love a game like this, and grew into a way to play it with friends who live too far away. Along the way I got a tidy little lesson in event sourcing: when the log is the truth, replay, reconnects, late joiners and scoring all come almost for free.

If you play a round, I'd love to hear which word your table fought hardest to save. Leave a comment, and subscribe if you'd like the next build in your inbox.