Showing Thousands of Entities While Keeping the Game Smooth

fast.gif

Play the game here: Karma Keepers

In the late game of Karma Keepers, you’ll see thousands of entities on screen. Each entity is made up of several components, like clothing with specific colors, shadows, and more. One of my main goals was ensuring the game runs smoothly no matter how many elements are being rendered, so I put a lot of effort into optimizing the game to handle this without sacrificing performance.

The game is built with Rust using a custom engine that exports to WebAssembly and WebGL2. This, by itself, makes it very performant compared to alternatives using interpreted languages.

But often, the real challenge is not the language, but the game’s logic. In Karma Keepers, there are four main elements to render: parallax backgrounds, sprites, geometric shapes, and text. Each element has its own transformation matrix and color properties, so it's crucial to handle how they’re drawn and in what order.

I'm using a technique called Sprite Batching, which I apply to everything: parallax patterns, sprites, text, and even shapes. Batching means sending all elements to the GPU at once, avoiding multiple draw calls. This matters because communication between the CPU and GPU is slow. By telling the GPU to draw 100 elements at once, instead of issuing 100 individual draw commands, performance improves significantly.

Each batch is defined by certain rules. Usually, if the resources differ from the last, you need to create a new batch. In our case, a new batch is required if the shader changes, the texture bound to the GPU is different, or the projection matrix is updated. Karma Keepers uses the same texture for all sprites. They are packed into a single texture atlas, and each sprite knows the coordinates of the element it needs to draw, so there’s no need to switch textures for each sprite. Text, on the other hand, uses a separate texture that updates based on the text to be rendered.

As for matrices, all transformations are computed on the CPU. While this is slower than doing it on the GPU, it allows us to bundle all vertices into the same batch more easily, simplifying the rendering process.

To optimize further, I divided the rendering into two phases: the game world and the UI.

Game World:

  1. First, the parallax background is drawn. This is a single draw call since it's the only background on screen.
  2. Next, all the sprites (souls, their clothes, and shadows) are rendered. For each, I calculate the transformation matrix and apply the appropriate color.

UI:

  1. The Blessings and the progress bar are rendered as sprites.
  2. Then, shapes like background rectangles for the Blessings are drawn.
  3. Finally, all the text is rendered.

By organizing the rendering in this way, the entire game is drawn in just five draw calls, which is a very low number.

To avoid drawing off-screen elements, I also check the camera bounds. Anything outside of the view is skipped, saving processing time and avoiding unnecessary data being sent to the GPU.

CPU side optimization:

The main CPU challenges are handling transformation matrices, colors, and collisions. But within reasonable limits, this is relatively fast. I'm using simple AABB (Axis Aligned Bounding Box) collisions, which aren't very complex. They keep souls from overlapping and allow entities to push each other when you're guiding them with the mouse. The collision system is based on simple rectangular bounds, with entities having a "force" to determine their push strength, guiding souls have a bit more force so they can move others.

And that's it! No magic, just organizing the code to make it easier for the GPU to process. There's still room for optimization, but it's more than enough for this game at this stage.

Thanks for reading, and don't forget to play the game! Appreciate the support!