Quick answer: Ebitengine games are Go, so crashes are panics. A panic in your Update or Draw method propagates up and ends the game, while a panic in a goroutine you spawned crashes the whole process unless recovered there too. Use recover with debug.Stack to capture the trace, report from both desktop and WASM builds, and group identical panics into one issue.

Ebitengine is a dead-simple 2D game library for Go built around a Game interface with Update, Draw, and Layout methods. Because it is Go, crashes are panics rather than exceptions, and Go's concurrency model means a panic in a goroutine you spawned will take down the entire process unless that goroutine recovers it. Ebitengine also compiles to desktop and to WebAssembly for the browser, so the same panic reports differently depending on target. This post covers how panics flow through the Ebitengine loop, the goroutine trap, and how to recover, capture stack traces, and report from both desktop and WASM builds.

Panics in the game loop

Ebitengine drives your game by calling Update and Draw on the Game interface every tick. When your code panics inside one of these, the panic propagates up through ebiten.RunGame, and unless you have recovered it, the Go runtime prints a stack trace to standard error and the process exits. The usual sources are nil map or pointer dereferences, index out of range on a slice of entities, and type assertions that fail. Because Update runs every frame, a panic that depends on a particular state is reliably fatal once that state is reached.

The stack trace Go produces is excellent: it names your functions, files, and line numbers, including the path from ebiten.RunGame down into your Update. The problem is delivery. On desktop that trace goes to a terminal the player does not have open, and on a WASM build it goes to the browser console. The information you need exists at the moment of the crash; the work is catching it before the process dies and shipping it somewhere you will actually see it.

The goroutine trap

Go's defining feature is also a crash-reporting hazard. A panic in the goroutine running your game loop can be recovered at the loop boundary, but a panic in a goroutine you spawned yourself, for background loading, networking, or audio streaming, cannot be recovered from the main goroutine. If that goroutine panics and nothing inside it recovers, the entire process crashes immediately, and the stack trace points into the background work rather than your Update.

This means recover must be placed in every goroutine you launch, not only at the game loop. A common indie mistake is to guard the main loop carefully and then spawn an asset loader goroutine with no recover, so a single bad decode there kills the game with a trace that looks unrelated to gameplay. Treat every go statement as a place that needs its own deferred recover if you want complete crash coverage, and capture which goroutine context the panic came from so you can tell loop panics from background ones.

Recovering and capturing the trace

The capture mechanism is Go's recover, used inside a deferred function. Wrap your Update and Draw bodies, or a single dispatch that calls them, with a defer that calls recover, and in the recovery path call runtime debug.Stack to get the full goroutine stack as bytes. Combine the recovered value, which is the panic payload, with that stack text to build a report. After capturing, you generally re-panic or exit cleanly, because the game state after a panic is not trustworthy, but you do it on your terms with the data already saved.

Place the same deferred recover at the top of every goroutine you spawn, each capturing its own debug.Stack so the trace reflects that goroutine. Add a breadcrumb of the active scene, tick count, and recent actions so the report has gameplay context beyond the raw trace. The recovered value plus the stack plus context is everything you need: Go's traces are precise, so once it reaches you, you can usually navigate straight to the offending line without reproducing the crash locally.

Desktop and WASM differences

Ebitengine compiles to native desktop binaries and to WebAssembly that runs in a browser canvas, and crash reporting differs between them. On desktop you have a normal Go runtime, files, and the option to write a local crash log before reporting, plus easy access to OS and GPU details. WASM is more constrained: there is no filesystem, network calls go through the browser, and some Go runtime behaviors around threads and timing differ, so a panic that never fires on desktop can appear under the browser's single-threaded model.

Tag every report with the target so you can tell them apart. For WASM, capture the browser and version and route the report through a browser-friendly transport, since you cannot write a file or block. For desktop, capture the OS, architecture, and GPU. The Go code that recovers and builds the report can be shared, but the delivery path and the platform fields must account for whether the build is a native binary or running inside a browser tab, because the players and their failure conditions differ.

Setting it up with Bugnet

Bugnet fits Ebitengine because the recover points are clean integration spots. In your deferred recovery functions, hand Bugnet the recovered panic value, the debug.Stack output, and your context, including the build target and active scene. The crash arrives as a readable Go stack trace with your functions and lines identified, plus the platform fields that separate desktop from WASM. Because you place recover in every goroutine, background-loader panics report with their own trace too, so you are not blind to the half of crashes that happen off the main loop.

On the dashboard, Bugnet groups identical panics into one issue with an occurrence count, so the same nil dereference across many players becomes a single ranked item rather than scattered console output you never read. Custom fields for build target, browser, OS, and game version let you filter immediately: if a panic only fires on WASM under one browser, or spans every desktop build, the grouped view shows it. An in-game report button also lets players flag non-fatal glitches while Bugnet captures the current scene state automatically.

Hardening before launch

Make recover coverage a checklist item rather than an afterthought. Audit every go statement in your codebase and confirm each launched goroutine has its own deferred recover that reports, then verify the game loop recovery works by triggering a deliberate panic in Update. Do the same for a spawned goroutine to prove a background panic reports instead of silently killing the process. Skipping the goroutine audit is the single most common way Ebitengine games leak crashes you never learn about.

Test both targets before shipping. Build the WASM version and confirm a panic reports through the browser transport with the right target tag, then do the same for your desktop binaries on each OS. Capture GPU and browser details so platform-specific failures are filterable. With recover everywhere, traces captured via debug.Stack, and panics grouped by signature across desktop and WASM, your post-launch view becomes a single prioritized list, and you spend your time on the panic that hits the most players rather than the one you happened to see in a terminal.

Ebitengine crashes are Go panics, and goroutines are the trap. Recover everywhere, capture debug.Stack, tag desktop versus WASM, and grouping ranks the rest.