I'm building a 2D Game Engine with WebGPU - Nanojet Devlog #1
I recently participated in the js13k game jam, a yearly competition where developers create games in just 13 kilobytes of JavaScript code. It runs for a whole month, from August 13 to September 13. I've been toying with game development as a hobby ever since I started programming years ago, and this experience made me realize it was high time to get serious about deepening my understanding of how game engines work under the hood.
Though I've used engines like Unity and Godot, they are complex beasts that abstract away a lot of things. This is great, until it's not. Anyone who has used these engines for a while has inevitably run into situations where things didn't work as expected or where the engine lacked an easy-to-use abstraction to achieve the desired results. If you don't have a deep understanding of the engine in question, or game engines in general, this can get pretty frustrating, and figuring out workarounds can be a huge time sink.
For js13k, I ended up using a really cool engine called LittleJs. Using it for about a month inspired me — if you can fit a whole game engine in less than 7k of compressed JavaScript code, building a game engine can't be THAT hard, can it? Why do I have a feeling this is gonna come back and bite me in the butt. Oh well.
So here we are. Welcome to the first devlog for "Nanojet" — my 2D game engine built on top of WebGPU! I've been working on this project on and off for a few weeks now, and there's already been quite a bit of progress. Today, I want to take you behind the scenes to show you how things have been going, from struggles with game loops to finally getting circles and rectangles bouncing around the screen at 120 FPS. A lot of circles and rectangles! I know, exciting, right?
I'm doing this mostly as a learning exercise, but I'm also serious about trying to make a nice and well-documented project that people can actually use in the next js13k competition, in about 9 months. So the clock is ticking.
Of course, the most common advice given to aspiring game engine developers online is usual just 'don't.' But here we are! Let's dive in!
Monorepo and setup
Starting off, I was trying to figure out a good structure for my project. After "don't", the second most common advice you'll find given to game engine developers is to not build the engine in a vacuum and instead build a game as you build the engine. I wanted both the game engine and the game itself to be in the same repository, but still keep them nicely separated. I also wanted the game to import the engine just like a user would, using an npm package. To make sure everything worked as expected.
So I tried a few approaches, but things weren't quite clicking until I discovered that npm actually supports Workspaces now — a feature that lets you manage multiple packages within a single repository. It ended up being exactly what I needed: clean, simple, and perfect for a project that's not supposed to be a massive enterprise-scale endeavor. A basic monorepo setup, but it's made my life a lot easier.
Game loop blues
With the repo ready, I decided to tackle the heart of the engine: the game loop. I went with a setup inspired by the book "Game Programming Patterns" by Robert Nystrom — a fixed update and variable render approach with extrapolation for smoothness. The advantage of this kind of game loop is that it ensures consistent game logic updates regardless of rendering performance, which makes gameplay more predictable. At this point, I'm just using the plain old Canvas2D API to display a small rectangle on screen and cobbled together some code to allow moving the rectangle using the keyboard. But for some reason I was seeing some visual stutter and blur when moving the rectangle around.
I went through several iterations: using interpolation instead of extrapolation, calling requestAnimationFrame
at the top of the loop instead of the bottom, recalculating lag at different points in the loop. Nothing seemed to eliminate the issue entirely. Finally, I decided to stick with my original implementation and accept that maybe some hiccups are just part of running in a browser environment. But hey, at least I managed to add tracking for the average update and render frame rates — you gotta take the wins where you can get them!
As a side note, I now think the stutters and blur might have been related to a combination of how I was handling keyboard input, the screen resolution and Canvas2D. As you'll see later, I've gotten rid of the problem when I introduced WebGPU along with proper handling of the canvas and screen resolution.
Enter the ECS
Next up, I implemented a simple Entity Component System, or ECS. LittleJs is great, but it uses a pretty common OO approach, using inheritance. I want to go a different route and see if I can make this work with ECS instead. My implementation is pretty barebones, but I’m keeping things lightweight since this is all for js13k and I need to think about code size. I went for a "Sparse" type of ECS because it seems easier to manage with minimal code and should work fine performance-wise for the kinds of games built during js13k.
Users of the engine will of course be able to define their own components but I want the engine to provide a few standard ones. The first one I implemented was a TransformComponent. It's in charge of holding typical information about an entity's postion, rotation and scale and it's backed by a 2x3 matrix.
Transform Component Evolution
Originally, I updated the internal matrix with every transformation, but this was inefficient since the matrix is mostly used for rendering or physics. So, I introduced a 'dirty flag' — now, the matrix only updates when it's actually needed.
Initially, getting the position or scale returned a copy of the internal Vec2, which wasn't intuitive for modifications. Changing properties like position.x only affected the copy, not the actual transform. Instead, I needed a way to mutate values directly while keeping performance in mind.
With advice from ChatGPT, I made Vec2
act like an observable. Now, you can get and set properties with regular dot accessors, and internally, setting a property triggers a callback that marks the matrix as dirty. This way, the matrix updates automatically whenever it's accessed next, balancing performance with usability.
WebGPU and Rendering System
Then came the big guns: WebGPU. I added a Renderer class and got my first rectangles on screen. Let me tell you, it was magical to see those boxes moving around, even if everything was hardcoded at first. I set up a RenderRectangleSystem
that takes any entity with a RectangleComponent
and a TransformComponent
and... well, it renders it!
Afterwards, I made the renderer more generic, allowing me to add circles as well. I created a RenderCircleSystem
, and soon enough, I had a screen full of bouncing circles and rectangles. Adding a VelocityComponent
and an AngularVelocityComponent
gave them motion, and I even added a basic MovementSystem
and RotationSystem
to handle updates. This worked great, with a few objects. But with 200+ objects, my frame rate started to drop. Time to optimize.
Performance Optimization - Instancing
Up until now we were making direct draw calls to the GPU for every single object to be rendered. I was confident that this was the problem. After a little bit of research I decided to try instancing. It was a game-changer. After implementing it, my performance shot up almost two orders of magnitude — I can now have thousands of moving entities on screen, all smoothly running at 120 FPS.
I also cleaned up my components a bit. Instead of having separate RectangleComponent
and CircleComponent
, I unified them into MeshComponent
and MaterialComponent
, making the render system more versatile. It’s a nice setup now, and adding new geometry types should be pretty straightforward.
Next Challenges
So, what’s next? Well, instancing brought a new problem: draw order. Right now, entities are drawn by geometry type, which means all circles render on top of all rectangles or vice versa. I need to sort that out and make sure objects are rendered in the order we expect them to be.
I also don’t have a concept of local vs. world coordinates yet, or any hierarchy for transforms, so that’s another big one. Another thing — the renderer class is getting a bit too bulky for my taste, so a refactor is probably on the horizon.
And finally, I need to make sure my builds are lean. I don’t want unnecessary debug code ending up in production builds. Tree-shaking, module splitting, excluding debug tools — that’s all on my checklist.
That’s where I’m at so far with Nanojet! It’s been a fun ride already, and there’s so much more to come. If you're interested in following along, make sure to subscribe. I’d love to hear your thoughts or suggestions down in the comments. Any optimization tricks or features you think I should prioritize?
Thanks for reading and I'll see you in the next one!