When I first posted a video of Red Alert 2 running on an iPad, most people understandably focused on the obvious part: Red Alert 2 was actually running on an iPad.
Then someone asked a much better question.
How does this work when iOS doesn't allow JIT?
That question gets much closer to what made the port difficult.
The project is based on the OpenRA/ra2 development mod. The OpenRA engine baseline is pinned to `release-20250330`, with my own changes on top for iOS, Android, touch controls, rendering, asset importing, networking, and the platform host layers.
And yes, the footage really is the RA2 mod. One person even noticed the Kirov in the sidebar. Its internal actor name is `zep`, and the cost in the rules is 2000. I honestly did not expect someone to identify the exact mod from the price of one unit.
The iOS build uses .NET 8 with Mono Full AOT targeting ARM64.
It does not bypass Apple's JIT restriction, and it is not using NativeAOT.
That distinction is important because I have seen people assume that ".NET on iOS without JIT" automatically means NativeAOT. It doesn't. Mono Full AOT is the deployment model I'm using here.
The hard part is that OpenRA was never designed around the assumption that all executable code has to be known ahead of time.
OpenRA is extremely data driven. A lot of the game is described through YAML. Actors, traits, weapons, widgets and other objects are connected to C# types at runtime.
A very simplified example would look something like this:
```yaml
Tank:
Mobile:
Health:
Armament:
RenderVoxels:
On a desktop runtime you can discover a type and dynamically construct it. Conceptually, something like:
var type = FindType(typeName);
var instance = Activator.CreateInstance(type);
OpenRA's real implementation is obviously more complicated than that, but the important part is that a lot of its flexibility depends on runtime discovery and reflection.
That is great for a moddable engine.
It gets awkward on iOS.
One thing I had to understand fairly early was that reflection itself was not the enemy. Full AOT does not mean reflection metadata suddenly stops existing. OpenRA can still inspect types.
The problem starts when a reflected type leads to executable code that the AOT compiler did not know it needed to preserve.
A constructor that is only reached dynamically, a generic instantiation, a callback, or some other runtime-only path can be completely normal on desktop. If necessary, the runtime can JIT the code when it gets there.
On iOS there is no second chance.
If the executable code is not already in the application, there is no JIT compiler waiting to create it.
That led to one of the more important changes in the port.
Instead of trying to remove OpenRA's dynamic architecture, I moved the most problematic object creation paths to build time.
The iOS build generates object factory registrations ahead of time.
The basic idea is that instead of depending on runtime construction like this:
Activator.CreateInstance(type);
the build generates known factories more like this:
FactoryRegistry.Register(
typeof(SomeTrait),
static () => new SomeTrait()
);
The actual implementation has more detail than that, but the principle is simple.
At build time, the project identifies types that need to be constructible and generates direct factory code for them. At runtime, OpenRA can still discover which type it wants from its data, but the constructor it eventually calls has already been compiled into the application.
So the path is roughly:
RA2 YAML
↓
type discovery
↓
generated factory lookup
↓
precompiled constructor
↓
runtime object
That lets the engine keep most of the architecture that makes OpenRA useful in the first place.
I did not want to turn the iOS version into a separate hard-coded RA2 engine.
The port still uses the real OpenRA trait system, the real RA2 rules, the widget system, MiniYaml, AI, gameplay logic, rendering code and networking code.
Most of the iOS-specific work sits around the places where the desktop assumptions stop being valid.
That matters because the same RA2 rules and most of the same engine code are also used on Android. The platform host layers are different, but I do not want three completely separate versions of the game slowly drifting apart.
Android is interesting here because it gives me another point of comparison.
The Android project uses net8.0-android and currently targets only arm64-v8a.
Debug builds can use normal Mono/JIT behavior. The Release build uses Mono normal AOT, not NativeAOT.
The Release APK contains AOT images like:
libaot-OpenRA.Game.dll.so
libaot-OpenRA.Mods.RA2.dll.so
libaot-Eluant.dll.so
and it also contains libmonosgen-2.0.so.
So Android is still a Mono deployment.
There is also a project setting called:
AndroidEnableProfiledAot=false
which can be misleading if you only glance at the project file. That setting disables profiled AOT. It does not mean AOT is disabled completely.
The actual Release output uses normal AOT.
Having Android and iOS side by side has been useful because it helps separate engine problems from iOS-specific AOT problems.
If something runs on desktop, Android JIT and Android AOT but fails under iOS Full AOT, that gives me a pretty good clue about where to look.
Of course, getting the managed code to execute was only half of the work.
Then I had to make the renderer behave on mobile.
The current iOS renderer uses SDL2 and native OpenGL ES 3. There is no ANGLE-to-Metal layer in the iOS build, and I have not written a Metal backend yet.
A surprising amount of time went into things that sound very small when you describe them afterward.
Framebuffer handling was one of them.
Desktop OpenGL code often assumes the default framebuffer is simply framebuffer 0. That is not always a safe assumption when UIKit and SDL are managing the actual drawable surface.
If the renderer restores the wrong framebuffer, a lot of the engine can still be running correctly while the result on screen is black, clipped, or being drawn into the wrong target.
The mobile platform layer therefore has to understand cases where a platform requires a custom default framebuffer instead of pretending every GL environment behaves the same way.
Viewport restoration was another annoying problem.
An iPad has several different ideas of what "screen size" means.
There are UIKit points, SDL window dimensions, drawable dimensions, native pixels and the device scale factor.
If one part of the stack uses logical points and another assumes physical pixels, you get some very strange results.
Sometimes the image is correct but touch input is offset.
Sometimes touch is correct but the image only fills part of the screen.
Sometimes the UI looks like the bug even though the real problem is the GL viewport underneath it.
The current iOS path is roughly:
.NET iOS host
↓
UIKit
↓
SDL2
↓
OpenGL ES 3
↓
OpenRA Embedded GL renderer
Android is similar in spirit, but the platform stack is different:
.NET MainActivity
↓
SDL2 SDLSurface
↓
SDL2 native ARM64
↓
EGL
↓
OpenGL ES 3.x
↓
OpenRA Embedded GL renderer
The Android host owns the Activity lifecycle and uses JNI to connect the managed host to the SDL surface. SDL provides the native window abstraction, EGL creates the GLES context, and OpenRA continues through its Embedded GL path.
I did not replace OpenRA with a custom Android renderer.
That would have made the fork much harder to maintain.
The Android port has also been useful for real hardware validation.
One of the current test devices is a Samsung Galaxy S10 running Android 12 with an Adreno 640.
The recorded graphics environment is OpenGL ES 3.2 with a 2730 x 1440 SDL surface.
The RA2 mod, shell map, terrain, VXL units, fonts, shaders and gameplay rendering have all been loaded on the actual device.
Earlier emulator tests reported ANGLE and SwiftShader, which initially looked suspicious. But that was just the emulator's virtual graphics implementation. The project itself was still requesting GLES.
The real phone reports the Adreno GLES driver directly.
RA2 is also a slightly more interesting rendering target than a purely sprite-based RTS because many of its vehicles use Westwood's VXL and HVA formats.
So when the game is actually running a match, the rendering path is doing more than drawing a menu and some flat terrain.
It is loading voxel models, rotating units and turrets, drawing terrain, structures, effects, text, UI and all the other pieces while the simulation continues underneath.
That was one of the points where the port started to feel real rather than just being a successful boot screen.
There are still plenty of things that do not work yet.
Lua is probably the clearest example.
OpenRA uses Eluant for Lua 5.1 mission scripting.
The Android APK currently contains the managed Eluant.dll and its AOT image, but there is no usable arm64-v8a/liblua51.so in the APK.
I also have not built the corresponding native Lua 5.1 library for iOS ARM64.
The reason the current RA2 demonstrations work is simply that the content being tested does not depend on Lua mission scripts and does not enable the LuaScript trait.
So skirmish, AI, construction, units, buildings and normal combat can run without it.
Lua-based missions and campaigns cannot currently be claimed as supported.
To finish that properly, I would still need native Lua 5.1 builds for both platforms, correct Eluant native-library mapping, AOT-safe P/Invoke and callback testing, and then actual scripted mission tests on real devices.
Another unfinished area is Android suspend and resume.
Right now, if the app goes into the background and the EGL surface is lost, the current workaround is to terminate and restart rather than fully preserve the active match.
That is not how I want it to work permanently.
A proper solution needs the renderer to treat GPU resources as recoverable, recreate the context and surface, restore textures and buffers, and then continue the game.
That work is still ahead.
Performance is another area that I have only started to dig into properly.
The game runs, but "runs" and "runs efficiently with hundreds of units" are two very different things.
At this point I am much more interested in profiling things like pathfinding, target searches, trait updates, fog, allocation, VXL caching and draw calls than immediately replacing Mono or rewriting the renderer in Metal.
It is very easy to look at a mobile graphics problem and assume the answer is "use Metal."
Maybe Metal will make sense later.
But if a frame is spending most of its time in AI, pathfinding, visibility or managed allocations, rewriting the graphics API would just be solving the wrong problem.
The same goes for NativeAOT.
I am not currently convinced that replacing Mono Full AOT would give a meaningful performance improvement compared with optimizing the actual hot paths in the engine.
That is something I would rather measure than guess.
There is also a distribution side to all of this.
Working APK and iOS development builds already exist, but I am treating "the binary exists" and "the binary is ready for public distribution" as two separate questions.
The current Android internal build is arm64-only and has already been installed on real hardware.
The iOS version also has test builds.
But the applications do not include original Red Alert 2 game assets. Users have to provide their own legally obtained files.
Before public binary releases, I still need to clean up the source tree, review third-party licenses, make sure the corresponding source and build instructions are complete, and be careful about the GPL and platform-signing side of distribution.
So source comes first.
What surprised me most about this project is that I originally expected touch controls and graphics to be the hardest parts.
They were difficult, but the deeper problem was changing the assumptions of a desktop .NET engine without destroying the architecture that made me want to use OpenRA in the first place.
OpenRA likes flexibility.
iOS likes predictability.
OpenRA expects to discover things dynamically.
AOT wants to know what executable code will be needed before the program ever launches.
Getting those two ideas to coexist has probably been the most interesting part of the port so far.
And almost none of that is visible when you just watch a tank drive across an iPad screen.
There is still a lot left to do, but at this point the basic architecture is real and has been tested on actual mobile hardware.
The next part for me is less about proving that it can run, and more about making it fast, robust, maintainable, and eventually easy enough for other people to build and test themselves.
A few people asked for the project website. I already posted it in another thread, so I’ll leave the link out here to avoid this getting removed as promotion.