Architecture

simple-3d is a strict layered wrapper. Every design decision flows from a single rule: engine internals must never appear in the public API surface.

Layer Overview

┌─────────────────────────────────────────────────────────────┐ │ Game code │ │ C++ subclass of Simple3D::Game + .lua scripts │ └────────────────────────┬────────────────────────────────────┘ │ │ Simple3D public API │ include/Simple3D/*.h │ (no Urho3D types; only math aliases) │ ┌────────────────────────▼────────────────────────────────────┐ │ simple-3d library │ │ • Simple3D::Game — application bootstrap │ │ • Simple3D::Entity — scene object (PIMPL) │ │ • Simple3D::Camera — viewport (PIMPL) │ │ • Simple3D::NetworkManager — ENet client/server (PIMPL) │ │ • Simple3D::Label — UI text (PIMPL) │ │ • Simple3D::Vector3/Color/Quaternion/… — math aliases │ └────────────────────────┬────────────────────────────────────┘ │ │ internal calls only │ src/Simple3D/Internal/ │ ┌──────────▼──────────────┐ │ Urho3D fork (U3D or │ │ NOVA3D) │ │ │ │ • Scene graph │ │ • Bullet 3D physics │ │ • Box2D 2D physics │ │ • Recast/Detour nav │ │ • ENet networking │ │ • LuaJIT scripting │ │ • bgfx / GLES renderer │ │ • Audio streaming │ └──────────┬──────────────┘ │ │ (NOVA3D backend only) ┌──────────▼──────────────┐ │ XNA C++ runtime │ │ (Robert Vokac's fork) │ │ rendering / audio / │ │ input abstraction │ └──────────┬──────────────┘ │ ┌──────────▼──────────────┐ │ GPU / Audio backends │ │ Vulkan / OpenGL ES / │ │ bgfx / OpenAL │ └──────────┬──────────────┘ │ ┌──────────▼──────────────┐ │ SDL3 / OS │ │ windowing, events, │ │ filesystem abstraction │ └─────────────────────────┘

Design Rules

Rule 1 — Public headers contain no Urho3D types

Files under include/Simple3D/ must never #include any Urho3D engine class header. This is enforced by a header-only compile test in the CMake build that compiles each public header without Urho3D on the include path.

The only exception is Core/Types.h, which imports Urho3D math types as Simple3D:: aliases. These types are ABI-identical — they compile to the same struct layout with the same vtable (none) and the same sizeof — so no conversion overhead occurs when passing them across the API boundary.

Rule 2 — Engine types are hidden via PIMPL

Every class with a backing Urho3D object declares struct Impl; in the public header and stores it via std::unique_ptr<Impl>. The Impl struct is defined only in the corresponding .cpp file, which is free to include any Urho3D header.

Rule 3 — Internal headers live in src/

Bridge headers in src/Simple3D/Internal/ allow different .cpp files to share internal conversions (e.g., GetNode(Entity*)) without exposing them to game code. These files are never installed and never included from include/.

Rule 4 — Game subclass = game entry point

Users subclass Simple3D::Game and override three methods. CreateGame<T>() handles all engine initialisation before calling Start(). Game code never touches Urho3D::Context or Urho3D::Application.

PIMPL Isolation — Detailed Example

Here is the full pattern as used for Entity:

// ── include/Simple3D/Entity/Entity.h ────────────────────────
#pragma once
#include <Simple3D/Core/Types.h>   // only math; no Urho3D engine headers
#include <string>
#include <functional>
#include <vector>
#include <memory>

namespace Simple3D {

class AnimationController;   // forward-declared — still no engine types

class Entity {
public:
    void SetPosition(float x, float y, float z);
    void AddModel(const std::string& path);
    void AddRigidBody(float mass);
    // ... more public methods ...

private:
    struct Impl;                            // forward declaration only
    std::unique_ptr<Impl> impl;            // owns the Urho3D Node

    // Allow internal bridge to access impl directly
    friend class EntityBridge;
};

} // namespace Simple3D
// ── src/Simple3D/Entity/Entity.cpp ──────────────────────────
#include <Simple3D/Entity/Entity.h>
#include "../Internal/UrhoEngine.h"     // Urho3D freely available here

namespace Simple3D {

struct Entity::Impl {
    Urho3D::SharedPtr<Urho3D::Node> node;
    // Other implementation details...
};

void Entity::AddRigidBody(float mass) {
    auto* rb = impl->node->GetOrCreateComponent<Urho3D::RigidBody>();
    rb->SetMass(mass);
    rb->SetCollisionEventMode(Urho3D::COLLISION_ALWAYS);
}

} // namespace Simple3D

The consequence: changing how AddRigidBody works internally (different Urho3D component, different configuration) only requires recompiling Entity.cpp — game code that calls entity->AddRigidBody(1.0f) does not recompile.

Game Bootstrap — What Happens Inside Run()

When the user calls CreateGame<MyGame>()->Run(), the following sequence runs before Start() is ever called:

  1. Context createdUrho3D::Context allocated, all subsystems registered.
  2. Renderer initialised — SDL3 window created, GPU context established, bgfx/GLES backend selected.
  3. Audio system started — OpenAL (or platform audio) initialised, master volume set to 1.0.
  4. Input system initialised — keyboard / mouse / touch event handlers installed.
  5. Scene created — A root Urho3D::Scene created with PhysicsWorld (Bullet 3D) and PhysicsWorld2D (Box2D) components attached automatically.
  6. Lua state initialised — Urho3D's LuaJIT scripting component registered; all Urho3D Lua bindings registered; Simple3D custom Lua bindings registered.
  7. Viewport created — Default Urho3D::Viewport set up for the full window.
  8. User's Start() called — At this point the scene, renderer, input, audio, and Lua are all ready.
  9. Main loop runs — Each frame: process events → update physics → call user's Update(dt) → render → present.
  10. User's Stop() called — On exit signal or Quit().

Entity Component Model (Internal)

Each Simple3D::Entity wraps exactly one Urho3D::Node. Components are created and configured on the node via Urho3D's built-in ECS:

Simple3D methodUrho3D callNotes
AddModel(path)node->CreateComponent<StaticModel>()Loads mesh from ResourceCache
AddDirectionalLight(c, i)CreateComponent<Light>(); light->SetLightType(LIGHT_DIRECTIONAL)
AddPointLight(c, r)CreateComponent<Light>(); LIGHT_POINT; SetRange(r)
AddSpotLight(c, r, a)CreateComponent<Light>(); LIGHT_SPOT; SetRange; SetFov
AddRigidBody(mass)GetOrCreateComponent<RigidBody>(); SetMass(mass)GetOrCreate → safe to call twice
AddBoxCollider(size)GetOrCreateComponent<CollisionShape>(); SetBox(size)Full-size Vector3, not half-extents
AddCapsuleCollider(r, h)SetCapsule(r*2, h, ...)
AddSphereCollider(r)SetSphere(r*2)
AddMeshCollider(path)SetTriangleMesh(model)Static bodies only (mass=0)
AddTriggerBox(size)SetBox; rb->SetTrigger(true)No physics response, only callbacks
AddAnimationController()GetOrCreateComponent<AnimationController>()
AddScript(path)CreateComponent<LuaScriptInstance>(); LoadFile(path)Logs warning if file missing
AddSprite2D(path)CreateComponent<StaticSprite2D>(); SetSprite(path)
AddAnimatedSprite2D(scml)CreateComponent<AnimatedSprite2D>(); SetAnimationSet(scml)Spriter .scml format
AddRigidBody2D(mass)CreateComponent<RigidBody2D>(); SetMass(mass)Box2D body
AddBoxCollider2D(w, h)CreateComponent<CollisionBox2D>(); SetSize(w, h)
AddCircleCollider2D(r)CreateComponent<CollisionCircle2D>(); SetRadius(r)

Entity Registry

A key design challenge: Urho3D internally uses raw Urho3D::Node* pointers. When a trigger fires, the callback receives a Node* — but game code needs an Entity*. The same problem arises in GetParent() and CreateChild().

The solution is a bi-directional registry stored inside GameImpl:

// Inside GameImpl (src, not public):
std::unordered_map<Urho3D::Node*, std::unique_ptr<Entity>> entityRegistry;

Every CreateEntity() call creates an Urho3D::Node, allocates an Entity, and inserts the pair. Every DestroyEntity() removes and deletes both. When trigger callbacks or parent lookups need to convert a Node* to an Entity*, they look it up in this map.

Entity::CreateChild() creates a child Node and also registers the resulting Entity in the same registry, so hierarchy-traversal functions always return valid Entity* values.

Physics Integration

simple-3d includes both a 3D physics world (Bullet) and a 2D physics world (Box2D) in the same scene. They do not interact — a 3D rigid body does not know about 2D rigid bodies and vice versa.

3D physics (Bullet)

PhysicsWorld is automatically added to the root Urho3D Scene in Game::Run(). Gravity is (0, -9.81, 0) by default. Physics runs at 60 Hz fixed timestep; rendering is uncapped.

Trigger volumes use RigidBody::SetTrigger(true), which disables collision response while preserving overlap events. The enter/exit callbacks set via SetOnTriggerEnter receive an Entity* resolved from the registry.

IsOnGround() performs a downward raycast of 0.7 m from the entity's origin. It hits any body on any collision layer. If you need layer filtering, use SetCollisionMask on the rigid body or perform your own raycast via Urho3D directly (this is an advanced use case not yet exposed in the simple-3d API).

2D physics (Box2D)

PhysicsWorld2D is added automatically alongside the 3D world. 2D bodies use RigidBody2D and 2D colliders use CollisionBox2D / CollisionCircle2D. They live in the XY plane. Tile maps loaded via LoadTileMap() automatically create static RigidBody2D collision shapes from Tiled collision layers.

Lua Scripting Architecture

Scripts are managed by Urho3D's LuaScriptInstance component. Each entity's script runs in its own Lua function environment (not a separate Lua state), so scripts can communicate via shared globals, but simple-3d convention discourages this. Prefer FindEntity() and explicit message passing.

The Lua state lifecycle:

  1. entity->AddScript("path.lua") loads and compiles the file via ResourceCache.
  2. A LuaScriptInstance component is attached to the Urho3D Node.
  3. When the scene first updates, Start() in the script is called once.
  4. Every frame, Update(dt) is called if it exists.
  5. When the entity is destroyed, Stop() is called if it exists.

Custom Lua bindings registration

Simple3D custom bindings are registered during Game::Run() initialisation, before user's Start() is called. They are registered via Urho3D's RegisterLuaLibrary mechanism and extend the Node Lua metatable:

// src/Simple3D/Core/LuaBindings.cpp
void RegisterSimple3DLua(Urho3D::Context* ctx) {
    auto* lua = ctx->GetSubsystem<Urho3D::LuaScript>();

    // Extend the Node metatable with simple-3d helpers
    lua->ExecuteString(R"LUA(
        function Node:MoveRelative(delta)
            local rb = self:GetComponent("RigidBody")
            if rb then
                local vel = Vector3(delta.x, 0, delta.z) / 0.016  -- approx
                rb:SetLinearVelocity(vel + Vector3(0, rb:GetLinearVelocity().y, 0))
            else
                self:Translate(delta)
            end
        end

        function Node:IsOnGround()
            -- raycast 0.7m down
            local hit = scene:GetPhysicsWorld():RaycastSingle(
                Ray(self:GetWorldPosition(), Vector3(0,-1,0)), 0.7)
            return hit.body ~= nil and hit.body ~= self:GetComponent("RigidBody")
        end
    )LUA");

    // Register global functions (PlaySound, FindEntity, …)
    RegisterAudioLua(lua);
    RegisterSceneLua(lua);
}

Resource Loading

All asset loading goes through Urho3D::ResourceCache, which is an LRU cache that loads assets from disk on first use and returns the same object on subsequent requests. Resources are reference-counted — they stay loaded as long as at least one component references them.

When you call entity->AddModel("Models/hero.mdl"), simple-3d calls:

auto* cache = GetSubsystem<Urho3D::ResourceCache>();
auto* model = cache->GetResource<Urho3D::Model>("Models/hero.mdl");
if (!model) {
    URHO3D_LOGWARNING("simple-3d: AddModel — resource not found: Models/hero.mdl");
    return;
}
staticModel->SetModel(model);

If a resource is not found, simple-3d logs a [Warning] message and returns without crashing. The entity is still created and all other calls still work — this allows development to continue with placeholder assets.

Threading Model

simple-3d is single-threaded. All public API calls must be made from the main thread. This is the same constraint as direct Urho3D use.

Internally, Urho3D may use worker threads for:

None of these affect the simple-3d API contract: Start(), Update(dt), Stop(), and all entity/camera/label operations are always called from the main thread.

Backend Switching

The CMake variable SIMPLE3D_ENGINE selects the backend. Both backends expose the same Urho3D API surface. Only the CMake linkage section in CMakeLists.txt differs:

# U3D backend linkage
find_package(U3D REQUIRED
    PATHS ${U3D_HOME}
          ${URHO3D_HOME})

target_link_libraries(simple3d
    PUBLIC Urho3D)
# NOVA3D backend linkage
find_package(NOVA3D REQUIRED
    PATHS ${NOVA3D_HOME})

target_link_libraries(simple3d
    PUBLIC nova3d)

Because simple-3d source files only include Internal/UrhoEngine.h (an umbrella header that maps to whichever backend is selected), switching backends requires only a cmake -DSIMPLE3D_ENGINE=NOVA3D reconfigure followed by a full rebuild. Game source files are untouched.

Complete Component Mapping

simple-3d callUrho3D component(s) createdMethod
AddModel(path)StaticModelCreateComponent
AddDirectionalLight(c, i)Light (DIRECTIONAL)CreateComponent
AddPointLight(c, r)Light (POINT)CreateComponent
AddSpotLight(c, r, a)Light (SPOT)CreateComponent
AddRigidBody(mass)RigidBodyGetOrCreateComponent
AddBoxCollider(size)CollisionShapeGetOrCreateComponent; SetBox
AddCapsuleCollider(r, h)CollisionShapeSetCapsule
AddSphereCollider(r)CollisionShapeSetSphere
AddMeshCollider(path)CollisionShapeSetTriangleMesh
AddTriggerBox/Sphere/CapsuleCollisionShape + RigidBody (trigger=true)Subscribe E_NODECOLLISIONSTART
AddAnimationController()AnimationControllerGetOrCreateComponent
AddScript(lua)LuaScriptInstanceCreateComponent; LoadFile
AddSprite2D(path)StaticSprite2DCreateComponent
AddAnimatedSprite2D(scml)AnimatedSprite2DCreateComponent
AddRigidBody2D(mass)RigidBody2DCreateComponent
AddBoxCollider2D(w, h)CollisionBox2DCreateComponent
AddCircleCollider2D(r)CollisionCircle2DCreateComponent
CreateCamera()Camera component on new NodeNew Node, CreateComponent<Camera>
CreateLabel(text)Text UI elementAdded to UI root element
LoadTileMap(path)TileMap2D on new NodeAlso creates RigidBody2D + CollisionPolygon2D for each Tiled collision layer

File Layout

simple-3d/ ├── include/Simple3D/ ← installed public headers (no Urho3D types) │ ├── Simple3D.h ← single-include umbrella │ ├── Core/ │ │ ├── Types.h ← math aliases (Vector2/3/4, Color, …) │ │ ├── Keys.h ← Key / MouseButton enum │ │ └── Game.h ← Game base class │ ├── Entity/ │ │ └── Entity.h ← Entity class (PIMPL) │ ├── Camera/ │ │ └── Camera.h ← Camera class (PIMPL) │ ├── Audio/ │ │ └── Audio.h ← free functions (PlayMusic, PlaySound, …) │ ├── Network/ │ │ └── NetworkManager.h ← NetworkManager class (PIMPL) │ └── UI/ │ └── Label.h ← Label class (PIMPL) │ ├── src/Simple3D/ ← private implementation │ ├── Internal/ ← bridge headers (Urho3D types allowed) │ │ ├── UrhoEngine.h ← full Urho3D umbrella include │ │ ├── EntityInternal.h ← GetNode(Entity*) / MakeEntity(Node*) │ │ └── CameraInternal.h ← GetCameraNode() / MakeCamera(…) │ ├── Core/ │ │ ├── Game.cpp ← Urho3D application bootstrap + registry │ │ ├── LuaBindings.cpp ← simple-3d custom Lua bindings │ │ └── NavMesh.cpp ← BuildNavMesh implementation │ ├── Entity/ │ │ └── Entity.cpp ← all Entity methods │ ├── Camera/ │ │ └── Camera.cpp │ ├── Audio/ │ │ └── Audio.cpp │ ├── Network/ │ │ └── NetworkManager.cpp │ └── UI/ │ └── Label.cpp │ ├── tests/Simple3D/ ← unit tests (headless, no GPU needed) │ ├── CMakeLists.txt │ ├── test_entity.cpp │ └── test_math.cpp │ ├── samples/ ← sample games │ └── blupi_proto/ ← 3D platformer acceptance test │ ├── CMakeLists.txt │ ├── main.cpp │ └── Data/ │ ├── cmake/ │ └── toolchains/ │ └── mingw-w64.cmake ← Windows cross-compile toolchain │ ├── CMakeLists.txt ← top-level build definition ├── ARCHITECTURE.md ├── API.md ├── LUA_API.md ├── CLAUDE.md ← AI assistant context file ├── plan.md ← task plan └── README.md

Internal Headers Reference

FilePurposeUsed by
Internal/UrhoEngine.h Umbrella include that pulls in all Urho3D headers needed by the implementation layer. Also defines backend-specific preprocessor guards. All src/ .cpp files
Internal/EntityInternal.h Declares GetNode(Entity*) (returns the backing Urho3D::Node*) and MakeEntity(Node*, GameImpl*) (wraps a Node in a new Entity and registers it). Game.cpp, Camera.cpp, trigger callbacks
Internal/CameraInternal.h Declares GetCameraNode(Camera*) and GetUrhoCamera(Camera*) for follow/orbit logic that needs to read the Urho3D camera position. Game.cpp

Never include internal headers from game code

Files in src/Simple3D/Internal/ are not installed and are not part of the public API. Including them from game code will break when the library is updated. Always use only #include <Simple3D/Simple3D.h>.