░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░████████ ░██████ ░████████ ░████████ ░██ ░███████ ░███████ ░████████ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░███████ ░██ ░██ ░██ ░█████████ ░███████ ░██ ░██ ░███ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██░█████ ░█████░██ ░████ ░████ ░██ ░███████ ░██ ░███████ ░██ ░██
terminal battleship, over a websocket relay
one package, one command. no repo clone, no uv, no python module invocations needed just to play.
pip install battle-sh
this installs a single battle-sh command that opens the player app, connects to a relay, or runs one — all from the same executable.
the whole experience is one executable — relay, host, and join all live under battle-sh.
battle-sh # open the Textual player app (Host / Join / Exit)
battle-sh --relay URL # same, with a Relay URL
battle-sh relay # run the Match Relay server
point clients at a relay with --relay, or the BATTLE_SH_RELAY environment variable. it defaults to ws://127.0.0.1:8765. reconnect grace is set with --grace-seconds (CLI-only).
battle-sh relay --bind-host 127.0.0.1 --port 8765
terminal b — host
battle-sh --relay ws://127.0.0.1:8765
# choose Host; share the Invite shown on the waiting screen
terminal c — guest
battle-sh --relay ws://127.0.0.1:8765
# choose Join; paste the Invite in-app
wss:// url — e.g. battle-sh --relay wss://relay.example.comthe Match UI shows a live scoreboard: active/remaining ships per side, hits, current turn, match state, and a connection indicator for both players.
ws://, no Caddy, no TLS — just the repo and uv.
same gates as CI:
shelluv sync --group dev
uv run pyright
uv run pytest
start the relay on loopback, then open two player terminals:
terminal — relayuv sync
uv run python -m battle_sh.networking.relay_cli --bind-host 127.0.0.1 --port 8765
terminal — host
uv run battle-sh --relay ws://127.0.0.1:8765
# choose Host; share the Invite
terminal — guest
uv run battle-sh --relay ws://127.0.0.1:8765
# choose Join; paste the Invite in-app
fixed three-band layout: top = match/role/turn + match time, middle = wide board with phase-aware controls, bottom = status/errors. match time starts when the guest joins — not during host lobby wait — and freezes on the winner/abandoned end screen. after end presentation, the app returns to the opening Host / Join / Exit menu.
waiting turns show a spinner; only Ctrl+C is honored to quit — q never quits.
| placement | 1–5 or Tab/Shift+Tab select ship · wasd or arrows move · e/r flip H↔V · t re-roll · y lock |
|---|---|
| combat | wasd or arrows aim (skips fired cells) · f/Enter/Space fire |
| any match phase | first Ctrl+C warns (status), second confirms abandon and sends leave_match — both sides end immediately, no reconnect grace, arm auto-clears |
back is available on Join and Host-waiting only, not mid-match. the invite for the guest is entered in-app on Join.
on a local relay, in two terminals:
wss:// on any cloud VM you already have — this project doesn't create the VM and isn't tied to a cloud.
rent (or reuse) a normal Linux VM — DigitalOcean, Hetzner, AWS, a home box, whatever. you point DNS at it, SSH in with the provided scripts, and they install the relay. works best on Debian/Ubuntu, since the script installs Caddy with apt. on other distros, install Caddy yourself first, then provision.
start a small Ubuntu VM in your cloud console, note its public IP, and make sure you can SSH as root (or a user that can sudo — the scripts assume you SSH as whoever can write /opt and manage systemd; root is simplest). open ports 22, 80, and 443 on the firewall/security group.
pick a hostname players will use, e.g. relay.example.com. create an A record (and AAAA for IPv6) pointing at the VM's public IP. wait until dig +short relay.example.com resolves correctly — do this before provisioning, since Caddy requests a real Let's Encrypt cert for that name and a wrong DNS entry means the cert fails and wss:// won't work.
from your laptop, with SSH access to the VM:
shell./scripts/provision-relay \
--host root@YOUR_VM_IP \
--domain relay.example.com \
--email you@example.com
copies the app to /opt/battle-sh, installs uv and Caddy if needed, writes a systemd unit (battle-sh-relay.service), and starts relay + Caddy. Caddy terminates HTTPS and forwards WebSockets to the relay on 127.0.0.1:8765.
players then connect with:
battle-sh --relay wss://relay.example.com
root@203.0.113.10 or user@relay.example.com — requiredwss://; A/AAAA must already point at the host — required/opt/battle-sh8765/root/.local/bin/uv--dry-run writes artifacts — required alongside itssh -o OPTION, repeatablepreview the generated files without touching a machine:
./scripts/provision-relay \
--host root@YOUR_VM_IP \
--domain relay.example.com \
--email you@example.com \
--dry-run \
--output-dir /tmp/battle-sh-deploy
players connect through an operator-owned WebSocket relay that tracks match membership and forwards host↔guest messages. peer-to-peer (WebRTC/NAT hole punching) was rejected because cross-city reliability and pasteable invites matter more than avoiding a short-lived VM. Docker was rejected as the deploy model in favor of uv + Caddy + systemd on any SSH-able Linux host. the relay stays dumb — no boards, fleets, or shot resolution live there.
rehearse provisioning without renting a VM. scripts/practice-vm boots a systemd Ubuntu container with SSH — a stand-in "cloud box" — then runs the real provision script against it.
--tls-internal. real internet-facing VMs should not pass that flag../scripts/practice-vm doctor # up -> provision -> status -> websocket smoke
# or step by step:
./scripts/practice-vm up
./scripts/practice-vm provision
./scripts/practice-vm status
./scripts/practice-vm smoke
./scripts/practice-vm down
SSH into the practice box: ssh -i .scratch/practice-vm/id_ed25519 -p 2222 root@127.0.0.1
./scripts/deprovision-relay --host root@YOUR_VM_IP
stops relay and Caddy, removes the unit, Caddyfile, and /opt/battle-sh. the Caddy package may stay installed on the OS.
https:// hostname with a valid TLS cert automatically — no port forwarding, no DNS records, no Caddy or Let's Encrypt to configure.
# run your relay locally as usual, then:
cloudflared tunnel --url http://127.0.0.1:8765
cloudflared prints a hostname like https://random-words.trycloudflare.com. players connect using that same hostname with wss:// in place of https://. good for quick sessions; for a stable long-term address, use a named tunnel bound to your own domain instead of the quick-tunnel default.