A two-player real-time terminal tag game built with Python, Textual, and WebSockets. Players connect through a central signaling server, then battle it out over a direct peer-to-peer TCP connection — no server middleman once the game starts.
+---------------------+
| . . . . . . . . . . |
| . @ . . . . . . . . | @ = IT (chaser)
| . . . . . . . . . . | # = Runner
| . . . . . . . . . . |
| . . . . . # . . . . |
+---------------------+
[#] YOU ARE RUNNER — escape [@]!
- 🎮 Real-time 10×10 grid gameplay at 120 ms ticks
- 🔗 WebSocket-based matchmaking and signaling
- 🤝 Direct TCP peer-to-peer connection (no relay after match)
- 🖥️ Modern terminal UI powered by Textual
- ⚡ Buffered input system — smooth movement even under lag
- 🏷️ Automatic role assignment: caller = IT, callee = runner
- 🔄 Retry logic for NAT traversal (7 connection attempts)
- 📋 Full debug logging to
game_debug.log
Player A Signaling Server Player B
│ │ │
│──── connect (WS) ─────────►│◄──── connect (WS) ───────│
│◄─── role: "caller" ────────│──── role: "callee" ──────►│
│ │ │
│─── ice-candidate ──────────►──── ip:port relay ───────►│
│ │ │
│◄══════════════ TCP direct P2P connection ══════════════►│
│ │ │
│◄──────────────── READY handshake (TCP) ───────────────►│
│ │
│◄═══════════════ pos / win messages (TCP) ══════════════►│
- Both players connect to the WebSocket signaling server.
- The server pairs them and assigns roles — caller (host) and callee (joiner).
- The caller opens a TCP server and relays its
ip:portvia the signaling channel. - The callee connects directly to that TCP address — signaling is no longer used.
- Both peers exchange a
READYhandshake, then the game loop begins.
- Python 3.8+
- pip packages:
textual,websockets - A running WebSocket signaling server on
ws://localhost:8080(or any compatible server at a custom URL)
# 1. Clone the repository
git clone https://github.com/yourusername/ascii-tag.git
cd ascii-tag
# 2. Install dependencies
pip install textual websockets# Connect to the default local signaling server
python main.py
# Connect to a custom signaling server
python main.py --server ws://your-server.com:8080| Key | Action |
|---|---|
W |
Move up |
A |
Move left |
S |
Move down |
D |
Move right |
Q |
Quit the game |
- The caller (first to connect) starts at position
(0, 0)and is IT. - The callee (second to connect) starts at
(9, 9)and is the runner. - IT must move onto the runner's tile to win.
- The runner must survive and avoid being caught.
ascii-tag/
├── main.py # Full game client — TUI, networking, game logic
├── game_debug.log # Auto-generated debug log (created at runtime)
└── README.md # This file
| Component | Description |
|---|---|
render() |
Builds the ASCII board string from current player positions |
blank_board() |
Returns an empty board for non-playing phases |
local_ip() |
Detects the machine's outbound IPv4 for TCP advertisement |
Game (App) |
Main Textual app — owns all state, UI, and networking |
_network() |
Background worker — manages WebSocket lifecycle |
_signaling() |
Processes all pre-game WS messages (waiting/matched/left) |
_host() |
Caller path — opens TCP server, waits for peer |
_join() |
Callee path — retries TCP connection to caller |
_start_game() |
READY handshake + spawns tick and recv tasks for both peers |
_game_tick() |
Per-tick: apply move → send pos → check win |
_recv_loop() |
Reads peer TCP stream; handles pos and win messages |
_check_win() |
Tags occur when both players share the same grid cell |
These constants at the top of main.py control game behaviour:
| Constant | Default | Description |
|---|---|---|
GRID_W |
10 |
Board width in cells |
GRID_H |
10 |
Board height in cells |
TICK |
0.12 |
Seconds per game tick (120 ms) |
SYM_A |
@ |
Symbol for the caller (IT) |
SYM_B |
# |
Symbol for the callee (runner) |
A full debug log is written to game_debug.log in the working directory on every run.
It captures connection attempts, role assignments, position updates, task lifecycle
events, and any errors from the network or game loops.
tail -f game_debug.log # live-tail while the game is runningpip install pyinstaller
pyinstaller --onefile --name AsciiTag main.pyThe binary will be placed in the dist/ directory.
| Package | Purpose |
|---|---|
textual |
Terminal UI framework (widgets, CSS) |
websockets |
Async WebSocket client for signaling |
- Configurable grid size via CLI flags
- Scoreboard and round counter
- Spectator mode via signaling server
- NAT hole-punching for wider P2P compatibility
- Sound effects via terminal bell sequences
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -m 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Open a Pull Request
This project is licensed under the MIT License — see the LICENSE file for details.
Built with ❤️ using Python, Textual, and raw TCP sockets.