De GDT: een self-hosted game backend voor Unity, tien jaar later

De backend die elk spel dat we maken aanstuurt, al tien jaar en tellende. Zeven games, elf platforms en één zelfgebouwde backend voor crashrapporten, remote config, cloud-authenticatie en console-ports. Eén ontwikkelaar, tien jaar werk, nu geschreven in C#.

Elk spel waar ik de afgelopen tien jaar aan heb gewerkt, communiceert met dezelfde backend: Marooners, Verdun, Tannenberg, Isonzo, Crash Drive 2 en Crash Drive 3, en sinds dit jaar een zevende game die door een andere studio is gebouwd. Steam, Epic, de Windows Store, PlayStation 4 en 5, Xbox One en Series, Switch, iOS, Android en WebGL; ze rapporteren allemaal aan één Google App Engine-project in PHP, ondersteund door Firestore.

Ik noem het de GDT, kort voor Game Development Toolkit. De naam klinkt generiek, maar dat is de tool ook: backend, admin, een klein spelersportaal voor accountkoppelingen en gesloten tests, en het Unity-pakket. Met elk nieuw spel kreeg het een nieuwe taak. De PHP-versie draaide tien jaar zonder een outage die een speler merkte, en is nu vervangen door een C#-versie. Dit artikel behandelt de PHP-jaren; de screenshots zijn afkomstig van de C#-versie, en een sectie aan het einde beschrijft wat er nieuw is.

Onze hostingkosten voor al deze games samen lagen tussen de 10 en 50 euro per maand, afhankelijk van het aantal spelers (voornamelijk in de gratis mobiele games). Als er niemand speelde, kostte het niets. Voor de premium games waren de kosten per betalende speler verwaarloosbaar. Ik heb het alleen gebouwd en er was bijna geen onderhoud nodig: Google hield de oude PHP-runtimes in stand, en de weinige overstappen naar een nieuwere versie veroorzaakten geen problemen. Het simpel houden, inclusief de keuze voor PHP, heeft zich enorm geloond.

Het is echter het deel van ons werk dat niemand ziet. Spelers en reviewers zien het spel, maar de tool die elke lancering en elke port mogelijk maakte, bevindt zich achter een admin-login. Ik wilde het al jaren laten zien. Hier is het resultaat.

Overzicht GDT

  • Games: 7 (zes van ons, één door een andere studio)
  • Platforms: 11
  • In gebruik: 2016 tot nu
  • Backend: PHP op Google App Engine, Firestore (ca. 20.000 regels); sinds september 2026 C# op Cloud Run
  • Maandelijkse kosten: 10 tot 50 euro voor alle games samen
  • Gebouwd en onderhouden door: één ontwikkelaar

---

Waar het begon

Voorheen schreef ik voor elk spel kleine PHP-scripts wanneer er iets online nodig was: live nieuws in het hoofdmenu, een teller, een versiecontrole of een klein event. Ze waren lelijk en ik moest ze telkens opnieuw schrijven.

Wat veranderde was de console-release van Marooners. We brachten een partygame, gemaakt door zes vrienden, naar PlayStation 4, Xbox One en PC. Ik vormde het porting-team, met Matt verantwoordelijk voor testing en UI. Zodra het spel uit onze handen was, had ik geen idee wat er binnenin gebeurde. Op de PC krijg je een bericht op het Steam-forum als er iets kapot gaat. Op consoles krijg je stilte, en later een certificeringsfout.

De eerste poging was Piwik met een Redis-backend. Ik wist dat de database de bottleneck zou worden en Redis is zeer snel, dus liet ik een freelancer dit opzetten omdat ik niet wist hoe Redis werkte. Ik kreeg het nooit goed werkend. Complexiteit die je niet hebt gevraagd, staat vaak in de weg; elke keer dat er iets misging, was ik bezig met het lezen van iemands anders architectuur in plaats van met het uitbrengen van het spel. Dat is iets waar ik niet tegen kan. Ik wil 's ochtends een feature bouwen en 's middags live hebben.

Ik gooide het project weg en bouwde mijn eigen systeem op App Engine en Firestore, in PHP, zonder schema en zonder framework. Ik vroeg hiervoor WBSO aan (de Nederlandse R&D-belastingregeling). Het bedrag was bescheiden, maar het vastleggen ervan als een serieus project zorgde ervoor dat ik het ook zo behandelde. Veel ervan werd gebouwd tijdens een kerstvakantie. Omdat het al werkte in Marooners, kostte het implementeren in Verdun na de vakantie slechts enkele dagen in plaats van weken.

Vanaf dat moment groeide het systeem met elk nieuw spel. De Eerste Wereldoorlog-games werden gemaakt door WW1 Game Series, het bedrijf dat Jos, Matt en ik oprichtten. Crash Drive 2 deed ik opnieuw met Matt, en Crash Drive 3 werd door vier personen ontwikkeld. Elke release voegde een feature of een platform toe, waarbij elke feature zo werd gebouwd dat oudere games bleven werken. De API-root heet nog steeds apiv1; een nieuw spel zou pas op apiv2 starten op de dag dat achterwaartse compatibiliteit onmogelijk zou worden. Die dag is nooit gekomen.

Wat de backend doet

Mensen horen "analytics backend" en denken aan een dashboard met een stijgende lijn. Voor teams van twee tot vijfentwintig personen doet het echter veel meer:

  • Crash- en foutrapportage: Exceptions, crash-dumps en bugrapporten met screenshots en logbestanden, direct in het dashboard van het betreffende spel.
  • Cloud-authenticatie: Steam tickets, Epic tokens, Windows Store, PSN. Eén endpoint, één gebruikersrecord per platform. Bans werken per spel of over alle games heen. Een verbannen Steam-speler krijgt ook een game-ban op zijn Steam-profiel.
  • Remote config: Getypeerde instellingen per spel die de client een dag cachet. Hiermee kunnen analytics worden aan/uitgezet, minimumversies worden ingesteld, cash-boost weekends worden gepland of balanswaarden worden aangepast zonder een patch.
  • Online statistieken: Globale tellers die alleen omhoog gaan, zodat een gemanipuleerde client de geschiedenis niet kan herschrijven. Spelers van Crash Drive 3 zijn bijvoorbeeld 149 miljoen kilometer gereden.
  • DLC- en eigendomscontroles: Voor Steam en Epic, zodat het spel niet blind hoeft te vertrouwen op de client.
  • Vrienden, uitnodigingen en sessies: Over verschillende platforms heen, met Photon-webhooks op de achtergrond.
  • Polls, vouchers en sleutels: Per platform voor pers en testers, inclusief een link-tracker met kliks en werkelijke conversies. Zo ontdekten we dat een link in Discord ongeveer tien keer beter converteert dan dezelfde link op Twitter.
  • Builds: Jenkins is gekoppeld aan het dashboard. Met één knop start een build, het resultaat landt automatisch op de Steam-latest branch, en een andere knop maakt het publiek. De build-log houdt de timing van elke stap bij.
  • Benchmarks: Het spel draait een scene of scenario, post de cijfers, en deze verschijnen in het dashboard zodat ze over verschillende builds vergeleken kunnen worden.
  • Discord-integratie: Nieuwe reviews, vermeldingen door curators, sales milestones, crash-pieken, cheaters, nieuwe builds en commits. Elke Steam-review landt in een kanaal met de speeltijd van de reviewer. Hierdoor kunnen we direct reageren op zowel positieve als negatieve reviews.
  • Gesloten testing: We inviteerden spelers die een eerder spel bezaten om het volgende te testen. Het backend-systeem controleert op Steam of ze het spel bezitten en genoeg hebben gespeeld, stuurt ze naar HelloSign voor de NDA, bevestigt de ondertekening, koppelt hun Discord en geeft ze de tester-rol en een Steam-key. Honderden testers zonder één spreadsheet.
  • Commit-historie: Een pagina gevoed door een post-commit hook (eerst SVN, later Git), plus integraties met Favro, Trello en UserEcho.

Dit alles bestaat uit ongeveer 20.000 regels PHP.

De beslissingen die ertoe deden

Waarschuwen op frequentie, niet op aantal

De belangrijkste regel in het systeem: de crash-alert kijkt niet naar het absolute aantal exceptions. Hij gaat af wanneer de exceptions per actieve gebruiker boven een drempelwaarde komen (momenteel 0,05), of wanneer fouten per sessie boven de 1,5 uitkomen. Op lanceerdag betekent dit dat er duizenden spelers kunnen zijn voordat er iets triggert. Twee jaar later, met slechts een paar honderd mensen online, vangt dezelfde regel nog steeds een kapotte patch binnen het uur op.

Verkoopmijlpalen die meeschalen met het spel

Bij minder dan 100 eigenaren pingt de backend Discord bij elke 10 verkopen. Bij minder dan 1.000 is dit elke 100. Bij minder dan 100.000 elke 5.000, en daarna elke 100.000. Op de lanceerdag ziet het hele team de eerste honderd verkopen één voor één binnenkomen, maar een jaar later wordt niemand gespamd. De berichten komen van een bot die we de "Milestone penguin" noemen.

Buckets in plaats van events

Het ontwerp is volledig afgestemd op de database (Firestore). Omdat Firestore geen joins heeft en beperkte queries ondersteunt, is het opslaan van elk event als een eigen rij te zwaar en te duur. Daarom worden analytics op de client gebundeld en op de server geaggregeerd in uurbundels en dagbundels (buckets). Eén rij per uur per item dat geteld wordt, met een lopend gemiddelde. Een dashboard voor een spel met duizenden dagelijkse spelers kost zo slechts een paar honderd Firestore-reads per uur.

De gekraakte build die de logs vergiftigde

Op een gegeven moment begon een gepirateerde build van Crash Drive 3 duizenden exceptions te versturen. De oplossing was één regel in de remote config: negeer elke PC-build onder versie 5000. Met remote config duurde dit vijf minuten. Dezelfde versie- en buildchecks bleken een goedkope manier om spelers met gemodificeerde executables, trainers en cheat-builds te spotten en te verbannen.

Drie crons voor elke taak

Elke geplande taak is drie keer geregistreerd, op minuut 00, 01 en 02. Elke run verwerkt de games waarvan de index overeenkomt met de minuut modulo drie. Eén grote cron zou leiden tot timeouts en load-spikes. Drie kleine crons houden de belasting vlak.

Vijf pogingen om kapotte JSON te herstellen

Clients sturen soms onbruikbare data: control characters uit stack traces, ongeldige UTF-8 uit apparaatnamen of backslashes uit Windows-paden. Het analytics-endpoint probeert vijf steeds agressievere reparaties voordat het opgeeft. Elke succesvolle reparatie post een melding naar mijn debug-kanaal, zodat ik productie kan debuggen zonder toegang tot de machine van de speler.

Cheaters, verbannen op basis van context

De cloud-auth call stuurt game-data mee: speeltijd, valuta, ontgrendelde items, aankopen. De server vergelijkt dit met wat fysiek mogelijk is. Minder dan een uur speeltijd maar alle auto's ontgrendeld? Directe ban. Omdat de regels in de backend leven, kunnen we ze dezelfde middag aanscherpen als er een nieuwe cheat verschijnt. De globale ban-lijst heeft inmiddels meer dan 130.000 vermeldingen.

---

De Unity platformlaag: elf modules, elf platforms

De backend was het startpunt, maar de platformlaag is waar het project echt rendeerde. Elke platform-SDK vraagt hetzelfde, maar in een andere vorm: een gebruiker aanmelden, een achievement ontgrendelen, een save schrijven, presence tonen of een sessie van een vriend joinen. De Unity-kant van de toolkit definieert één abstracte module voor elk van deze taken, met één implementatie per platform.

Vanuit de game-code ziet dat er zo uit:

// Ontgrendel een achievement, op welk platform deze build ook draait
AchievementsModule.SP.SubmitAchievementStatus(SystemModule.SP.PrimaryPlayer, "FirstStunt", 100, null);

// Tel iets op voor de community-totalen
M2HGDT.AddQueuedOnlineStat("pushedCacti", 1);

// Lees een waarde die we kunnen wijzigen zonder een patch
int secondsBetweenAds = OnlineSettings.GetInt("MinTimeBetweenAds", 240);

// Vertel het platform wat de speler nu doet
PresenceModule.SP.SetPresence(SystemModule.SP.PrimaryPlayer, PresenceDataCD3.GetData(scene, eventType));

De eerste regel wordt een Steam, Epic, Xbox, Game Center of Play Games achievement, of een PlayStation trophy, afhankelijk van de build. De tweede wordt gebundeld en als één teller opgeslagen. De derde is een getypeerde lookup in een settings-bestand. De vierde update de rich presence op ondersteunde platforms en doet niets waar dat niet mogelijk is. De game-code is op alle platforms identiek.

Dit maakte consoles niet automatisch "makkelijk" (certificering blijft certificering), maar het zorgde er wel voor dat elk platform slechts één keer uitgevonden hoefde te worden. Toen Crash Drive 3 op twaalf platforms tegelijk verscheen, was het grootste deel van het platformwerk al betaald door eerdere games.

Waarom het simpel bleef

PHP heeft geen build-stap. Firestore heeft geen schema. App Engine schaalt automatisch op en af. Eén entry-point routeert elk verzoek via de URL, en een per-game config overschrijft de defaults. Dat is de volledige architectuur.

Ik heb gezien hoe studio's van onze omvang tekstboek-cloudarchitecturen adopteerden met queues, containers en een service per onderdeel, om vervolgens al hun tijd te besteden aan het in stand houden daarvan in plaats van aan het maken van games. Een andere Unity-ontwikkelaar vertelde me over een backend die was ontworpen om oneindig te schalen, maar die minstens duizend euro per maand kostte bij nul spelers.

Elke abstractie die je toevoegt, is iets dat je om drie uur 's nachts moet begrijpen als een lancering misgaat. Ik wilde het hele systeem in een middag kunnen lezen, en dat kan ik nog steeds.

Twijfels en stabiliteit

Er waren momenten dat ik me afvroeg of het een fout was om dit zelf te bouwen. Unity kondigde remote settings aan, en andere bedrijven lanceerden hosted analytics-producten met mooiere dashboards. Overstappen zou makkelijk zijn geweest.

Maar toen werden die producten stopgezet, anders beprijsd of herbouwd, wat zou betekenen dat we onze integratie opnieuw moesten schrijven. Mijn backend veranderde nooit onverwacht, omdat niemand behalve ik hem kon wijzigen. Voor een kleine studio die elke twee jaar een spel uitbrengt en deze jarenlang ondersteunt, is die stabiliteit meer waard dan welke feature een hosted product dan ook kon bieden.

De overstap naar C# en Cloud Run

De afgelopen weken heb ik de lessen uit de PHP-jaren toegepast in een nieuwe C#-versie.

  • C# in plaats van PHP op Cloud Run: Elke game draait hier nu op. Het grootste voordeel is dat er aan beide kanten van de verbinding dezelfde taal wordt gebruikt. De request-code bevindt zich in een project dat de Unity-client deelt; een verkeerde veldnaam is nu een compile-fout in plaats van een lege waarde in productie.
  • Schoonmaak: Functies zoals polls, vouchers en de link-tracker, die ooit voor één specifiek spel waren gebouwd, zijn nu gegeneraliseerd.
  • Minder Photon: Het nieuwe Unity-pakket bevat geen directe Photon-referenties meer.

Ik heb de buckets, de schemaloze store, de redundante crons en de alerts per actieve gebruiker behouden. Dat zijn de redenen waarom het systeem standhield.

2026: De C#-versie

De rewrite duurde twee maanden. Het is één C#-server op Cloud Run tegen dezelfde Firestore-data. Cloud Run schaalt naar nul, net als App Engine, dus de kosten blijven gelijk. De URL's en JSON-structuren zijn hetzelfde gebleven, omdat een Switch-build uit 2021 waarschijnlijk geen update meer krijgt en het verschil niet mag merken.

Het admin-paneel heeft een redesign gekregen met functies die ik al jaren wilde:

  • Instellingen met historie en waarden per platform, build-range en taal.
  • Feature flags die uitgerold kunnen worden naar een percentage van de spelers.
  • Live events op een kalender.
  • Bugrapporten gegroepeerd per exception, inclusief de build die ze fixt.
  • Eén profiel per speler over alle games heen, inclusief cloud-saves en export-opties voor privacy-aanvragen.
  • Heatmaps waarbij posities per cel worden gesommeerd voordat ze worden verzonden, zodat de kosten laag blijven.

Tien jaar later

Ik ben trots op dit systeem. Het heeft zich bewezen bij elke lancering. Het wordt nog steeds dagelijks gebruikt door anderen: BlackMill Games, die de WW1-games nu beheren, draaien Verdun, Tannenberg en Isonzo erop, en hebben hun nieuwe game dit jaar ook op dit systeem gebouwd. Een backend die een eigenaarswissel overleeft, doet iets goed.

Elke game die we hebben draait nu op de C#-versie, en de volgende zal dat vanaf de eerste dag doen. Omdat veel kleine Unity-studio's hetzelfde platformprobleem steeds opnieuw oplossen: de C#-versie kan draaien in het Google Cloud-project van een andere studio, op hun eigen rekening, met hun eigen spelersdata.