Offlining a Live Game With .NET Native AOT
Introduction
When it comes to software engineering, I’d like to think of myself as a generalist. Still, over my 12-year career, a major focus has been building scalable backends. I’ve worked at Amazon and Twitch to build out large-scale systems that support millions of users.
For the last five years I have been working on Towerborne, an action RPG brawler developed by Stoic Studio and published by Xbox Game Studios. Towerborne launched on Steam in early access on September 10th, 2024 and then on Xbox in game preview on April 29th, 2025. On February 26th, 2026, Towerborne had its full 1.0 release on Steam, Xbox, and PS5.
I was hired at Stoic as a backend engineer to support the development of the persistent live components of Towerborne. Over the course of my time here, I’ve worked to grow both the backend infrastructure as well as the backend team itself.
The full 1.0 release of Towerborne shipped with zero backend. No live services. No databases. No cloud infrastructure. The game is fully functional offline.
This is the story of how years of backend service code ended up shipping inside the game itself.
Towerborne: The Live-Service Game
Towerborne was originally envisioned as an always-online MMO-lite action RPG brawler. Players would group together with their friends in a shared social space known as The Belfry. From there, players would venture out to the hexagonal world map and enter into 2.5D side-scrolling brawler combat missions. Loot, quests, and an ever-expanding world map provide a compelling "just one more" gameplay loop. The live-service structure meant the game would be ever-changing and expanding with new content and features continuously releasing over time.
The Towerborne Backend
As the founding member of the backend team, I worked to establish the underlying technical architecture that powers the persistent live components of the game. As the backend team grew, we built numerous C# microservices running in Kubernetes hosted on Azure. Viewing this as a long-term live-service game, we designed our systems with that in mind. Multiple region-aware matchmaking flows. An internal web portal for customer support. Player reporting and moderation systems. Cross-platform account linking. Login queues. Extensive load testing. The list goes on and on.
A key principle of the live-service design is server-authoritative data. This means that persistent player data needs to be stored in a remote database, not the player’s local machine. One especially heavy piece of our backend architecture that made this possible was the inventory service. The term “inventory” here is a bit overloaded given just how many of the game’s systems use it. In Towerborne nearly every piece of persistent player data is part of the inventory. Beyond traditional inventory items like weapons and gear, the inventory also includes stats, quest state, conversation history, achievement progress, and more.
Individual gameplay features are implemented as API calls from the Unreal game client to our backend with the actual logic living exclusively in the C# microservice codebase. The service receives a request to perform a particular action on a specific player’s inventory. The service fetches the inventory from the Azure Cosmos database, confirms that the requested action is valid, modifies the inventory appropriately, persists the updated inventory back to the database, and returns a response to the game client that includes a list of all item changes as well as any other side effects that may have occurred from the action.
Much of this is powered by a powerful custom conditional language that we created. Designers use this language throughout the game’s systems to configure when and how different parts of the game should change based on player actions that flow through the backend services. This includes the entirety of the game’s quest progression system, defining what actions the player needs to take to progress through the campaign.
A Specific Inventory Example
Let’s take a specific example, dismantling items at the forge. Players interact with the forge and choose individual pieces of gear to dismantle, converting them into a different resource called spirit dust.
To help introduce this system to players, an early game quest objective requires players to dismantle a specific item, the “Aspect of Dismantling”. This quest objective is configured using the conditional language like this:
self.Dismantle{DismantleItemOnyxId:208242625956810752} > 0This indicates that the dismantle stat, filtered to only dismantles of that specific item id, must be greater than 0. When this condition is true, the quest objective will be complete.
After selecting "Dismantle", the Unreal game client makes an HTTP request to the inventory service's dismantle endpoint. The body contains a JSON payload indicating the specific item being dismantled.
POST https://{host}/api/v1.0/forge/inventories/{accountId}:{inventoryId}/bulkDismantle{
"items": [
{
"itemId": "66c66152-0ac8-41cd-a450-2ee827767e8a",
"count": 1
}
]
}After processing the request and updating the inventory in the database, the JSON response body returned to the client includes all the results of the operation.
{
"playerUpdates": {
"inventoryId": "bf4ec3fa-0cc6-4962-af1b-9ae9a03e1091:452df4b0-88b3-4192-94a6-3d489a634315",
"updatedInventoryETag": "580013e3-0000-0800-0000-698294bf0000",
"previousInventoryETag": "5800c7d6-0000-0800-0000-6982949c0000",
"updatedActiveQuests": [
{
"id": "83be0101-0565-4e26-84ba-dcca600fb761",
"completed": false,
"itemName": "QST_FTUE2",
"objectives": [
{
"objectiveId": "205789981380186112",
"status": "Complete",
"stats": []
},
{
"objectiveId": "205789994357362688",
"status": "Incomplete",
"stats": [
{
"name": "Enhance",
"dimensions": [],
"currentValue": 0,
"targetValue": 0,
"comparison": ">"
}
]
}
],
"allObjectivesCompleted": false,
"dynamicData": {
"Dismantle{DismantleItemOnyxId:208242625956810752}": 1,
"Enhance": 0
},
"pinned": false,
"questStatus": "Active"
}
],
"inventoryItemChanges": {
"itemsAddedOrUpdated": [
{
"itemName": "Resource_Dismantle_SpiritDust_1",
"onyxId": "80479155036098560",
"countDelta": 140
}
],
"itemsRemoved": [
{
"itemId": "66c66152-0ac8-41cd-a450-2ee827767e8a",
"itemName": "Aspect_T01_Uncommon_Diamond_Dismantle",
"onyxId": "208242625956810752",
"countDelta": -1
}
]
}
}
}This JSON response shows the removal of the aspect item, the addition of the spirit dust, and the updated quest status. The quest objective that requires this item to be dismantled is now complete. The one remaining quest objective requires the player to enhance any item. The dynamic data on the quest item shows the corresponding stat tracking with the single specific item now dismantled and zero items enhanced.
This is just one example out of many complex core gameplay systems that live in the Towerborne backend. Over many years of building out the live-service game, these systems have been iterated on and tested repeatedly. During this time we built up a comprehensive suite of automated testing including unit, integration, and functional tests that help us pin down the exact functionality and edge cases of all these interlinking systems.
The Pivot
As Towerborne went live in Steam early access and Xbox game preview, we scaled up backend operations. We performed extensive load testing and formalized a backend on-call complete with a robust suite of dashboards, alarms, and runbooks. We monitored how players interacted with the game and continued to build out new services and features.
Last year, I learned some surprising news. We would be pivoting from an always-online free-to-play live-service model to a buy-once premium model with no backend components and full offline support. Moreover, we would only have about 6 months to complete this. As the backend lead, this was a lot to take in. I knew this wasn't just a matter of turning the services off. Those services contained core gameplay logic that was never intended to run on the client. I took the rest of the day off and spent some time thinking through the difficult challenges ahead.
Potential Solutions
As the dismantle example above demonstrates, there’s a significant amount of core gameplay systems where the underlying logic is contained exclusively in the backend. The Unreal game client knows nothing about how item dismantle operations work, only how to prepare the backend HTTP request and interpret the corresponding response.
The most obvious solution here was to rewrite each of these backend C# systems as Unreal C++ code. This would be an incredibly risky undertaking. There were hundreds of backend APIs that needed to be converted like this. Furthermore, each of these APIs relied on complex interlocking logic systems powered by the aforementioned custom conditional language. The C++ code would also need to be able to parse and understand this language to support all the existing content. Without our established C# test suite, it would be extremely tricky to pin down functionality and make sure every edge case was accounted for. Was this even possible in just 6 months?
What about other solutions? In the era of Docker we are primed to think about portability. Surely we could find a solution to directly leverage our existing C# codebase. What about running the services locally on specific ports? That won’t work on consoles. What about C# to C++ solutions like Unity’s IL2CPP? Proprietary and closed source. None of the immediately obvious solutions were viable here.
.NET Native AOT
Eventually my research led me to .NET Native AOT. Normally C# gets compiled into an intermediate language that only gets compiled down to platform-native code on-demand via the common language runtime. However, through Native AOT, a C# project can be directly compiled into platform-native code. This seems promising, but there’s a major problem. Native AOT is only officially supported on Windows and Linux. We also need to ship on Xbox and PS5.
Continuing to research usages of Native AOT on consoles led me to the open source FNA project. FNA is a modern reimplementation of Microsoft’s XNA Game frameworks. XNA was first introduced in the mid 2000s for developers to build games for the Xbox Live Indie Games marketplace using C#. Despite the fact that XNA has been discontinued by Microsoft, it still has many supporters who have continued to release XNA/FNA games over the years. Part of the FNA project involves modern console support which is powered by custom Native AOT ports.
I dug in and got GitHub access to FNA’s Native AOT ports for Xbox and PS5 as well as some private channels in the FNA Discord. Knowing that there were other examples in the world of C# being run on consoles using Native AOT gave me some level of hope that just maybe this might be possible. However, there were still many unknowns and overall this was a huge risk. I presented my findings and asked for two weeks for the backend team to come up with a proof of concept. A valid proof of concept meant demonstrating that we could take some of our existing C# code and call it from the Unreal game client on all three platforms we needed to support.
Over those two weeks we had to solve numerous problems. Building the Native AOT DLL on each platform. Loading it from the Unreal game client. Invoking exported DLL functions from C++. And so forth. There were several challenges and headaches along the way, but at the end of the two weeks we were able to successfully load the player’s inventory on the Unreal game client through a Native AOT DLL call on Windows, Xbox, and PS5. With this foundational proof of concept in place, we got the go ahead to begin work on a generalized solution to support all of the backend that would be required in the offline game. My initial dread from when I first heard the news about our offline pivot was gone, replaced with excitement and confidence in a novel path forward.
Serverless Service
In coming up with a generalized solution, our goal was to minimize the required changes to the C++ Unreal game client code. The most obvious way to approach this problem might appear to be to create new exported DLL functions for each backend API that needs to function offline. Instead, we created a single new exported DLL function called ProcessHttpRequest. This function takes in a struct representing a standard HTTP request and returns a struct representing a standard HTTP response. We started referring to this system with the tongue-in-cheek name “serverless service” and it stuck.
In order to iteratively develop the offline architecture while also continuing to support the live-service flows, we introduced a local feature-flag that controls whether this new serverless mode is enabled. When disabled, the game functions as it did for the online live-service era sending out real HTTP requests. However, when the feature-flag is enabled, HTTP requests to the Towerborne service domains instead get routed through the local DLL rather than over the internet. From the Unreal game client’s perspective, it is still continuing to make the same HTTP requests as it did in the live game; none of the code surrounding these individual API requests needs change.
In the live game, every API call that affected the player’s inventory triggered a write to the corresponding record in our Azure Cosmos database. From a player’s perspective, the game is constantly saving their progress. To achieve parity in the offline game, we exposed two functions in the AOT DLL for getting and setting a player’s inventory (equivalent to the Cosmos DB inventory document). When the game first starts up, the local save file on disk is read and the inventory is loaded into the DLL’s memory. As the various serverless HTTP operations occur throughout gameplay the DLL’s in-memory inventory state gets updated. After these operations, if the inventory was changed, the client fetches the new full inventory state from the DLL and saves it back to disk.
Code Gen
The ProcessHttpRequest function serves as the single entry point for the vast majority of calls from Unreal to the Native AOT DLL. This includes the routing logic necessary to execute the correct code based on the route and verb included in the HTTP request. This is essentially replacing the routing that would normally be handled by a .NET controller.
Each route has to be registered into a mapping that ultimately resolves to a function that gets executed. Since we had hundreds of APIs that needed to be supported, this meant a significant amount of boilerplate code would need to be written. Luckily, we already had experience using code-gen on Towerborne.
Each of our services exposes an OpenAPI Swagger endpoint in our internal development environments. This endpoint serves a JSON response that defines the full API specification for all operations the service supports. During the live game’s development and operation, we used this to generate the C++ code for making calls to the backend, significantly cutting down on boilerplate coding tasks around defining request/response structs and JSON serialization/deserialization.
We leveraged this same approach here using the same OpenAPI JSON to generate boilerplate C# code for registering and wiring up all endpoints within the serverless codebase. Interfaces are generated for each controller with a function for each action. Functions only need to be implemented as necessary for the systems that are required in the offline game.
Every route is registered to a dictionary like this:
public static void RegisterAllRoutes(RouteRegistry registry)
{
var route_AcceptQuestV1 = new RouteEntry(
"POST",
"/api/v1.0/inventories/{inventoryId}/quests/accept",
RouteConstants.InventoryQuestsV1.Name,
RouteConstants.InventoryQuestsV1.AcceptQuest
);
route_AcceptQuestV1.HasRequestBody = true;
route_AcceptQuestV1.RequestBodyType = "AcceptQuestRequest";
registry.RegisterRoute(route_AcceptQuestV1);
// ... other route registrations
}Eventually an HTTP request to that route will make its way here:
// <auto-generated>
// This code was generated by NativeAOTCodeGen.py from Swagger API specification.
// Changes to this file may cause incorrect behavior and will be lost if the code is regenerated.
// </auto-generated>
public interface IInventoryQuestsV1Service
{
AcceptQuestResponse AcceptQuest(BelfryInventoryId inventoryId, AcceptQuestRequest request);
// ... other quest routes
}The implementation of this interface will have the AcceptQuest function call the existing quest accepting logic that was previously used by the InventoryService in the live game.
Unreal Native AOT Interop
Foundational to this approach is the need to cross from the Unreal C++ boundary into the C# DLL code. This boundary is inherently risky as it lacks much of the standard safety checks we normally rely on in managed code.
The Unreal game client loads the DLL file on startup. The individual exported functions from the DLL are resolved by name.
void FBfServerlessModule::LoadFunction(TFunc& FuncPtrOut, const TCHAR* FuncName)
{
FuncPtrOut = reinterpret_cast<TFunc>(FPlatformProcess::GetDllExport(DllHandle, FuncName));
if (!FuncPtrOut)
{
BF_STATIC_LOG(LogBfServerlessService, Error, TEXT("Failed loading func %s"), FuncName);
bDllFailed = true;
}
}BfServerless::ProcessHttpRequestFunc FBfServerlessModule::DllProcessHttpRequest = nullptr;LoadFunction(DllProcessHttpRequest, TEXT("ProcessHttpRequest"));Unreal has no additional information about the loaded functions, only a raw function pointer. As such, it is critical that the corresponding C# and C++ structs are exactly aligned. If they are not perfectly byte-for-byte compatible, we will have problems.
The C# function and struct for processing an HTTP request looks like this:
[UnmanagedCallersOnly(EntryPoint = "ProcessHttpRequest")]
public static unsafe void ProcessHttpRequest(
UnmanagedProcessRequest* unmanagedRequest,
UnmanagedDelegate* unmanagedDelegate)
{
// ... function implementation
}[StructLayout(LayoutKind.Sequential)]
public unsafe struct UnmanagedProcessRequest
{
public char* Method;
public char* Path;
public char* Content;
public UnmanagedDictionaryPair* Headers;
public UnmanagedDictionaryPair* QueryParameters;
public int HeadersNum;
public int QueryParametersNum;
}The corresponding C++ struct looks like this:
struct FProcessHttpRequestRequest
{
const CharType* Method = nullptr;
const CharType* Path = nullptr;
const CharType* Content = nullptr;
const FDictionaryPair* Headers = nullptr;
const FDictionaryPair* QueryParameters = nullptr;
int32 HeadersNum = 0;
int32 QueryParametersNum = 0;
};This explicit low-level contract is what makes the entire serverless HTTP abstraction possible. By constraining the interop to a minimal number of tightly controlled boundary data structures, we can safely support hundreds of APIs previously powered by live backend systems.
Repo Structure and Code Reuse
Throughout the development of Towerborne, we maintained our individual backend service codebases in various Azure DevOps (ADO) git repositories. For each service, we split out the codebase between a web and library project.
The web project is intended to contain the code for actually running the service in Azure. Controller actions that act as the entry point for individual HTTP requests. Service startup code preparing the IoC container. Various middlewares providing additional telemetry and debugging information. And so forth.
The library project, on the other hand, contains the actual core gameplay logic. In the inventory service's case, this includes the C# classes that model the request and response for every API operation as well as the underlying logic for handling the countless inventory related systems such as crafting, quests, loot, etc. As a separate project, the library can be imported in other services when we need to reuse that logic without pulling in any of the web project's code.
This structure fit neatly into what we needed for the offlining work. A new serverless repo was created for generating the Native AOT DLL files. The repo contains a core project that depends on the pre-existing library projects allowing for code reuse. Individual platform projects were created for handling all the custom build and linking logic required for Windows as well as each supported console.
Dependency Injection
Throughout the development of our microservices, we heavily leveraged dependency injection. As part of a .NET web application's startup process, you register the individual types that should be part of the inversion of control (IoC) container. Individual classes inject their dependencies as interfaces in their constructor arguments. This allows different concrete implementations to be used depending on the context. For example, an interface for a telemetry client may be utilized throughout the codebase. The concrete implementation in the live-service sends actual telemetry data to a remote endpoint. A mocked implementation is used in unit tests to validate the correct event would be sent at the appropriate time.
services.AddSingleton<ITelemetryClientWrapper, AppInsightsWithPlayFabTelemetryClient>();We leveraged this existing dependency injection structure to properly set up the AOT DLL build. By defining a custom IoC container and injecting it with the concrete implementations required for offline play we were able to minimize the amount of refactoring necessary to make everything work. For the previous telemetry client example, we simply inject a no-op implementation in the serverless code.
services.AddSingleton<ITelemetryClientWrapper, NoOpTelemetryClientWrapper>();Build and Deploy Process
One challenging part of this phase of development was the need to continue adding new features to the game while simultaneously converting the game's architecture for offline play.
Based on the relative timelines of these efforts, this meant we needed to continue to add new functionality to internal builds of the live-service game to meet certain publisher milestone requirements despite the fact that when these features would ultimately get released to the player it would be in the offline game. As a result, we needed to continue to build out and deploy new backend functionality in our internal development environments that would never actually need to be deployed to live player-facing production environments.
Luckily, with the Native AOT solution, we had a framework in place where this could be accomplished without doubling up on the amount of work required.
New features could continue to be developed in the inventory service. These changes would get deployed to our internal development environment's microservices to power new internal builds of the live-service game client. With minimal additional work, this same inventory logic could be used in the AOT serverless codebase to build out the DLL files needed to support the same functionality in the offline game client.
Demonstration
Let's now take a look at that same dismantle operation from before in the offline game.
I'm again dismantling a special aspect item at the forge. This will: remove the item from my inventory, grant me some spirit dust, and progress a specific quest objective.
By launching the game with some additional logging commands, the exact request and response JSON that goes through the DLL gets written to the logs.
The request looks like this:
{
"method": "POST",
"path": "/api/v1.0/forge/inventories/76561197976044629:f7cf0323-133f-49d6-872b-776f37ff7185/bulkDismantle",
"headers": {
"RequestId": "FF02220D4F31B8CA9C49358D150498D2",
"Accept-Language": "en",
"Inventory-ETag": "05aee381-3031-498c-a82c-04a88ac7001c",
"Content-Type": "application/json"
},
"content": {
"items": [
{
"itemId": "c186b300-2cdb-4562-9373-c22d4969b4e8",
"count": 1
}
]
},
"queryParameters": {},
"pathParameters": {}
}You can see the HTTP verb and path are present which is enough for the DLL to route to the correct logic. Some custom HTTP headers are present which have various uses. The actual HTTP request body is in the content field and in this case specifies the exact item that is being dismantled.
The logs show the AOT DLL properly routing the request:
[2026.02.03-23.26.17:281][715]LogBfServerlessService: Display: FBfServerlessModule::LogCallbackImpl : [StoicBackendCore.Routing.HttpRequestRouter]: Routing request: POST /api/v1.0/forge/inventories/76561197976044629:f7cf0323-133f-49d6-872b-776f37ff7185/bulkDismantle
[2026.02.03-23.26.17:281][715]LogBfServerlessService: Verbose: FBfServerlessModule::LogCallbackImpl : [StoicBackendCore.Routing.RouteRegistry]: Matched route: POST /api/v1.0/forge/inventories/76561197976044629:f7cf0323-133f-49d6-872b-776f37ff7185/bulkDismantle -> InventoryForgeV1.BulkDismantleItemsThe response looks like this:
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"content": {
"playerUpdates": {
"inventoryId": "76561197976044629:f7cf0323-133f-49d6-872b-776f37ff7185",
"updatedActiveQuests": [
{
"id": "e4c63122-dc63-40dd-8cf0-ef7e82aed103",
"acceptedTimestamp": 1770161096,
"completed": false,
"itemName": "QST_FTUE_Forge_EnhanceDismantle",
"objectives": [
{
"objectiveId": "393044533027278848",
"status": "Complete"
},
{
"objectiveId": "393044647133319168",
"status": "Incomplete"
}
],
"allObjectivesCompleted": false,
"dynamicData": {
"Dismantle{DismantleItemOnyxId:208242625956810752}": 1,
"Enhance": 0
},
"pinned": false,
"questStatus": "Active"
}
],
"inventoryItemChanges": {
"categoriesUpdated": [],
"itemsAddedOrUpdated": [
{
"itemName": "Resource_Dismantle_SpiritDust_1",
"onyxId": "80479155036098560",
"countDelta": 140
}
],
"itemsRemoved": [
{
"itemId": "c186b300-2cdb-4562-9373-c22d4969b4e8",
"itemName": "Aspect_T01_Uncommon_Diamond_Dismantle",
"countDelta": -1
}
]
}
}
},
"success": true,
"errorMessage": null
}The 200 status code is present in the response so that the game client code can interpret this as a standard HTTP success response. The actual content of the response shows all the side effects of the action that the client needs to handle. The removal of the aspect item, the addition of the spirit dust, and the changes to the quest state. After dismantling the item, one out of the two objectives on this quest is now complete.
Comparing these JSON requests and responses with those from before, you can see how the exact same structures from the live-service game have made their way into the offline build. It's satisfying to see how smoothly this paradigm scales applied across hundreds of backend API calls.
Additional Use Cases
For Towerborne, this approach to offlining the game was retrofitted on top of the live-service game after years of development. It allowed what would have otherwise been an extremely difficult undertaking to be accomplished in a relatively short amount of time.
The topic of always-online live-service games shutting down and ultimately becoming unplayable has been a popular topic of internet discourse for many years. Through my work on Towerborne I've seen first-hand just how challenging and time-consuming it can be to make a game originally designed like this work offline. Every game has its own unique challenges in both design and technical architecture, making offlining a uniquely complex undertaking that is hard to understand for many of its players. However, the Native AOT approach we leveraged shows that it is possible and I hope other developers finding themselves in a similar position find it useful in showcasing one possible path.
Beyond this, I think there's a case to be made for designing a new game from the ground up with this architecture. At the very least, gamers who are skeptical about investing their time into a live-service game out of fear of it shutting down could rest easy knowing that the developers have built the game with this failsafe in mind.
Some games might even find a way to make both types of backend operation part of their design. Imagine an always-online MMO style game where server-authoritative data is critical. Now imagine a standalone offline side-story that uses the same gameplay mechanics and systems. The same C# code powers both modes, but is used in drastically different ways.
Another possibility could be for supporting local development flows. Throughout the development of Towerborne, we struggled to find the best approach for this. Flaky backend development environments can have a real impact on content creators who need things up and running to do their work. At the same time, backend engineers need to roll out new features quickly leading to some inevitable friction. One can imagine an approach that gives people the option to use the Native AOT DLL when running the game through the Unreal editor, but interacts with a real backend when running an actual game build.
Legacy
While there's a part of me that is sad we won't get to continue building out that online sandbox we spent so many years dreaming up, this is just about the best possible outcome given the circumstances. At the end of the day, Towerborne will live on. No shifts in market conditions, server costs, deprecated middlewares, cloud outages, or any other business realities can ever stop that.
Towerborne launched on Steam, Xbox, and PS5 on February 26, 2026.
See you on The Belfry!









