Comparing Two Custom Game Engine GUI Systems
The last post walked through my second engine's GUI, and the center of that design was one line, the entire style engine implemented as the compiler-generated assignment operator:
if(guiManager.styleExists(line)) *control = *(guiManager.styles[line]);The engine that came after it (Carbon Monoxide, the third one, the mobile rewrite) declares its GUI base class like this:
class cmGUIComponent
{
public:
cmGUIComponent(const cmGUIComponent&) = delete;The exact operation the old library stands on is a compile error in the new one. Every widget class repeats the deletion: the button, the label, the image, the scroller all open by outlawing the copy. If a single line summarizes what two more years of writing GUIs taught me, it's that one.
This continues the series taking one system at a time out of my custom engines, except this time it's two systems held next to each other. The layout post and the arrival-animations post each pulled one idea out of the Carbon Monoxide GUI on its own. This post reads it against its predecessor, because the same person solving the same problems twice is a controlled experiment, and the diff is more instructive than either version alone. It is also consistent enough to state its conclusion up front: everywhere the old system managed a hazard with discipline, the new one manages it with structure.
Half the Widgets, a Different Job
The old library is 4,439 lines with sixteen control types. The new one is about 3,200 lines (components plus the screen system) and its factory constructs seven: component, label, scaled label, button, option button, image, scroller.
The missing nine are the tool controls, which is what actually changed. The color picker, the gradient slider, the thumbnail file browser, the viewport, the draggable window, all the controls that existed because the particle editor was the real user, have no descendants here. By the Carbon Monoxide era, tool interfaces didn't need a homemade web browser any more, because they used Dear ImGui. The engine's texture browser today is class TextureBrowser : public ImGuiModalPopup<TextureBrowser>, a hundred-odd lines of immediate-mode calls, and the retained GUI never hears about it.
That's the first evolution, and it isn't a technique. It's a scope. The 2012 system carried editors and games on one tree. The successor let somebody else's library carry the editors, and a GUI that only has to be a game GUI gets to be smaller than the one it replaced while doing its actual job better.
The one place the widget set grew is the control desktop never asked for: the scroller. It has a drag threshold, an inertia decay constant, a release velocity taken as the last frame's offset over the frame time, and snap-to-target seeking with its own interpolation. Nothing in the old engine needed any of that, because desktops have scroll wheels and tools have patience. Fingers have neither.
The God Object Dissolved
The strangest thing in the old library was the base class: two hundred lines of public fields, the union of every field any control needs, so that every button carries the color picker's hue and a GL texture handle for a gradient it will never draw. I defended it in the last post, and I still do. Uniform state was the precondition for the trivial parser and the assignment-operator style engine. It was a price, paid knowingly, in one place.
The successor stopped paying it. cmGUIComponent carries only what every component genuinely has: name, geometry, the two anchors, scale and layout modes, visibility, children, and the animation channels. The button's textures, label text and font size live in cmGUIButton. The label's stroke, shadow and wrapping live in the label. The option button adds its checked state and nothing else. Fields are protected, and the outside goes through setters.
What bought back the parser the god object used to pay for is that parsing went virtual. Each class reads its own members:
void cmGUIButton::FromJson(Json::Value& jsonObj)
{
cmGUIComponent::FromJson(jsonObj);
UNPACK_JSON_MEMBER(jsonObj, (*this), m_textureOff);
UNPACK_JSON_MEMBER(jsonObj, (*this), m_textureOn);
UNPACK_JSON_MEMBER(jsonObj, (*this), m_text);
UNPACK_JSON_MEMBER(jsonObj, (*this), m_color);
...
}The macro stringifies the member name and strips the m_, and that becomes the JSON key. The old system's parser was one function with an 83-comparison else-if ladder, every key a hand-typed string that had to match a field somewhere else in the file. The new system's entire property vocabulary is 37 of those one-line declarations, spread across the classes that own the fields, and a key cannot drift from its member because it is generated from the member. Colors serialize as hex strings, vectors as {x, y} objects, and the serialization contract itself is compile-checked. A static_assert(cm::json::Jsonable<cmColor>) sits in the color header like a unit test that runs at build time. The old parser knew every property of every control. The new one knows nothing except how to dispatch "type" through a factory, and that ignorance is the design.
Copying Got a Name
So if the copy constructor is deleted, what happened to prototypes? They kept working. They stopped being implicit:
virtual void CloneInto(cmGUIComponent* other) const;
virtual cmGUIComponent* CloneComponent() const { return Clone<cmGUIButton>(); }CloneInto is virtual and per-class: the button's override casts, copies the button's own fields, delegates the base's to the base, and clones children through their own CloneComponent. A component can still be built once and stamped out in copies, which is the whole prototype idea. But a clone is now a statement each class signs, with the slicing hazard removed by construction, instead of a bitwise habit that only worked because all state had been flattened into one class in advance.
What did not survive is the stylesheet. There is no java.style in the new engine, no class blocks, no cascade, no restyling every button in the project from one file. Skinning a Carbon Monoxide interface means touching components one at a time, in data or in code, which is exactly the complaint I raised in the arrival-animations post about easing curves being set per call site. The old system remains genuinely better at this one thing. The evolution deleted a hazard and a superpower that were the same mechanism, and only the hazard's replacement got built. If I ever write a fourth GUI, the cascade is the piece I'd steal back from 2012.
The Text Format Died and Came Back
The old GUI was data all the way down: 8,451 lines of .gui text, close to two lines of interface for every line of C++ that interpreted it. The mobile rewrite launched without any of that. The 2014 game's screens are C++ classes, fifteen of them, wired up in code. Whatever the reason at the time (schedule, or the text format being tangled up with the tool controls it existed to serve), the rewrite threw away what had most distinguished its predecessor.
Then it grew back. The engine that stayed alive taught its screens LoadFromJson: a screen file is a JSON array of typed component objects, children nested inside parents, anchors by name. Here is one of the main menu's buttons from Dungeoneer, the dungeon game built on the engine today:
{
"name": "btnGame",
"type": "button",
"text": "Launch Game",
"textureOff": "button.tga",
"position": {
"x": 0,
"y": "0.125 * 1"
},
"size": {
"x": 1,
"y": 0.1
},
"anchorParent": "Center",
"anchorSelf": "Center",
"scaleMode": "RelativeToHeightAspect"
}The same game's screens also settle an old account from this series. Its inventory panel is a container with "layoutMode": "Flow" and a padding value, the layout container whose absence I called out when I wrote about the anchor system. True of the 2014 snapshot, no longer true of the engine that kept living: the container exists, and the shipped data uses it.
Look again at that button's position, because "y": "0.125 * 1" is a string, and its siblings sit at "0.125 * 0" and "0.125 * 2". The menu spaces its rows by handing the loader a multiplication per entry.
That works because of the quiet resolution of my favorite bug from the last post. The old size converter was a homemade calc() that accepted 100%-32 and computed nonsense if the percentage appeared on the right of the operator, a bug that never fired only because I wrote all 8,451 lines of the data as well as the parser. The new system's rule is one branch:
inline void FromToken(Json::Value& token, float& value)
{
if(token.isString())
{
value = cm::Script::Expression::EvaluateFloat(token.asString());
return;
}
value = token.asFloat();
}Any numeric property may be a string, and strings go to a real expression evaluator: cparse, a shunting-yard library, bound to the same scope the engine's scripting language uses. The homemade converter with the latent bug became a grammar with operator precedence, and the deeper change is who wrote it. The 2012 system hand-parsed its layout format and hand-parsed its expressions, and the successor parses JSON with jsoncpp and expressions with cparse. Somewhere in those two years I stopped writing parsers for things that already had parsers.
Navigation Grew a Stack, and the Tripwire Retired
Old navigation was web navigation: a button's link= property loads another page, total-replace, with a 404 page for broken links. It also produced the most dangerous code path in the library. The click handler destroys every control including the button currently executing its own member function, and the system survived that through a controlReset flag checked by the dispatch loop. A tripwire, plus discipline.
The successor's navigation is a stack. Screens are registered objects (RegisterScreen<T>() derives the screen's JSON filename from the class's own static name) and movement is TransitionTo(screen, pops, clearStack) or TransitionPop(). Pushing is entering a menu, and popping is the back gesture. But the structural fix is when any of that happens. TransitionTo only sets flags and starts every component's out-animation. The actual teardown lives in the screen's update:
if(m_bTransitioning)
{
bool bFinished = true;
for(int i = 0; i < (int)m_components.size(); ++i)
{
if(m_components[i]->IsVisible() && !m_components[i]->IsTransitionFinished())
{
bFinished = false;
break;
}
}
if(bFinished)
{
// pop, clear, OnExit, push the next screenOne deferral buys two things. The click handler that triggers navigation returns long before anything is deleted, so the reentrancy hazard the old system tripwired is unreachable by construction. There is no flag because there is nothing for a flag to guard. And the out-animations always get to finish, because finishing them is what releases the navigation. When I wrote about the animation system in August I said the 2014 snapshot had no clean way to say "when the out animation completes, change screen," and that every screen wrote it by hand. The engine that kept living grew exactly that signal, and it grew it as the navigation system rather than as a callback. The completion check is the screen change.
As for the 404 page: its descendant is gPlatform->DebugLog("Screen not registered: " + strName). That looks like a downgrade until the typed path comes into view. Push<ScreenMainMenu>() cannot name a screen that doesn't exist, because the type is the name and registering it is what constructs it. The missing-page state didn't get a better renderer. For most of the API it stopped being expressible. A 404 page that becomes a compile error never has to render at all, though I'll grant the old system was more charming about it.
Input Stopped Speaking Win32
The old entry point, fourteen years running:
void message(unsigned int message, unsigned int wParam, unsigned int lParam);The new ones:
virtual bool OnMouseDown(cm::MouseButton button, const Vector3& vPos);
virtual bool OnDrag(const Vector3& vMousePos);
virtual bool OnKeyDown(int nKeyCode);The SDL2 port of the old engine could keep the Win32 dialect by renaming constants, because every consumer already spoke it. The mobile engine never had that option, because a finger does not pack its coordinates into lParam, so input became typed parameters on named methods, and the dialect died at the platform boundary where it should have lived all along.
Mouse capture is the cleanest before-and-after in the whole comparison. The old system's capture was the four-thousand-pixel hitbox: while a window is being dragged, its hit test inflates by 2000 pixels in every direction so the cursor can't outrun it. Indefensible in principle, never failed in practice. The new system remembers which component the press landed on:
if(m_pActiveComponent)
{
m_pActiveComponent->OnSoftRelease(vPos);
m_pActiveComponent = NULL;
}The screen tracks the active component from the press, and a release or a drag that lands somewhere else is delivered to the component that owns the gesture as a soft event. The scroller uses the same channel in reverse. The default handlers bubble unclaimed touches up the tree, which is how a drag that begins on a button is legally inherited by the scrolling container above it. The maneuver the old windows could only perform by being four thousand pixels wide is ordinary bookkeeping now.
One rule crossed the gap untouched: in both systems, a click only counts if the press and the release land on the same control. Some decisions are correct outright, and the tell is that rewrites don't touch them.
The Switchboard Became Closures
Everything the old GUI did was reported to one function pointer as (name, action, button, position), and the game's side was a switchboard. The particle editor's ran to 551 lines servicing 242 controls. One breakpoint gave a transcript of the entire interface. One renamed control silently disconnected from the game.
The successor types all three channels. Screens are classes, so per-screen logic lives in OnInit, OnUpdate, and an OnEvent(EventClick, pComponent) that hands over the component pointer rather than a name to compare. And buttons carry their handler with them, a std::function<void(cmGUIButton*)> riding on the button itself. Here is the btnGame from the JSON above being wired in Dungeoneer's main menu screen:
SetButtonOnClick("btnGame", [stateManager](auto) {
if(auto playState = stateManager->PushGameState<StatePlay>())
{
playState->LoadMap("assets\\dungeons\\ranch.json");
}
});What didn't improve: the join is still a string. Rename a component in the JSON and SetButtonOnClick quietly returns a null pointer that may or may not be checked, which is the same disease with a better prognosis. And the transcript property is genuinely gone. Handlers are distributed across screens and closures now, and there is no single function to breakpoint and watch the whole interface think. I'd make the same trade again, but it is a trade, not a gift.
Two smaller changes mark the same shift in who the interface answers to. SetText routes through a string table, so text in the old engine was the display string and text in the new one is a key into strings_en.txt, because a commercially published mobile game localizes. And every screen's Init records View_ plus its name to telemetry, because every screen view is an analytics event now. Nothing in the 2012 engine ever phoned home. Both lines are fingerprints of the fact from the rewrite post: part of the codebase belongs to somebody else.
Hot Reload Moved Into the Engine
My favorite program in the old tree was the "GUI editor": 228 lines, no editing features, a loop that polls the modification time of every file on screen and reloads what changed. The refresh button, held down.
The new engine doesn't have that program because every screen is that program. In development, a screen polls its own file's modification time once a second, copies the source-tree version over the packaged copy when it changes, and rebuilds itself in place, tearing down, reloading and re-initializing while the game runs. Pressing F2 opens the screen's JSON in a file browser, in case the text editor that was supposed to already be open wasn't.
Same idea, third home. It came across because it was never really a feature of either system. It's a property that falls out of cheap total-replace loading of a text format, and it beats any editing UI I would have built instead. I said that in the last post about 2012. The 2014 engine losing the text format and then re-growing it is the strongest evidence I have that it's true.
The Ghosts Continue
The old post ended with the absences, reserved menu-bar type codes with no menu classes, a holdfocus flag read by nothing. For symmetry I went looking in the new library, and the room is furnished there too. The screen event enum declares EventMouseDown, EventMouseUp and EventMouseDrag, and only EventClick is ever fired. Screens have an OnEnter that nothing calls. A fade member is declared, initialized to null, and its update sits fully commented out next to a k_fFadeTime constant of 500 milliseconds that has been waiting a very long time. Every button builds a translucent red debug quad over its touch rect every frame, with the one line that would draw it commented out. Even the scroller has one: the line that would have recorded the previous frame's scroll amount is commented out directly under the assignment that computes the release velocity, so the inertia the finger gets is whatever the final frame happened to measure.
I no longer read these as untidiness. Two libraries, written two years apart and groomed for very different lengths of time, carrying the same density of scaffolding and reserved intentions, is what a system looks like while somebody is still thinking about it.
What Didn't Need to Evolve
The comparison is mostly a story of change, which makes the pieces that crossed unchanged the interesting ones:
The nine anchor points. The old system called them anchor and clamp. The new one calls them anchorSelf and anchorParent. They are the same nine points, and in the current engine the enum is generated by machinery that also round-trips it through strings for the JSON. Three representations across fourteen years, one idea.
Query-time geometry. Neither system stores a screen rectangle, ever. Both resolve layout on demand against the live window size, which is why window resizing and device rotation were never features in either. They were properties the systems had by construction. Learned in 2012, never unlearned.
The unit quad. Every textured rectangle in both engines is the same quad in a VBO drawn under a transform: in the old one through immediate-mode GL 1.x, in the new one behind a renderer interface with desktop, mobile and null backends. The habit outlived the entire graphics API it was formed on.
And names as identity: both systems register controls in a map by name and let the game reach in. The new one adds typed lookup through dynamic_cast and prefix queries, but the string-keyed heart is the same, weaknesses included.
None of this survived by being copied. The two libraries share no code, and the field names show the second was written from a memory, not a paste. What survived was conclusions. Code is apparently the least durable form a decision can take.
The Direction of the Arrow
Line the changes up and they all point the same way. The slicing hazard became a deleted constructor. The reentrancy tripwire became a deferred transition that cannot re-enter. The capture hack became bookkeeping. The hand-typed key ladder became keys generated from the members they bind. The hand-written parsers became libraries. The serialization contract became a concept the compiler checks. It's the same shift the engine series kept finding at larger scale (convention becoming enforcement) happening inside one subsystem, one hazard at a time.
The arrow isn't purely forward, and I've tried to say so where it isn't: the cascade died, the switchboard transcript died, a 404 page became a log line, and I miss two of those three. But the summary holds. The 2012 GUI worked because I was careful with it: the percent sign on the correct side, the include at the top of every frame, the flag checked before touching a member. The 2014 GUI, and the decade of refinement it got, work whether or not I'm careful, because the care was moved out of my habits and into the types. Between two systems that both work, the one that no longer needs me on my best behavior is the one to keep, and it is, in fact, the one that's still alive.