Making Custom Game Engine

  11 min read

In my previous article I covered what's ECS in overall, and what's ECSY, some other tools and potential bottlenecks.

In this article I'll cover some in-depth benchmarks of what I built and play around it a bit.

Behind the scenes, before writing this article, I have quickly rewritten .js code examples to .ts and got rid of ECSY library:

languages

90% of code is TypeScript now

ECSY has neat concept but I figured out I don't like its internals and replaced it with my own ECS implementation. And even though my goal was just to get everything sorted and look good, clean, and typed, - it allowed me to achieve like at least x20 less GPU usage:

frames

x20 less GPU usage now

I initially got bombuzled because I thought it's all on my CPU now, and I broke all the performance. Of course you can't notice the difference on such a simple example without hacky tricks: it runs smoothly at full 60fps in both cases. But with x6 throttling on CPU, I quickly realized I now have better CPU usage, too:

6x-throttle

x6 throttled Intel i7-9700, before and after: now it has less frame drops

So it turns out clean code and types lead to performant applications and games. Who knew?

Now the project structure:

. # root folder with all the config files
src # folder with source code
src/asm # experimental folder with AssemblyScript files that are later compiled into `.wasm` modules
src/components # the first whale of ECS pattern: components
src/elements # `.tsx` React Elements to render UI like buttons and menus
src/entities # second whale of ECS pattern: entities
src/game # the rest files related to game logic, like World.ts
src/helpers # little utility functions
src/main # electron "main process"-related files like background.ts and create-window.ts
src/pages # all possible screens that I'll have in game, at this step is not actively used though
src/systems # finally third whale of ECS pattern: systems

Since components are basically javascript objects for data storage, my whole ECS implementation ended up with those three files:

src/game/Entity.ts # Base class for all entities to inherit
src/game/System.ts # Base class for all systems to inherit
src/game/World.ts # Almost like singleton-class, no inheritance needed

And instead of so-called ECS "Queries", well... Have you heard about such a thing as objects? Hashes? Associative arrays, anyone? Why everything should be stored in classes? So I got rid of such class as "Component" and consecutively I no more need those weird "Queries". Also, I don't store anything in another consuming data structure called "Collections" (basically arrays of objects). Collections are overrated and simply slow. So I use only plain arrays and nested objects, never an object inside an array (except for input arguments but never for storing). Pretty much like React, actually... This way I get beautiful syntax for accessing data out of the box.

So instead of this awful API to extract components:

entity.getComponent(Acceleration).acceleration
entity.getComponent(Position).position
// etc...

I can extract components much simpler now:

entity.props.velocity
entity.props.position
// etc...

And the best part is that they're perfectly typed, so unlike in ECSY, I now have hints for the components and their data:

types

Now I have those precious hints

Those hints are based on each particular System, so it can only access those properties related to a system:

const Renderable = { ...Position, ...Shape } // <-- I can only access those components, related to the system

export default class RenderableSystem extends System<typeof Renderable> { ... }

So it turns out I don't need any special structures for querying data: JSON is my API. I guess this is one of the main reasons why my implementation got more lightweight and performant than ECSY framework.

Basically it's all just a web application on steroids, so in my root React component I use a couple React Hooks to do the following.

const world = useMemo(() => {
  if (ctx) {
    const wd = new World({
      systems: [
        /* World initialized with these listed systems */
        /* It is able to fill them up dynamically later */
      ],
      entities: [
        /* Might initialize world with some entities */
        /* Also able to add them dynamically later */
      ],
    })
    return wd
  }
}, [ctx])

And this is how game loop gets started, when world is initialized:

useEffect(() => {
  if (world) {
    console.info('Game loaded!')
    world.run(performance.now(), setFps)
  }
}, [world])

At this point we're basically out of React paradigm, and can make all the logic inside our ECS paradigm.

I can still use React to render whatever UI on top of the game, accessing systems, entities, their components and data. Which is kinda cool: React for user interface menus and buttons, ECS for game loop and continuous game logic handling.

Is ECSY dead?

I heard this question on GitHub, and the answer tends to be "yes", - in addition to all the performance problems and awful syntax it involves, it seems to be unmaintained too.

I initially liked ECSY but quickly realized I like the ECS pattern, not the ECSY library.

Some people still try to adopt it by making things like ecstra, though I'm sceptic about it, since it's still based on ECSY with all its weird queries and component-classes.

Even with custom ECS implementation, I still face the same problem I mentioned in my previous article though: RenderableSystem. It's very naive and still re-draws the whole screen entirely as before, with no optimizations, so it's still the bottleneck of whatever engine I'm coming up with, and still upon my attentive observation. First candidate for replacement.

What's about AssemblyScript?

I currently still have no use to that basically, but I have plans on it, and I'm preparing some ground for it. I made a couple Pull Requests so it can be actually usable to me, with all the conveniences I'm used to. Like TypeScript hints: AssemblyScript#1705, and globs: AssemblyScript#1716. I'm using previously mentioned 1 + 2 = 3 little WASM module, just to make sure it's all working, with my own fork listed in my package.json, before my changes get landed into AssemblyScript:

package.json

{ "devDependencies": { "assemblyscript": "https://github.com/jerrygreen/assemblyscript/tarball/jerrygreen" } }

Conclusion

ECS is a neat architecture, but ECSY framework is poorly designed, non-performant, and basically unmaintained, so I came up with writing my own ECS implementation that fits my needs, fortunately the concept is real simple.

Even if I trust the rendering part to some library rather than my custom RenderableSystem class, I might still use the rest of my ECS implementation for calculating game logic, and even move it to server-side, to orchestrate all the world interactions for multiplayer, for example. Fortunately, no any RenderableSystem needed on server, so basically no such bottleneck at all.

I don't have much functionality in my game yet: still just a few flying circles and rectangles. I'm hoping to feature other interesting parts of the project I'm making. But for now...

That's it!

Comments 6

Hopefully it will be possible to see and leave comments right out here later, but for now:
Please leave feedback on Github
or
Write your thoughts privately via email