NativeAOT for gaming

Who this article is for

This article is for C# game developers shipping titles with the Microsoft Game Development Kit (GDK), and for engine and tools programmers who own the build and publish pipeline. It assumes you are comfortable with MSBuild project files and with .NET publishing, but it does not assume any prior NativeAOT experience.

What NativeAOT is

Normally, C# compiles to an intermediate language (IL). At run time, the .NET runtime's just-in-time (JIT) compiler translates that IL into machine code the first time each method runs. NativeAOT (ahead-of-time) compilation does that translation once, on your build machine, so the game you ship contains only machine code.

The result is a fully self-contained native executable produced at publish time. Nothing in the shipped title generates code at run time. For games, where frame-time consistency, deterministic memory behavior, and fast startup are hard requirements, this model aligns much more closely with console expectations than the traditional JIT-based .NET runtime.

For the platform-neutral .NET view of the same feature, see Native AOT deployment overview and Limitations of Native AOT deployment.

This page covers why NativeAOT matters for GDK titles, what it costs you, and how to structure a codebase so that adopting it is straightforward rather than a late-stage porting emergency.

Important

NativeAOT for XBOX requires the private XBOX GDK (GDKX) and an active Microsoft ID@XBOX or licensed developer agreement. Desktop GDK (Gaming.Desktop.x64) targets can use the public .NET NativeAOT toolchain directly.

Why NativeAOT for games

No JIT warm-up, no tiered recompilation

With the JIT, a method's first execution pays compilation cost, and tiered compilation re-JITs hot methods later in the process lifetime. Both show up as frame-time spikes, typically during the worst possible moments: level load, first enemy spawn, first particle system, first shader-bound code path.

NativeAOT compiles everything before you package the title. Every method runs at full optimization from the first call. The result is a flatter frame-time graph and far fewer "first time we do X" hitches.

Startup time

Console certification requirements include time-to-first-frame and time-to-interactive budgets. NativeAOT startup is dominated by image load and your own initialization, not runtime bring-up. Titles typically see startup reductions in the range of several hundred milliseconds to multiple seconds compared to a JIT-based configuration, depending on assembly count.

Memory footprint

Consoles have a fixed memory budget with no swap. NativeAOT removes:

  • The JIT compiler and its working set.
  • Metadata and IL that would otherwise be retained for runtime compilation.
  • Code that trimming proved unreachable.

Trimming is mandatory under NativeAOT, which means unused engine subsystems, unused serializers, and unused third-party dependencies do not consume memory in the shipped title.

Deterministic, inspectable binaries

A single native binary makes symbolication, crash dump analysis, and Performance Investigator for XBOX (PIX) captures behave the way native C++ developers expect. Stack frames are real frames. Profiler tooling in the GDK that understands native images works without a managed-runtime shim.

Certification and platform policy alignment

Several platforms restrict or prohibit runtime code generation and writable- executable memory. NativeAOT sidesteps this class of problem entirely, because no code is generated after build time.

What NativeAOT costs you

Be explicit about these constraints early; they drive architecture.

Constraint Impact on a game codebase How to navigate it
No runtime code generation System.Reflection.Emit, DynamicMethod, and expression-tree compilation are unavailable. Replace with source generators or hand-written code at compile time. Compiled expression trees usually map onto a generated switch or delegate table.
No dynamic assembly loading Assembly.LoadFrom and plugin-style downloadable content (DLC) as managed assemblies don't work. Ship DLC as data, not code. Compile optional content into the base image and gate it with entitlements, or drive it from a data-driven scripting layer such as Lua.
Trimming is always on Reflection over types that are only referenced by string name will fail at run time unless preserved. Prefer direct type references. Where reflection is unavoidable, annotate with [DynamicallyAccessedMembers] or preserve the type in an ILLink descriptor file, and cover the path with an automated test in the NativeAOT configuration.
Longer publish times Whole-program compilation is slower than a JIT build. Keep a JIT-based inner loop for daily iteration and run NativeAOT in continuous integration (CI) and for milestone builds. See Maintain two build configurations.
Larger build artifacts per configuration Each runtime identifier (RID) is a separate full compile. Build RIDs in parallel on CI agents, cache intermediate output, and only produce the RIDs a given milestone actually needs.
Generic virtual methods over value types Some patterns require pre-instantiation and can bloat code size. Prefer non-generic interfaces, generic constraints resolved at compile time, or static abstract interface members. Watch the size report from IlcGenerateMapFile to catch instantiation explosions early.

Example build flow for an XBOX title

The following walkthrough shows an end-to-end NativeAOT build of a small GDK title, from project file to a package deployed on a development kit. Run these commands from an XBOX GDK developer command prompt so that the GDK tools (makepkg, xbapp) and the GDK MSBuild targets are on the path.

Note

Exact runtime identifier and property names are defined by the GDK version you have installed. Confirm them against the GDK release notes and sample projects before wiring them into your own build scripts.

1. Declare the NativeAOT configuration in the project file.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <LangVersion>12.0</LangVersion>
    <IsAotCompatible>true</IsAotCompatible>
  </PropertyGroup>

  <!-- Only publish NativeAOT for the shipping configuration. -->
  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <PublishAot>true</PublishAot>
    <SelfContained>true</SelfContained>
  </PropertyGroup>

</Project>

2. Build and run the JIT configuration on your desktop while iterating.

dotnet build .\MyGame.csproj -c Debug
dotnet run --project .\MyGame.csproj -c Debug

3. Publish the NativeAOT image for the target console family.

dotnet publish .\MyGame.csproj -c Release -r win-x64 ^
    -p:GdkTargetPlatform=Scarlett ^
    -o .\out\Scarlett

Publishing runs the ILCompiler (ILC) over your assemblies and their dependencies, then links the result into a single executable. Trim and AOT analyzer diagnostics (IL2xxx and IL3xxx) surface here if they were not already fixed during step 2.

4. Lay out and package the title.

makepkg genmap /f .\chunks.xml /d .\out\Scarlett
makepkg pack /f .\chunks.xml /d .\out\Scarlett /pd .\packages

5. Deploy to a development kit and run it.

xbapp deploy .\out\Scarlett
xbapp launch MyGame_1.0.0.0_x64__8wekyb3d8bbwe!Game

6. Collect symbols for crash triage.

Keep the .pdb produced alongside the native executable with the matching package build. Without it, crash dumps from the native image cannot be symbolicated.

Best practices

Enable the analyzers at the start of the project

The single biggest predictor of a painful NativeAOT port is discovering reflection dependencies six weeks before submission. Enable analyzers immediately, even if you don't publish AOT builds yet:

<PropertyGroup>
  <!-- On .NET 8 and later, this single property also turns on
       EnableTrimAnalyzer, EnableSingleFileAnalyzer, EnableAotAnalyzer,
       and IsTrimmable. Set those individually only to override it. -->
  <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<PropertyGroup>
  <!-- Promote only the trim, single-file, and AOT diagnostics to errors,
       rather than every warning in the build. -->
  <WarningsAsErrors>
    $(WarningsAsErrors);IL2026;IL2055;IL2057;IL2062;IL2067;IL2070;IL2072;IL2075;IL2087;IL3050;IL3051;IL3052;IL3053
  </WarningsAsErrors>
</PropertyGroup>

TreatWarningsAsErrors=true also works, but it promotes every warning in the build, including unrelated C# and analyzer diagnostics such as unused variables and obsolete APIs. On an existing codebase that usually produces a wall of failures that have nothing to do with AOT compatibility. Listing the IL2xxx and IL3xxx codes explicitly in WarningsAsErrors keeps the gate tight and the signal clean. MSBuild has no wildcard syntax for warning codes, so extend the list as you encounter additional diagnostics.

These analyzers report IL2xxx and IL3xxx diagnostics at normal build time on your fast JIT-based iteration loop. Fixing them incrementally costs almost nothing; fixing them all at once costs a milestone.

Replace reflection with source generators

Most reflection in a game engine is serialization, dependency injection, or data-binding. All three are better served by source generators, which run at compile time and produce trim-safe, AOT-safe code.

// Reflection-based JSON: fails or bloats under NativeAOT.
var save = JsonSerializer.Deserialize<SaveGame>(json);

// Source-generated context: fully AOT-safe and faster.
[JsonSerializable(typeof(SaveGame))]
internal partial class SaveJsonContext : JsonSerializerContext { }

var save = JsonSerializer.Deserialize(json, SaveJsonContext.Default.SaveGame);

Apply the same principle to entity component registration, gameplay ability tables, and localization lookups: generate the registry, don't scan for it.

Keep the interop boundary blittable and LibraryImport-based

GDK titles call into native code constantly: Direct3D 12, XGameRuntime, XCurl, XAudio2, and PIX markers. Use [LibraryImport] rather than [DllImport] so that platform invoke (P/Invoke) marshalling code is source-generated at compile time instead of emitted at run time.

The following is a complete file. [LibraryImport] requires the containing type to be partial, and the project must set <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace MyGame.Interop;

internal static partial class XGameRuntime
{
    [LibraryImport("XGameRuntime.dll", EntryPoint = "XGameRuntimeInitialize")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvStdcall) })]
    internal static partial int Initialize();
}

Note

On C# 12 or later you can write the collection expression CallConvs = [typeof(CallConvStdcall)] instead of new[] { ... }. The array initializer form shown above compiles on every language version that supports [LibraryImport] (C# 11 and later).

For callbacks invoked from native code, use [UnmanagedCallersOnly] and function pointers rather than delegate marshalling.

Prefer blittable structs and Span<T> over marshalled classes and arrays. Blittable signatures produce zero-stub, zero-allocation transitions, which matters when the call happens per-draw or per-audio-callback.

If you would rather not write and maintain this interop layer yourself, XBOX GDK.NET provides an AOT-safe projection of the Gaming Runtime, XBOX services, and PlayFab that already follows these rules.

Control garbage collection pressure

NativeAOT does not remove the garbage collector (GC). It removes the JIT. Frame-time spikes caused by Gen0 collections are still yours to solve.

  • Prefer struct for per-frame data: transforms, contacts, particles, input events.
  • Use ArrayPool<T> and MemoryPool<T> for transient buffers.
  • Pre-size collections during load; never grow them mid-frame.
  • Enable Server GC only if you have the memory headroom; on XBOX, Workstation GC with a well-controlled allocation rate is often the better choice.
  • Consider GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency around gameplay, and force a collection during explicit load screens.

Measure with PIX timing captures. An allocation you can't see in a capture isn't costing you anything; one you can see is worth fixing.

Configure publish settings for shipping builds

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <UseSystemResourceKeys>true</UseSystemResourceKeys>
  <StackTraceSupport>false</StackTraceSupport>
  <OptimizationPreference>Speed</OptimizationPreference>
</PropertyGroup>

<!-- Set the instruction set per target, never globally. -->
<PropertyGroup Condition="'$(GdkTargetPlatform)' == 'Scarlett'">
  <IlcInstructionSet>x86-64-v3</IlcInstructionSet>
</PropertyGroup>

Notes on the aggressive settings:

  • InvariantGlobalization removes International Components for Unicode (ICU). Only enable it if you do not rely on culture-aware comparison or formatting; localized titles usually cannot.
  • UseSystemResourceKeys=true strips framework exception message text and replaces it with bare resource identifiers, so a caught ArgumentOutOfRangeException reports a key instead of a description. That materially degrades crash triage. Keep it false in any build that reports telemetry or goes to playtesters.
  • StackTraceSupport=false shrinks the binary meaningfully but degrades crash reports. Keep it true in any build that reports telemetry.
  • OptimizationPreference=Size should be used only if you are image-size constrained; Speed is the correct default for gameplay code.

Choosing an instruction set

IlcInstructionSet lets you target a known, fixed console CPU rather than a lowest-common-denominator baseline. That is a genuine advantage of fixed-hardware platforms, but the value is not portable across targets and an image compiled for an unsupported instruction set faults at run time.

Target Safe baseline Why
XBOX Series X|S (Scarlett) x86-64-v3 Zen 2 cores support AVX2, BMI, and FMA.
XBOX One / XBOX One X (XboxOne) x86-64-v2 or lower Jaguar cores support AVX and F16C but not AVX2, so x86-64-v3 produces an image that faults.
Gaming.Desktop.x64 Leave unset Raising the baseline silently raises your minimum PC specification. Treat it as a business decision, not a default.

If you ship both console generations from one codebase, scope the property with a condition per target as shown above, or set it from the command line for each publish invocation. Never set a single global value.

Maintain two build configurations

We recommend that you develop against CoreCLR and ship with NativeAOT. NativeAOT performs whole-program compilation, so publish times are substantially longer than a JIT build and there is no hot reload. Paying that cost on every code change will noticeably slow your team down.

Configuration Purpose
JIT / CoreCLR Daily iteration, hot reload, fast edit-compile-run loop, unit tests.
NativeAOT Nightly CI, performance testing, all milestone and submission builds.

Note

CoreCLR does run on console, so you get the same fast iteration loop on target hardware that you have on desktop. You are not restricted to developing against a PC build. However, this is supported on development kits only. CoreCLR is not permitted on retail consoles, and a CoreCLR build cannot be submitted for certification. Any build that goes to cert, to external playtesters on retail hardware, or to store submission must be a NativeAOT build.

Because the two configurations differ in how code is compiled, they can differ in behavior, most often around trimming, reflection, and interop marshalling. Run the NativeAOT configuration in CI from the very first sprint. A nightly build that has been green for a year will stay green; one that is first attempted at alpha will not.

Log through platform diagnostics, not the console

Console.WriteLine does not reliably reach the debugger output window on XBOX. Route logging through System.Diagnostics.Debug.WriteLine, OutputDebugString, or your own sink that forwards to the GDK diagnostics channel. Wire this up behind an interface so the desktop and console builds differ in one place.

Check third-party dependencies for AOT compatibility

Before taking a dependency, check whether it declares IsAotCompatible or is documented as trim-safe. Common problem areas: object-relational mappers (ORMs), inversion of control (IoC) containers, dynamic proxy and mocking libraries, older serializers, and anything that offers a "just works, no configuration" reflection-driven application programming interface (API). A library that is convenient precisely because it uses reflection is the library most likely to break you.

Engine and framework support

Your ability to use NativeAOT is usually decided by your engine, not by your own code. The table below reflects the state of the ecosystem as of August 2026.

Important

This landscape moves quickly. Confirm status against your engine's current release notes before committing to a plan. Note also that several engines gate console support behind a non-disclosure agreement (NDA), so the public documentation may not describe what is actually available to you as a registered developer.

How to read the status column

Status What it means
Supported The engine or library ships NativeAOT support in a released version, and titles built on it have shipped to retail. You can plan to submit a title on this configuration. Check the Notes column for which platforms are covered, because console support is often a separate, NDA-gated distribution.
Experimental Support exists in a preview branch, a community fork, or an unsupported code path. It may build and run, but it is not covered by the vendor's support commitments, may break between releases, and should not be assumed to be certifiable. Prototype on it if you like, but do not plan a ship date around it without written confirmation from the vendor.
No documented support The vendor publishes nothing about NativeAOT, and no known shipped title uses it. Assume it does not work.

Engines built on true NativeAOT

These use the standard <PublishAot>true</PublishAot> / ILCompiler pipeline.

Engine Status Platforms Notes
FNA Supported XBOX (GDKX), PC, other consoles Ships via NativeAOT on XBOX and other consoles. Console backends are distributed under platform NDAs.
MonoGame Supported XBOX (GDKX), PC Use version 3.8.5 or higher. Use the WindowsDX12 backend for GDK targets.
Godot (C#) Supported PC; console support via third-party porting partners Use version 4.2 or higher. Godot does not ship first-party console exports.
Stride Experimental PC only NativeAOT publishing is not part of a supported release path, and no console target is available.

Engines using a different AOT mechanism

These ship AOT-compiled console builds, but not via .NET NativeAOT. The benefits described on this page largely still apply; the specific tooling and project properties do not.

Engine Mechanism Notes
Unity IL2CPP Intermediate Language To C++ (IL2CPP) transpiles IL to C++ and remains the required backend for XBOX and all other consoles. Unity's ongoing Mono-to-CoreCLR migration replaces the JIT runtime for editor and desktop; it does not introduce NativeAOT. There is no announced roadmap to adopt it.
Flax Engine Mono full-AOT Used for consoles and iOS; CoreCLR/JIT on desktop. A community NativeAOT prototype exists but is not in mainline.

Note

Do not treat IL2CPP and NativeAOT as interchangeable. Both eliminate the JIT and both impose similar restrictions on reflection and dynamic code, so the architectural guidance on this page transfers well. The build configuration, analyzers, and Ilc* properties do not.

Library-level support

If you are building a custom engine on the GDK, your rendering and windowing layer determines your ceiling.

Library Status Notes
Silk.NET Supported Supported from 3.0. May require explicit platform registration at startup. Trimmer annotations are not yet exhaustive, so expect to add your own [DynamicallyAccessedMembers] annotations or descriptor entries.
Raylib-cs Supported A clean P/Invoke binding layer with no AOT-hostile patterns. Static linking via DirectPInvoke is demonstrated by the community.
OpenTK Experimental Publishes under NativeAOT in community configurations, but there is no vendor support statement.
Veldrid No documented support Assume it does not work until you validate it yourself.

Why some engines can't support it

The blockers are consistent across the ecosystem, and they're worth recognizing in your own code as well:

  • Runtime C# scripting. Any engine offering live script recompilation via Roslyn scripting fundamentally cannot ship that feature under NativeAOT.
  • Dynamic assembly loading. Plugin and modding architectures built on Assembly.LoadFrom have no NativeAOT equivalent.
  • Reflection-based discovery. Scanning assemblies to auto-register components, assets, or systems breaks under trimming unless every type is explicitly preserved.
  • Reflection.Emit and dynamic. Dynamic proxies, compiled expression trees, and Dynamic Language Runtime (DLR) call sites all require runtime code generation.
  • Open generic instantiation at runtime. MakeGenericType over a type only known at runtime fails when that instantiation wasn't compiled in.
  • WinForms/WPF in the shipping runtime. Windows Forms (WinForms) and Windows Presentation Foundation (WPF) are not AOT-compatible. This is precisely why MonoGame's old WinForms-based WindowsDX backend could not be AOT-published, and why the newer WindowsDX12 backend matters for GDK targets.

Migration checklist

  1. Turn on the trim and AOT analyzers, and promote the IL2xxx and IL3xxx diagnostics to errors with WarningsAsErrors.
  2. Inventory and remove Reflection.Emit, Activator.CreateInstance(Type), and Type.GetType(string) usage.
  3. Move serialization to source-generated contexts.
  4. Convert DllImport to LibraryImport; make interop structs blittable.
  5. Add a NativeAOT configuration to CI and keep it green.
  6. Profile startup, frame time, and peak working set against the JIT build and record the deltas.
  7. Verify crash reporting and symbolication work on the native image before submission.