Skip to content

Instantly share code, notes, and snippets.

@devrim
Created March 13, 2026 06:27
Show Gist options
  • Select an option

  • Save devrim/c32eb4f68415ad5be92d72f59acc1e56 to your computer and use it in GitHub Desktop.

Select an option

Save devrim/c32eb4f68415ad5be92d72f59acc1e56 to your computer and use it in GitHub Desktop.
LuckyEngine Architecture Self-Review & Critique

LuckyEngine Architecture Review & Critique

A brutally honest self-review of LuckyEngine's architecture. Every finding is backed by exact file paths and line counts.


Table of Contents

  1. Codebase At A Glance
  2. Architectural Critique
  3. What's Done Well
  4. Proposed Improvements
  5. Priority Ranking

Codebase At A Glance

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)

Architectural Critique

1. Scene.cpp: The 5,734-Line God Object

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 #include statements 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.


2. EditorLayer.cpp: The 5,053-Line Orchestrator That Does Everything

Files: LuckyEditor/src/EditorLayer.h (300 lines), EditorLayer.cpp (5,053 lines)

The numbers:

  • 78 methods (31 public, 47 private)
  • 53 member variables
  • 101 #include statements (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

3. Static Facade Epidemic

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.


4. Components.h: The 49-Struct Monolith

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:

  1. Components.h — define the struct
  2. CopyableComponents.h — add to copyable list
  3. SceneSerializer.cpp — add serialization
  4. SceneHierarchyPanel — add editor UI
  5. ScriptGlue/ — add C# bindings (if script-accessible)
  6. 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.


5. SceneSerializer.cpp: 2,916 Lines of Centralized Pain

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.


6. Three Physics Engines, No Unified Interface

LuckyEngine has three physics backends:

  • Jolt — Full 3D physics with PhysicsScene / PhysicsBody / PhysicsShape abstractions
  • MuJoCo — Robotics physics with its own MujocoSceneInstance class
  • Box2D — 2D physics managed directly by Scene

What's wrong:

Jolt has a proper abstraction layer (PhysicsAPIJoltAPI, PhysicsSceneJoltScene, PhysicsBodyJoltBody). 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.


7. Friend Class Overuse

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.


8. The GetWindow() Leak

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.


9. Event System: Type-Safe But Inflexible

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.


10. TimeManager Is Correct But Scene Abuses It

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.


11. 70 reinterpret_casts

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.


12. The _em / _px Naming Confusion

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.


What's Done Well

This isn't Ned. The engine has genuine architectural strengths:

  1. Ref / Scope / WeakRef — Consistent, well-defined smart pointer hierarchy. Intrusive ref counting on RefCounted is correct for a game engine (avoids the shared_ptr control block overhead). The codebase enforces this — almost zero raw new/delete.

  2. 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.

  3. 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.

  4. Zero mega-include abuse#include "Hazel.h" is not used outside the main umbrella header. Individual files include only what they need.

  5. Asset system abstraction — The EditorAssetManager / RuntimeAssetManager split with AssetManagerBase is clean. Assets are referenced by handle, not path. Async loading with dependency tracking works.

  6. Physics abstraction (Jolt)PhysicsAPIPhysicsScenePhysicsBodyPhysicsShape is a proper abstraction hierarchy. Adding a new physics backend (if done like Jolt, not like MuJoCo) is straightforward.

  7. PanelManager — The editor panel registration system is well-designed. It's just underused (22 UI methods in EditorLayer should be panels).

  8. NVRHI abstraction — Graphics API code is isolated behind NVRHI. No raw Vulkan calls leak into engine code. Shaders are HLSL. Pipeline state is cached.

  9. Minimal dynamic_cast — Only 7 in the entire codebase. Type safety is enforced at compile time, not runtime.

  10. HZ_CORE_ASSERT / HZ_CORE_VERIFY — Debug vs. release invariant checking is properly separated.


Proposed Improvements

Scene Decomposition

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
Loading

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:

  1. Create ISceneSubsystem interface
  2. Extract PhysicsManager first (biggest win — removes 11 callbacks and ~1,500 lines)
  3. Extract RenderManager (removes ~1,000 lines of render/debug code)
  4. Extract ScriptManager, AnimationManager, etc. one at a time
  5. Scene shrinks to ~1,500 lines of entity management

EditorLayer Decomposition

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
Loading

Key changes:

  1. Extract ProjectManager — owns all project create/open/save/recent logic
  2. Extract BuildManager — owns MuJoCo compilation state (8 variables), asset pack state (7 variables), C# reload state
  3. Move 22 UI_* methods into proper EditorPanel subclasses
  4. 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.


Static Facade Replacement

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
Loading

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.


Component System Modernization

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.


Priority Ranking

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment