Quick answer: C# games on Mono fail mostly as managed exceptions you can catch through AppDomain and unobserved-task handlers, with readable stack traces, plus rarer native faults inside the Mono runtime or native interop. Hook the managed handlers, guard your interop boundaries, and attach the Mono and game version, OS, and platform, then group duplicate exceptions so one common NullReferenceException does not bury everything else.

Many indie games, from MonoGame and FNA titles to custom engines, run C# on the Mono runtime, which gives you managed exceptions with real stack traces, a genuine luxury compared with a raw native crash. But that luxury only helps if you capture the exceptions before the process exits, and Mono can still fall over natively in its garbage collector, its JIT, or across a P/Invoke boundary into native libraries. This post covers how C# Mono games crash, which runtime handlers catch the managed cases, and how to attach the runtime context that makes each stack trace fully actionable.

Managed exceptions are your best case

The everyday failure in a C# Mono game is a managed exception: a NullReferenceException on an object you assumed was set, an IndexOutOfRangeException on a collection, an InvalidOperationException from modifying something mid-iteration, or an exception thrown in async code. The great advantage is that these carry a full managed stack trace naming your methods and lines, so once captured they are usually quick to fix. The risk is purely that an unhandled one silently terminates the process and the trace is lost.

Mono's exception model is the standard .NET one, so the same patterns apply. A try and catch around a known-fragile call lets you report and recover, but you cannot wrap everything, which is why global handlers matter for the exceptions you did not anticipate. Treat the managed exception as the report you always want to preserve, because compared with a native fault it practically writes your bug report for you if you simply capture it in time.

The global handlers to register

At startup, subscribe to AppDomain.CurrentDomain.UnhandledException, which fires for exceptions that escape every try and catch on any thread. Its event args carry the exception object with its message, type, and stack trace. Also subscribe to TaskScheduler.UnobservedTaskException, which catches exceptions in tasks that were never awaited, a common silent failure in async game code where a fire-and-forget task throws and no one notices until something downstream behaves oddly.

These two handlers cover the bulk of managed failures you did not explicitly wrap. Inside each, capture the full exception including inner exceptions, since the root cause is often nested several InnerException levels deep, and a report that only shows the outermost wrapper hides the real fault. Add the current scene or game state and submit immediately. Be aware UnhandledException usually means the process is about to terminate, so flush the report synchronously rather than queuing it for a later send that will never happen.

Native faults in the runtime and interop

Mono itself is native code, and it can crash natively: a segfault in the garbage collector during a bad pin, a JIT fault, or, most commonly, a crash across a P/Invoke or interop boundary into a native library like a graphics or audio backend. These do not produce a managed exception, because the managed runtime is the thing that fell over, so AppDomain handlers never see them and you get a raw process death instead.

Guard your interop boundaries deliberately. Validate pointers and handles before passing them to native calls, and wrap clusters of P/Invoke calls so a managed-side guard can catch obviously bad state before it reaches native code. For the native faults that still slip through, fall back to a breadcrumb log flushed to disk during play and submitted on the next launch, recording the last managed operation before the process died. That last breadcrumb very often names the interop call that crossed into the native crash.

The runtime context a Mono crash needs

Capture the Mono runtime version, the target framework, the JIT or AOT mode if relevant, the OS and version, the architecture, and the available memory. Mono behaves differently across platforms and versions, and a managed exception that only appears under a specific Mono build or on one OS is a real pattern you can only see if the runtime version is on the report. Add your game's build version and a build hash so reports map to the exact code.

Then add game state and any custom player fields. The combination turns a stack trace into a precise account. An InvalidOperationException that only shows under one Mono version points at a runtime behavior difference, while a native interop crash that clusters on one OS points at a native dependency for that platform. Without the runtime context, a managed stack tells you the method but not the environment, and environment is frequently the difference between a bug you reproduce in minutes and one that haunts you for weeks.

Setting it up with Bugnet

Bugnet provides an SDK and an in-game report button that suit a C# Mono game. Wire the SDK into your AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException handlers so every escaped managed exception, with its full inner-exception chain and stack, is reported with the Mono version, OS, and game state attached. Route your interop breadcrumb log through the same SDK so the rarer native faults arrive in the same dashboard rather than disappearing as silent process deaths.

Occurrence grouping keeps the common cases from drowning the rare ones. A single NullReferenceException on a hot path can generate thousands of identical reports, and Bugnet folds them into one counted issue, so a rare but serious native interop crash stays visible instead of being buried. Custom fields like Mono version, OS, and architecture become filters, so you can confirm an exception is isolated to one runtime or platform and target your fix precisely rather than chasing a stack trace with no sense of where it actually happens.

Building a reliable C# crash pipeline

Before release, confirm both global handlers fire by deliberately throwing on a background thread and in an unawaited task, and confirm your interop breadcrumb captures a forced native fault. Verify your reporter flushes synchronously from UnhandledException, since an asynchronous send started as the process tears down will usually lose the report. Test on each platform you ship to, because Mono's behavior and your native dependencies differ across Windows, Linux, and Mac.

Once live, sort the grouped occurrence list and fix the loudest managed exceptions first, watching counts drop with each patch, while keeping an eye on the rarer native interop reports that often signal a deeper problem. C# on Mono gives you readable stack traces that make most bugs tractable, and a pipeline that reliably captures both the managed and native cases is what lets a small team turn that readability into a steadily more stable game across every platform it targets.

C# Mono games give readable managed stack traces, so catch them with the global handlers, guard interop for native faults, and attach the Mono version and OS to each report.