Okay, after many hours of testing and banging my head against the wall, here’s a summary of the changes that made the setup significantly more stable and reliable for me.
-- Client side:** OTClient (OTCv8) already has a WebSocket transport in its network layer. Point it at your domain over a standard port. The game protocol rides inside the WS frames
-- Server side:** a tiny bridge. I used Go with
gorilla/websocket: accept the WS connection, dial a plain TCP socket to TFS, copy bytes both directions. A few hundred lines, one binary, autostart on boot.
-- Cloudflare:** point the game subdomain at the VPS, turn the cloud orange, done. WS proxying is on.
-- Keep the real client IP:** the bridge speaks
PROXY protocol v2 to TFS (TFS has
proxyProtocol = true), using Cloudflare's
CF-Connecting-IP header. So IP bans, logging and anti-multi-client still see the real player IP, not Cloudflare's.
The part nobody tells you about is making it
stable — that's where I lost the most time, so here are the gotchas for me
## The gotchas that actually matter
These four are the difference between "it connects" and "it's kinda solid i think with ARGO from cloudflare it could be better"
### 1. Force the client to IPv4
Cloudflare always publishes an AAAA (IPv6) record now, and you
cannot turn that off zone-side anymore. If your client's resolver races IPv6 vs IPv4, you get
reconnect storms — connections flapping. Force the client's resolver to IPv4-only. This was the single biggest stability win. (And no, "Pseudo IPv4" does not help — it only rewrites an IP header, it doesn't change the transport.)
### 2. Raise TFS's connection timeout
TFS closes a socket if it sees no data for ~30 seconds (the default read/write timeout). On raw TCP that's fine. But the
Cloudflare WS path buffers/stalls traffic in short bursts, especially when a player is standing still and only sparse keepalive pings flow. That transient gap can cross 30s → TFS kills a connection that is actually alive → drop → reconnect.
Raise the timeout to
60s. I matched it to
pzLocked (60s) on purpose, so a combat disconnect stays in-world exactly as long as the PZ rule already allows — it adds
no extra combat-log window. Truly dead sockets still get reaped by TCP keepalive. This killed the idle drops completely.
### 3. Run the bridge at NORMAL OS priority
I tried being clever and set the Go bridge to
High process priority "for performance." It made latency
worse — a busy Go process at High priority fights the OS network scheduling and adds jitter. Leave the bridge at
Normal. (Your TFS process can be High, that's fine — it's the bridge specifically that must not be.)
### 4. One socket, one standard port
Cloudflare only reliably proxies WebSocket on its
standard ports. Don't let the client rotate through extra game ports "for fallback" — the non-standard ones fail the vast majority of the time and just cause confusing dropouts. Pin the game to one standard port and keep a single socket.
---
## Bonus 1: seamless (silent) reconnect
Once drops were rare, I added an
RSA-secured token reattach: the server hands the client a single-use, encrypted token; if the connection ever breaks, the client silently reconnects and reattaches to its
existing in-world character — a brief freeze or rly small dropout, then you're back, instead of being kicked to the character list. Most servers don't have this at all. The important detail is handling the race where the reconnect arrives
before the server has cleaned up the old connection — kick the stale one, retry briefly, then attach.
-- Bonus 2: stay listed on otservlist without exposing your IP
Server lists (otservlist etc.) ping your
login server directly — which would expose your origin. Solution: a
separate, grey-clouded subdomain pointing at a cheap external proxy (a small VPS running HAProxy with
send-proxy-v2), which forwards to your real login port. Firewall your origin so it only accepts that proxy's IP. Result: you show up
online on the lists, players still connect through Cloudflare for the game, and your real IP stays hidden the whole time.
## Security: how it's actually locked down
This isn't just "hide the IP and hope." It's layered — defense in depth:
1.
Hidden origin + Cloudflare DDoS. Players and server-lists only ever see Cloudflare IPs. Volumetric (L3/L4) and application (L7) attacks hit Cloudflare's network, not your box.
2.
Origin firewall locked to Cloudflare. Even if someone
does discover your real IP, the game ports only accept connections from Cloudflare's published IP ranges — everything else is dropped. In a 5.5-hour test the firewall blocked 240 direct scanner probes,
zero of them from Cloudflare, while 100% of legit traffic went through. [[Leaking the IP is no longer game-over]]!!
3.
Real player IP preserved (PROXY protocol v2). A naive proxy makes every player look like they come from Cloudflare — which silently
breaks IP bans, rate limits and anti-multiclient. PROXYv2 forwards the real
CF-Connecting-IP to TFS, so all your IP-based moderation keeps working exactly as it did on raw TCP.
4.
Encrypted, single-use reconnect token (RSA). The seamless-reconnect token is RSA-secured and consumed on first use, and the channel rides the standard OT XTEA encryption. A token can't be sniffed off the wire and replayed to hijack someone's session.
5.
Rate limiting at the bridge. The bridge enforces a minimum gap between (re)connections per source, so connection-flood / reconnect-spam is throttled
before it ever reaches TFS.
6.
WSS / TLS in transit. The player↔Cloudflare leg is HTTPS / WebSocket-Secure, so the tunnel is encrypted on the public internet
on top of the game's own encryption.