Moonchess: vanishing tic-tac-toe, online and practice modes
Online play over WebSockets with any-string game codes, seats and spectators, and a 1-second heartbeat. Practice mode against Columbina, backed by a full solver, with an accuracy slider, spoilers, undo and piece freshness. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
c52f30dc59
18 files changed
+1743
No files matched your search
@@ -0,0 +1,109 @@
|
||||
# Moonchess
|
||||
|
||||
Tic-tac-toe where each player can have at most three pieces on the board. Placing a
|
||||
fourth isn't possible: once you have three down, your oldest vanishes as your turn
|
||||
starts (it's shown faded during your opponent's turn), so you can place right back on
|
||||
its square. This follows Genshin Impact's Moon Chess.
|
||||
|
||||
## Running locally
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/python server.py # http://127.0.0.1:8080
|
||||
.venv/bin/python server.py --host 0.0.0.0 --port 8080 # reachable on your LAN
|
||||
.venv/bin/python practice.py # vs the computer: http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Tests: `.venv/bin/python -m unittest discover -s tests -t .`
|
||||
|
||||
## How it works
|
||||
|
||||
- `game.py`: the rules, no networking.
|
||||
- `server.py`: one aiohttp process. `/ws?game=CODE` is the live connection, and
|
||||
everything else is static files.
|
||||
- `practice.py`: a separate server for playing against the computer. It holds no state:
|
||||
the page sends the position to `POST /api/play` and gets the next one back.
|
||||
- `solver.py`: solves every reachable position (~67k, about a second at startup) so the
|
||||
computer knows the perfect move everywhere.
|
||||
- `static/`: the pages. Plain HTML/CSS/JS, no build step. `board.js` is the board
|
||||
drawing shared by `app.js` (online) and `practice.js`.
|
||||
|
||||
Practice mode: you start as X, which moves first. Click either side at any time to swap,
|
||||
and the computer (Columbina, after the Genshin Impact minigame this game comes from)
|
||||
plays whichever side you aren't. The accuracy slider is the chance each
|
||||
computer move is perfect; otherwise it plays a random empty square. It can be changed
|
||||
mid-game too. Undo (or Ctrl/Cmd+Z) goes back to your previous turn, taking back
|
||||
Columbina's reply as well. Perfect play is a draw: you can only threaten one line
|
||||
at a time, so you only lose by missing a block.
|
||||
|
||||
Seating: each game has a list of slots. Slots 0 and 1 are X and O, and the rest are
|
||||
spectators. A new connection takes the earliest empty slot, so if a player drops,
|
||||
whoever connects next gets their seat. Refreshing fast enough to grab it is allowed.
|
||||
|
||||
Heartbeat: the client pings every second. A seat whose player has been silent for 2
|
||||
seconds shows as "Disconnected", and after 4 seconds they're dropped and the seat
|
||||
shows as "Empty seat". The client dims the board and reconnects if it hasn't heard
|
||||
from the server in 4 seconds. That automatic reconnect (unlike a refresh) comes from
|
||||
the same page load, so it reclaims its own slot even if the server hasn't dropped the
|
||||
old connection yet. A player who refreshes during the "Disconnected" window lands in
|
||||
a spectator slot and has to refresh again once their seat shows "Empty seat".
|
||||
|
||||
Codes: any string of up to 32 characters works as a code, ignoring case and extra
|
||||
whitespace. Joining a code that has no game creates one. "Generate code" just picks a
|
||||
random 5-character code in the browser. Games live in memory: a game is deleted as soon as
|
||||
nobody is connected to it (so if the last person in a game refreshes, it starts over),
|
||||
and restarting the server ends all games.
|
||||
|
||||
## Deploying behind Cloudflare (Cloudflare Tunnel)
|
||||
|
||||
A tunnel lets Cloudflare reach the server without opening router ports or exposing
|
||||
your home IP. WebSockets work through it without any extra configuration.
|
||||
|
||||
1. Run the server on the box, bound to localhost: `.venv/bin/python server.py --port 8080`
|
||||
2. Install `cloudflared`, then:
|
||||
```sh
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create moonchess
|
||||
cloudflared tunnel route dns moonchess moonchess.example.com
|
||||
cloudflared tunnel route dns moonchess practice.moonchess.example.com # if running practice.py
|
||||
```
|
||||
3. `~/.cloudflared/config.yml`:
|
||||
```yaml
|
||||
tunnel: moonchess
|
||||
credentials-file: /home/YOU/.cloudflared/<tunnel-id>.json
|
||||
ingress:
|
||||
- hostname: moonchess.example.com
|
||||
service: http://localhost:8080
|
||||
- hostname: practice.moonchess.example.com # optional, if you run practice.py
|
||||
service: http://localhost:8081
|
||||
- service: http_status:404
|
||||
```
|
||||
4. `cloudflared tunnel run moonchess` (or `sudo cloudflared service install` to run it at boot).
|
||||
|
||||
To keep the game server running, an example systemd unit (`/etc/systemd/system/moonchess.service`):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Moonchess
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/moonchess
|
||||
ExecStart=/opt/moonchess/.venv/bin/python server.py --port 8080
|
||||
Restart=always
|
||||
User=moonchess
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
If you'd rather use the regular orange-cloud proxy with port forwarding, point the
|
||||
DNS record at your IP and forward a port to the server. That also works, because
|
||||
Cloudflare proxies WebSockets on all plans. Cloudflare only proxies certain ports
|
||||
(e.g. 80, 443, 8080, 8443), so bind to one of those or put a reverse proxy in front.
|
||||
|
||||
## Future work
|
||||
|
||||
- Seat-open alert: give everyone (especially spectators) an obvious signal when a
|
||||
player seat becomes empty, so they can race to refresh and grab it.
|
||||
Reference in new issue
Block a user