DEV Community

Dima Popov
Dima Popov

Posted on

Five Months Building a Game Engine, One Month Building a Game. Why I Wrote My Own Engine in C

I've been developing games for more than ten years. During that time, I've released mobile and web games both in teams and solo. At different times, I used libGDX, internal engines (Lua and C++), and Defold. For the last 6+ years, I've been regularly releasing games with Defold.

I like minimalist tools such as Defold, raylib, and libGDX. They give me basic building blocks that I can use to make games the way I want.

Over the years with Defold, I wrote a lot of code on top of the engine. At some point I started joking: "With this much of my own tech, I could have written my own engine by now." At first it was just a joke, but over time I had more and more of my own tech, and the idea started to feel less like a joke.

I also wanted to understand rendering and graphics APIs more deeply. Existing engines usually hide most of this layer behind their own API.

On March 9, 2026, I created the Neotolis Engine repository and made the first commit.

A small terminology disclaimer. People often distinguish between game engines and frameworks, and in its current form Neotolis Engine is closer to a framework. I don't draw a strict line between the two, so from here on I'll just call Neotolis Engine an engine.

I'm building the engine for myself and my games. The code is open under the MIT license and anyone can use it, but the engine is developed primarily for my own projects.

Neotolis Engine is written in C17, compiles to WebAssembly, and uses WebGL 2 in the browser and OpenGL 3.3 on desktop.

I spent a long time choosing between WebGL 2 and WebGPU. According to the Poki Player Device Report, as of August 19, 2026, WebGPU is available to 89.54% of players (+7.23% in Compatibility Mode), but there are still many issues with specific devices and browsers in issue trackers. So for now I chose WebGL 2, although in a year or two I'll most likely have to write a WebGPU backend.

I'm not building an engine just for the sake of building an engine. I'm a game developer, and I'm making a tool that I plan to use to release my games.

About four months after I started development, Neotolis Engine already had enough features to move from technical demos to making a game. I had a 2D prototype of Not a Trolley Problem made with PixiJS in 13 days for Gamedev.js Jam. I had wanted to develop the idea in 3D for a while, and on July 16 I started making a new version on my own engine.

In 30 days, I got the new 3D version to a demo state and showed it to players at Comic Con Tashkent on August 15-16. The game works, people enjoy playing it, and I'm happy with the result. There is still a lot of work ahead, both on the game and on the engine.

This is what a game made with Neotolis Engine looks like:

The Engine I Wanted to Build. 6 Principles

Before I started development, I wrote down six principles. They describe how I see the engine and how I like to work: what matters to me and what I value in tools and code. These principles define the architecture of Neotolis Engine.

1. Code-first

For me, code is the clearest and most flexible way to describe a game. All the logic is in front of me, and I can easily change the order of systems, enable or disable them, or add new ones.

That's why Neotolis Engine has no required GUI settings, config files, binary settings files, or XML markup. The order of systems, render pipeline, resource building, and UI are defined in code. If a specific game needs a config file or CLI parameters, it can add them itself.

// shortened frame from Not a Trolley Problem
static void frame(void) {
    /* Input */
    nt_window_poll();
    nt_input_poll();
    game_input_capture(&s_input);

    /* Update */
    nt_resource_step();
    nt_material_step();
    game_runtime_host_update(
        &s_runtime,
        &s_input,
        s_cli.disable_autosave
    );

    /* Render */
    game_render_pipeline_begin_frame(&s_render_pipeline);
    game_render_pipeline_draw(
        &s_render_pipeline,
        &s_runtime.world,
        &s_runtime.features,
        &s_input,
        s_runtime.ready,
        game_scenes_should_render_world(&s_runtime.scenes)
    );
    nt_gfx_end_frame();

    /* Present */
    nt_window_swap_buffers();
}

int main(void) {
    /* initialization */
    nt_app_run(frame);
}
Enter fullscreen mode Exit fullscreen mode

I like the old-school approach where the engine gives you an update loop and basic building blocks, while the developer decides what happens inside, how it happens, and in what order.

2. Explicit over implicit

It's important to me that engine behavior is explicit and predictable. When an engine makes decisions for me, everything is fine until I need different behavior. Then I have to find where that decision is hidden and how to work around it.

If I do something wrong, the engine should immediately tell me with an error. It shouldn't try to fix the situation itself or make a decision for me.

For example, many engines already have a predefined rendering setup: opaque, transparent, and their own sorting rules inside each one. You can configure it, but the basic decisions have already been made by the engine. Neotolis Engine does the opposite: the game defines the passes and the order of objects inside each pass.

For every render item, the game sets its own sort_key. In one pass, the key can be based on material, in another on depth, and where sorting isn't needed, it can be skipped entirely.

The same applies to errors. For example, if you pass more textures when creating a material than it supports, the engine won't trim the list or choose something on its own:

NT_ASSERT(desc->texture_count <= NT_MATERIAL_MAX_TEXTURES);
Enter fullscreen mode Exit fullscreen mode

This approach requires more code, but everything stays explicit and predictable. There is no hidden behavior or engine magic somewhere inside.

3. KISS: Keep It Simple, Stupid

I try to solve specific problems in the simplest way possible. If a solution can be made generic without extra complexity, great. If making it generic means building extra layers and abstractions, I'd rather solve the current problem directly.

For me, KISS doesn't mean writing everything myself. A small module can be easier to make myself, while for something more complex I can use an existing solution. That's why I use cglm for math, GLFW for desktop windows and input, and Clay for UI layout.

For me, the main goal isn't to write as little code as possible. It's to avoid bringing complexity into the project that I don't need.

On the web, simplicity has another very concrete dimension: build size.

4. Tiny size

For web games, size is especially important. Poki recommends keeping the initial load under 5 MB and the whole game around 8 MB.

I track runtime and asset size separately. WASM size is calculated on every PR together with the difference from the previous version. If a small change suddenly adds tens of kilobytes or a heavy dependency gets into the runtime, I'll see it immediately.

Demo WASM Assets Other Final size
Hello 8.505 KB - 4.499 KB 13.004 KB gzip
UI Showcase 491.070 KB - 9.669 KB 500.739 KB gzip
Text Rendering 43.479 KB 7.583 MB 8.825 KB 7.634 MB gzip

"Other" means index.html and the loader JavaScript.

These are the sizes of specific demo builds. Each demo includes only the modules it needs, and unused code doesn't end up in the final WASM.

Text Rendering stands out a lot. Almost all of its size comes from text_cjk.ntpack: it contains 39,238 Chinese, Japanese, and Korean glyphs and takes 7.571 MB gzip.

I also try to keep asset size as small as possible. The builder processes them in advance, and assets can be split into separate packs. This means the game only needs to load what is required to start, while music, CJK glyphs, or HD atlases can be loaded later.

5. Set of modules

Neotolis Engine consists of separate modules, and a game includes only what it needs.

A 2D game can work without the mesh component and mesh renderer. If a game doesn't use sprites, it doesn't need the sprite renderer. If it doesn't need the resource system, that doesn't have to be included either.

The Bench Shapes demo is built only from core, app, window, input, gfx, math, log, and shape renderer. There is no resource system, UI, mesh renderer, or most of the other modules in this build. At the same time, it's a 3D scene made of primitive shapes where you can fly around with WASD, Space/Shift, and control the camera with the mouse.

You can also build a headless version for tests or a server by replacing window, input, and graphics with empty stub implementations.

Development tools are separate modules too. Metrics, debug overlay, and DevAPI can be used during development and left out of the release build.

6. Prebuilt assets

All heavy asset work happens at build time. The game gets data that is already prepared.

Runtime doesn't work with source formats such as PNG, glTF, or TTF at all. It only receives data that has already been processed by the builder.

The builder converts meshes, prepares fonts, builds atlases, compresses textures with Basis Universal, and puts everything into .ntpack.

Asset build rules are written in regular C code. For example, here is a shortened fragment of the builder from Not a Trolley Problem:

NtBuilderContext *ctx =
    nt_builder_start_pack(pack_path(out_dir, "game.ntpack"));

/* Font: bake only glyphs used by the game */
/* LOC_CHARSET_NON_ASCII is generated from localization */
#define GAME_CHARSET NT_CHARSET_ASCII LOC_CHARSET_NON_ASCII

nt_builder_add_font(
    ctx,
    "assets/fonts/Rubik-Regular.ttf",
    &(nt_font_opts_t){
        .charset = GAME_CHARSET,
        .resource_name = "game/font_body",
    });

/* Texture: compress at build time */
const nt_tex_compress_opts_t compress =
    nt_tex_compress_etc1s_high();

nt_tex_opts_t tex_opts = nt_tex_opts_defaults();
tex_opts.compress = &compress;

nt_builder_add_texture(
    ctx,
    "assets/ui/currency/tram_token.png",
    &tex_opts);

/* Atlas: pack UI sprites and compress the page */
const nt_tex_compress_opts_t ui_compress =
    nt_tex_compress_etc1s_high();

nt_atlas_opts_t atlas_opts = nt_atlas_opts_defaults();
atlas_opts.compress = &ui_compress;

NtAtlasBuild *ui_atlas =
    nt_atlas_begin(ctx, "ui", &atlas_opts);

nt_atlas_sprite_opts_t tutorial_hand_opts =
    nt_atlas_sprite_opts_defaults();

tutorial_hand_opts.name = "tutorial_hand_tap";

nt_atlas_add(
    ui_atlas,
    "assets/ui/tutorial_hand_tap.png",
    &tutorial_hand_opts);

/* Build the atlas and add it to the pack */
nt_atlas_commit(ui_atlas);

nt_builder_finish_pack(ctx);
nt_builder_free_pack(ctx);
Enter fullscreen mode Exit fullscreen mode

ETC1S and UASTC are available for textures. A specific charset is set for a font, and the atlas is fully built and compressed during the build step.

The result is stored in .ntpack. It's a simple flat binary format: a header, a list of assets, the data itself, and optional metadata. After loading, runtime validates the pack and accesses the required data directly by offset inside the loaded block.

.ntpack intentionally has no backward compatibility. Runtime expects an exact format version, and if the version doesn't match, it simply refuses to load the pack.

The six principles above define how Neotolis Engine works. But having my own engine gives me one more important thing: I choose the technologies myself and can add them when I need them.

Freedom to Choose Technologies. Adding Slug

I got lucky with the timing of text rendering. I had just reached fonts and was about to implement them in the usual way with SDF.

On March 17, 2026, Eric Lengyel published the Slug reference shaders. With SDF, a glyph is converted in advance into a distance field texture. Slug instead stores the vector outline of the glyph as curves and renders it directly on the GPU.

SDF is simpler and works great for most tasks. But I liked that Slug keeps the actual vector representation of the font. So I decided to try Slug instead of SDF.

By March 31, the first version of the Slug renderer was already working in the engine, and on April 2 I had a text demo with several languages. It took 16 days from the publication of the reference shaders to a working renderer in the engine.

How I Use AI in Development

I also want to talk separately about AI. All Neotolis Engine code is written by AI. I don't write code myself. I define what should work and how, make architectural decisions, design APIs, set tasks, and review the code.

To give an idea of the size of the project, the source code is currently split like this:

Part of the project Files Non-empty lines
Engine runtime 224 46,694
Asset builder 27 14,812
Tests 154 64,296
Total 405 125,802

The number of lines by itself says nothing about code quality, but it shows the scale of the project and how much of it is tests. The count includes C, C++, JavaScript source files, without dependencies, examples, generated code, or empty lines.

For large tasks, I use GSD (Get Shit Done), a framework for spec-driven development with agents. It collects context and requirements, creates a spec, splits the task into stages, and then handles implementation and verification.

Most of the code is written by Claude. I tried using Codex, but I don't like how it writes code. It adds too much ceremony, extra checks, and complexity in places where things could be simpler. Claude usually does exactly what I asked for.

Codex is very good for review, though. Usually Claude does the task, then Codex reviews the code, Claude addresses the comments, and I send the result for review again. There can be several cycles like this.

After a few such cycles, I read the PR myself. And I still regularly find strange decisions. Code can work and pass several AI reviews but still be simply illogical. Sometimes there are too many layers, sometimes a simple thing is spread across several functions, and sometimes a solution is too narrow even though it could easily be made generic.

I barely touch tests and CI anymore. I used to read those too, but code is written very quickly, there are many reviews, and there simply isn't enough attention for everything. So I decided to spend my focus on engine code, architecture, and API.

CI was written entirely by AI, and I'm happy about that. I barely know how it works internally. CI just works, runs the required builds and checks, and if something breaks, the agents deal with it first.

With the game, the approach is different. There I care more about the result in the game than about exactly how the code is written.

And this is where a feedback loop is especially important for AI: the agent needs to be able to make a change, run the game, check the result, and fix it if necessary.

For this, the engine has DevAPI. Through it, an agent can control the game, get its state, inspect UI, objects, and logs, and call game functions. If it needs to check visuals, it can take a screenshot or record a video.

For longer scenarios, such as recording a trailer or checking a full playthrough, the agent can write a Python bot that plays the game by itself.

I barely read the game code itself. I mostly look at the result. I constantly play the build, check mechanics, UI, and balance. If something works strangely or simply feels bad, I send it back to the agent to fix.

So with the engine, I mainly control the code and implementation, while with the game I control the behavior and how it looks.

Not a Trolley Problem. A Game in One Month

On July 16, I started making the 3D version of Not a Trolley Problem with Neotolis Engine.

Not a Trolley Problem is an incremental game about the trolley problem. The idea is to take the moral dilemma and make it completely absurd. Instead of deciding who to save, you drag people onto the tracks, earn money, buy upgrades, and gradually automate the whole process.

The game itself wasn't made from scratch. Before this, I already had a 2D prototype made with PixiJS that I built in 13 days for Gamedev.js Jam. The main idea, mechanics, and understanding of what I wanted were already there.

The basic loop started working pretty quickly: a crowd, a trolley, you can grab people, throw them onto the tracks, and get coins.

Then I improved the game iteratively. I added mechanics, reworked the UI, tried different visuals, and changed the balance. Everything currently in the game was made by AI. I decided what needed to be done, looked at the result, and gave feedback.

The game very quickly showed what the engine was missing. When you make separate demos, many things simply never come up. In a real game, they started appearing on their own: shadows needed a depth compare sampler, post-processing needed RGBA16F render targets, and optimization required changing how buffers worked.

I simply hadn't thought about some of these things before the game. A real project tests an engine much better than separate technical demos.

After a month, the game already looked like a demo. I added upgrades, automation, different people and bosses, saving, a tutorial, and two localizations. I built browser and Windows versions.

And about a month after starting the 3D version, I was already showing the game at Comic Con Tashkent and letting people play it at the booth.

Conclusion

Right now, Not a Trolley Problem is more of a demo than a finished game. The main loop already works, there is content, and I can give it to people to play, but there is still a lot of work ahead.

For me, what matters is that Neotolis Engine can already be used for more than separate technical demos. I can build a real game with it and gradually develop the engine together with the game.

Of course, I would have made this game faster with an existing engine. But I like building tech.

I expect the time I'm spending on Neotolis Engine now to start paying off later. On the second game I won't have to write half of these things again, on the third even less, and gradually I'll have my own set of tools that fits my games specifically.

I plan to turn Not a Trolley Problem into a full game. After that, I want to make more projects with Neotolis Engine and keep developing the engine together with them.

Links

This is an English translation of my original Russian article. The translation was prepared with AI assistance and reviewed by me.

Top comments (0)