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

A self-hostable 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.
• Every move is saved as an event, and the game 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.

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.

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 communities, prompts and 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. 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, and draws a family tree of how each word grew from the last.

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.

The client is mobile-first and installable as a home-screen app: a bottom tab bar on phones, prompt and dictionary side by side on wider screens. There's also a proper Android app, built with Capacitor, and the game itself didn't change to make it.

There's no winner. The scores don't rank players. They hold 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, defined once in a 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:

  • Lobby. People arrive through the invite link.
  • Isolation. Pick the cut-off community you'll play, such as fog-bound tower keepers, a mining seam, or an Antarctic station.
  • Aspects. Together, write three truths about the community. Every word you coin later should grow from them.
  • Archetypes. Each player claims a role, like "The Keeper" or "The One Who Leaves".
  • 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.
  • Legacy. The ending arrives. Each adopted word either survives, gets misremembered, or becomes lost.
  • Archived. The dictionary stays readable forever.

Nine communities ship with the app. Each ending is chosen so the Legacy has teeth: the community doesn't just drift apart, something specific closes it. The towers come down. The pit closes. The station is automated.

Every word can point back to earlier words, or to one of the three truths. That's how the app draws its family tree.

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.
How it works: the ending, where words are lost or survive
At the end, each word gets a fate. Most are lost.

How it works

Under the hood there's a React client, a TypeScript game engine, Postgres, a small Python scoring service and an open-source voice server. The engine is the only authority: it decides whether a move is legal.

The one idea worth slowing down for is how moves are stored. 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's written on the scoresheet as an event, a fact that will never be erased.
  • The board is the current state. You never need to save it, because anyone can replay the scoresheet from move one.

Last Words works exactly like that. The engine has two pure functions. decide(state, intent) is the arbiter: it either refuses with a friendly reason ("Only the host can do that.") or returns new events. fold(events) replays the scoresheet to rebuild the current state.

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

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.
  • Ana's browser sends a proposeWord intent down the WebSocket.
  • The server opens a Postgres transaction and takes an advisory lock on the table's id. Think of it as a "talking stick" for that table.
  • It reads every event for the table and folds them into the current state.
  • decide() checks the rules: right phase, has a form and a meaning, not a duplicate.
  • A new WordProposed event is appended, the transaction commits, and the server sends the whole new state to everyone at the table.
  • Within about three seconds, the Python scoring service notices the new event and recomputes that table's scores.

On networks that quietly drop WebSocket frames, the client falls back to plain HTTP and polls every three seconds, so the game stays playable.

The invite is just the page URL with the table id in it. Your identity is a random id kept in the browser, so if you reload or lose wifi, you come back to the same seat with the same role.

Latecomers are welcome until the end of Age 3. They get a catch-up card with the community, the three truths and the free roles. The Legacy is deliberately closed: deciding the fate of words you never coined isn't something to hand a stranger.

Three design decisions worth stealing

  • Broadcast the whole state, not the diff. With five players and a few hundred events, that's cheap. It wipes out a whole family of "my screen disagrees with yours" bugs, because there's nothing to merge.
  • A database lock as the "room server". A Postgres advisory lock on the table id acts like a talking stick: only one process at a time can decide and append. Idle tables use no memory at all.
  • The host drives, anyone can stop. Only the host moves the game to the next phase. The content belongs to everyone, and anyone can pause the table at any time, no explanation needed.

What was hard (and what I learned)

  • The bug that ate the join message. Over a LAN address like http://192.168…, the page isn't a secure context, and crypto.randomUUID() simply doesn't exist there. The error silently swallowed the join message, and the screen said "Finding the table…" forever. A small UUID helper built on getRandomValues fixed it.
  • Peer-to-peer voice works until it doesn't. The first voice version had everyone send 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 server means one upload per person and automatic reconnects.
  • Prompt decks can stall. Drawing "the next unseen prompt" by counting history broke once the deck ran out. The engine now rotates on a separate draw counter that only ever goes up.

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

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 like side projects with a philosophical streak, Lifetime Contribution Balance asks what each of us has received from society, and what we have given back.

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.