ttt-lan
v1.1.0
Published
Zero-config LAN tic-tac-toe for your terminal: discover colleagues, invite, play, and share a gossiped leaderboard.
Maintainers
Readme
ttt-lan
Play tic-tac-toe with the people sitting around you. No server, no signup, no IP addresses to type.
npx ttt-lanIt asks your name once, then shows everyone else on your office network who is also running it —
with their records, ratings, and ping. Type /invite arjun, they type /accept, and you are playing.
Results are gossiped across the network, so the leaderboard stays in sync on every machine, even ones
that were not involved in the game.
╭─ TTT-LAN ────────────────────────────────── you: Riya (1042 Elo) ─╮
│ │
│ # Player Status Elo W-L-D Ping │
│ 1 Arjun#7f3a idle 1088 14-6-2 3ms │
│ 2 Meera in-game 1031 8-9-1 11ms │
│ 3 Dev idle 996 4-4-0 7ms │
│ 4 Sana away 974 2-7-1 — │
│ │
│ 4 players online · 214 matches recorded │
╰────────────────────────────────────────────────────────────────────╯
/invite <name|#> /who /scores /name <new> /help /quitRead this first — the three things that actually go wrong
1. Your firewall will ask permission on the first run. Click Allow, on private networks. This is by far the most common "it doesn't work" report. macOS and Windows both prompt; if you dismiss it, other people simply never see you. On Windows, make sure the Private box is ticked, not just Public.
2. A corporate VPN will usually break discovery. VPN clients hijack the default route, so broadcasts go down the tunnel instead of onto the office LAN. Symptom: the lobby stays empty even though a colleague two desks away is running it. Fix:
ttt-lan --iface 192.168.1.41 # your real LAN address, from /whoami or `ip addr`3. Some managed office access points enable "client isolation", which blocks all peer-to-peer
traffic. No fallback can rescue this — the network is deliberately preventing your machines from
talking to each other. If /connect to a colleague's address also fails, this is almost certainly
why. Ask your network admin, or use a different network.
If the lobby is empty for 10 seconds you will get an on-screen hint pointing at these.
Requirements
- Node.js ≥ 20.11 (22 LTS recommended). Check with
node --version. - Everyone must be on the same subnet — same office wifi or switch.
Install
npx ttt-lan # no install, always the current version
npm i -g ttt-lan # or install it properlyPlaying
Once you are in the lobby:
| What you want | What you type |
|---|---|
| See who is around | /who |
| Challenge someone | /invite arjun or /invite #2 |
| Accept a challenge | /accept |
| Play a square | 5 — just the number, 1–9 like a numpad |
| Say something | /chat nice one or > nice one — works in-game and in the lobby |
| Quick reactions | /gg, /nice, /oops |
| Taunt the loser | /taunt — winners only 😏 |
| Give up | /resign |
| Play again | /rematch (colors swap each time) |
| See the leaderboard | /scores |
| Change your name | /name Riya |
| Leave | /quit, or Ctrl-C twice |
Squares are numbered like a phone keypad, and you can also use tl, mid, br, or b2:
1 │ 2 │ 3
───┼───┼───
4 │ 5 │ 6
───┼───┼───
7 │ 8 │ 9When broadcast is blocked
If nobody shows up automatically, one person runs /whoami and reads out their address; everyone
else runs:
/connect 192.168.1.42That is enough to bootstrap the whole office — peers exchange their rosters on connect, so one manual link pulls in everyone that person can see.
Trash talk
Win a game and /taunt flashes a message across the loser's terminal:
╔══════════════════════════════════════════════════════════════════╗
║ Tumse na ho payega ║
║ — Riya ║
╚══════════════════════════════════════════════════════════════════╝/taunt alone sends the classic; /taunt anything you like sends your own. Only the winner of the
last game can fire one — losing and taunting is just noise. The banner fades after a few seconds.
/chat also works in the lobby now, so you can round people up before starting a game. And when
someone gets hot, everyone sees it:
🔥 Arjun is on a 3-win streak!Ratings
Standard Elo, starting at 1000. Ratings are never stored: they are recomputed from the match log
every time, so any two machines holding the same games always agree on the standings, no matter what
order the results arrived in. Players with fewer than 3 games show as (provisional) and rank below
established players.
Command-line options
ttt-lan # normal launch
ttt-lan --name Riya # skip/override the name prompt
ttt-lan --port 37025 # pin the TCP port
ttt-lan --discovery-port 37030 # pin the UDP port (multi-instance on macOS)
ttt-lan --iface 192.168.1.41 # pin the broadcast interface (VPN escape hatch)
ttt-lan --connect 192.168.1.42 # bootstrap a manual peer at startup
ttt-lan --hotseat # two players, one keyboard, no network
ttt-lan --no-color --ascii # dumb-terminal mode
ttt-lan --debug # verbose file logging
ttt-lan --reset # wipe profile + results (asks first)--ascii swaps the box-drawing characters and emoji for plain ASCII; use it with --no-color on
terminals that mangle Unicode (notably older Windows cmd.exe). NO_COLOR is honored too.
Your data
Everything lives in ~/.ttt-lan/:
profile.json your id and display name
settings.json turn timeout, theme, sounds
results.jsonl every match you know about, one JSON object per line
ttt-lan.log debug log (1MB × 3, rotated)Set TTT_LAN_HOME to move it elsewhere. Deleting results.jsonl only forgets your local copy of
the history — anyone else still holding those games will gossip them back.
Privacy
Anyone on your network can see your name and play you. Don't use a name you wouldn't put on a whiteboard. Names, chat, and match results are broadcast unencrypted on the local network. This is a toy for a trusted office LAN, not a secure messenger.
Strings arriving from the network are stripped of control characters and ANSI escape sequences before rendering, so a colleague cannot name themselves something that scribbles on your terminal.
Development
npm test # node --test, ~150 tests
npm run typecheck # tsc --noEmit over JSDoc types; there is no build step
npm start # run from sourceThe code is plain ESM JavaScript with JSDoc types — npm run typecheck type-checks it without a
compile step, and src/ runs directly as written.
Running several instances on one machine
Each instance needs its own data directory:
TTT_LAN_HOME=/tmp/ttt/a ttt-lan --name Alpha &
TTT_LAN_HOME=/tmp/ttt/b ttt-lan --name Bravo &
TTT_LAN_HOME=/tmp/ttt/c ttt-lan --name Charlie &On Linux and recent Node this just works — SO_REUSEPORT lets them share the discovery port.
On macOS/BSD with older Node, SO_REUSEADDR alone is not enough for two processes to bind the
same UDP port, so give each instance its own discovery port and link them manually:
TTT_LAN_HOME=/tmp/ttt/a ttt-lan --name Alpha --discovery-port 37020 --port 37021 &
TTT_LAN_HOME=/tmp/ttt/b ttt-lan --name Bravo --discovery-port 37030 --port 37031 \
--connect 127.0.0.1:37021 &Without this, the second instance appears to start fine and simply never sees the first — which costs an afternoon if you do not know to look for it.
Layout
src/
cli.js argument parsing and bootstrap
app.js wiring: discovery + sessions + match + store, exposed as a controller
engine.js pure tic-tac-toe (no I/O, no async)
elo.js pure rating math
framing.js NDJSON accumulator for TCP — the module most worth trusting
protocol.js message schemas, validation, sanitization
session.js TCP server/client, rate limiting, ping/pong liveness
discovery.js UDP beacons, roster, TTL
match.js match state machine (host is authoritative)
store.js profile + append-only match log
leaderboard.js standings derived by replaying the log
ui/ Ink components and the command parserengine.js and elo.js import nothing from the project and have no side effects, so they are
trivial to test and reason about. The host owns the board — guests send move intents and render
whatever the host says the position is, which removes the entire class of desync bugs.
License
MIT
