I wrote a 3,600-line compatibility layer so that 30,716 lines of 2010 C++ would compile for iPhone unchanged.
I had a shoot-'em-up sitting on a disk, finished in 2010 and untouched since. It was written against a library that only ever ran on Windows and DirectX, has been discontinued for years, and has no source available. The obvious move was to rebuild it in a modern engine. I did something else: I replaced the layer underneath the game and left the game itself alone.
The app now on the App Store is running that 2010 code. This is what that took.
ANTI-ARES was finished in 2010, written in Visual C++ 2008. I built it as a Windows game and was satisfied with that. Handing out copies myself was the only distribution I could think of at the time, so it just stayed where it was. I did not decide against releasing it — I simply never got around to it.
Sixteen years later I decided to get it onto a phone.
The game was written against Dark GDK, a C++ game library from The Game Creators. It wrapped DirectX in a beginner-friendly API: you called functions like dbSprite() and dbPlaySound() and got a game. If you have not run into it, think of it as a procedural, C-style layer over DirectX aimed at hobbyists in the late 2000s.
It is a dead end three times over:
There is no path that carries Dark GDK itself to iOS. So there were only ever two options.
| Option | Lines to rewrite | |
|---|---|---|
| A | Rebuild the game in an engine (Unity or similar) | 30,716 |
| B | Replace the layer beneath the game | 0 |
I did consider A seriously. The art and the logic were both in hand, so rebuilding in Unity was not out of reach. Two things ruled it out.
The first is effort. Re-reading 30,000 lines while moving them into a different framework is not a spare-time job.
The second decided it: a rewrite gives you no guarantee that the game still feels the same. A shoot-'em-up lives or dies on a few pixels of hitbox, the exact step of a bullet's velocity, one frame of delay. If I rebuilt it and accumulated a hundred "close enough" decisions, and the result played differently — I would have no way to check. The only reference is my own memory of a game I wrote sixteen years ago.
A compatibility layer makes that problem disappear by construction. The code running is the same code, so the behaviour cannot drift. There is no fidelity work to do, because there is no gap to close.
So: B.
The replacement floor is SDL3. SDL gives you a window, a GPU-backed renderer, input, and (through SDL_mixer) audio, with the same API across Windows, macOS, Linux, iOS and Android. It is open source under the zlib licence.
The whole idea fits in one sentence. I put up the same sign Dark GDK had, with somebody else behind the counter.
dbSprite(id, x, y, img) and friends.DarkGDK.h — declarations (88 functions)DarkGDK.cpp — implemented on SDL3TouchPad.cpp — on-screen controls for iOS
shim/DarkGDK.h declares exactly the same names with exactly the same signatures. The game does #include "DarkGDK.h" and calls dbSprite(), and never finds out that DirectX became SDL3 underneath it.
Concretely: the ported game includes five headers and nothing else, and contains zero platform conditionals — no #ifdef __APPLE__ anywhere in the game code.
DarkGDK.h Data.h Jiki.h Sound.h TouchPad.h
Dark GDK is large. I implemented what this one game actually calls, and nothing else.
| Count | |
|---|---|
db* functions declared by the shim | 88 |
| — of those, reimplemented from Dark GDK | 81 |
| — of those, additions of my own | 7 |
Distinct db* functions the game calls | 81 |
This is the reason 3,600 lines was enough. It is not "a reimplementation of Dark GDK"; it is the minimum set that makes one specific game run.
This is the actual work. Matching names does not mean matching semantics.
Dark GDK is retained: you register a sprite by ID and the library keeps drawing it. SDL3 is immediate: every frame, you draw what you want in the order you want it.
So the shim keeps its own sprite table and sorts it by priority every frame before handing it to SDL. That is what makes dbSetSpritePriority() mean anything.
/* Use at() in the comparator. operator[] inserts when the key is
missing, and inserting during a sort triggers a rehash. */
std::sort(ids.begin(), ids.end(), [](int a, int b) {
const Sprite &sa = gSprites.at(a);
const Sprite &sb = gSprites.at(b);
if (sa.priority != sb.priority) return sa.priority < sb.priority;
return a < b;
});
Dark GDK does transparency with a colour key, and this game keys on pure green, (0,255,0). I handed that to SDL's colour-key support and green boxes showed up around the menu arrows on iOS.
The cause was in the assets. The BMPs are a mix of bit depths — 8, 16 and 24. In the 16-bit files, pure green had been rounded through RGB565/555 and came back as (0,252,0) or (0,248,0). An exact-match key does not catch that. On top of it, whether a colour key survives a surface conversion into alpha varies by platform and depth.
The fix was to stop delegating: convert to RGBA32, walk the pixels, and zero the alpha of anything within a tolerance of green. The game only ever uses pure green for transparency and has nothing else close to it, so there is no risk of punching holes in the art.
dbFlipSprite sounds like a vertical flip. It is a horizontal flip.
I worked this out from the title screen. The right-pointing menu arrow ▶ is built by applying dbFlipSprite() to the left-pointing ◀ sprite. ◀ is vertically symmetric, so a vertical flip would have changed nothing. The only operation that yields ▶ is a horizontal flip.
This matters beyond arrows: horizontal and vertical flips differ by a 180° rotation, so getting the axis wrong rotates every flipped terrain tile in the game.
wsprintf, caught by templateThe game calls Win32's wsprintf in 91 places. That does not exist on iOS either.
template <size_t N, typename... Args>
int wsprintf(char (&buf)[N], const char *fmt, Args... args) {
return std::snprintf(buf, N, fmt, args...);
}
Taking the buffer by array reference is the point. The size N is deduced at each call site, so forwarding to snprintf is safe. And if any call site had been passing a pointer instead of an array, it would fail to compile rather than silently truncating.
The nastiest part of a port like this is code that depends on incidental behaviour of the original implementation. I hit two.
dbSprite() must not change visibility. The obvious implementation makes a sprite visible — you are drawing it, after all. That broke the destructible hatches: the game hides a hatch with its own hide call after it is destroyed, then calls dbSprite() to keep updating its position. Making that call visible again resurrects hatches you already blew up. The game always calls the show function explicitly when it wants something visible — I checked all 193 call sites.
dbCloneSprite() can invalidate its own source. Written the obvious way, explosions stopped rendering entirely:
gSprites[iDestination] = *src; // dangling
Inserting a new key can rehash the unordered_map, which moves the element src points at, and the assignment then reads freed memory. The game clones an explosion sprite to a fresh ID every time one goes off, so this fires constantly. Copy to a value first, then insert.
TouchPad.cpp is 1,614 lines and is entirely new — the original is a keyboard game and there is no keyboard on a phone.
What it does is narrow: draw buttons, and turn touches into key states.
int dbKeyState(int iKey) {
PumpForInput();
if (ScriptHolding(iKey)) return 1;
/* Virtual keypad. The game cannot tell this from a real keyboard. */
if (TouchPad_IsKeyDown(iKey)) return 1;
...
}
The game asks for dbKeyState(44) — the Z key — and has no idea whether a keyboard or a thumb answered. That is why touch controls landed without a line of game code changing.
Layout lives in a text file and switches by context (title, in-game, paused, and so on), so the button set changes with the screen without the game knowing.
App Review rejected the first build with ITMS-90683:
Missing purpose string: NSCameraUsageDescription
Missing purpose string: NSBluetoothAlwaysUsageDescription
The game uses neither. The references came from statically linked SDL. Chasing symbols in the binary found them:
SDL_camera_coremedia.o references AVCaptureDevice → flagged as camerahid.o (HIDAPI) references CBCentralManager, pulling in CoreBluetooth → flagged as BluetoothApple flags the reference, not the use. Writing purpose strings for capabilities the app does not want seemed worse than not shipping them at all, so I built SDL with SDL_CAMERA=OFF and SDL_HIDAPI=OFF.
An unplanned benefit: building the macOS version under AddressSanitizer surfaced a buffer overrun that had been in the game for sixteen years.
global-buffer-overflow Jiki.cpp Jiki::BackGroundInit()
An initialisation loop ran 2,001 iterations over arrays sized 1,301 — a counter constant copied from a different subsystem, writing 700 elements past the end. It never surfaced on Windows and I never noticed.
Nothing to do with porting as such, but a decent argument for it: moving code to another environment shows you things the original one hid.
| Lines | |
|---|---|
| Original game code (2010) | 30,716 |
Compatibility layer DarkGDK.cpp / .h | 1,900 |
Touch controls TouchPad.cpp / .h | 1,700 |
| Total written | 3,600 |
3,600 lines bought me 30,716 lines that never had to change. It holds a steady 60fps on device.
Writing a compatibility layer is not automatically the answer. It worked here because of some specific conditions:
If the game had been deeply tied to a wide engine API, this would not have been worth it. But replacing the floor is sometimes both faster and more certain than rebuilding the house, and I think that option gets considered less often than it deserves. In my case it cost 3,600 lines.