Practice mode is now at /practice on server.py instead of a separate server, with a button in the bottom-right corner of each page to the other mode. Touch: no double-tap zoom, no sticky hover after a tap, no long-press selection on the board, and a pressed look on tapped squares. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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
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
Practice mode (vs the computer) is at /practice on the same server. The switch in the
bottom-right corner of each page goes between the two modes.
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=CODEis the live connection,/practiceandPOST /api/playare practice mode, and everything else is static files.practice.py: practice mode's move endpoint, mounted byserver.py. It holds no state: the page sends the position toPOST /api/playand 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.jsis the board drawing shared byapp.js(online) andpractice.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.
- Run the server on the box, bound to localhost:
.venv/bin/python server.py --port 8080 - Install
cloudflared, then:cloudflared tunnel login cloudflared tunnel create moonchess cloudflared tunnel route dns moonchess moonchess.example.com ~/.cloudflared/config.yml:tunnel: moonchess credentials-file: /home/YOU/.cloudflared/<tunnel-id>.json ingress: - hostname: moonchess.example.com service: http://localhost:8080 - service: http_status:404cloudflared tunnel run moonchess(orsudo cloudflared service installto run it at boot).
To keep the game server running, an example systemd unit (/etc/systemd/system/moonchess.service):
[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.