Writing · 7 September 2026 · by Mike Hergaarden
The backend that runs every game we make, ten years and counting
Seven games, eleven platforms, one backend for crash reports, remote config, cloud auth and console ports. Built by one developer, in use for ten years, rewritten in C# in 2026, and something I enjoy setting up for other studios.
Every game I worked on in the last ten years talks to the same backend. Marooners, Verdun, Tannenberg, Isonzo, Crash Drive 2 and Crash Drive 3, and since this year a seventh game that another studio built on it. Steam, Epic, the Windows Store, PlayStation 4 and 5, Xbox One and Series, Switch, iOS, Android, WebGL. All of it reports to one Google App Engine project, written in PHP, backed by Firestore. We call it the GDT, short for Game Development Toolkit, and the name covers both the backend and the Unity package that talks to it. The PHP has done its ten years. Its replacement in C# is finished at the end of September 2026, and then our games move over to it. The story below is the PHP years, the screenshots are the new version, and the section near the end says what it adds.
It costs between 10 and 50 euros a month, depending on how many people are playing. That is for every game and every player at once, with the free mobile games bringing in most of the traffic. If nobody played, it would cost nothing. For the WW1 games it barely registered, because a paid game has far fewer players per euro of revenue than a free one.
I built it on my own, and the nice part is how little there has been to maintain. Almost never a forced upgrade because something stopped working. Google keeps old PHP runtimes going for a long time, and the few times I moved to a newer one, nothing broke. It just runs. Ten years in, I have not regretted keeping it boring.
It is also the piece of our work I have never been able to talk about. Players see the game and reviewers see the game, while the thing that made every launch and every port possible sits behind an admin login. I think it is one of the most valuable things I built, and I have wanted to show it around for years. So here it is.
| Games on it | 7, six of ours and one by another studio |
| Platforms | 11 |
| In use since | 2016, still running |
| Backend | PHP on Google App Engine, Firestore, about 20,000 lines; C# on Cloud Run from the end of September 2026 |
| Monthly cost | 10 to 50 euros, all games together |
| Built and maintained by | one developer |
| Other studios | Welcome: it runs in your own Google Cloud project, and I enjoy setting it up with you |
Where it started
Before this there were small PHP APIs, one per game, each doing a couple of things. Show live news in the main menu, count something, check a version. They were ugly and I kept writing them again from scratch. For a while I had wanted one reusable thing for all the online bits that make a game feel alive, but it never got further than the next small script.
What changed that was the Marooners console release. We were taking a party game made by a group of six friends to PlayStation 4, Xbox One and PC, and I was the porting team. Matt helped with testing and UI. Once the game left our hands I had no idea what happened inside it. On PC you get a Steam forum post when something breaks. On console you get silence, and later a certification failure.
The first attempt was Piwik with a Redis backend, set up by a freelancer from Upwork because I had no idea how Redis worked. I never got it running properly. It was too complex for what I needed, and every time something went wrong I was reading someone else’s architecture. So I threw it out and built my own on App Engine and Firestore, in PHP, with no schema and no framework.
I applied for WBSO, the Dutch R&D tax credit, for that rebuild. Financially it did not amount to much, most of it was still my own time and money. What it did was force me to write the thing down as a serious project, and once it was on paper as one, I treated it as one. The console deadline and the WBSO application together turned the pile of small scripts into one project, built properly this time.
A lot of it was built over a Christmas holiday. Because it was already working in Marooners by then, dropping it into Verdun right after the break took days rather than weeks. From the outside it looked like a huge amount of work had landed in Verdun at once, and someone on the WW1 team asked whether I had worked straight through the holidays. I had not. The work had been done earlier, for another game.
From there it grew one game at a time. The WW1 games were made by WW1 Game Series, the company Jos, Matt and I founded, with a team that grew to twenty-five over the years. Crash Drive 2 was me again, with Matt on testing and UI. Crash Drive 3 was four of us, for the game and the platform work together. Every release added a feature or a platform, and every feature was built so that the older games kept working. The API root is still called api_v1. The idea was that games already out there would keep calling version one forever, and a new game would start on an api_v2 folder the day backwards compatibility became impossible. That day never came.
What the backend does
People hear “analytics backend” and picture a dashboard with a line going up. It does a lot more than that, for teams of anywhere between two and twenty-five people. The list:
- Crash and error reporting. Exceptions, errors, crash dumps, bug reports with screenshots and log files. All of it shows up in the dashboard for that game.
- Cloud authentication. Steam tickets, Epic tokens, Windows Store, PSN. One endpoint, one user record per platform. Bans work per game or across all our games at once, which is very useful against trolls. A banned Steam player also gets a game ban that Steam shows on their profile, and I will admit that helping Steam out that way is satisfying.
- Remote config. Typed key-value settings per game that the client caches for a day. Toggle analytics, set minimum versions, schedule a cash boost weekend, change a balance number without a patch.

- Online stats. Global counters that only go up, so the community can see totals. That they only go up is on purpose, so that a tampered client cannot rewrite history. Crash Drive 3 players have driven 149 million kilometers, drifted 35 million of them, and pushed 38 million barrels off cliffs.
- DLC and ownership checks for Steam and Epic, so the game does not have to trust the client.
- Friends, invites and sessions across platforms, with Photon webhooks underneath.
- In-game polls, vouchers, key sets per platform for press and testers, and a link tracker: one short link per store and per channel, with clicks and, because the game reports back, actual conversions. That is how we learned that a link in Discord converts around ten times better than the same link on Twitter.
- Builds. Jenkins is wired into the dashboard. One button starts a build on the build PC, the result lands on the Steam latest branch by itself, and another button moves it to the public branch. Nobody has to touch SteamPipe by hand.
- Discord. New reviews, curator mentions, sales milestones, crash spikes, cheaters, new builds, commits. The team lives in Discord, so the backend posts there. Every Steam review lands in a channel under the reviewer’s name, with their playtime and whether Steam counts it toward the score. A player with 210 hours in Crash Drive 2 wrote “i truly miss playing this game!” and the whole team saw it within the hour. The same goes for a negative review, which means we can respond almost immediately.

- Closed testing. For the WW1 games and again for Crash Drive 3, we invited players who already owned an earlier game of ours to test the next one. The backend checks on Steam that they own it and have played it enough, sends them to HelloSign for the NDA and checks by itself that it came back signed, gives them the tester role in Discord, and hands out a Steam key. Nobody on our side touches any of it. Hundreds of testers without a single spreadsheet.


- A commit history page, first SVN and later Git, fed by a post-commit hook, and integrations with Favro, Trello and UserEcho. The backend already knew everything, so it was cheaper to put those there than anywhere else.
All of that is roughly 20,000 lines of PHP, and this is how it fits together:

The dashboard for Crash Drive 3, four years after launch. Of the last three hundred players to sign in, four in ten were on Android, a quarter on iPhone, a quarter on Switch. That mix is why we ship everywhere: the people who paid on Steam or Switch always have someone to drive into.
The decisions that mattered
Alert on the rate, not the count
The most useful rule in the whole system: the crash alert does not look at the number of exceptions. It fires when exceptions per active user go over a threshold, currently 0.05, or when errors per session go over 1.5. On launch day that means thousands of players before anything triggers. Two years later, with a few hundred people online, the same rule still catches a broken patch within the hour. I have never had to retune it.
What this bought us, over and over, is speed on mysterious bugs. A crash that only happens on one platform, on one build, for a handful of players, shows up in the logs page with a screenshot and a log file within the hour. We fix it, press build in the dashboard, the build lands on the Steam latest branch, we press once more to make it public, and the alert goes quiet.

Sales milestones that scale with the game
Under 100 owners the backend pings Discord every 10 sales. Under 1,000, every 100. Under 100,000, every 5,000. After that, every 100,000. On a launch day the whole team watches the first hundred sales come in one at a time, and a year later nobody is being spammed. The messages come from a bot we call the Milestone penguin, and the penguin still visits: Crash Drive 2 passed 2.95 million unique players in March 2026. That number only counts players since the backend arrived. Crash Drive 2 had already done over 100 million plays on the web and over ten million Android downloads before then, when all I had was a store dashboard and a guess. I did not expect a small feature like this to do so much for morale.

Buckets, not events
The whole design bends around the limits of the database. It was called Datastore when I started and Firestore later, and either way it is not MySQL: no joins, limited queries, and a cap on how much you can read or write in one go. So analytics are batched on the client and aggregated on the server into hourly and daily buckets with a running average. A dashboard for a game with thousands of daily players is a few hundred Firestore reads, cached hourly in a bucket file.
I did miss the freedom of a real query language now and then. But if you plan the buckets ahead of time you rarely need it, and the trade is what keeps App Engine on one small instance and the bill tied to the player count.
The cracked build that poisoned the logs
At some point a pirated Crash Drive 3 build started submitting exceptions by the thousands. Not an attack, just a broken crack crashing on machines around the world. The fix was one config line: ignore any PC build below version 5000. The same line keeps those builds out of online play, because the cloud auth call runs through the same check. I had not planned for piracy as a data quality problem, but with remote config it took five minutes, and the cracked version lost multiplayer at the same time.
Three crons for every job
Every scheduled job is registered three times, at minute 00, 01 and 02, and each run takes the games whose index matches its minute modulo three. One cron that walks every game in a row would run into timeouts and a load spike each hour. Three small ones, each with a third of the games, finish comfortably and keep the instance count flat. It looks like a copy-paste mistake in the config file, and it is my kind of solution: one modulo, and no queue or scheduler to keep alive. With twice the games it would be modulo six, and nothing else would change.
Five attempts to repair broken JSON
Clients send garbage. Control characters from stack traces, invalid UTF-8 from device names, backslashes from Windows file paths. Then there is the traffic that was changed on the way: parental filters that rewrite the request body, and countries where the connection gets intercepted or blocked outright, Iran being the most memorable one. The analytics endpoint tries five increasingly aggressive repairs before giving up, and each successful repair posts a note to my debug channel saying which one it was. So I can debug production without ever touching a player’s machine.
Cheaters, banned on context
The cloud auth call also sends along a bit of game data: playtime, currency, unlocked items, purchases. The server compares those against what is physically possible. Someone who has played for under an hour and already owns every car, or holds ten million in-game cash without a single purchase, gets an instant ban. Because the rules live in the backend, we can tighten them the same afternoon a new cheat shows up.
A ban can be for one game or for all of them. A blocked device stays blocked across every game we make, so we do not spend our lives chasing the same people from title to title. The global ban list has over 130,000 entries by now, nearly 10,000 of them from Crash Drive 3 alone. Reacting fast has mattered more against cheaters than any detection scheme I could have built.
The server assumes every client can lie. It checks everything itself, and the counters only go up.
The Unity platform layer: eleven modules, eleven platforms
The backend is why I started. The platform layer is why the whole thing paid off.
Every platform SDK wants the same things from you in a different shape: sign in a user, unlock an achievement, write a save, show presence, join a friend’s session. The Unity side of the toolkit defines one abstract module for each of those, and one implementation per platform.
From the game’s side it looks like this. These four lines are taken from Crash Drive 3 as it ships:
// Unlock an achievement, on whatever platform this build runs on
AchievementsModule.SP.SubmitAchievementStatus(SystemModule.SP.PrimaryPlayer, "FirstStunt", 100, null);
// Count something for the community totals
M2HGDT.AddQueuedOnlineStat("pushedCacti", 1);
// Read a value we can change without a patch
int secondsBetweenAds = OnlineSettings.GetInt("MinTimeBetweenAds", 240);
// Tell the platform what the player is doing right now
PresenceModule.SP.SetPresence(SystemModule.SP.PrimaryPlayer, PresenceDataCD3.GetData(scene, eventType));
The first line becomes a Steam achievement, an Epic achievement, an Xbox achievement, a PlayStation trophy, a Game Center achievement or a Play Games achievement, depending on the build. It is queued, sent once, retried up to three times, and never resubmitted with lower progress in the same session. The second line is batched with everything else the client wants to report and lands in the backend as one increase-only counter, which is where the 38 million barrels come from. The third line is a typed lookup in a settings file the client refreshed from the backend that day, with a default for a client that has never been online. The fourth line updates rich presence on Steam, Epic, Xbox and PlayStation, and does nothing on the platforms that do not have it. The game code is the same on all of them.
The game implements one abstract class that answers the questions the toolkit cannot: are we in the main menu, show this popup, is the player loading a level, join this session. With that, the toolkit handles console engagement screens, controller-to-user mapping, suspend and resume, cross-play invites and PSN activities on its own.
This did not make consoles easy. Certification is still certification, and the paperwork does not care about your architecture. What it did was make sure each complex platform only had to be figured out once. I did the Marooners ports to PlayStation 4, Xbox One and PC alone. For the first Switch port and the first Xbox Series port we brought in an outside company, once each, mostly to save time. Every port after that, on every game, we did ourselves. When Crash Drive 3 shipped on twelve platforms on the same day, most of the platform work had already been paid for by an earlier game.
Releasing something roughly every year helped too. Each release only needed one platform brought up to date, instead of all of them at once. The work moved from the technology to the rules: the certification checklist, the store requirements, the things that change between platform holders and over time.
Why it stayed simple
The system works because it is simple. PHP has no build step. Firestore has no schema. App Engine adds an instance when it gets busy and removes it when it does not. One index.php routes every request by looking at the URL, and a per-game config file overrides a default config file. That is the entire architecture.
I have watched studios our size adopt textbook cloud architectures with queues, containers and a service per concern, and then spend their time keeping that alive instead of shipping. A Dutch Unity developer once told me about the multiplayer backend he had built with advice from one of the big cloud vendors. It was designed to scale to anything. It also cost at least a thousand euros a month at zero players, twenty times what ours costs with thousands of them online. The game could not afford to exist while it waited for its audience.
Every abstraction you add is something you have to understand at three in the morning when a launch goes wrong, and something you pay for every month whether or not anyone is playing. I wanted to be able to read the whole system in an afternoon, and I still can.
Simplicity is also what made the growth possible. Adding a feature was a new file in actions/ and a new else if. Adding a game was a config file. Adding a platform was a folder of modules. Nothing ever needed a migration.
The doubt, and how it aged
There were moments I wondered whether building this myself was a mistake. Unity announced remote settings. Other companies launched hosted analytics and config products with nicer dashboards than mine. Switching would have been easy, and I remember thinking I might just be stubborn.
Then those products got retired, or repriced, or rebuilt in a way that would have meant rewriting our integration. Each time I was glad I had not leaned on them. My backend never changed underneath us, because nobody but me could change it. For a small studio that ships a game every year or two and supports each one for years after, that stability is worth more than any feature a hosted product could have given us.
What I would do differently
Less than you might think.
- C# instead of PHP, on Cloud Run instead of App Engine. Almost done, live at the end of this month. One language on both ends of the wire is the real gain: the request code sits in a project the Unity client shares, so a wrong field name is a compile error instead of an empty value in production. Ten years of switching between C# in the game and PHP in the backend was enough. PHP itself was the right call for those years: no build step, runtimes Google kept alive, nothing to babysit. The switch is about one language, not about PHP falling short.
- A cleanup pass on the rougher features. Polls, vouchers and the link tracker worked, but they were built for one game’s need and never generalized. The rewrite was the moment to do that, and they got it, together with a list of things I had wanted for years. More on that below.
- Less Photon in the core. The analytics and time sync leaned on Photon because every game we made used it. That was a habit. The new Unity package has no Photon reference; a game that wants ping statistics in the dashboard hands the package a small adapter.
What I kept: the buckets, the schemaless store, the redundant crons, the alert per active user, and a bill that follows the players. Those are the reasons it lasted, and they are all in the C# version.
2026: the C# version
The rewrite took the summer of 2026 and is finished at the end of September, when our games move over. It is one C# server on Cloud Run against the same Firestore data. Cloud Run scales to zero like App Engine does, so the bill behaves the same. The shape stayed what it was, one entry point, a file per action, a config per game, and so did the URLs and the JSON, because a Switch build from 2021 is not going to get an update and must not notice the difference. The old clients run against the new server unchanged. A comparison tool checks every read-only answer against the PHP, a replay tool plays the live traffic mix of a game against the test game, and a smoke test opens every admin page before a deploy. New games get a second, signed API next to it, and a Unity package that shares its request code with the server.
The package reads the way the old one did: GDTOnlineSettings.GetInt("MinTimeBetweenAds", 240) for the Crash Drive 3 line above, and flags, live events and analytics are the same kind of one-liner, GDTFeatureFlags.IsOn("newGarage") and GDTAnalytics.TrackEvent("Gameplay", "MatchEnd", map, seconds). Nothing in game code holds a reference to the client. Mobile builds can hold every analytics call until the player agreed. Consoles plug their storage and browser in through one small interface, so the package never references a console SDK and can live in a public repository. The editor window checks the connection, refreshes the settings cache a build ships with, draws a heatmap in the Scene view and writes the analytics call for you.
The admin got the redesign it was time for. There is a search box that finds a player, a setting, a code or a bug group in one go, a bell with what changed since you last looked, a dark mode, and a layout that works on a phone. The rest, as a list:
- Settings keep a history per key with one click to put an old value back. A value can differ per platform, build range and language: Gallipoli does well in Turkey, so the message of the day can say so in Turkish. A change can be scheduled for a moment in the future.
- Feature flags roll out to a percentage of players, stable per player, per platform if you want. A development build sees what a scheduled change will do before it happens.
- Live events on a calendar: a start, an end, a message and the settings that apply while it runs.
- Bug reports are grouped by exception and stack. A group has a status, the build that fixes it and a note, and a report from a newer build than the fix marks it regressed on its own.
- Players have one profile: every game they are in, a timeline of what we know, team notes, a message to the game’s mailbox with an optional reward, bans, cloud saves with the last five versions to put back, purchases. An export and a delete for privacy requests.
- Leaderboards with per-platform groups, so a mobile score never sits next to a PC score. In-app purchases are checked with Apple and Google before the game hands anything out.
- Analytics got an explanation on the page, a picker, and saved views, so a chart someone found useful is one click for the next person. Players per country come from the header Cloudflare adds, at no extra cost. And a wizard: pick what you want to measure (match length per map, where new players stop, what sells, which levels are too hard, whether a feature gets used), fill in the names your game uses, and it writes the Unity call and saves the view that reads the answer. The same wizard sits in the Unity editor.
- Heatmaps. The game sends a name and a rounded position,
deathsat an x and z, and the admin draws where it happens; in Unity the same grid lands in the Scene view on top of the map. Hosted analytics products offered this once and dropped it, which is the story of every hosted product we leaned on. The cost stays flat: positions are added up per cell before they are sent and per day on the server, one record per heatmap per day, and past a cap the server keeps a share of the samples and scales the counts back up, the way Google Analytics sampled. With few players nothing is dropped, so a test build shows every point. - Polls were rebuilt: a status, results per answer, a preview of what the player gets, and a poll window in the Unity sample that works as is.
- Alerts are configurable per game and post to Discord or a webhook. Other systems get their own API token instead of a shared key, and every call with a wrong key is listed, so a stale key shows up right after a change.
- A portfolio page across games, an audit log of every admin change, a storage page with what costs money, and a crons page that says which scheduled job ran late.
- Roles and help. Admins see everything, team members the pages for their games, moderators only the players. Every page has a fold with the Unity code that goes with it, and the documentation lives in the admin itself, so a new team member does not need me.





These screenshots show the test game, with made-up numbers.
Ten years on
I am proud of this thing. It has proven itself on every launch we did, and it is still in daily use by people other than me: BlackMill Games, who make the WW1 games now, still run Verdun, Tannenberg and Isonzo on it, and when they built their new game this year they decided to keep it rather than replace it. A backend that survives a change of owner is doing something right.
I would also like to see it run for more than our own games. Every small Unity studio I talk to solves the same platform problem again from scratch, and it would be nice if some of this work saved someone that year. With the C# version that is a real option. It runs in your own Google Cloud project, on your own bill of tens of euros a month, with your players’ data in your account and nobody else’s. You get the server, the admin and the Unity package, and I set it up with you. If that is you, I would enjoy hearing about it. You know where to find me.
Either way, the next game I make runs on the C# version from the first day, and at the end of this month so do the games we already have.
Who wrote this
I’m Mike Hergaarden, founder of M2H. I’ve been building multiplayer games since 2004, co-created Photon Unity Networking with Exit Games, and with the M2H team shipped Crash Drive 3 across twelve platforms with everyone playing in one world. The technical side is the part of the job I like most.
More writing
- Shipping to twelve platforms on one day September 2026
- Optimizing performance of Unity games July 2021
- The server side of game development January 2012
Get the next piece by email
This is the writing list: pieces like this one, a few times a year, when there is something worth writing down. Game news is a separate list. No schedule, no filler, and you can leave in one click.
No inbox? Follow along on LinkedIn, or put the RSS feed in your reader.