Online
GameSpy switched its servers off in 2014, and THUG's online menus have been dialing a dead number ever since. Now Multiplayer › THUG Online Play talks to thugonline, a small Go service, and the retail menus can't tell the difference: lobbies, chat, homies, hosting and joining through NAT, ranked games, online stats.
1Who's in the lobby: pushed live by the events long poll
2Host Game: POST /v1/games, every 15 seconds while it runs
3Chat: POST /v1/lobby/chat, rate limited
4The game list: GET /v1/lobby/games, no IP addresses in it
5Powered by GameSpy. Not anymore.
- Users: the player list comes from the lobby join, then live
player_joined/player_leftevents. - Host Game: the host reports its game (mode, level, players, ratings) like GameSpy's qr2 keys used to.
- Enter Message: chat goes to the service and back out to everyone in the lobby, the sender included.
- Games: refreshed every 3 seconds while the screen is up; the menu is only rebuilt when the list changed.
- The logo is a texture in the retail data. It stays. Respect your elders.
1There was no URL to change
The plan was simple: find the old GameSpy host names, point them somewhere new. The plan lasted about ten minutes.
The port's GameSpy/ folder only had MD5 left in it, and every online script function in
ftables.cpp sat behind #ifdef __PLAT_NGPS__: the PS2 build. On PC, LobbyConnect,
ProfileLogIn, ChooseLobby simply didn't exist. The menus were all there in the retail scripts, though,
calling functions into the void.
So the PS2 version's GameSpy managers (LobbyMan, BuddyMan, StatsMan) were
ported onto a new client, with the same names and the same script functions, under a new switch:
The same trick across GameNet.cpp, ServerList.cpp and GameHandler.cpp: each
PS2 block that called GameSpy got a THUG_ONLINE twin that calls the new client. The message of the day,
the lobby list, posting a game, the homie status when you join one: same places, new plumbing.
The PS2 code still asks GameSpy for the message of the day at online.epicmaxim.net/motd.txt. Nobody has answered there in a long time.
2thugonline: one Go program instead of six servers
GameSpy was a zoo: Presence on one port, IRC-style lobbies on another, a server browser, a query protocol, NAT negotiation, a stats server. OpenSpy rebuilds all of it. THUG only needs a slice, so thugonline does that slice over plain HTTPS:
| THUG needs | GameSpy did it with | thugonline |
|---|---|---|
| Profiles, homies, status | Presence (TCP 29900) | /v1/session/login, /v1/buddies, /v1/presence |
| Lobbies, chat, who's there | Peer chat (IRC, TCP 6667) | /v1/lobby/* + events |
| Hosting and the game list | qr2 (UDP 27900), server browser (TCP 28910) | /v1/games, /v1/lobby/games |
| Getting through NAT | NAT negotiation (UDP 27901) | UDP rendezvous + /v1/nat/connect |
| Ratings, top lists | Stats (TCP 29920) + HTTP files | /v1/matches*, /v1/stats/* |
Live state (sessions, lobbies, hosted games) lives in memory, in one hub behind one mutex.
PostgreSQL keeps what has to last: profiles, homies, scores. Things that happen to you (someone joins, someone
chats, a homie starts hosting, a player wants through your NAT) come back by long polling:
Why not WebSockets? Because the game would then need a WebSocket client on two platforms. A long poll is just an HTTP request that takes its time, and every OS already ships an HTTP client with TLS.
2,700 lines of Go, two dependencies (pgx and x/crypto), one static binary. GameSpy needed a data center.
3The NAT punch
THUG is peer to peer: the host's machine is the server, on UDP port 5150. Behind a home router, nobody can reach it. GameSpy's trick was NAT negotiation, and thugonline does the same dance with fewer steps:
- The game sends a 24 byte packet from its own game socket to the service's UDP port. The service now knows which address and port the player's router uses for that socket.
- A player picks a game. The service tells the host, by event, where the player is, and tells the player where the host is.
- Both send a few packets at each other. Each router sees outgoing traffic first, so it lets the answers in.
- The game joins as if it were on a LAN.
On the game side, the packets go out of the game's own sockets, and the answers come back through a hook the
netcode already had for GameSpy: Net::App::SetForeignPacketHandler, for anything that isn't a game
message. Then eight punches, 100 ms apart, and in we go:
The UDP port only answers sessions it knows, never with more bytes than it got. Nobody gets to use it as a megaphone.
4The client inside the game
Online::Client in Code/Sk/GameNet/Online/client.cpp is the whole bridge. HTTPS comes
from the system: libcurl on macOS, WinHTTP on Windows (Wn32/Code/Plat/Sk/GameNet/p_http.cpp), so
certificates are checked like a browser would, with zero vendored TLS. Two worker threads do the waiting (one for
requests, one parked on the long poll) and the answers run on the main thread, from a logic task, where scripts are
safe to call:
The ported managers then read like their PS2 originals with the GameSpy calls swapped out: peerJoinGroupRoom
became POST /v1/lobby/join, the playerJoined callback became a player_joined
event, and the same player_list_add_item scripts draw the same menus.
5Ranked games, without trusting the host
The host runs the game, so the host reports the scores. That's also exactly how you'd cheat. The PS2 code had an answer already: when a ranked game starts, the host sends every player a stats challenge over the game connection. Now that challenge carries a match id, and each player confirms it with their own session. Only confirmed players get stats, only on the 12 ranked levels, only with two or more of them, only without cheats.
1Tony hosts, Bam joined through the NAT punch
2Game started: POST /v1/matches, challenge sent to Bam
3Bam's own session confirms the match
- Tony hosts; Bam is in his game, one router away.
- The host registers the match and forwards its id in the old
MSG_ID_CHALLENGEmessage. - The player confirms it on their own; the host can't vouch for anyone.
The rating keeps the game's own scale, because the menus divide by it: up to 500 for games played, plus up to 100 for your best score and 100 for your best combo on each ranked level, measured against the best on that level. 2,900 tops, shown as rank 0–10. The top lists come back in the exact text format GameSpy's files had, so the PS2 parser reads them unchanged.

/v1/matches/report.

Bam won with 100 points. Tony idled. Rating: 210 to 10. Fair.
6The netcode had some surprises
Getting two copies of the game to actually meet turned up bugs that had nothing to do with GameSpy, and that broke LAN play too:
- The game's port was random. Sockets bound to "this machine's address", which the port looked up as
<Unknown>. The bind failed, and the OS picked any port on the first send. The host was never on 5150. - Server ids were 64 bit pointers squeezed into 32 bit checksums. Someone had left a
Dbg_Assert(0); // This code is unsalvageableon it. Now each server gets a small id of its own. - Game lists never refreshed. A stub made
TryJoinServerIPSuccessalways true, so the screen kept taking the "join by IP" path instead of asking for new games.
7Pointing it somewhere
The game goes to https://online.epicmaxim.net, built in from the CMake option
THUG_ONLINE_URL. Your own server (or one on your laptop) goes in config.ini:
And if the service goes away mid-lobby, the polls fail, the session ends, and the game shows the dialog it always had:

How it got here
| Commit | What |
|---|---|
4998a45 | The client, the ported lobby / homie / stats managers, the netcode fixes |
9a7673d | Service deployment and docs, session limits, everything tested in game |
ace058d | The service moves out to its own repository |
Somewhere a 2003 Neversoft programmer wrote peerConnect and went home for the weekend. We finished it for them. Twenty-three years late, but it compiles.

