terminal battleship, over a websocket relay

docs

install

one package, one command. no repo clone, no uv, no python module invocations needed just to play.

shell
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.

play

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).

three-terminal walkthrough

terminal a — relay
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
hosted relayagainst a hosted relay, use the wss:// url — e.g. battle-sh --relay wss://relay.example.com

the Match UI shows a live scoreboard: active/remaining ships per side, hits, current turn, match state, and a connection indicator for both players.

local dev

ws://, no Caddy, no TLS — just the repo and uv.

checks

same gates as CI:

shell
uv sync --group dev
uv run pyright
uv run pytest

run it

start the relay on loopback, then open two player terminals:

terminal — relay
uv 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

live match ui

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.

keymap

waiting turns show a spinner; only Ctrl+C is honored to quit — q never quits.

placement1–5 or Tab/Shift+Tab select ship · wasd or arrows move · e/r flip H↔V · t re-roll · y lock
combatwasd or arrows aim (skips fired cells) · f/Enter/Space fire
any match phasefirst 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.

smoke checklist

on a local relay, in two terminals:

  1. host creates a match from the opening menu — lobby shows waiting-for-guest, no match time yet
  2. guest joins with the invite in-app — both see match time start, three-band chrome stays stable
  3. placement — move/rotate/re-roll with keys, then y to lock; opponent wait shows a spinner
  4. combat — aim with arrows/WASD, fire with f, confirm skip over already-fired cells
  5. quit path — two-step Ctrl+C abandons immediately for both players; both see Match Abandoned with frozen match time, then return to the opening menu
  6. optional — play through to Winner and confirm frozen match time on the end screen

hosting a relay

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.

1. create a vm

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.

2. point a domain at it

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.

3. provision

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

flags

--host
SSH target, e.g. root@203.0.113.10 or user@relay.example.com — required
--domain
public DNS name for wss://; A/AAAA must already point at the host — required
--email
email for Caddy / Let's Encrypt registration — required
--install-dir
remote install directory, default /opt/battle-sh
--port
local relay port behind Caddy, default 8765
--uv-bin
remote uv binary path, default /root/.local/bin/uv
--tls-internal
use Caddy's self-signed local cert — for Docker practice VMs without public DNS; do not use on a real internet-facing host
--dry-run
write the Caddyfile and systemd unit locally, no SSH
--output-dir
where --dry-run writes artifacts — required alongside it
--ssh-option
extra ssh -o OPTION, repeatable

preview 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

why a relay, not p2p

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.

practice the flow in docker

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.

not productionDocker is not how this deploys in production (uv + Caddy + systemd on a real host is). this is only a local practice VM. because there's no public DNS, provisioning uses --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

teardown

./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.

don't want to run a VM? if you'd rather not rent or manage a box, expose a relay running on your own machine with a Cloudflare Tunnel. it opens a secure outbound connection to Cloudflare's edge, so you get a public 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.