A brutally honest self-review of LuckyEngine's architecture. Every finding is backed by exact file paths and line counts.
- Codebase At A Glance
- Architectural Critique
- 1. Scene.cpp: The 5,734-Line God Object
- 2. EditorLayer.cpp: The 5,053-Line Orchestrator That Does Everything
- 3. Static Facade Epidemic
- 4. Components.h: The 49-Struct Monolith
- 5. SceneSerializer.cpp: 2,916 Lines of Centralized Pain
- 6. Three Physics Engines, No Unified Interface
- 7. Friend Class Overuse
- 8. The GetWindow() Leak
- 9. Event System: Type-Safe But Inflexible
- 10. TimeManager Is Correct But Scene Abuses It
- 11. 70 reinterpret_casts
- 12. The
_em/_pxNaming Confusion
- What's Done Well
- Proposed Improvements
- Priority Ranking
| Metric | Value |
|---|---|
| Total source files (.h + .cpp) | 771 |
| Header files (.h) | 417 |
| Implementation files (.cpp) | 354 |
| Component types | 49 |
| Static facade classes | 13 |
| Singleton classes | 7 |
friend class declarations |
42 files |
reinterpret_cast usages |
70 |
dynamic_cast usages |
7 |
| C# InternalCall bindings | 508 |
| Largest single file | Scene.cpp (5,734 lines) |
| Largest single method | UI_ShowCreateAssetsFromMeshSourcePopup (1,306 lines) |
Files: Hazel/src/Hazel/Scene/Scene.h (556 lines), Scene.cpp (5,734 lines)
The numbers:
- 111 public methods
- 62 private methods
- 30 member variables
- 43
#includestatements in the .cpp - 19 methods over 50 lines
Scene manages 15+ distinct responsibilities:
| Responsibility | Evidence |
|---|---|
| Entity lifecycle | CreateEntity(), DestroyEntity(), DuplicateEntity() |
| Entity hierarchy | ParentEntity(), UnparentEntity(), ConvertToWorldSpace() |
| Jolt Physics | UpdateJoltAcquisition/Control/Physics/Validation/Export() — 5 methods |
| MuJoCo Physics | UpdateMujocoAcquisition/Control/Physics/Export() — 4 methods |
| Box2D Physics | UpdateBox2DPhysics(), Update2DPhysicsTransforms(), CreateBox2DWorld() |
| 3D Rendering | OnRenderEditor() (353 lines), OnRenderRuntime() |
| 2D Rendering | Render2DPhysicsDebug() |
| Debug Visualization | RenderMujocoCollidersDebug() (652 lines), RenderPhysicsDebug() (145 lines), RenderSkeletonDebug() |
| C# Scripting | UpdateScriptsOnPreUpdate/OnUpdate/OnPhysicsUpdate/OnLateUpdate/OnPostUpdate() — 5 methods |
| Animation | UpdateAnimation() (94 lines) |
| Robot Controllers | UpdateRobotControllers() (68 lines) |
| Audio | UpdateAudioSceneSubsystem() |
| Data Recording | UpdateObserverData(), UpdateObserverVideo() |
| Asset Management | GetAssetList() (566 lines) |
| Prefab Instantiation | InstantiatePrefab(), InstantiateMujocoScene() |
The RenderMujocoCollidersDebug() method alone is 652 lines — longer than many entire classes in the codebase. GetAssetList() is 566 lines of asset traversal and validation logic that has nothing to do with scene management.
The real problem: Scene is the nexus through which everything flows. Physics can't step without Scene. Scripts can't run without Scene. Rendering can't happen without Scene. Every new subsystem inevitably adds methods to Scene because Scene owns the entt::registry and the TimeManager, and there's no delegation layer between them.
Impact: Adding a new physics callback requires modifying Scene.cpp. Adding a new rendering pass requires modifying Scene.cpp. Adding a new script lifecycle event requires modifying Scene.cpp. A single merge conflict in Scene.cpp blocks everyone.
Files: LuckyEditor/src/EditorLayer.h (300 lines), EditorLayer.cpp (5,053 lines)
The numbers:
- 78 methods (31 public, 47 private)
- 53 member variables
- 101
#includestatements (21 header + 80 cpp) - 22
UI_*methods that are inline UI logic, not panels - 18 methods over 50 lines
- 55 static variables in the implementation
The 1,306-line method:
UI_ShowCreateAssetsFromMeshSourcePopup() at line 2881 is a 1,306-line function. It handles mesh source asset import, animation import configuration, collider generation, multi-column UI with search — all in one function. This single method is larger than most entire files in the codebase.
22 UI methods that should be panels:
EditorLayer has a PanelManager system — it's right there, ready to use. Yet 22 UI methods are implemented directly in EditorLayer instead of as panels:
| Method | Lines | Should Be |
|---|---|---|
UI_ShowCreateAssetsFromMeshSourcePopup() |
1,306 | MeshImportPanel |
UI_DrawMenubar() |
385 | MenuBarPanel |
UI_DrawTitlebar() |
348 | TitleBarPanel |
UI_StatisticsPanel() |
228 | StatisticsPanel (it's even named "panel"!) |
OnKeyPressedEvent() |
159 | KeybindManager |
UI_ShowWelcomePopup() |
126 | WelcomePanel |
UI_FFmpegSettingsWindow() |
101 | FFmpegExportPanel |
UI_ShowNewProjectPopup() |
87 | ProjectCreationPanel |
53 member variables including 8 MuJoCo compilation state variables, 7 asset pack build state variables, 4 titlebar color animation floats, and popup data structures. EditorLayer is simultaneously:
- The scene lifecycle manager (play/stop/simulate)
- The project manager (create/open/save)
- The UI framework (menubar, titlebar, popups)
- The build system (C# compilation, asset packing)
- The MuJoCo compilation orchestrator
- The keyboard shortcut handler
The engine has 13 static facade classes where every method is static:
| Class | File | What It Hides |
|---|---|---|
Renderer |
Renderer/Renderer.h |
Render command queues, shader library, GPU stats |
AssetManager |
Asset/AssetManager.h |
Delegates to Project::GetAssetManager() |
PhysicsSystem |
Physics/PhysicsSystem.h |
s_PhysicsAPI, settings, mesh cache |
Input |
Core/Input.h |
Key/mouse/controller state maps |
Log |
Core/Log.h |
4 spdlog loggers, tag map |
Platform |
Core/Platform.h |
OS utilities |
SelectionManager |
Editor/SelectionManager.h |
Selection contexts, mesh hits |
PhysicsLayerManager |
Physics/PhysicsLayer.h |
Layer collision matrix |
Font |
Renderer/UI/Font.h |
Default fonts |
ImGuiEx::Fonts |
ImGui/ImGuiFonts.h |
ImGui font registry |
AssetImporter |
Asset/AssetImporter.h |
Serializer map |
AudioThread |
Audio/Audio.h |
Thread, job queue, timer |
PolicyNetwork |
Learn/PolicyNetwork.h |
ONNX network cache |
Plus 7 singletons with Get() / GetInstance(): Application, ScriptEngine, MiniAudioEngine, EditorLog, EditorStack, FatalSignal, Project.
What's wrong:
Static facades are untestable. You cannot mock Renderer::Submit() in a unit test. You cannot run two PhysicsSystem instances in parallel. You cannot serialize the state of Input for replay.
The AssetManager facade is particularly egregious — it's a static class whose every method delegates to Project::GetAssetManager(), which returns a polymorphic AssetManagerBase*. The static facade adds zero value over just calling Project::GetAssetManager()->GetAsset() directly. It exists purely to save typing.
The cascading coupling problem: Renderer is static → SceneRenderer calls Renderer::Submit() directly → you can't render without the global renderer being initialized → headless testing requires initializing the entire GPU stack or mocking at a level that doesn't exist.
File: Hazel/src/Hazel/Scene/Components.h — 1,139 lines, 49 component structs
30 files include Components.h across physics, audio, scripting, rendering, and serialization. Every time a component struct changes, 30 translation units recompile.
The real problems:
Mixed data and logic. Most components are pure data (good), but PrefabComponent has 13 methods and TransformComponent has 8. Components with methods become harder to serialize, harder to copy, harder to introspect.
No component registration system. Adding a new component requires touching:
Components.h— define the structCopyableComponents.h— add to copyable listSceneSerializer.cpp— add serializationSceneHierarchyPanel— add editor UIScriptGlue/— add C# bindings (if script-accessible)ComponentComparison.h— add comparison
There's no single registration point. No compile-time list. No reflection. Each step is manual and easy to forget. The /send-pr skill has a check for this (Check 24) because it's been forgotten enough times to warrant automated enforcement.
MuJoCo components are special-cased. There are 8 MuJoCo-specific component types (MujocoBodyComponent, MujocoGeomComponent, MujocoBoxColliderComponent, etc.) mixed into the same file as core engine components. These are domain-specific to robotics simulation and don't belong alongside generic engine components like CameraComponent or TextComponent.
File: Hazel/src/Hazel/Scene/SceneSerializer.cpp — 2,916 lines
All 49 component types serialize and deserialize in this single file. Adding a component means adding ~50 lines of YAML serialization code to an already massive file.
What's wrong:
This is the antithesis of the open-closed principle. The file must be modified for every component addition. Two developers adding different components will conflict in the same file. The serialization logic for AudioListenerComponent has nothing in common with MujocoBoxColliderComponent, yet they live side by side.
Contrast with ScriptGlue: The scripting system handles this correctly — 508 InternalCalls are spread across 60 modular files in Script/ScriptGlue/, one per component/system. A code generator aggregates them. The serialization system should follow the same pattern.
LuckyEngine has three physics backends:
- Jolt — Full 3D physics with
PhysicsScene/PhysicsBody/PhysicsShapeabstractions - MuJoCo — Robotics physics with its own
MujocoSceneInstanceclass - Box2D — 2D physics managed directly by Scene
What's wrong:
Jolt has a proper abstraction layer (PhysicsAPI → JoltAPI, PhysicsScene → JoltScene, PhysicsBody → JoltBody). It was designed to support multiple backends.
MuJoCo ignores this abstraction entirely. MujocoSceneInstance doesn't implement PhysicsScene. It has its own stepping methods (SimulateAcquisition, SimulatePhysics, SimulateExport) that happen to match the TimeManager phases but aren't polymorphic. Scene.cpp has separate callback registrations for Jolt and MuJoCo:
Scene registers:
- UpdateJoltAcquisition/Control/Physics/Validation/Export (5 callbacks)
- UpdateMujocoAcquisition/Control/Physics/Export (4 callbacks)
- UpdateBox2DPhysics + Update2DPhysicsTransforms (2 callbacks)
That's 11 physics callbacks managed manually in Scene instead of through a unified physics manager.
Box2D is even worse — it's handled directly in Scene.cpp via CreateBox2DWorld() (106 lines inline in Scene). No abstraction at all.
The consequence: Every time physics stepping logic changes (new phase, new timing requirement), three separate code paths in Scene.cpp must be updated independently. There's no guarantee they stay consistent.
42 files contain friend class declarations. Some are justified (Renderer needing internal access to Application). Many are not:
Scene.h has friends for: SceneSerializer, SceneSettingsPanel, SceneHierarchyPanel, ECSDebugPanel, SceneRenderer, Prefab, PrefabSerializer, EditorLayer, ScriptEngine, SelectionManager.
That's 10 friend classes for Scene alone. Each friend can read and write Scene's private state. The encapsulation boundary is fictional — if 10 classes can bypass it, it doesn't exist.
EditorLayer has friends for: Viewport, SimpleUXMode, SceneSettingsPanel, DataPanel, MujocoForceToolPanel, CollidersManager, HazelAgentInterface.
7 more friends. These exist because panels need to reach into EditorLayer's state (scene references, MuJoCo compilation state, viewport management). The proper fix is to expose that state through a controlled API, not to grant blanket access.
File: Hazel/src/Hazel/Core/Application.h:129
inline Ref<GLFWWindow> GetWindow() { return m_Window.As<GLFWWindow>(); }The base class stores Ref<Window>, but GetWindow() casts and returns Ref<GLFWWindow>. Every caller gets the concrete GLFW type, not the abstract Window. This defeats the entire purpose of the Window abstraction.
NullWindow exists for headless testing, but GetWindow() will crash or return null if the window isn't GLFW. Any code calling GetWindow()->GetNativeWindow() (a GLFW-specific method) will fail silently or crash in headless mode.
The abstract Window base class becomes dead weight — nobody uses it because they can always get the concrete GLFWWindow directly.
The event system uses a class hierarchy (Event base, WindowResizeEvent, KeyPressedEvent, etc.) with EventDispatcher pattern matching by type.
What's wrong:
It's statically typed and dispatches through virtual methods and type IDs. This means:
- You can't subscribe to "all physics events" without listing each type
- Adding a new event type requires a new class, a new enum entry, and updating every dispatcher that should handle it
- Events are processed synchronously in the layer stack — a slow handler blocks everything
- The event queue in Application uses
std::deque<std::pair<bool, std::function<void()>>>— a pair of bool + heap-allocated function object per event. The bool tracks sync state. This is a lot of allocation churn for high-frequency events.
The system works, but it's not designed for the throughput a game engine needs. Physics alone can generate thousands of contact events per frame.
TimeManager is one of the best-designed systems in the engine. It has clean phase ordering (Acquisition → Control → Physics → Validation → Export), supports multiple runners at different frequencies, handles deterministic and non-deterministic modes, and provides proper step context with interpolation alpha.
The problem is how Scene uses it.
Scene's InitTimeRunners() registers 20+ callbacks with TimeManager — every subsystem's update function is registered as a lambda capturing this. This creates a hidden web of execution order dependencies that only exists at runtime:
Scene::InitTimeRunners() registers:
UpdateJoltAcquisition, UpdateJoltControl, UpdateJoltPhysics, UpdateJoltValidation, UpdateJoltExport,
UpdateMujocoAcquisition, UpdateMujocoControl, UpdateMujocoPhysics, UpdateMujocoExport,
UpdateBox2DPhysics, Update2DPhysicsTransforms,
UpdateScriptsOnPreUpdate, UpdateScriptsOnUpdate, UpdateScriptsOnPhysicsUpdate, UpdateScriptsOnLateUpdate, UpdateScriptsOnPostUpdate,
UpdateRobotControllers,
UpdateObserverData, UpdateObserverVideo,
UpdateAnimation,
UpdateAudioSceneSubsystem
These should be registered by the subsystems themselves, not enumerated in Scene. If a subsystem manager owned its TimeManager registration, Scene wouldn't need to know about physics stepping, script updates, or audio processing at all.
70 reinterpret_cast usages across 22 files. The highest concentrations:
| File | Count | Context |
|---|---|---|
JoltScene.cpp |
13 | Jolt ↔ engine type punning |
DeviceManager.cpp |
11 | Vulkan handle conversions |
JoltShapes.cpp |
7 | Shape data extraction |
NsightAftermathGpuCrashTracker.cpp |
6 | GPU debug handle casting |
Renderer.cpp |
5 | Render pipeline handle conversions |
imgui-node-editor |
7 | Vendor code (not ours) |
The Jolt and Vulkan casts are defensible — these are FFI boundaries with C libraries. But 13 reinterpret_cast in a single file suggests the Jolt integration layer is too thin. A proper wrapper would isolate type punning to one place, not scatter it across scene management code.
File: Hazel/src/Hazel/ImGui/ImGuiUtilities.h
The DPI scaling system uses a user-defined literal originally named _em (as documented in memory and the architecture doc), but the actual code uses _px:
constexpr float operator""_px(long double val) { return static_cast<float>(val) * DPI::GetUIScaleFactor(); }The memory file says _em, the code says _px. The concept is "scaled pixels" but the name _px suggests "raw pixels" — the opposite of what it does. 10.0_px doesn't mean 10 pixels; it means 10 * uiScale pixels. This is a naming trap for anyone reading the code for the first time.
This isn't Ned. The engine has genuine architectural strengths:
-
Ref / Scope / WeakRef — Consistent, well-defined smart pointer hierarchy. Intrusive ref counting on
RefCountedis correct for a game engine (avoids the shared_ptr control block overhead). The codebase enforces this — almost zero rawnew/delete. -
TimeManager — The phased stepping system is genuinely excellent. Fixed phases with ordered execution, multiple runners at independent frequencies, deterministic modes for ML training — this is production-quality simulation infrastructure.
-
ScriptGlue modularization — 508 InternalCalls split across 60 files with code generation. This is the right pattern. If every system followed this model, the codebase would be significantly more maintainable.
-
Zero mega-include abuse —
#include "Hazel.h"is not used outside the main umbrella header. Individual files include only what they need. -
Asset system abstraction — The
EditorAssetManager/RuntimeAssetManagersplit withAssetManagerBaseis clean. Assets are referenced by handle, not path. Async loading with dependency tracking works. -
Physics abstraction (Jolt) —
PhysicsAPI→PhysicsScene→PhysicsBody→PhysicsShapeis a proper abstraction hierarchy. Adding a new physics backend (if done like Jolt, not like MuJoCo) is straightforward. -
PanelManager — The editor panel registration system is well-designed. It's just underused (22 UI methods in EditorLayer should be panels).
-
NVRHI abstraction — Graphics API code is isolated behind NVRHI. No raw Vulkan calls leak into engine code. Shaders are HLSL. Pipeline state is cached.
-
Minimal dynamic_cast — Only 7 in the entire codebase. Type safety is enforced at compile time, not runtime.
-
HZ_CORE_ASSERT/HZ_CORE_VERIFY— Debug vs. release invariant checking is properly separated.
The 5,734-line Scene must be split. The goal: Scene owns the entt::registry and entity lifecycle — nothing more. Subsystem managers own their own logic and register with TimeManager directly.
classDiagram
class Scene {
-entt::registry m_Registry
-EntityMap m_EntityIDMap
-TimeManager m_TimeManager
-vector~ISceneSubsystem*~ m_Subsystems
+CreateEntity(name) Entity
+DestroyEntity(entity)
+GetRegistry() registry&
+GetTimeManager() TimeManager&
+RegisterSubsystem(ISceneSubsystem*)
+OnRuntimeStart()
+OnRuntimeStop()
}
class ISceneSubsystem {
<<interface>>
+OnSceneStart(Scene&)*
+OnSceneStop()*
+GetName()* string_view
}
class PhysicsManager {
-Ref~PhysicsScene~ m_JoltScene
-vector~Ref~MujocoSceneInstance~~ m_MujocoInstances
-b2World* m_Box2DWorld
+OnSceneStart(Scene&)
+OnSceneStop()
}
class ScriptManager {
+OnSceneStart(Scene&)
+OnSceneStop()
}
class RenderManager {
+SubmitScene(SceneRenderer&, Scene&)
+RenderDebugVisualization(Scene&)
}
class AnimationManager {
+OnSceneStart(Scene&)
+OnSceneStop()
}
class AudioManager {
+OnSceneStart(Scene&)
+OnSceneStop()
}
class RobotManager {
+OnSceneStart(Scene&)
+OnSceneStop()
}
class DataRecordingManager {
+OnSceneStart(Scene&)
+OnSceneStop()
}
Scene --> ISceneSubsystem
ISceneSubsystem <|.. PhysicsManager
ISceneSubsystem <|.. ScriptManager
ISceneSubsystem <|.. RenderManager
ISceneSubsystem <|.. AnimationManager
ISceneSubsystem <|.. AudioManager
ISceneSubsystem <|.. RobotManager
ISceneSubsystem <|.. DataRecordingManager
Key change: Each subsystem registers its own TimeManager callbacks in OnSceneStart(). Scene doesn't need to know what frequency physics runs at or what phase scripts update in. Scene just calls subsystem->OnSceneStart(*this) and the subsystem handles the rest.
Migration path:
- Create
ISceneSubsysteminterface - Extract
PhysicsManagerfirst (biggest win — removes 11 callbacks and ~1,500 lines) - Extract
RenderManager(removes ~1,000 lines of render/debug code) - Extract
ScriptManager,AnimationManager, etc. one at a time - Scene shrinks to ~1,500 lines of entity management
classDiagram
class EditorLayer {
-Ref~Scene~ m_EditorScene
-Ref~Scene~ m_CurrentScene
-SceneState m_SceneState
-PanelManager m_PanelManager
-ProjectManager m_ProjectManager
-BuildManager m_BuildManager
+OnAttach()
+OnUpdate(Timestep)
+OnImGuiRender()
+OnScenePlay()
+OnSceneStop()
}
class ProjectManager {
+CreateProject(params) bool
+OpenProject(path) bool
+SaveProject()
+GetRecentProjects() span
}
class BuildManager {
-future~void~ m_MujocoCompile
-future~AssetPacks~ m_AssetPack
-atomic~float~ m_Progress
+CompileMujoco(scene) future
+BuildAssetPack() future
+GetProgress() float
+IsBuilding() bool
}
class MenuBarPanel {
+OnImGuiRender(isOpen)
}
class TitleBarPanel {
-uint32 m_TargetColor
-uint32 m_ActiveColor
+OnImGuiRender(isOpen)
}
class MeshImportPanel {
+OnImGuiRender(isOpen)
}
class StatisticsPanel {
+OnImGuiRender(isOpen)
}
EditorLayer --> ProjectManager
EditorLayer --> BuildManager
EditorLayer --> PanelManager
PanelManager --> MenuBarPanel
PanelManager --> TitleBarPanel
PanelManager --> MeshImportPanel
PanelManager --> StatisticsPanel
Key changes:
- Extract
ProjectManager— owns all project create/open/save/recent logic - Extract
BuildManager— owns MuJoCo compilation state (8 variables), asset pack state (7 variables), C# reload state - Move 22
UI_*methods into properEditorPanelsubclasses - EditorLayer becomes a thin orchestrator (~1,000 lines) that wires subsystems together
Immediate win: The 1,306-line UI_ShowCreateAssetsFromMeshSourcePopup() becomes MeshImportPanel — a standalone panel that can be iterated independently.
Not all static facades need to change. Log and Platform are genuinely stateless utilities. But the stateful ones should become injectable services:
classDiagram
class IRenderer {
<<interface>>
+Submit(lambda)*
+GetShaderLibrary()* Ref~ShaderLibrary~
+GetCapabilities()* RendererCapabilities
}
class VulkanRenderer {
-CommandQueue m_Queue
-ShaderLibrary m_Shaders
}
class NullRenderer {
+Submit(lambda)
+GetShaderLibrary() Ref~ShaderLibrary~
}
IRenderer <|.. VulkanRenderer
IRenderer <|.. NullRenderer
class IPhysics {
<<interface>>
+CreateScene(Scene&)* Ref~PhysicsScene~
+GetSettings()* PhysicsSettings&
}
class JoltPhysics {
-PhysicsSettings m_Settings
-MeshColliderCache m_Cache
}
IPhysics <|.. JoltPhysics
Why this matters: With NullRenderer, you can run the entire engine in headless mode without GPU initialization. Scene tests don't need Vulkan. Physics tests don't need rendering. The engine becomes testable at the subsystem level.
Practical migration: Keep the static facades as thin wrappers that delegate to the active service instance. Change one at a time. No big bang.
Step 1: Split Components.h
Hazel/src/Hazel/Scene/
Components/
CoreComponents.h — ID, Tag, Transform, Relationship, Domain
RenderComponents.h — Mesh, StaticMesh, Light, Sky, Camera, Sprite, Text
PhysicsComponents.h — RigidBody, Colliders, CharacterController
MujocoComponents.h — MujocoBody, MujocoGeom, MujocoColliders
AudioComponents.h — AudioListener, SourceReflection, Reverb
ScriptComponents.h — Script, RobotController, Animation
Physics2DComponents.h — RigidBody2D, BoxCollider2D, CircleCollider2D
Components.h — #includes all of the above (backwards compatible)
Step 2: Distributed serialization
Follow the ScriptGlue model — each component file includes its own serialization:
// RenderComponents.h
struct MeshComponent {
AssetHandle Mesh;
// ...
static void Serialize(YAML::Emitter& out, const MeshComponent& c);
static void Deserialize(const YAML::Node& node, MeshComponent& c);
};Or use a registration macro:
HZ_REGISTER_COMPONENT_SERIALIZER(MeshComponent, SerializeMesh, DeserializeMesh);SceneSerializer.cpp shrinks from 2,916 lines to a simple loop over registered serializers.
Ordered by impact / effort ratio:
| Priority | Change | Effort | Impact | Lines Saved |
|---|---|---|---|---|
| 1 | Extract PhysicsManager from Scene |
Medium | High | ~1,500 |
| 2 | Move 22 UI_* methods to panels |
Low | High | ~2,500 |
| 3 | Extract BuildManager from EditorLayer |
Low | Medium | ~500 |
| 4 | Split Components.h into per-domain files | Low | Medium | Compile time |
| 5 | Extract ProjectManager from EditorLayer |
Low | Medium | ~400 |
| 6 | Extract RenderManager from Scene |
Medium | High | ~1,000 |
| 7 | Distribute serialization to component files | Medium | High | ~2,000 |
| 8 | Unify MuJoCo under PhysicsScene abstraction |
High | Medium | ~500 |
| 9 | Fix GetWindow() to return Ref<Window> |
Low | Low | 0 |
| 10 | Make Renderer injectable (NullRenderer) |
High | High | Testability |
Items 1-5 are quick wins that can each be done in a single PR. Items 6-10 are larger efforts that pay off over time.