OpossumTS.net - Where I Learned to Build Things
Before I had a job in software, I had a TeamSpeak server.
TeamSpeak, if you missed that era, was how gaming communities talked to each other before Discord ate the category. You ran a server, people connected to it with a client, and they sat in channels. Getting on one usually meant knowing somebody. Servers were closed things you got invited to.
OpossumTS.net (named after an old username of mine, and nothing cleverer than that) inverted it. Anyone could turn up and get a permanent, private, maximum-quality channel of their own, for free, from a web form, in about thirty seconds, with no admin involved. The channel tree wasn’t the plumbing behind the product. It was the product.
That ran from roughly 2017 to 2020. Across its life it saw 11,812 distinct identities from 51 countries, with around 150 people on it on an average day, and they spent 210,308 hours connected to it between them. That is around twenty-four years of cumulative time sitting in voice chat. Then the database server died, I had less and less time to give it, and the community merged into another one so it could carry on being looked after properly.
I built the software, but I did not run the place on my own. An admin team moderated it, answered reports and ran events. People applied to join them through a form on its own subdomain, and the staff kept their availability in the forum like a rota. This post is about the machinery, so they spend most of it offstage. That is not how it actually worked.
I recently went digging through old disks and found most of the code. This is a walk through everything I built on top of that server, what it taught me, and the handful of things I got wrong that I only discovered by reading it back seven years later.

“Der kostenloser, privater Teamspeak-Channel für dich und deine Teammates?” - and that last word is mid-type. The headline cycled through who the server was for: your friends, your guild, your teammates, your Twitch viewers. Whatever you had, it had a channel for it.
Underneath sat a live viewer of who was on the server at that moment, then a section headed OpossumTS.net in Zahlen with counters that pulled live from the rank system’s API: registered users, the days they had spent on the server between them, web requests, voice traffic in terabytes, channels created, uptime. They render blank now, because the API they called is gone.
Below that were the feature bubbles. Gratis Channel advertised that you could kick and ban trolls in your own room, set a password and hand out ranks. Videochat promised free webcam calls with screenshare, which is the self-hosted Jitsi from later in this post. And the partner section listed exactly one: TeamSpeak itself.
Every screenshot in this post took some doing. The site has been gone for years, so what I did was pull each page out of the Internet Archive and load it in a browser today, or, where the archive had lost the stylesheet, point it back at the real CSS and images off those same drives and serve the whole thing on localhost. Everything you see is original.
How you talk to a TeamSpeak server
Everything below is built on one interface, so it is worth thirty seconds on how it works.
A TeamSpeak server does the voice on its own port, and separately exposes ServerQuery: a plain-text admin protocol you reach over telnet, on port 10011. You connect, you log in, and you type commands at it. It answers with a single line of key-value pairs separated by spaces and pipes, and every string is escaped in TeamSpeak’s own scheme where a space becomes \s and a pipe becomes \p.
clientlist -uid -ip
clid=42 cid=2334 client_nickname=Felix client_unique_identifier=xk3...= |clid=43 ...
That is the whole API. There is no REST, no JSON, no webhooks, no events pushed to you unless you hold the connection open and subscribe. Every single thing in this post is that protocol in a loop: connect, ask who is online, compare it to a database, issue commands, disconnect.
What you can do through it is broad, and that is why the server could become a platform. You can create and delete channels, rename them, edit their descriptions, move clients between them, add and remove server groups, upload icons, poke somebody, message them, ban them, and read the whole channel tree with occupancy counts. The bots in this post are all doing those things on a timer.
The parts of TeamSpeak I leaned on hardest:
- Server groups are the badge system. A group has a name, an icon, and permissions, and a client can hold many. Everything visible about a person in this project - their level, their rank, their champion, their badges - is a server group assignment.
- Channel descriptions render a subset of BBCode, including images, and nothing else. No scripts. That constraint drove a lot of architecture, as you’ll see.
- Talk power gates who may speak in a channel. Set it high enough and a channel becomes a read-only signpost.
- A poke is an attention-grabbing direct message that pops up over the client. It turned out to be the most useful primitive I had.
The client is not extensible in any interesting way, so all of the intelligence has to live server-side and express itself through those few surfaces. In practice you are writing a program whose entire user interface is a tree of rooms, some icons, and the occasional pop-up.
Free channels
The pitch was simple enough that it worked: come in, click a button, get a channel that’s yours and stays yours.
The form takes a channel name, an optional password, a username, an email, and your TeamSpeak identity. Before it creates anything it runs five separate gates: a session rate limit, a name collision check, one channel per TeamSpeak UUID, one per IP address, and one per email. Then it creates the channel with the codec pinned to Opus voice at quality 10, the maximum. The marketing line about every channel being top quality was, in the end, one hardcoded constant.

That is the gate doing its job. “Du bist nicht mit OpossumTS.net verbunden” - you are not connected to OpossumTS.net - and no form appears for anyone the voice server cannot see.
The later version of that form does more once the channel exists. It marks your TeamSpeak account with a server group that means “this person has a channel”, so the uniqueness check survives the database and the tree disagreeing. Then it moves you into your new channel, grants you channel-admin over it, and pokes you with “Vergiss nicht deine Email-Adresse zu bestätigen :)”. You pressed a button and you were standing in your own room, in charge of it, being told to check your inbox. The session then goes on a twenty-minute cooldown.
Confirming the email did two more things: it sorted your channel alphabetically into the tree, and it started the retention clock. A separate job capped each channel at four sub-channels, because otherwise people built entire cities.
One thing happened without me thinking about it. Every channel got a topic set automatically:
Channel erstellt via Webinterface! UUID --> <uuid> | Username --> <name>
The channel tree recorded its own provenance. That turned out to matter more than I expected, because it was the one piece of ownership data that lived somewhere other than the database.
Give away free permanent channels, though, and you’ve created a landfill. People make a channel for one evening and never come back. Within months you have thousands of dead rooms and nobody can find anything.
A garbage collector for a social space
So the actual product isn’t creation, it’s expiry. And you can’t just delete things out from under people.
What I ended up with was a three-stage lifecycle, and it’s the piece of design I’m still happiest with:
- A crawler walks the channel list continuously and stamps
lastusewhenever anyone is sitting in a channel. - At 20 days idle, it puts a warning icon on the channel itself and emails the owner once. The icon matters more than the email. It shows up in everyone’s client, so your friends see it too and tell you.
- At 30 days idle, the channel is deleted.
Ten days between the warning and the axe, a couple of protected channels exempted by ID, and spacers ignored. No moderator ever had to make a judgement call about whether a room was dead.
The base tool was a GPL channel deleter written by somebody else for ts-n.net. What I bolted onto it was the email pipeline, the forum integration, the sub-channel cap, and a reminder script I named nerven.php, German for “to nag”.
Channels that make themselves
Owned channels were only half of it. The other half was the public channels, the ones you drop into when you just want to find somebody to play with, and those have the opposite problem: they need to be there before anyone needs them, and there need to be exactly enough.
Too few and the server looks full. Too many and the tree is a wall of empty rooms.
So a small Node bot watched the public channels on a five-second loop. When every one of them had somebody in it, it started creating numbered overflow channels under a parent, each with a client limit, and it kept creating until there was always exactly one free channel waiting. As people left, the surplus empties were meant to be reaped again.
The effect is that capacity breathed. Busy evening, the tree grows. Everyone goes to bed, it shrinks back. Nobody ever arrived to find nowhere to sit, and nobody scrolled past forty empty rooms to get anywhere.
The size of those rooms mattered as much as the number of them, which took me a while to work out. An open channel with no limit sounds friendly and is actually the worst option: two people trying to play a duo queue get joined by four strangers, and the conversation is over. So the generated channels came in fixed sizes, a two-person channel, a three, a four, each kind topped up independently as demand moved. If you wanted a duo you took a Zweier-Channel and nobody could crowd you, and the server still put a fresh empty one underneath it for the next pair.
That is really a moderation feature wearing a capacity feature’s clothes. Setting a hard limit of two means I never have to arbitrate whether somebody was being rude by joining.
That bot has some of the worst code in the entire project. It checks whether a channel matches a name by putting indexOf() straight inside an if. indexOf returns -1 when there is no match, and -1 is truthy in JavaScript, so the branch fires for every channel that isn’t a match at all. A real match at the start of the string returns 0, which is falsy, so the one case it should have caught is the one case it skipped. And the function that deletes surplus empty channels is written, complete and correct, and then never called anywhere. The call site invokes the counting function a second time with the deletion function’s arguments.
Which means the tidying up fell to the thirty-day reaper from the previous section. My channel garbage collector spent three years quietly cleaning up after my channel generator.
Finding your own channel
Solving the landfill created the next problem: navigation. If there are three thousand channels, finding your own is genuinely hard.
So I made a channel that was a teleporter. Join the one called “Mein Channel” and a bot looks you up by your TeamSpeak UUID and moves you into your own channel. It’s thirty-three lines and it solved the single most common complaint on the server.
The same pattern got reused for streamers, which I’ll come back to. And once you have a bot that can move people around, you inevitably write the stupid version too: magische-reise.php, the “magic journey”, takes a name and moves that person through every empty channel on the server at ten-millisecond intervals. On a server with thousands of channels that is a long trip, and it never stopped being funny.
Knowing who you are
Every one of those subsystems needed to know who you were, and I really did not want to build a login.
TeamSpeak’s server query interface will tell you the IP address of every connected client. So when you opened the website, the first thing it did was ask the voice server which clients are connected right now from the same IP as this web request?
$detected_clients = $ts3->clientList(array(
'client_type' => '0',
'connection_client_ip' => getClientIp()
));
That is only a shortlist, though, not proof. Households share addresses, and an IP is not something you own. So you pick which of those clients is you, and then the actual verification happens:
The server generates an eight-character random code, and the bot pokes it to that TeamSpeak client. A poke is TeamSpeak’s attention-grabbing direct message, so it pops up in front of whoever is actually sitting at that client. You type the code back into the web form, and only if it matches does anything get written.
That last part is what matters. Sharing an IP gets you a list. Receiving the poke proves you are the person in the client. It was the cheapest thing I could build that a script could not walk through, and it existed because the moment you put a “create a channel” button on the public internet, automated junk finds it. There is also a counter capping you at three codes per session, so nobody could use the verification page as a poke-spam weapon aimed at somebody else, and a list of server groups barred from the tool entirely.
Only after the code matched did it set a cookie across the whole .opossumts.net domain, holding your encrypted TeamSpeak identity, valid for fourteen days.
That tool is the one piece of this that is still public. It began as Multivit4min’s verification script, and I took it over, reworked the design and kept it running: github.com/FelixGerberding/Teamspeak-Verifizierung. Its README describes the flow in one sentence, and it is the sentence above: the site works out who you are on TeamSpeak from your IP, then pokes you with a code you have to type back.
That single cookie is why nothing else needed accounts. There is no user table anywhere in this project. Your TeamSpeak identity was the account, federated across a dozen subdomains by one cookie. The same page handled rules acceptance and self-assignable server groups, with a cap on how many you could give yourself.
The one part I would still flag at myself: getClientIp() reads HTTP_CLIENT_IP and X-Forwarded-For before falling back to REMOTE_ADDR, and both of those are headers the caller controls. That doesn’t break the verification, because the poke is what actually proves anything, but it does mean the shortlist could be pointed at somebody else’s address. I didn’t know what a spoofable header was in 2017. Getting the design right and the details wrong is roughly the shape of everything I learned in this period.
Around that sat proper account linkers for four platforms, each using the real mechanism rather than an honour system:
| Platform | How it was verified |
|---|---|
| Steam | OpenID |
| Twitch | OAuth |
| osu! | the official user API |
| League of Legends | Riot’s third-party-code endpoint |
The League one is the one I’d defend today. Riot gives you an endpoint where the player pastes a code into their game client’s settings and the API confirms it, which makes it genuinely unspoofable. Somewhere in there I’d figured out the difference between asking someone to type their name and actually verifying it.
The League of Legends layer
The server’s real gravity was League of Legends. People came to find teammates, and that’s what the integration was for.
A job pulled each player’s current rank, their most-played champion and their queue type from Riot’s API, then reconciled TeamSpeak server groups: add the group for your current rank, add the group for your main champion, remove the ones that no longer applied. Your badge in voice chat tracked your actual ladder position without you touching anything.
The champion groups built themselves. A script fetched Riot’s Data Dragon champion list, and for every champion it made sure a server group existed with that name, downloaded the champion’s portrait, piped it through an image resizer to get it to 32 by 32, and assigned it as the group’s icon.
That’s about 150 server groups, each wearing the right champion art, regenerated whenever Riot shipped a new champion. It’s the single highest ratio of visible payoff to code I have ever written.
There were chat commands too, listening on channel text:
!gamerank <summoner>reported the rank of everyone in that player’s current live match!mains <summoner>listed their most-played champions
!gamerank was the one people used. A group deciding whether to queue could scout the enemy team without leaving voice.
The part that got out of hand
At some point the League integration stopped being a TeamSpeak feature and became its own product, on its own domain, with its own data warehouse and a public sitemap.
Match JSON went into a matches_participant table through an insert with roughly a hundred positional parameters, written out four times over with zeros padding different gaps, one variant per era of Riot’s schema, so older cached matches still loaded. It is the least elegant code I have ever written and it worked fine.
On top of that sat a post-match coach. The first line of that file is a comment reading // TeamSpeak-Ready Formatting, which tells you what it was for: your game ends, and the analysis arrives as formatted text in voice chat.
It parsed Riot’s frame-by-frame match timeline and ran 24 detectors. Twenty-one of them were positive and three were negative, which was deliberate. I wanted it to find something nice to say. It also took a flag for ARAM, so it dropped the lane and creep-score checks in a mode where they mean nothing.
The most involved one looked for junglers camping a lane. For every timeline frame before the 17-minute mark it bucketed your kills and assists by the victim’s lane, then fired only if one lane held more than 40% of your early kills:
TOP-Camper - Hast du ein Zelt auf der Toplane aufgeschlagen? (Did you pitch a tent on top lane?)
There was live scouting as well: hand it a summoner, it pulled the game currently in progress and fetched all ten players’ historical winrate on the exact champion they’d just locked in, fanned out in parallel to a small winrate service of mine running on a bare IP address, with no domain in front of it. Another script built a playedWith frequency map across your match history: a social graph, derived from who keeps turning up in your games.
And when the crawl hadn’t caught up, the output said so: “Noch nicht alle Matches gescannt!” Past me was better about admitting incomplete data than a lot of shipping analytics products are.
The rest of it was a public stats site in its own right: champion pages, champion stats, matchups, patch history, league positions, account pages, a file of champion tips, and a generated sitemap. It was meant to be found by search engines, not just used from voice chat.
Other games and servers
League got the deepest integration, but the same pattern got copied outward. Chat commands pulled live stats for Fortnite, Rainbow Six Siege (through r6stats), Overwatch, PUBG and Planetside 2. One of those files still carries a comment saying the command was built for a streaming network I was also working with. That is how most of these things spread.
The community ran actual game servers too, not just voice. There was a Garry’s Mod Trouble in Terrorist Town server, which had its own badge for anyone who joined it, and a Minecraft server with a web panel on its own subdomain. Digging through the old drives, the Minecraft world folders and a Spigot jar are still sitting there next to everything else.
Twitch, running in both directions
A lot of the community streamed, so the Twitch integration became one of the larger subsystems. What makes it worth describing is that it pushed in both directions.
TeamSpeak advertised the stream. A job polled Twitch for each configured streamer, and when one went live it rewrote their channel in place. The channel name became Stream - <game>, so the channel tree showed what was being played right now. The channel description became a formatted card with the stream’s preview thumbnail, the streamer’s name, their status, the game and their follower count.
The preview image URL carried a cache-busting hash computed from the game, follower count and status, so clients only re-downloaded the thumbnail when something had actually changed. That’s a real piece of engineering hiding in a file that otherwise echoes raw HTML.
It also built a directory. Alongside the live channel it created a --> streamername channel with the talk power set to 200, meaning nobody could speak in it. Those weren’t rooms, they were signposts. And the lift pattern from earlier made them functional: join --> streamername and you get moved into that streamer’s actual channel. The whole navigation layer was built out of channels.
Discord got announcements, posting “X is live playing Y” when someone went live. It rotates randomly between three different phrasings so the announcements don’t read as a bot.
And the stream advertised TeamSpeak. A browser-source widget for OBS polled the occupant list of a TeamSpeak channel every ten seconds and rendered it as an on-stream overlay. Viewers watching the stream saw who was in voice; people in voice saw who was streaming. There was also a small helper that generated multistream links for co-streaming.
The points economy
TeamSpeak servers were discovered through public server-list sites, and those sites ranked by votes. So I paid people to vote.
The loop: a bot notices you’re connected, asks the list site whether you have an unclaimed vote, and if you do it increments your count, drops you into a server group that multiplies how fast you level up, claims the vote so it can’t be counted twice, and credits 20 points to your forum account.
Then it messages you in voice. And it branches:
“Vielen Dank für deinen Vote! Als Dankeschön erhältst du 20 Punkte und einen Vote-Boost :)”
or, if your TeamSpeak identity has no forum account attached:
”…Als Dankeschön erhältst du einen Vote-Boost! Wenn du im Forum registriert wärst, hättest du ebenfalls 20 Punkte erhalten.”
The nudge to register arrives at the exact moment you’re being rewarded, and it tells you precisely what you left on the table. I would like to claim I designed that carefully. I think I just wanted the message to be accurate.
The other way to earn was stranger. There was a page called the Coin-Miner, linked from the forum, where you could earn forum currency by leaving a tab open. It ran AuthedMine, the consent-required version of CoinHive, with a slider for how many threads to give it. More threads, more load on your machine, more points. The page said so plainly, with a live counter of points per second, a graph, and a leaderboard of top miners:
“Hier kannst du Punkte durchs simple AFK-Sein verdienen. Je höher die Threads, desto mehr wird dein PC ausgelastet - du bekommst allerdings auch mehr Punkte.” (Earn points by simply being AFK. The higher the threads, the more your PC is loaded, but the more points you get.)
A cron collected the hashes and paid out at 10,000 hashes to one point, into the same ledger, under the reason “AFK-Farming Auszahlung”.
I want to be careful about how I present this, because browser mining got a deservedly bad name. What I’d say for it is that this was the opt-in product, on a page you had to deliberately open, with the CPU tradeoff stated on the page and a disclaimer that we took no liability for it. What I’d say against it is that I was seventeen and it did not occur to me to think much harder than that.
Thirty badges
The points bought badges, and the badges hung off every loop in the product.
There were participation badges for using the forum, for using the video chat, for linking an osu! account (called Kreisklicker, “circle clicker”), for joining the Trouble in Terrorist Town server, and for subscribing to the WhatsApp newsletter, which was a thing people did in 2018. There were donor badges at two tiers, and one for buying merchandise, which means at some point I had merchandise.
There was a referral ladder at one, three and five friends recruited. Anniversary badges at one, two and three years on the server. Vote milestones. Esports team allegiances for Fnatic, Cloud9, Schalke 04 and G2. And a Galactic Empire versus Rebel Alliance pair, so people had something to argue about.
The flagship purchases were three badges called Wohlhabend, Reich and Superreich - well-off, rich, super rich - at 500, 1,500 and 3,000 points. You ground votes for a currency whose most expensive item was a badge announcing that you had money. Everyone was in on the joke.
Badges you own and badges you display were separate: you collected many and equipped one as your icon. Awards arrived as a forum message from the bot with the badge art embedded. And for anything that happened off the platform, a generator minted ten-character redeem codes in bulk. That is how merch buyers, event attendees and a Christmas Eve 2018 giveaway got theirs.
Every one of those icons came out of icongenerator.eu, which I had built for exactly this. TeamSpeak wants small square icons for server groups, and making a consistent set of them by hand is miserable, so I wrote a tool that put a flat glyph on a coloured tile and exported it at the right size. That is why it exists. It ended up being useful to other people running servers, but the first user was this one, and the badge grid above is entirely its output.
Everything is a PNG
Because TeamSpeak’s channel descriptions render only formatted text and images, with no scripts, anything dynamic had to be a server-rendered image. That single client limitation shaped a surprising amount of the architecture.
The live view counter was a PNG. The rank progress bar was a PNG. The profile banner was a PNG. And because the client fetched each of those images itself, straight from whatever host was named, everything remote got rewritten to pass through an image CDN of my own first. That was a privacy feature and a security hole at the same time, and I got exactly one of them right.
There was also a watermarking service that was better than it needed to be: it corrected EXIF orientation, then sampled the image thirty times to measure average brightness, presumably so it could pick a light or dark watermark that stayed legible. I have no memory of writing that.
The personal banner
Underneath the badges was a level system that tracked how long you’d been connected, subtracted idle time, and promoted you into a higher server group as you crossed thresholds. Certain groups multiplied your accrual rate, and that is what the Vote-Boost plugged into. Your level was your badge in voice chat, so there was nothing separate to go and look at.
The banner is where this got strange. TeamSpeak shows a host banner in the top right of the client, permanently, the whole time you are connected. It is one image URL configured on the server, so every person sees the same thing. That is what a host banner is.
Mine wasn’t. The URL was a PHP endpoint, and when your client fetched it, the endpoint looked up which connected user was calling from that address and drew your banner. Same URL for everybody, a different image for each person, refreshed continuously in the corner of your client all day.
The background did the real work. It checked, in order: what Steam game are you in right now, and if you were in one it used that game’s artwork. Otherwise, which League champion are you playing right now, read from the table the live in-game check kept up to date, and it used that champion’s splash art. Only if you were doing neither did you get a house image. So the background of your banner changed while you played, without you ever asking it to.
Champion splash art is roughly 1215 by 717 and the banner is 1200 by 320, which means something has to be thrown away. A centre crop puts you at chest height and cuts the head off about half the roster. So the fetch went through an image service with a=attention, a saliency-based crop that scores the image for the interesting region, faces and edges and saturation, and crops to that instead of to the middle. It also passed blur=3, because otherwise white text over splash art is unreadable. Two query parameters doing the work of an art director.
Over the top of that went your total hours, or your active hours, or your idle hours, or your leaderboard position, rotating between them; the current user count; the time; and a progress bar to your next level. The bar has a small trick in it, where it looks for progressbar/<percentage>.png on disk and only renders one if it is missing, so the cache filled itself in one integer at a time.
Sometimes it swapped the greeting for a nudge to vote for the server, or for the last donation received. The most valuable advertising space I had was a corner of everybody’s TeamSpeak client, and it belonged to me.
I got this one back properly. The drawing code still exists, so I stubbed out the database lookups, kept every GD call and coordinate exactly as written, and ran the original PHP against the original assets. The background, the font and the progress bar are all straight off the disk, and the bar still carries the label it was rendered with in 2019:

There was also a personal stats image served from a deliberately unguessable sixty-character filename, so nobody could enumerate other people’s.
Making the numbers public
The stats pages were deliberately public: a leaderboard, all-time and monthly and weekly rankings, a country breakdown, a platform breakdown. Members could see the whole server’s numbers, not just their own.

The all-time board is the one that still gets me. Top place is 5,217 hours of active voice time. Not connected, actively talking. That’s 217 days. Second is 2,593, and the member in tenth still managed 1,657. I’ve blanked the names.

That decision turns out to matter a great deal later on. These two pages needed no repair work at all: they were public, so the crawler got their stylesheets and fonts along with the markup, and they still load as they did. The dashboard did not, because it was behind a login.

Protecting people from each other
This is the one place the platform could have hurt somebody.
TeamSpeak clients render image tags in channel descriptions by fetching the image directly from whatever host it names. On a normal server that’s a curiosity. On mine, where every single user could edit their own channel description, it’s an IP harvesting vector handed out with the product. Point an image at a logger, and you collect the address of everyone who clicks your channel.
So a bot walked every channel description on the server, found every image tag, and for anything not on a short allowlist it stripped the scheme, base64-encoded the URL, and rewrote the tag to point at my own CDN. The image still displayed. It was just fetched server-side, so the member’s IP never reached the third party. Descriptions edited to add a logger got neutralised on the next pass.
I’d love to end that section there. But the small image proxy sitting next to it was fifteen lines that would readfile() any URL you handed it, with no allowlist at all. A textbook SSRF. Right instinct, one of two implementations wrong. That pairing shows up a lot in this codebase.
The other real problem was bans not sticking. A TeamSpeak ban is against an identity and an IP, and trolls worked out immediately that a VPN gets you a new IP and a fresh start. So a query client called OpossumTS-AntiVPN sat on the server collecting the address of everyone connected and checking each one against two independent reputation services, iphub and nastyhosts. Verdicts were cached in a table so an address was only ever looked up once, and the policy was configurable: max blocked if either service objected, min only if both did, default trusted iphub alone. Anyone caught was moved into a server group that could see almost nothing, by a script I had, with great maturity, named idiotenfilter.
The rest of the moderation kit was less clever and more necessary: a ban list, a country blocker, mass poke and poke spam tools, empty-channel reports, and a reporting flow where a complaint raised in voice landed in Discord, with an appeals endpoint next to it. A separate bridge pushed forum posts into Discord.
Video chat
In February 2019 the server got video calls, and the design is the simplest thing in this post, because it removed the only hard part.
The hard part of a video call is not the video. It’s coordination: making a room, getting a link, sending it to the right people, and everyone joining the same one. So I didn’t build any of that. The room name is derived directly from the ID of the TeamSpeak channel you are sitting in, hashed and prefixed:
$room = "opts-" . md5($cid);
Everyone in the same voice channel who clicks video lands in the same room, necessarily, because the channel is the room key. There is no invite, no link to paste, no way to end up in the wrong call. You were already in a room with these people; the video just joined you to it.
Underneath it was a self-hosted Jitsi Meet instance, embedded through the JitsiMeetExternalAPI in an iframe rather than sending people to a third-party service. Your TeamSpeak avatar was passed through as your Jitsi avatar, so you looked like yourself. A liveness endpoint got polled so the rest of the system knew which channels currently had video going. Using it once unlocked the Video-Chatter badge, and the bot messaged you a link to go and equip it.
There was a mobile side too: a rebranded Jitsi Android app, built and served as an APK straight off my own CDN, with a browser requirements check and mobile detection deciding who got sent where. In 2019, self-hosting group video for a few thousand gamers and shipping your own Android build of it felt completely reasonable, and I would not attempt it now.
The GDPR pipeline
The thing on those drives that most surprised me is buried in the API and keyed on TeamSpeak UUID.
A self-service export walks every subsystem that holds anything about you - the voice server’s own client record, the channel creator’s row with your username and email, both ranksystem tables - and federates it into a single JSON download. There’s a matching deletion path next to it.
That’s a right-of-access and right-to-erasure implementation across six databases, on a hobby project, in the GDPR’s first year. Nobody made me do that. I read the regulation, worked out that it applied to me because I was holding other people’s data, and wrote the endpoint.
Running the thing
The ops half taught me as much as the code.
It ran on Proxmox, across more than one voice server. Slots auto-scaled: a job watched used against maximum and raised the server’s client limit whenever the gap closed, so it never showed as full, which also kept me from paying for slot licences I wasn’t using. Deploys went out through a git webhook that pulled on push, and a script pulled traffic numbers off the Cloudflare analytics API.
Monetisation was very 2018: AdSense, plus CoinHive mining in the browser, plus CoinHive’s proof-of-work captcha, plus a YOURLS URL shortener on a short domain whose interstitial page mined while you waited for the redirect. CoinHive shut down in March 2019, right in the middle of all this. I’m not going to pretend that was a good idea, but it was absolutely an idea of its time and I’d rather write it down than leave it out.
And there was a forum underneath all of it, running WoltLab Suite: boards, a blog, a filebase, a calendar with events, a team application system, and an absence tracker for staff.
The estate
The subdomains are the clearest map of how far this sprawled:
| Host | What it was |
|---|---|
www | the community, the forum, the channel creator |
api | around 150 endpoints, the bots, the game integrations |
ranksystem | levels, online time, public stats |
verify | identity linking |
video | self-hosted Jitsi |
forum, help, bewerbung | community, support, team applications |
static, img | assets |
afk, mcpanel | AFK handling, Minecraft panel |
*.opossum.media | CDN, image proxy, watermarking, internal admin |
*.leaguespeak.de | the League analytics product and its Riot asset mirror |
Plus the joke endpoints, which I refuse to leave out: one served random German pickup lines and another served flat jokes, both callable as chat commands. On a server whose entire purpose was getting strangers to talk to each other, a !flachwitz command is arguably product work.
The features that never worked
Reading a codebase back after seven years is a specific kind of humbling. The uncalled delete function in the channel bot was not a one-off.
Two badges were unobtainable. The vote milestone check reads:
if ($votes >= 30) { addBadge(476); } // Aktiver Voter
elseif ($votes >= 90) { addBadge(477); } // Vote-Fanatiker
elseif ($votes >= 333) { addBadge(478); } // Vote-Bot
Ascending thresholds in an if/else chain, so the first branch swallows everyone. Nobody who voted 90 or 333 times could ever be awarded those badges, because 30 always matched first. The referral ladder directly above it is ordered correctly, five then three then one, which is what makes this clearly a slip rather than a misunderstanding. Two badges sat in the public catalogue for years that no player could earn.
The lane camper detector never fired once. It looks up the victim of a kill using a variable that is never assigned anywhere in the function. So the lookup always failed, the counters stayed at zero, and my favourite piece of analysis silently returned nothing, every time, for its entire life.
And the lane-win check was half dead, because I’d typed $$myParticipant with two dollar signs, which PHP reads as an entirely different thing. That half of the condition never evaluated.
The system was big enough that whole features could quietly not run, and nobody, including me, noticed for years. That’s a lesson you can only learn by building something bigger than you can hold in your head.
The version other people could run
Somewhere in the middle of all this, other people running TeamSpeak servers started asking how they could have the same thing. Not the community, the machinery: the self-service channels, the verification, the ranks, the moderation tooling. The honest answer was that they could not, because it was a pile of single-purpose PHP scripts hardcoded to one server, held together by a database schema that only made sense to me.
So I started building the version they could run, and that became TeamSpeak.gg, which was the same set of ideas rebuilt as something multi-tenant, with an actual API in front of a protocol that only speaks telnet. It is the direct descendant of everything above, and the reason it exists at all is that this server was visible enough for strangers to ask about it.
How it ended
The database died in March 2020 because the server ran out of disk. That is all it was. Not a hack, not corruption, not anything interesting: storage filled up, MySQL wrote into a space that wasn’t there, and the files it left behind were not the kind you can talk back into being a database. There was no clean dump to fall back to, because what I had was the live data directory, and that was the thing that had just been damaged.
The honest reason it ended, though, is that by then I did not have the time it deserved. A server this size is not a weekend hobby. Somebody has to be there when the disk fills up, when a report comes in at midnight, when a game API changes shape and half the badges stop updating. I had been that person for three years and I was running out of runway.
So rather than let it drift, we merged the community into another one that could keep it maintained. That felt like the wrong outcome at the time and I think it was clearly the right one. The alternative to handing something over is not keeping it. The alternative is watching it get slowly worse while you tell yourself you’ll get to it this weekend, and then one day the database dies and there is nobody with the time to fight for it.
What survived is oddly instructive. The code is all there, on old drives, because code lives in files. The channel tree carried its own ownership data in those auto-generated topics. And the numbers survived for a reason I didn’t plan: because I’d made the server’s statistics public, as a feature for members rather than a private dashboard, the Internet Archive crawled them. Every figure at the top of this post came back out of archived pages of my own stats site.
What did not survive is the channel tree, which is the thing I opened this post by calling the product. It only ever existed inside the client, drawn live from a server that stopped answering in 2020. No crawler could reach it, because it was never a web page. So there is no picture of it anywhere, mine or anybody else’s, and it is the one image I actually wanted for this.
The lesson I’d hand to anyone running something like this: the things you publish outlive the things you keep.
The other lesson took longer and shows up in everything I build now. A disk filling up is the least interesting way to lose three years of something, and it is entirely a consequence of me being the person responsible for a disk. So these days I go out of my way not to be. The benchmark tracker I built for my Guild Wars 2 guild runs on Aurora DSQL and Lambda, where there is no disk to fill, no instance to patch, and nothing to be on call for. It scales to zero and costs about forty cents a month when nobody is using it.
The cost is not the point. I learned, expensively, which parts of running something I actually enjoy. I still like building the thing. I have no interest in being the person who notices the volume is at 98%.
What it was actually for
None of this was a job. Nobody asked for any of it, and the whole thing made roughly enough money to pay for its own hosting.
It’s where I learned to build software. Not from a course, but from having a few thousand people who would immediately tell me when something was broken. Every feature in this post started as a specific irritation: the channel list is full of dead rooms, nobody can find their own channel, people are pasting IP loggers into descriptions, the server keeps showing as full, we can’t tell who’s actually any good at this game.
That’s also where the ops half came from. Somebody has to notice the disk filling up, and rotate an API key that expires every 24 hours, and decide what happens to a channel when its owner disappears, and answer the report that just came in. Doing both halves at once, badly, on something real, taught me more than any tutorial did. It’s a straight line from here to still enjoying the same thing: a problem worth solving, an excuse to use something I haven’t used before, and people on the other end who will tell me when I’ve got it wrong.
The server is gone. I still have the badges.