Skip to content
Vilanel
Devlog

Notes from building an engine

No release schedule and nothing to sign up for. Just a record of what I built, and why I built it that way, written as it happened.

15 entriesFebruary 2025 to August 2026

Mixing animations by speed

Before this, blending between a walk and a run meant wiring up the maths yourself, one clip at a time. It works for two. It falls apart at five, and it falls apart completely the moment you want to blend by direction as well as speed.

Now the clips carry their own positions, walk at this speed, run at that one, and you ask for a point in between. The number of clips stops mattering.

Keeping them in step is the part that makes it usable. Two animations blended at arbitrary amounts drift apart within a second unless their timing is linked, and a walk blending into a run with the feet out of step looks worse than either one on its own.

Opening more than one file format

Mixamo hands out FBX. Sketchfab source archives are FBX. A prop downloaded from anywhere is usually OBJ. Telling someone to install Blender and convert their character before the engine will look at it is a worse answer than reading the format.

One place handles all three rather than three separate importers. Everything downstream receives the same thing regardless of what it came from, so nothing else in the engine knows or cares which format was on disk.

Four tree views that disagreed with each other

None of that was a decision anybody made. It was the same problem solved four times, months apart, by someone who had forgotten exactly how they solved it the first three.

They now share one implementation, which owns the row itself: the highlight, the selection marker, the indentation, the expand arrow, the icon and the label. The scene tree, the asset browser, the skeleton view and the interface designer all use it, and all four picked up the proper selected styling on the way.

It owns the row and nothing above the row. The four trees are showing completely different things, so anything that also owned the structure would have to understand all four kinds of content, which is how a shared piece turns back into four.

Double click should open the right thing

A level is what the viewport and the scene tree are already for. An interface file has its own canvas, its own palette of widgets and its own undo history. Neither wants a page listing its file size.

All four ways of opening something, a double click on a tile, a double click in the list, the right-click menu, and creating a new one, now go through the same path. Something that opens correctly one way and wrongly another is worse than something that is always wrong, because only half of it looks broken.

Opening a different level with unsaved changes asks first. Opening one with nothing unsaved does not, because a confirmation that appears every single time is one nobody reads by the third time.

A window for looking at animations

The preview is deliberately cheap. Drawing it properly would have meant a second full renderer, with its own lighting and shadows and everything else, for a panel a few hundred pixels tall.

Playback runs through the real animation system rather than a simplified copy, so a clip previewed here and a character in the level move through exactly the same code. Two implementations would eventually disagree, and the preview would be the one that lied.

The asset browser also got right-click menus, on files, on folders and on empty space, and the scene tree got rename and duplicate. Duplicating a group of objects fixes up the links between them, so the copy is a real copy rather than a second set of children hanging off the original.

Animation should not play while you are editing

A walk animation moves the character forward. That movement was being applied every frame the editor was simply open, so the position you had placed the character at slowly drifted, and the level came back modified without anyone having touched it.

Physics already worked this way: objects stay where you put them until you press play. Animation just had not been given the same rule.

The pose is still calculated while stopped, just without advancing time. It has to be, or an animated character collapses into a heap the moment it stops moving. It also means the pose updates as you edit the animation, which is the behaviour you want anyway.

Import did nothing at all

There is now a file picker, and you can drag files onto the window from anywhere. Both go through the same path, so there is one answer to where an imported file lands: the folder the browser is currently showing. With no project open it says so instead of quietly swallowing the files.

Dragging in several files at once arrives as one import rather than several. A model and the data file beside it are two files describing one thing, and importing them separately would have converted a half-complete model first.

Copying the file in is all import does. The engine is already watching that folder and converts anything that appears, so an import that ran the converter itself would be a second, competing way to do the same job.

Animation state machines, and only running one of them

State machines are built from the same nodes and wires as everything else in the graph editor, so they inherited the canvas, saving, undo and the properties panel without any of it being written again.

The obvious way to build one out of the existing pieces was a chain of either-or nodes. That does not work, and the reason is worth writing down: those nodes calculate both of their inputs before choosing between them. Twenty states, twenty animations sampled every frame, nineteen of them thrown away.

The fix could not be a new node, because a node's inputs are always calculated before the node runs. It had to happen when the graph is compiled, by only generating the work for a state inside the branch that runs when that state is active. The test measures it: two states, one animation sampled.

Blending between states brought its own question about where the timer lives. The same graph running on two characters has two blends in progress, so the blend belongs to the character rather than to the graph.

Repainting a texture did nothing

The converter now records what each output was built from, so a change to any input rebuilds the things that depend on it.

The bigger change in the same stretch of work was that the engine stopped scanning for changes. It used to check every file in the project every few frames, looking for anything newer than last time. That costs the size of the project rather than the size of the change, and it could not say what had actually changed, so any edit rebuilt everything.

It now waits for the operating system to say. Two things about that are not obvious: saving a file is rarely a single event, because applications write a temporary file and rename it, so reacting to the first notification means reading a half-written file. And notifications can be dropped under load, which has to be reported rather than silently ignored, or the thing you just changed is the one that does not rebuild.

Letting the build write the repetitive part

A tool now reads the source code while the project builds and works those lists out for itself. Exposing a new value to the editor is a one-word mark next to it, rather than a second place you have to remember to update and a silent bug when you do not.

It is kept behind a boundary on purpose. A future version of C++ will do this on its own, and when it does, swapping to it should not require touching anything that relies on it.

The other half of the same work was checking that game code was built against the engine it is being loaded into. A mismatch used to be a crash with no useful message. It is now a message.

A project is a folder, and the Hub opens it

The Hub is the window you open first. It lists your projects, creates them from templates, and manages which engine versions are installed, because having two projects on two versions is normal and neither project should have to care.

It is a separate application rather than part of the editor. An editor that can only be started by another program is one whose startup nobody can debug, so the entire connection between them is a single command-line flag. The Hub can be rewritten without touching the editor, and the editor works fine with no Hub at all.

Managing engine versions settles the question on its own: something that chooses between installed engines cannot live inside one of them.

There was no play mode

The game now runs only when you press play, and stopping puts everything back where you left it.

Pause holds the clock. Step advances exactly one tick, which is how you look at a collision or a trigger that only happens on a single frame.

Starting the game tints the whole editor and puts a coloured border around it. Editing during play and losing the work when you stop is the most common way to lose progress in any engine, and the person about to do it is not looking for a warning.

Shadows were redrawing everything

An animated character had it worse: it was never tested for visibility at all, never simplified at distance, and present in every shadow whether or not it was anywhere near one.

The same visibility test now runs for the camera and for each shadow. They share one list of objects, because the objects are the same and only what each one can see differs.

The subtle fix in the same work was motion. The renderer averages each frame with the ones before it to remove jagged edges, which needs to know where everything moved to. An animated character reported how the whole object moved and nothing about how it bent, so an arm swinging while the body stood still reported no movement at all, and the edge of it both smeared and flickered.

A level is a file, not code

The file format is now the definition of what a level is. Anything that survives being saved survives a play session, and anything that does not was never going to survive being reloaded either.

That is also why leaving play mode restores the level by saving and reloading it rather than by taking a snapshot. The save and load path gets exercised every single time anyone presses play, which is far more testing than it would otherwise get.

If saving fails, play does not start. Entering a session you cannot leave is worse than not entering one.

A window, a graphics device, and the reload loop

The gameplay boundary was the third thing built, deliberately early. Reloading code while it is running places real constraints on how everything else is written, so finding out late what those constraints are would have meant rewriting whatever had been built in the meantime.

The renderer came together quickly after that: materials and lighting, shadows, film-style colour handling, and then letting the graphics card decide what to draw and smoothing the image across frames.

Two decisions from the very start shape a lot of the code. Errors are returned rather than thrown, and types are identified by a hash of their name rather than by anything the compiler provides, because a hash survives a code reload and a compiler-provided identity does not.