Multiplayer server for Ship of Harkinian. Play Ocarina of Time together — coop randomizer, story coop, and any custom gamemode the client adds — over a single WebSocket relay.
The server is a thin relay: it routes messages between rooms of clients, applies rate limits, and filters who sees which rooms. It knows nothing about specific gamemodes — gameplay logic lives entirely in the client. Clients announce in their handshake which gamemode packs they have installed locally and only see rooms for those packs.
To play you need the matching client:
skijer/Shipwright — branch Not-Enough-Items.
Vanilla Shipwright builds do not speak this protocol.
git clone https://github.com/skijer/harpoon.git
cd harpoon
python -m venv venv
source venv/bin/activate # Windows: .\venv\Scripts\Activate.ps1
pip install -r requirements.txt
python server.pyThe server listens on ws://0.0.0.0:8765 by default.
To override host or port:
python server.py --host 0.0.0.0 --port 8765- Python 3.11+
- A reachable port (default 8765). Open it in your firewall / cloud security group if you want remote players.
In SoH's network menu, point Host at your server's address and Port at
8765. Use ws:// for plain (LAN, dev) and wss:// for production —
see the TLS section below.
The fastest way to stand up a server on a fresh box (e.g. over SSH on a VPS). Requires only Docker with the Compose plugin (install).
git clone https://github.com/skijer/harpoon.git
cd harpoon
docker compose up -d --build # build + start in the background
docker compose logs -f # follow logsThe server listens on ws://0.0.0.0:8765. The container restarts
automatically on crash or reboot (restart: unless-stopped) and runs as
an unprivileged user. To stop and remove it: docker compose down.
Once the publish workflow has run (see below), anyone can pull the image straight from GitHub Container Registry and run their own server, with no git checkout and no build toolchain:
docker run -d --name harpoon -p 8765:8765 --restart unless-stopped \
ghcr.io/skijer/harpoon:latestdocker build -t harpoon .
docker run -d --name harpoon -p 8765:8765 --restart unless-stopped harpoonOverride the bind or port by appending server flags — they pass straight
through to server.py:
docker run -d -p 9000:9000 harpoon --host 0.0.0.0 --port 9000For public wss://, run a TLS reverse proxy in front (see below). The
docker-compose.yml ships with a ready-to-uncomment Caddy service that
auto-provisions Let's Encrypt certificates.
.github/workflows/docker-publish.yml
builds a multi-arch (amd64 + arm64) image and pushes it to GHCR on every
push to main and every v* tag. It needs no secrets — it uses the repo's
built-in GITHUB_TOKEN.
After the first successful run the package is private by default. Make
it public once so others can docker pull without authenticating: GitHub →
your profile → Packages → harpoon → Package settings → Change
visibility → Public.
The server speaks plain ws://. For anything beyond LAN, terminate TLS
at a reverse proxy. The simplest setup is Caddy
— auto-provisions Let's Encrypt:
harpoon.example.com {
reverse_proxy 127.0.0.1:8765
}Bind the Python server to 127.0.0.1 so only Caddy can reach it.
Players connect to wss://harpoon.example.com.
The same pattern works behind an AWS ALB (HTTPS listener with an ACM cert, target group → EC2 port 8765). Bump the ALB idle timeout to 3600s — the default 60s kills WebSocket connections.
- Lobby & rooms. Players create / list / join rooms; the server tracks membership and broadcasts roster updates.
- Per-scene area-of-interest. Per-frame state (position, skeleton, custom items) only goes to teammates in the same scene as the sender. Cuts bandwidth and prevents leaking activity outside the scene.
- Token-based session resume. Clients can reconnect with a 256-bit token issued at handshake without re-announcing identity.
- Schema validation. Every primitive has a pydantic schema with size and type bounds. Malformed payloads are rejected before they reach any handler.
- Rate limits + role gating. Built-in defaults per primitive
(e.g.
PLAYER.UPDATE_FULL_STATE60/sec,INVENTORY.GIVE_ITEM200/sec). Admin / host primitives require the matching role.
- Per-process IP hashing. Raw client IPs are never written to logs or sent to other clients. Each connection gets a salted-and-truncated SHA256 hash that's stable for a server run, useless across restarts.
- No personal data persisted. No database, no IP logs, no message archive. Everything lives in memory and is gone on restart.
- Connection caps. 256 concurrent sessions, 4 per peer hash, 64
total rooms. Tunable in
transport.py. - Handshake watchdog. Connections that don't send a valid
HARPOON.HANDSHAKEwithin 10s are dropped. Pre-handshake messages other than HANDSHAKE / RESUME are rejected. - Protocol marker. Handshake must include
protocol="harpoon". Soft barrier against the server being repurposed as a generic relay.
The server is honest-operator trust: anyone running it can read every message in plaintext. For end-to-end privacy run your own instance — the same model as gb-yoshi-web, syncplay, and similar hobby relays.
harpoon/
protocol/ JSON envelope + pydantic schemas
primitives/ Per-domain handlers (player, combat, save, …)
security/ Permission table + rate limiter + token gen
transport.py WebSocket lifecycle + AOI broadcasting
dispatcher.py Routes messages to primitives by name
session.py Per-connection state
room.py Room membership + config
logging.py Coloured console logging
server.py Entry point — argparse, SSL setup, serves forever
Adding a new primitive:
- Define a pydantic schema in
harpoon/primitives/<domain>.py - Register it with
@d.register("DOMAIN.NAME", schema=…) - Add a rule to
_PLAYER_DEFAULTS(or_ADMIN_EXTRAS/_HOST_EXTRAS) inharpoon/security/permissions.py
That's it — the dispatcher picks it up at startup.
- HarbourMasters — Ship of Harkinian and libultraship.
- @garrettjoecox — Anchor, whose save-sync model the randomizer mode is based on.
- zeldaret/oot — the OoT decompilation everything stands on.
— Maintained by @skijer.