I think it is without question that you should change direction. Below I discuss in detail why I feel this way.
"Keep it simple, stupid!" KISS principle - Wikipedia
Make your first engine by designing it so that it can run a single game.
An engine that can run any kind of game will have a lot of moving parts and incidental complexity that the few games you build with it won't be able to fully utilize. You'll spend most of your time spinning your wheels, letting perfect be the enemy of good. [1]
General-purpose engine code should be a DSL over a virtual machine. [2]
So far, I've linked a few examples for general-purpose engines, mostly in footnotes. Be sure to click through those. There are also plenty of examples in the other direction, which I'll start to get into now.
I put together the simplest possible game engine (as in, not a general-purpose engine, it's an engine whose only purpose is to run the game it's written for) quite a number of years back for a simple Space Invaders clone:
pixels/examples/invaders at main · parasyte/pixels
The engine is in the simple_invaders
directory. This link points at the shell, which uses the engine as a library and displays on screen whatever texture data the engine writes.
The architecture of this engine is literally as simple as it gets. You construct a World
, periodically call the update()
method, passing in player control inputs, and then call the draw()
method to create the texture data. The meat of the work is just a few loops inside update()
to process entity AI, animations, collision events... It contains absolutely none of the dynamic runtime decisions that your design has, like plugins or arbitrary function pointers.
So, why bring it up if it's so simple and practically incomparable to your architecture? Well, I wrote the whole thing in about a week, according to git history. On the one hand, that's a pretty rapid development cycle. On the other hand, there isn't much to Space Invaders, the whole game can be described by a state machine. And that's essentially what was written. My point is that game engines do not require everything to be dynamic and defined at runtime. Maybe growing and shrinking a few vectors is enough.
Another game that I wrote (from scratch, including the engine) in a week was an entirely original concept:
blipjoy/sombervale: Sombervale, a Rusty Jam 2021 game
For this one, I started out with a very different architecture. Instead of hard coding the various entities as vector fields of the world, I decided to make the whole world a single vector of dynamically dispatchable types, leaving a comment that I might want to replace it with an ECS in the future. Sure enough, this design bit me almost right away and I replaced it with shipyard
the following day.
The problem is that this design is just downright awful. You run into mutable aliasing problems trivially because the only way to update entities in a single vector is to iterate over it. And as soon as entities have any cross relationships (like collision detection), the whole thing falls apart.
In the much simpler hard coded design, cross cutting concerns like collision detection are dead simple -- as long as entities of the same type do not need to interact with one another. Space Invaders makes an excellent case study for this: Invaders do not collide with other invaders. Lasers/bullets do not collide with other lasers/bullets. Shields do not collide with other shields. Everything is separate by design. You can iterate these entities without needing to also mutate them at the same time.
Could Sombervale have been written with the same simple architecture instead of relying on ECS? Maybe! Frogs don't collide with other frogs. Shadow creatures don't collide with other shadow creatures. Wall tiles don't collide with other wall tiles. There's only one player entity. Seems like it could have worked. But I poisoned the engine in the very first commit by trying to cram everything into a single vector. ECS was my escape hatch to keep that unsatisfactory design.
Quick side note on ECS
The good news? ECS is very powerful if you want to create emergent experiences for players. When digging into the topic, you will hear of "fire-ice swords" and the like, where elemental powerups can compose with weapons in unintentional but unique and compelling ways. You may not have intended fire-ice swords to exist, but they can be created as a side effect of the dynamic runtime. That emergent behavior can serve as a provocative game mechanic itself.
The bad news? ECS is complete overkill if your game designs are linear and rigid, like classic arcade games or modern casual games. And it's really difficult to get right! Use a library, there's no use trying to write your own. Rolling your own is just a distraction.
To me, bevy kind of misses the point. It's a general-purpose engine, but there is no VM. Rust is playing a dual role as both the implementation language and the interface for a DSL composed of systems. It feels out of touch with what general-purpose game engines actually do in practice. I choose to believe this is because it's still very early in the design phase. It will still take some time for it to evolve beyond "Rust is the DSL."
It's probably worth talking about DSLs
DSL is a term that is easy to throw around without being clear about its meaning. The way I am using the term is in the sense of Greenspun's tenth rule. As a game grows in scope, it's wise to move away from hard coding things like level structure, animation stacks, AI, etc. toward defining the atomic pieces as data that can be loaded separately from the game and manipulated at runtime. This makes modding straightforward: just replace the data that defines levels and scripts with modified variations.
Levels can be edited in tile map editors and saved as JSON. The level data can contain entity references like the player's spawn point, doors that lead to other levels, enemy spawners, and so on. These pieces of data are a DSL, and the engine is its interpreter. Very much like a VM that interprets byte code. Some VMs are explicitly designed as such, others are implied Greenspun-style.
DSLs are bad when they are too rigid to do what is required by the designer. The hardcoded systems need to resemble machine instructions for the DSL to be capable of "total conversion" mods. Or scripting needs to be exposed as part of the data, part of the DSL.
Check out CellPond and the associated videos. This is a good DSL. One of the fascinating things about this area of research is that I don't personally have any good way of articulating what a DSL is. You just kind of know it when you see it. Something like code that consumes data and makes dynamic decisions based on how that data is interpreted.
So, to that end, DSLs are often opaque and hidden in plain sight. It's really hard to identify them, and more difficult to extract them into general-purpose VMs.
This is the secret ingredient that bevy's architecture is missing. The DSL doesn't fit the purpose of, well, general-purpose engines. My hope is that they will get there eventually. But as of now, it's not what you want to be inspired by.
And then you'll blame the tools for your mistake. Don't be this guy. ↩︎
There is a lot to say on this topic, but some prominent examples are ScummVM and the Another World engine. "Fantasy computers" like CHIP-8 and PICO-8 fall into this category. Do these examples seem too old and simple? What about Doom 3? Some engines use Lua, some use Python, some go all-in on real-time visual design, etc. ↩︎