Ebiten is a 2D game engine written entirely in Go, and it’s a lot of fun. As a way for Go engineers to have fun building games, Ebiten is great.
The issues arise in the engine motivation and implementation. Go is a unique language which encourages highly parallel applications with low overhead. In college, I used a 256 core Xeon Phi processor to test different multithreading libraries and languages and found Go to be a top performer. Goroutines are a powerful tool which distinguish the language from a sea of similar languages.
The issue is Ebiten doesn’t, and arguably can’t, take meaningful advantage of this feature. Ebiten features often aren’t thread-safe, and recommended game design doesn’t encourage the use of Goroutines. Further, 2D games often lack the computational requirements to justify a goroutine, which results in the benefits of the language not shining through.
My (Simple) Go Game
My ultimate goal was to write a game that made my CPU go burrr. It’s a childish desire but there’s something fun about using every ounce of compute I can muster to perform a task.
A mass enemy FPS game, where enemies multiply as you kill them, seemed like the way to go. Using a Go interface I could make hot-swappable enemy logic, starting with a simple follow algorithm with the plan of moving to real-time reinforcement learning algorithm.
Game Structure
Ebiten games have two recurring functions; Update and Draw, which do exactly what you expect. These are called at a set ticks per second (TPS), which slows as your machine becomes overloaded.
Games are initialized, and usually data is stored in a game struct, which holds pointers to other entities or features of the game. The Update function updates the struct data, and the Draw function writes to the screen.
Draw Function
One of the issues is the ebiten.DrawImageOptions{}
isn’t thread safe, along with the DrawImage
function. DrawImageOptions is a struct which should be reset after each draw. Creating a new one every time you draw causes excessive memory writes, while using it in goroutines leads to write collisions.
DrawImage suffers from a similar fate, as it primarily writes to the options struct and then calls DrawRectShader which writes to the screen through the GPU. This operation again isn’t thread safe, and although you can do preprocessing in goroutines you can’t actually write to the screen in a multithreaded fashion.
Update Function
The update function doesn’t suffer from the same single-thread limitations as Draw, however it deals with a 2D limitation which is the logic often isn’t that complex.
Take my mass-enemy FPS game. For each enemy, I check against a list of projectiles to see if they collide. There are a few hundred projectile collisions to compare, plus potentially hundreds of thousands of enemies, creating a fun O(n^2) problem to chew through with goroutines.
I tried solving this in two fashions; the first was spinning off a goroutine for each projectile and comparing it against each enemy position in a slice. This had the benefit of adding more logic into each goroutine, however had the disadvantage of not producing a goroutine based design. Calls had to be wrapped up quickly so the slice could be read from or modified again. Although there was no cap on the size of the slice, append calls required allocation and garbage collection. Ultimately this method only allowed ~100k enemies on the screen, and only had about 30% CPU utilization on my Intel 12700.
The second fashion was using an even/odd channel architecture. I would start in the Update function with all the enemies in the even channel, pop it off to perform projectile calculations, and then insert it onto the odd channel. Once this was complete I’d move to the Draw function where I’d pop the enemy off the odd channel, Draw it onto the screen, and then write it to the even channel. Unfortunately even though the Draw step can’t be done with goroutines I still have to go through the overhead of using channels, and once you consider the memory write issues it’s slower than spinning off independent threads. I could only run about 60k enemies without seeing a framerate reduction and I only hit ~20% CPU utilization across all cores.

The real kicker is if I don’t use any goroutines at all, I get the same 100k capability, just hitting one CPU core at 100%.
Ebiten itself
The question must be asked: if it’s hard to add goroutines and channels to game logic, maybe the value add is in the engine itself. This doesn’t hold up when we look at the engine.
Ebitenutil doesn’t use go routines or channels at all. The internal logic library uses some, but the use is sparing and isolated mostly to rendering. Although I haven’t gone very in depth in the engine logic, my cursory review suggests there’s no meaningful performance benefit to them, nor is the implementation unique to Go.
Conclusion
Ultimately the limited threadsafe architecture of the engine combined with the logic limitations of 2D games makes it hard to justify this Go based game engine. I don’t want to criticize the impressive work done by the Ebiten team, or tell someone they can’t have fun, but this is fundamentally a toy engine.
Although I’m sure a game could be conceived which benefits strongly from Go, the language benefits don’t propagate themselves to the average game. The overhead from goroutines and excess writes slow down what should be a relatively fast TPS/FPS, and any non-hobby developer should consider a more appropriate language and engine.
Afterward
If anyone is curious, the game I wrote is here, called rl-simple-game as of May 2025. Despite its name, the reinforcement learning isn't implemented, as I got sidetracked hitting performance bottlenecks. The branch my comparisons were done on is called threading-test.