Game engines are more than libraries glued together
The value of vision and the cost of external dependencies
When starting a massive technical project (such as, in our case, a game engine), the path of least resistance is to take a bunch of working components, glue them all together and then ship it! What could be easier?
Unfortunately, it seems that empirically this simply doesn't work. It's not a path to success: GitHub is littered with failed projects taking this approach while the most promising up-and-coming game engines (Godot, Bevy, Our Machinery) eschew this in favor of a much more integrated design.
Why? The underlying libraries (like SDL2 or OpenGL) may all be great, high quality pieces of software. In fact, the engines that develop momentum often rely on these same foundational libraries, and individual game projects (like Stellaris) can succeed by doing precisely the thing I'm arguing you shouldn't do!
So what's the difference? What is a game engine, and why can't we make one by slapping high-quality libraries together in a sticky, gluey blob?
Let's begin with something controversial: a stack made for a single game is not a game engine.
Instead, they made something that could be replaced by a game engine, but is a qualitatively different product. As a single-game-stack, they have one goal: make the game they were designed for as effectively as possible.
They don't need to care about:
- attracting users
- efficiently teaching those new users
- working on diverse developer hardware
- prioritizing flexibility, even when it comes at a performance premium
- managing backwards compatibility
- supporting diverse use cases
In exchange, they don't benefit from:
- pooling development resources, either via licensing revenue or open source contributions
- the architectural battle-hardening won by having to meet those harsher requirements
- a thriving ecosystem of learning materials, compatible assets and extensions
Engines live and die on the strength of their communities. Single-game stacks are judged by how quickly, cheaply and effectively they can make the single game they were made for.
Single-game-stacks and game engines are two distinct things, each with their own strengths and challenges. If you take a technique that works well for single-game-stacks (like gluing together libraries) and apply it to making game engines, past performance, as they say, is not a guarantee of future results.
Don't get me wrong: libaries rock, and dependencies are Good, Actually. Bevy has dozens of them, both direct and transitive!
Without libraries, your velocity will crawl to a halt, and just as importantly, you won't be giving back to the ecosystem.
The problem here is the dreaded glue code: code that exists solely to get everything to play nice together and talk to each other. Glue code is brutal to maintain because:
- It breaks with alarming regularity whenever your dependencies change their major version.
- No, don't just never update your dependencies. Bad!
- It is soul-sucking to write and update.
- What, you thought game engine coding was all HDR rainbows and skeletally-animated unicorns?
- It's painful to test.
- I hope you like writing mocks!
- It makes the cost of switching dependencies incredibly high.
- Swapping an integration layer for a complex library is often nearly as much work as writing the code yourself.
- Abstractions are lossy, and nothing will make you realize it faster than trying to swap a "quick and easy" integration.
- Maintaining glue code won't get you promoted, so it'll be neglected and left to rot.
- Open source may not care about promotions, but boy do contributors hate volunteering for tedious tasks.
If your entire engine is glue code: guess what the vast majority of your work will be.
It gets so much worse though. Suppose you need that bug fix from your dependency, or worse, want a shiny new feature to unblock your work?
Roll a d12 to determine what happens:
- You open an issue. It's ignored, then closed by stalebot after two years.
- You open an issue, but find that the work is blocked on a rewrite that's been ongoing for the past 18 months.
- You open an issue, and find that the bug is "working as intended".
- Your dependency has been abandoned because the solo maintainer burned out.
- Your dependency has been abandoned because the VC-backed company behind it was accquired.
- You open a PR, which is promptly closed for failing to follow the coding style guide for the project.
- You open a PR, and it sits unreviewed.
- You open a PR, but the maintainer disagrees with your architectural choice. Spend 3 months in review discussions.
- You open a PR, but another major user says it will break their workflow. Your PR is closed.
- You try to open a PR, but find that the maintainer only accepts PGP-signed patches via plain text email. You waste two days setting this up.
- You learn a new language, carefully read the style guide and
CONTRIBUTING.md, submit a PR, wait 2 months and get the PR merged! You must wait 3 months for the next release.
- A maintainer takes pity on you and actually just fixes your issue.
Truly, can't you see all the time you're saving? It's so Agile™!
So what makes a dependency Good?
- permissive (or at least compatible) licensing
- contributor-friendly culture
- fast reviews, merges and releases
- high bus factor
- small or unopinionated scope
- far from your core domain-specific logic
- handles a lot of annoying edge cases for you (thanks
- aligned with your vision of what the library should be
Unfortunately, if you're just gluing together libraries, you don't get to make that choice. You can't pick good dependencies or bad ones (except between competitors): you're stuck with what they give you.
A real, honest-to-god plan: none of this "we'll hire the best and work really hard!" pablum. Trying hard is not a solution, and it certainly is not a competitive edge.
Similarly, getting off the ground faster is not a competitive edge: it simply closes the gap between you and the decade of work ahead of you to catch up with the entrenched, multi-million dollar companies that you're hoping to compete with.
You cannot beat an entrenched competitor by playing their own game, but catching up faster. So what makes your engine different? Who would use it, and why?
Gluing libraries together doesn't help here: instead, it makes the problem worse. Every library is opinonated: you must either write your own, or smooth over the differences.
- harder to learn
- harder to use
- harder to maintain
- harder to optimize
And once you've glued libraries together, and you've rendered your test scene with blazing fast hyperrealistic shadows and a billion particles, doing the second 90% of the work feels brutal. You're taking working code, performing an unending series of cosmetic tweaks to it, and throwing away all of the advantages you've gained by reusing already working tools.
You're left with two options:
- Don't refactor for a unified UX: Fail to accquire users, struggle to execute any grand vision, watch the tech debt pile higher.
- Refactor for a unified UX: Burn out your team, constantly break your users, frustrate investors with the lack of progress.
Pick your poison.
Ultimately, if you want to make the Next Great Game Engine (I do!), your project needs a vision that will attract users, investors, toolmakers and contributors.
It needs features that set it apart, problems it can solve better than any of the titans, and a clear, unified model that it can teach to users and point at to keep tech debt at bay.
For Godot, that's a pervasive focus on user-friendliness, the enduring value of open source and the value of a great editor-first workflow.
For Our Machinery, that's an emphasis on hackability, the importance of performance, and the benefits of being able to build your own tools.
If you want to join us in shaping the future of game development, you better be able to tell me: what does that shiny future look like?
Thanks for reading: hopefully it was educational, thought-provoking, and/or fun. If you'd like to read more like this in the future, consider signing up for our email list or subscribing to our RSS feed.