Quick answer: Use Android's logcat tool via ADB (Android Debug Bridge). Run 'adb logcat' to stream device logs in real time, or filter by your app's tag with 'adb logcat -s YourGameTag'. For crash-specific output, filter on AndroidRuntime to see fatal exceptions.
Learning how to set up error logging for mobile games is a common challenge for game developers. Mobile games crash on thousands of device configurations you will never own. When a player's session ends in a silent force-close, the only thing standing between you and a blind guess is your error logging pipeline. This guide walks through setting up robust error logging on Android and iOS, from platform-native tools like logcat and crash reports through symbolication, remote log collection, and handling the constraints that make mobile uniquely difficult: battery life, storage limits, and privacy regulations.
Android Logging with Logcat
Android's primary logging mechanism is logcat, a system-wide circular log buffer that captures output from every process on the device. Your game writes to it through the Android Log class in Java/Kotlin or __android_log_print in native C/C++ code. Each message has a tag (typically your game's package name or subsystem) and a priority level ranging from VERBOSE to ASSERT.
During development, you stream logcat output over ADB to see errors in real time:
# Stream all logs from a connected device
adb logcat
# Filter to only your game's tag at Warning level and above
adb logcat -s "MyGame:W"
# Capture crash stack traces from the Android runtime
adb logcat -s "AndroidRuntime:E" "DEBUG:*"
# Dump the last crash and exit
adb logcat -b crash -d
The -b crash buffer is particularly useful. Android maintains a dedicated crash buffer that stores tombstone-style stack traces for native crashes (SIGSEGV, SIGABRT) and uncaught Java exceptions. This buffer persists across app restarts, so you can pull crash data even after the player has relaunched the game.
For native NDK games, the tombstone files written to /data/tombstones/ on the device contain the full native stack trace, register state, memory maps, and the signal that killed the process. On rooted development devices you can pull these directly; on production devices you rely on the crash buffer or a reporting SDK.
iOS Crash Reports and Diagnostics
On iOS, the operating system itself generates structured crash reports when your game terminates abnormally. These .ips files (JSON format since iOS 15) capture the exception type, faulting thread, full stack trace for every thread, and binary image load addresses. Players can share these through Settings > Privacy > Analytics > Analytics Data, or you can retrieve them from a connected device through Xcode's Devices window.
A raw iOS crash report looks like this before symbolication:
Thread 0 Crashed:
0 MyGame 0x0000000104a8c1f0 0x104a00000 + 573936
1 MyGame 0x00000001049f3a24 0x104a00000 + -50652
2 UIKitCore 0x00000001a7e234c8 0x1a7b00000 + 3290312
3 libdispatch.dylib 0x0000000102d5f4a0 0x102d5c000 + 13472
Those hexadecimal addresses only become useful after symbolication. Xcode can symbolicate automatically if the matching dSYM bundle is available in your archive. For builds distributed through TestFlight or the App Store, uploading dSYMs to App Store Connect allows Apple to symbolicate crash reports server-side, which appear in the Xcode Organizer's Crashes tab grouped by crash signature.
To manually symbolicate a crash report when automatic resolution fails:
# Find the UUID of your binary in the crash report
grep "MyGame arm64" MyGame-crash.ips
# Verify your dSYM matches that UUID
dwarfdump --uuid MyGame.app.dSYM
# Symbolicate a single address
atos -arch arm64 -o MyGame.app.dSYM/Contents/Resources/DWARF/MyGame \
-l 0x104a00000 0x0000000104a8c1f0
# Symbolicate the entire crash report
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
symbolicatecrash MyGame-crash.ips MyGame.app.dSYM > symbolicated.crash
Symbolication for Native Mobile Builds
Symbolication is the process of converting raw memory addresses in a crash report into human-readable function names and source line numbers. On mobile, this is complicated by the fact that both Android and iOS use address space layout randomization (ASLR), meaning the base address of your binary changes every launch. The crash report records the actual load address, and the symbolication tool calculates the offset to look up in the symbol file.
Android NDK symbolication uses the ndk-stack tool or addr2line from the NDK toolchain. You need the unstripped .so files that match the exact build:
# Pipe a tombstone or logcat crash through ndk-stack
adb logcat -b crash -d | ndk-stack -sym ./obj/local/arm64-v8a/
# Resolve a single address with addr2line
$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
-f -e ./obj/local/arm64-v8a/libMyGame.so 0x8c1f0
# For Breakpad-format minidumps from Android
dump_syms libMyGame.so > libMyGame.sym
minidump_stackwalk crash.dmp ./symbols/
iOS symbolication requires dSYM bundles as described in the previous section. For games using Unity's IL2CPP backend, the symbolication story is more complex: the C++ code generated from your C# scripts is compiled into native code, and the mapping between IL2CPP addresses and your original C# method names requires both the dSYM and the LineNumberMappings.json file produced during the build. Unreal Engine games on iOS follow the standard dSYM workflow since the engine is natively compiled.
Regardless of platform, the cardinal rule is the same: archive your symbol files for every build you distribute. If you lose the symbols for a build, every crash report from players running that version becomes permanently unreadable. Your CI pipeline should treat symbol archival as a build failure if it does not succeed.
Remote Logging and Log Collection
Crash reports capture fatal errors, but many of the most damaging bugs in mobile games are non-fatal: a quest that fails to progress, an inventory item that disappears, a multiplayer desync that ruins a match. To diagnose these, you need remote logging that captures structured events and error-level messages from production devices and sends them to your backend.
A basic remote logging system has three components:
1. A severity-filtered logger. In production builds, only log at INFO level and above. Debug and verbose messages generate too much data and burn through battery and storage on player devices. Your logging wrapper should compile out VERBOSE and DEBUG calls entirely in release builds, not just filter them at runtime:
// Cross-platform logging wrapper (C++)
enum class LogLevel { Debug, Info, Warning, Error, Fatal };
void GameLog(LogLevel level, const char* tag, const char* fmt, ...) {
#ifdef NDEBUG
if (level < LogLevel::Info) return;
#endif
#if defined(__ANDROID__)
int prio = ToAndroidPriority(level);
__android_log_vprint(prio, tag, fmt, args);
#elif defined(__APPLE__)
os_log_with_type(OS_LOG_DEFAULT, ToOSLogType(level), "%{public}s", buf);
#endif
// Also write to the ring buffer for remote upload
LogRingBuffer::Instance().Push(level, tag, buf);
}
2. A local ring buffer with disk persistence. Hold the last N log entries (typically 500 to 2000) in a memory ring buffer. Flush to a size-capped log file on a timer (every 30 to 60 seconds) or when the severity is ERROR or above. Rotate log files when they exceed a configured size, keeping at most two or three rotations to limit disk usage:
// Log rotation configuration
struct LogConfig {
size_t max_file_size = 512 * 1024; // 512 KB per file
int max_rotations = 3; // Keep 3 rotated files
int flush_interval = 30; // Seconds between flushes
bool flush_on_error = true; // Immediate flush on ERROR+
bool compress_upload = true; // gzip before sending
};
3. A batched uploader. Compress the log file with gzip and upload it to your logging backend in a single HTTPS POST. Schedule uploads during natural pauses (scene transitions, menu screens) or defer to Wi-Fi and charging states using the platform's background task APIs. Never upload during active gameplay; the network latency and CPU cost of compression can cause frame drops.
Battery, Storage, and Performance Constraints
Mobile devices impose constraints that do not exist on PC or console. Logging that would be perfectly reasonable on a desktop machine can destroy battery life, fill up limited storage, or cause frame hitches on a mobile device. Every design decision in your logging pipeline must account for these limits.
Battery. Each disk write and network request wakes hardware components and consumes power. Writing one log entry per frame at 60 FPS produces 3,600 writes per minute, which on some devices measurably reduces battery life during extended play sessions. The solution is batching: accumulate entries in memory and flush to disk on a timer or when a severity threshold is crossed. For network uploads, use the platform's work scheduler (Android's WorkManager or iOS's BGTaskScheduler) to defer uploads to charging or Wi-Fi states.
Storage. Many mobile devices, especially in emerging markets, have as little as 16 or 32 GB of total storage. Players will uninstall your game if it consumes too much space. Cap your total log storage budget at 2 to 5 MB and enforce it with log rotation. Delete uploaded logs immediately after a successful server acknowledgment. If the device is critically low on storage, disable logging entirely rather than contributing to the problem.
Performance. String formatting, JSON serialization, and file I/O on the main thread can cause frame drops. Perform all log formatting on a dedicated background thread. Use a lock-free ring buffer to pass entries from the game thread to the logging thread. On Android, avoid using Log.d() with string concatenation in hot paths, because the string is allocated and formatted even if the log level is filtered out. Use the Log.isLoggable() guard or compile out debug logging entirely.
// Android: avoid allocating strings for filtered log levels
if (Log.isLoggable("MyGame", Log.DEBUG)) {
Log.d("MyGame", "Player position: " + player.x + "," + player.y);
}
// Better: use a wrapper that compiles out in release
GameLog.d("Player position: %f, %f", player.x, player.y);
Privacy Compliance for Mobile Logging
Mobile platforms and regulations impose strict requirements on what data you can collect from player devices. Getting this wrong can result in app store rejection, GDPR fines, or player trust violations. Your logging pipeline must be designed with privacy as a structural constraint, not an afterthought.
Personally identifiable information (PII). Never log device identifiers (IMEI, IDFA, Android ID), email addresses, real names, IP addresses, or location data unless you have explicit consent and a documented legal basis. Crash reports inherently contain memory addresses and thread states, which are not considered PII, but any custom log messages your game writes could accidentally include player names, chat messages, or account IDs. Implement a PII scrubber that runs before log entries are written to disk or transmitted over the network.
GDPR and CCPA. If your game is available in the EU or California, players have the right to request deletion of their data, including error logs. Your logging backend must support identifying and deleting all log entries associated with a specific player. Use an opaque, resettable session ID as the correlation key rather than a persistent device identifier. Document your logging data collection in your privacy policy and present it during your consent flow.
Apple App Tracking Transparency. Since iOS 14.5, any collection of data that could be used to track users across apps requires an ATT prompt. Crash reporting data is generally exempt because it is used for app functionality rather than tracking, but if your logging payload includes advertising identifiers or if you correlate crash data with analytics profiles from other apps, you must present the ATT dialog. Apple reviews this during app review and will reject apps that collect tracking data without consent.
Google Play data safety. The Play Console requires you to declare all data types your app collects, including crash logs and diagnostics. Declare that you collect "crash logs" and "diagnostics" under the "App info and performance" category, mark them as collected automatically, and specify that they are used for "app functionality" and "analytics." Failing to declare crash log collection accurately can result in a policy enforcement action.
SDK Integration with Bugnet
Bugnet's mobile SDK handles the entire error logging pipeline out of the box. It integrates with Android's native crash handler and iOS's exception reporting, captures both fatal and non-fatal errors, manages local log buffering with configurable size limits, and uploads compressed log batches during non-gameplay moments. The SDK is designed to have zero measurable impact on frame rate and minimal battery footprint.
Initialize the SDK as early as possible in your application lifecycle so that crashes during startup are captured:
// Android (Kotlin) - Initialize in Application.onCreate()
class MyGameApp : Application() {
override fun onCreate() {
super.onCreate()
Bugnet.init(this, BugnetConfig(
projectKey = "your-project-key",
enableCrashReporting = true,
enableRemoteLogging = true,
maxLocalLogSize = 2 * 1024 * 1024, // 2 MB cap
uploadOnWifiOnly = true
))
}
}
// iOS (Swift) - Initialize in application(_:didFinishLaunchingWithOptions:)
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Bugnet.start(config: BugnetConfig(
projectKey: "your-project-key",
crashReporting: true,
remoteLogging: true,
maxLocalLogBytes: 2_097_152, // 2 MB cap
uploadOnWifiOnly: true
))
return true
}
Once initialized, the SDK automatically captures crashes with full native stack traces, symbolicates them against the symbols you upload through your CI pipeline, and groups them by crash signature in your Bugnet dashboard. Non-fatal errors logged through Bugnet.logError() appear alongside crashes with full context, making it straightforward to see whether a non-fatal issue escalates into a crash pattern.
For engine-specific integration, Bugnet provides plugins for Unity and Unreal that hook into the engine's existing logging infrastructure. In Unity, the plugin captures Debug.LogError and Debug.LogException calls automatically. In Unreal, it registers as an output device to intercept UE_LOG messages at Warning level and above. Both plugins handle IL2CPP and native symbolication transparently.
Related Issues
If you are working with Unity or Godot and want a quick start on crash reporting, see our guide on adding crash reporting in 10 minutes. For deeper coverage of symbolication across all desktop platforms, check out capturing and symbolicating crash dumps from player devices.
Test your logging pipeline on the lowest-spec device you support. If it works there, it works everywhere.