FastAPI WebSocket Hex game server with matchmaking, plus reference clients
Project description
Hex Game Server
FastAPI-based Hex game server with WebSocket matchmaking, authoritative game state, win detection, a random-move test client, and a Vite/Tailwind/shadcn-ui frontend with a landing page, operational overview dashboard, and statistics leaderboard.
Packaged as hexgame on PyPI: pip install hexgame gives you the
hexgame-server command (the FastAPI server) and the hexgame command
(random, play, and gui clients).
The current implementation covers Phases 1-7 from PLAN.md:
- Fixed in-memory game slots.
- Board-size-aware matchmaking.
- Best-of match series with configurable odd series lengths.
- WebSocket real-time gameplay.
- Server-authoritative move validation and turn tracking.
- Hex win detection.
/project landing page,/docsusage guide,/overviewmonitoring page, and/statisticsmodel leaderboard.- Model-driven clients, including a pygame GUI client for visual board output.
- GUI niceties: winning-path highlight in gold, configurable pause between
games of a series (
--match-delay, SPACE to skip), opponent's model name and username shown in the side panel, version in the window title. - Reconnect tokens,
/ws/reconnect, and a--reconnect-tokenCLI flag on the clients for resuming an interrupted match. - Optional Redis-backed slot, game, session, and reconnect-token state.
- Optional PostgreSQL/SQLAlchemy completed-series history.
User accounts and ratings are intentionally not implemented yet.
Requirements
- Python 3.10+ recommended.
- Node.js 20+ recommended only if you rebuild the overview frontend yourself.
pygame(the[gui]extra) is required for the pygame GUI client.- Redis is optional (the
[redis]extra). The default backend is in-process memory for local development. - PostgreSQL is optional (the
[postgres]extra). Completed-series history is disabled unlessHEX_DATABASE_URLis set.
Installation
The project is packaged as hexgame. Installing it provides two console
commands: hexgame-server (the FastAPI server) and hexgame (the clients).
End users — install from PyPI:
pip install hexgame # server + random/model clients
pip install "hexgame[gui]" # also the pygame GUI client
pip install "hexgame[all]" # everything: redis, postgres, gui
Developers — editable install from a checkout:
python -m pip install -e ".[dev,all]"
# or, equivalently:
python -m pip install -r requirements.txt
Rebuilding the overview frontend (only if you change frontend/):
cd frontend
npm install
npm run build # writes to src/hexgame/server/static/overview/
Using The Hosted Server
The model clients default to the hosted arena:
wss://hexgame.codingdojo.ai
That means users can install the package, add or choose a model, and connect directly without running their own FastAPI server:
pip install "hexgame[gui]"
hexgame play --model-name model_random --board-size 7
hexgame gui --model-name human --board-size 7
Open:
- Landing page:
https://hexgame.codingdojo.ai/ - Documentation:
https://hexgame.codingdojo.ai/docs - Overview dashboard:
https://hexgame.codingdojo.ai/overview - Statistics leaderboard:
https://hexgame.codingdojo.ai/statistics
Running A Local Server
From the repository root:
hexgame-server --port 8000
Then open:
- Health:
http://127.0.0.1:8000/health - Slot state JSON:
http://127.0.0.1:8000/slots - Landing page:
http://127.0.0.1:8000/ - Documentation:
http://127.0.0.1:8000/docs - Overview dashboard:
http://127.0.0.1:8000/overview - Statistics leaderboard:
http://127.0.0.1:8000/statistics - OpenAPI/Swagger UI:
http://127.0.0.1:8000/api/docs
Point clients at the local server with --server:
hexgame play --model-name model_random --board-size 7 --server ws://localhost:8000
hexgame gui --model-name human --board-size 7 --server ws://localhost:8000
hexgame-server is a thin wrapper around uvicorn hexgame.server.main:app;
pass --host, --port, --workers, --reload, or --log-level as needed.
If WebSocket clients receive HTTP 404 on /ws/matchmake, reinstall the package
(pip install -e ".[dev,all]") so uvicorn[standard]/websockets are present,
then restart the server.
Docker Compose
Build and run the server with Redis and PostgreSQL:
docker compose up --build
Compose starts:
app: FastAPI server onhttp://127.0.0.1:8000redis: active slot/game/session/reconnect-token statepostgres: completed-series history through SQLAlchemy ORM
Stop the stack:
docker compose down
Remove persisted Redis/PostgreSQL data:
docker compose down -v
Redis State Backend
By default, state is stored in memory:
hexgame-server --port 8000
To persist active slot state in Redis and share slot/game/session/reconnect state between server processes, start Redis and run:
HEX_STATE_BACKEND=redis \
HEX_REDIS_URL=redis://127.0.0.1:6379/0 \
hexgame-server --port 8000
Redis stores active slots, board state, series score, public model names,
public usernames, connection status, and reconnect tokens. Raw WebSocket
objects are never stored in Redis; after a server restart, persisted players
are marked disconnected and can return through /ws/reconnect using their
reconnect token.
Use HEX_REDIS_KEY_PREFIX to isolate environments that share the same Redis
database.
Database History
Completed series can be written through SQLAlchemy ORM to PostgreSQL. Set
HEX_DATABASE_URL before starting the server:
HEX_DATABASE_URL=postgresql+psycopg://hex:hex@127.0.0.1:5432/hexgame \
hexgame-server --port 8000
By default, HEX_DATABASE_AUTO_CREATE=1 creates the completed_series table on
startup. Set HEX_DATABASE_AUTO_CREATE=0 if migrations or external schema
management should own table creation.
The completed-series record stores slot id, board size, series length, winner, score, public model names, public usernames, final board, and the final public slot snapshot. It does not store reconnect tokens or WebSocket objects.
Frontend
The landing page and overview dashboard live in frontend/ and are built with:
- Vite
- React
- TypeScript
- Tailwind CSS
- shadcn/ui-style local components
During frontend development:
cd frontend
npm run dev
The Vite dev server proxies /slots and /api/statistics to
http://127.0.0.1:8000.
Build the production dashboard:
cd frontend
npm run build
The production build writes to src/hexgame/server/static/overview/. FastAPI serves the
landing page at /, the documentation page at /docs, the dashboard at
/overview, the leaderboard at /statistics, and assets from
/overview/assets/....
API
HTTP
GET /health
Returns:
{"status": "ok"}
GET /slots
Returns the current state of all slots. It is safe for clients and dashboards: it does not include raw WebSocket objects or secrets.
Example:
[
{
"slot_id": 1,
"state": "full",
"board_size": 11,
"series_length": 3,
"player_count": 2,
"connected_player_count": 2,
"players": [-1, 1],
"player_models": {"-1": "model_alphazero", "1": "human"},
"player_usernames": {"-1": "alice", "1": "bob"},
"connected_players": [-1, 1],
"disconnected_players": [],
"current_turn": -1,
"winner": null,
"move_count": 8,
"board": [[null, -1]],
"wins_required": 2,
"current_game_number": 1,
"player_1_wins": 0,
"player_2_wins": 0,
"series_winner": null
}
]
GET /
Serves the built landing page.
GET /docs
Serves the built project documentation page.
GET /overview
Serves the built dashboard.
GET /statistics
Serves the built model statistics and leaderboard page.
GET /api/statistics
Returns completed-series statistics. If database history is disabled, the
response is empty and persistence_enabled is false.
Example:
{
"persistence_enabled": true,
"totals": {"matches": 12, "games": 31, "models": 4, "model_entries": 24},
"leaderboard": [
{
"model_name": "model_alphazero",
"username": "alice",
"matches": 8,
"wins": 6,
"losses": 2,
"games_won": 14,
"games_lost": 7,
"win_rate": 0.75
}
],
"board_sizes": {"7": 6, "11": 6},
"series_lengths": {"1": 8, "3": 4},
"recent_matches": []
}
WebSocket
/ws/matchmake?board_size=11&series_length=3
Allowed board sizes:
7, 9, 11, 13, 19
Allowed series lengths:
1, 3, 5, 7, 9, 11, 13, 15
series_length defaults to 1. The server only matches players who request
the same board size and the same series length.
Clients may include model_name and username to display non-secret model
labels and owner names in /slots and /overview:
/ws/matchmake?board_size=11&series_length=3&model_name=model_alphazero&username=alice
/ws/join-slot?slot_id=1
Joins a specific waiting slot as player_2. The joining client inherits the
board size, series length, wins required, and current score rules already set
by player_1 in that slot.
/ws/reconnect?slot_id=1&token=<reconnect_token>
Reconnects a player to a reserved seat after a temporary disconnect. The token
is issued only to that client in the joined payload. It is not exposed by
/slots or the overview dashboard.
WebSocket Protocol
Every message uses this shape:
{
"type": "message_type",
"payload": {}
}
Client To Server
hello
{
"type": "hello",
"payload": {
"protocol_version": 1,
"client_name": "hex-client"
}
}
move
Clients send only coordinates. The server assigns the player identity from the WebSocket connection.
{
"type": "move",
"payload": {
"q": 3,
"r": 5
}
}
chat
{
"type": "chat",
"payload": {
"message": "Good luck!"
}
}
resign
{
"type": "resign",
"payload": {}
}
ping
{
"type": "ping",
"payload": {}
}
Server To Client
joined
{
"type": "joined",
"payload": {
"slot_id": 1,
"player": -1,
"color": "red",
"board_size": 11,
"series_length": 3,
"reconnect_token": "client-private-token",
"protocol_version": 1
}
}
Store reconnect_token client-side for the current match. Treat it like a
short-lived secret: it proves ownership of the reserved seat.
reconnected
{
"type": "reconnected",
"payload": {
"slot_id": 1,
"player": 1,
"color": "blue",
"board_size": 11,
"series_length": 3,
"protocol_version": 1,
"slot": {
"slot_id": 1,
"state": "full",
"connected_players": [-1, 1],
"disconnected_players": [],
"current_turn": -1,
"move_count": 8,
"board": [[0, -1]],
"player_models": {"-1": "model_alphazero", "1": "human"},
"player_usernames": {"-1": "alice", "1": "bob"},
"current_game_number": 1,
"player_1_wins": 0,
"player_2_wins": 0,
"wins_required": 2
}
}
}
The reference clients use player_models / player_usernames from this
snapshot to restore the Opponent label after a reconnect.
waiting_for_opponent
{
"type": "waiting_for_opponent",
"payload": {
"slot_id": 1,
"board_size": 11
}
}
game_start
{
"type": "game_start",
"payload": {
"slot_id": 1,
"board_size": 11,
"series_length": 3,
"players": [-1, 1],
"first_turn": -1,
"current_game_number": 1,
"player_1_wins": 0,
"player_2_wins": 0,
"wins_required": 2,
"player_models": {"-1": "model_alphazero", "1": "human"},
"player_usernames": {"-1": "alice", "1": "bob"}
}
}
player_models and player_usernames are keyed by string player IDs ("-1"
and "1"). Empty objects ({}) mean the players are anonymous. Clients
should not assume both keys are present — match a side by its string key and
fall back to None. This payload is sent both at series start and at the
start of each subsequent game in a multi-game series.
move
{
"type": "move",
"payload": {
"player": -1,
"q": 3,
"r": 5,
"next_turn": 1
}
}
move_rejected
{
"type": "move_rejected",
"payload": {
"reason": "Not your turn"
}
}
game_over
{
"type": "game_over",
"payload": {
"winner": -1,
"reason": "connected_sides"
}
}
series_update
Sent after each completed game in a series.
{
"type": "series_update",
"payload": {
"player_1_wins": 1,
"player_2_wins": 0,
"current_game_number": 2,
"wins_required": 2,
"series_length": 3
}
}
series_over
Sent when a player reaches the required number of wins.
{
"type": "series_over",
"payload": {
"winner": -1,
"player_1_wins": 2,
"player_2_wins": 0,
"wins_required": 2,
"series_length": 3
}
}
Other server messages:
pongchaterroropponent_disconnectedopponent_reconnected
Player IDs
The protocol uses numeric IDs everywhere a player appears:
-1 = player_1 = red = left-to-right
1 = player_2 = blue = top-to-bottom
New clients should treat -1 and 1 as the canonical protocol values. The
server still owns identity: clients do not send their player id in move
messages, and any extra player field in a move payload is ignored.
Gameplay Rules
- The current
src/hexgame/server/config.pymapsPLAYER_1 = -1andPLAYER_2 = 1. player_1(-1, red) moves first.- A game is one Hex board.
- A series is best-of
1,3,5,7,9,11,13, or15games between the same players. - The series ends as soon as a player reaches
ceil(series_length / 2)wins. - First turn alternates by game number: odd games start with
player_1(-1), even games start withplayer_2(1). - Coordinates are
(q, r). - Board access is
board[r][q]. player_1(-1, red) wins by connecting left to right.player_2(1, blue) wins by connecting top to bottom.- Neighbors use the axial-like offsets:
(+1, 0), (-1, 0), (0, +1), (0, -1), (+1, -1), (-1, +1)
The server rejects moves when:
- The game has not started.
- The game is paused while a disconnected opponent is inside the reconnect window.
- The game is already finished.
- It is not the sender's turn.
- Coordinates are outside the board.
- The target cell is occupied.
- The payload is malformed.
Reconnect Behavior
When a player disconnects, the server keeps the slot, game board, series
score, and seat assignment in memory for RECONNECT_TIMEOUT_SECONDS from
src/hexgame/server/config.py. The remaining player receives
opponent_disconnected and the match is paused. During the pause, moves are
rejected with Game paused for reconnect.
Getting the token
When a client connects via /ws/matchmake or /ws/join-slot, the server's
joined payload includes a reconnect_token that's specific to that seat.
Both reference clients (hexgame play and hexgame gui) print it to stdout
on first join:
reconnect: slot 3 token a1b2c3d4e5
The token is also written to the JSONL replay log next to the joined event.
Treat it like a short-lived secret — it's never exposed by /slots or the
overview dashboard.
Reconnecting
Either CLI flag form works:
hexgame play --slot-id 3 --reconnect-token a1b2c3d4e5
hexgame gui --slot-id 3 --reconnect-token a1b2c3d4e5
Both route to /ws/reconnect?slot_id=3&token=a1b2c3d4e5. The raw URL also
works for custom clients:
ws://127.0.0.1:8000/ws/reconnect?slot_id=3&token=a1b2c3d4e5
If the token is valid and the reconnect timeout has not expired, the server
sends reconnected with the current public slot snapshot (board, current
turn, score, player_models, player_usernames) and notifies the opponent
with opponent_reconnected. The GUI restores the full game state — including
the opponent panel row — from that snapshot. If the timeout expires first, the
slot is reset and the remaining player is notified and closed.
--reconnect-token without --slot-id is rejected with a clear error.
Clients
The model and GUI clients default to wss://hexgame.codingdojo.ai. Use
--server ws://localhost:8000 only when you are running your own local server.
Random Client
The reference random client plays uniformly random legal moves. It is useful for smoke testing matchmaking, gameplay, and win detection.
Run two clients in separate terminals:
hexgame random --board-size 11 --seed 1
hexgame random --board-size 11 --seed 2
Useful options:
hexgame random \
--server wss://hexgame.codingdojo.ai \
--board-size 11 \
--series-length 3 \
--seed 42 \
--move-delay 0.1
There is also a helper script:
bash src/hexgame/client/run_pair.sh
Model Client
hexgame play (module hexgame.client.model_client) loads a model module
dynamically. The module must export:
def agent(board, action_set):
...
--model-name accepts any of four forms, resolved in this order:
- A filesystem path (
./examples/model_dqn.py,/abs/path.py, or any value containing/or ending in.py). Loaded directly withimportlib.util, so nosys.path/PYTHONPATHsetup is needed. This is the most reliable form for unpackaged models. - A bundled model name:
hexgame.client.models.<NAME>— currentlymodel_randomandmodel_first. - A top-level module name on
sys.path— dropmy_agent.pyin your working directory and pass--model-name my_agent. examples.<NAME>— convenience fallback for repo checkouts run from the project root withPYTHONPATH=., so--model-name model_dqnfindsexamples/model_dqn.py.
A ModuleNotFoundError raised inside a resolved module (e.g. a missing
torch import in the model file) is not swallowed — it propagates with
its original message so the real cause is visible.
# my_agent.py (in your current working directory)
from random import choice
def agent(board, action_set):
# board contains 0, -1, and 1
# action_set contains legal (row, col) moves
return choice(list(action_set))
hexgame play --model-name my_agent --board-size 7
hexgame gui --model-name my_agent --board-size 7
hexgame play --model-name ./my_agent.py --board-size 7 # explicit file path
hexgame play --model-name my_agent --server ws://localhost:8000
The model-facing board used by the WebSocket clients follows the server protocol convention:
0 = empty
-1 = red / model player 1
1 = blue / model player 2
The action_set passed to the model uses (row, col) coordinates. The client
converts model output back to the server protocol shape {q, r}.
Before sending a move, the client verifies that the model returned an integer
(row, col) pair that is present in action_set. If a model returns an
occupied cell, an out-of-bounds cell, a scalar, or coordinates in {q, r}
order by mistake, the client stops with a clear model move error instead of
sending an illegal move to the server.
Model clients also export a small JSONL replay log by default under
replays/. The log records server messages, applied moves, model
choices, rejected moves, terminal events, and board snapshots around model
decisions. Use --replay-log off to disable it or --replay-log path.jsonl to
choose an explicit export path.
Run two model clients:
hexgame play --model-name model_random --board-size 7 --series-length 1
hexgame play --model-name model_first --board-size 7 --series-length 1
To keep the same slot after a completed series and wait for another opponent,
add --keep-slot:
hexgame play --model-name model_alphazero --board-size 7 --keep-slot
To join a specific waiting slot, add --slot-id. The client inherits the slot's
board size and series length from the server:
hexgame play --model-name model_random --slot-id 3
To resume an interrupted match, pass --slot-id together with
--reconnect-token. The token is printed to stdout on the first joined
message (look for a line like reconnect: slot 3 token a1b2c3d4e5) and is
also recorded in the replay log:
hexgame play --slot-id 3 --reconnect-token a1b2c3d4e5
The combination routes to /ws/reconnect?slot_id=...&token=... instead of the
normal matchmaking endpoint. --reconnect-token without --slot-id is
rejected with a clear error.
Bundled example models (in hexgame.client.models):
model_randommodel_first
The repository's examples/ directory holds heavier, optional ML models
(model_alphazero, model_dqn, ...) plus their weights and a C++ MCTS
extension. Those are not part of the installed package; run them from a repo
checkout with examples/ on PYTHONPATH.
Pygame GUI Model Client
hexgame gui (module hexgame.client.gui_client, needs the [gui] extra) is
the graphical version of the model client. The window title shows the package
version (Hex Client v<version>). The side panel shows:
- Status — current state / countdown
- Model — your model name
- Opponent — the opposing player's
model_nameandusername(server-supplied via thegame_start/reconnectedpayload) - Slot, Game, Score, Moves, Replay
- Players rows for
player_1(red, left↔right) andplayer_2(blue, top↔bottom), with a chip on the side whose turn it is - Last move coordinates
On the board, the last move gets a yellow ring, and when a game ends the winning path is highlighted with a thick gold border on the cells that connect the two goal edges (computed locally with the same BFS the server uses).
When playing a multi-game series, the GUI pauses on the final board between
games so you can see the result before it resets. The pause is
--match-delay seconds (default 3.0, set to 0 to disable) and the status
line counts down ("Next match in 1.7s — SPACE to skip"). Press SPACE at
any time to skip ahead.
Run it with:
hexgame gui \
--model-name model_random \
--server wss://hexgame.codingdojo.ai \
--board-size 7 \
--series-length 3 \
--match-delay 3 \
--seed 42 \
--move-delay 0.1 \
--replay-log auto
Close the GUI with Esc, Q, or the window close button. Press SPACE
during an inter-match pause to start the next game immediately.
To play as a human from the GUI, use --model-name human. When it is your
turn, click an empty Hex cell to send the move:
hexgame gui --model-name human --board-size 7
To join a specific waiting slot from the GUI, add --slot-id. The GUI ignores
its local --board-size and --series-length for gameplay after joining and
uses the settings reported by the server:
hexgame gui --model-name human --slot-id 3
To keep the same slot after a completed series and wait for another opponent,
add --keep-slot. The server resets the series score, keeps the player as
player_1 in that slot, and moves the slot back to waiting:
hexgame gui --model-name human --board-size 7 --keep-slot
To resume an interrupted match, the GUI accepts the same
--slot-id + --reconnect-token combination as hexgame play. The token is
printed to stdout on the first joined message and recorded in the replay
log; on reconnect the GUI restores the full board, turn, score, and opponent
label from the server's snapshot:
hexgame gui --slot-id 3 --reconnect-token a1b2c3d4e5
Tests
Install the dev + optional dependencies, then run pytest from the repo root
(pyproject.toml puts src/ on the path, so no install is strictly required,
but the database/redis tests need those extras):
python -m pip install -e ".[dev,all]"
python -m pytest
Run frontend build verification:
cd frontend
npm run build
The current test suite covers:
- Slot assignment and reset behavior.
- Board-size-aware matchmaking.
- Series-length-aware matchmaking and best-of scoring.
- Protocol validation.
- Move validation and turn order.
- Hex win detection.
- WebSocket matchmaking and gameplay.
- Overview endpoint serving.
Project Layout
pyproject.toml Package metadata, dependencies, console entry points
MANIFEST.in Extra files to include in the source distribution
src/hexgame/
server/ -> console command: hexgame-server
__main__.py CLI wrapper around uvicorn (hexgame.server.main:app)
main.py FastAPI routes and global SlotManager
config.py Slot, board-size, protocol, and player constants
models.py GameSlot, PlayerConnection, SlotAssignment, HexGameState
protocol.py Message parsing and message factories
slots.py SlotManager and slot lifecycle
redis_slots.py Optional Redis-backed SlotManager
database.py SQLAlchemy ORM models and completed-series repository
game.py Move validation and win detection
websocket_manager.py WebSocket receive loop and gameplay handling
static/overview/ Built Vite dashboard (shipped in the wheel)
client/ -> console command: hexgame {random,play,gui}
__main__.py Subcommand dispatcher
random_client.py Random-move client (hexgame random)
model_client.py Model-driven client (hexgame play)
gui_client.py Pygame model/human client (hexgame gui, [gui] extra)
client_safety.py Model-output validation and JSONL replay logging
hex_engine.py Local Hex engine used by ML models
models/ Bundled example model agents (model_random, model_first)
run_pair.sh Launches two random clients against a local server
frontend/
src/ Vite React overview source
vite.config.ts Builds into src/hexgame/server/static/overview/
examples/ Heavy/optional ML extras (not part of the package):
model_alphazero.py, model_dqn*.py, *.pt weights, hex_mcts.cpp, setup_mcts.py
tests/
test_*.py Unit and integration tests
Operational Notes
- The default
memorybackend is single-process only. - Set
HEX_STATE_BACKEND=redisbefore runninghexgame-server --workers N(N > 1). Redis stores shared slot/game/session state, while WebSocket connections remain attached to the worker that accepted them. /overviewis an operational/debug dashboard. Protect or disable it before exposing this service beyond a trusted local network.- On disconnect, the slot is held for the reconnect timeout. Clients using
--keep-slotcan keep a slot after a finished series or after an opponent disconnects.
Troubleshooting
/, /docs, or /overview is blank
Rebuild the frontend:
cd frontend
npm run build
The built index.html must reference assets under /overview/assets/....
Random client gets HTTP 404
Reinstall the package (so uvicorn[standard]/websockets are present) and
restart the server:
python -m pip install -e ".[dev,all]"
hexgame-server --port 8000
This usually means the server was started before WebSocket support was available, or another app is listening on port 8000.
Slot state looks stale
With the memory backend, restart the server. With Redis, inspect or clear keys
under HEX_REDIS_KEY_PREFIX if you intentionally want to reset persisted slot
state.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hexgame-0.1.7-py3-none-any.whl.
File metadata
- Download URL: hexgame-0.1.7-py3-none-any.whl
- Upload date:
- Size: 135.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7457be7bee077bbcdb8e7e673c914824e79b76e6f1b5d0501cd61dd4b472dcc
|
|
| MD5 |
1a9d913b62913f386f578cdda84fb305
|
|
| BLAKE2b-256 |
d258e32e4296220afd36fbbeb13cf95d2cc3aa848efb229942873b93f2b0612c
|