2.1 · Application Lifecycle
Application is the singleton at the root of every LuckyEngine process. It boots the window, owns the layer stack and render-thread context, pumps OS events, and runs the frame loop that ticks every other subsystem.
EntryPoint.h and the per-frame work inside Application::Run().Overview
Every LuckyEngine executable — the editor, the runtime, the headless app, the tests — is a thin shell around a single
Application instance. Client code never writes main() by hand; EntryPoint.h defines it and calls into
the client-supplied CreateApplication() factory. The returned Application* is then driven through its lifecycle:
construct → Init → Run → Shutdown → delete.
Once running, Application::Get() is the global handle every other system uses to reach the window, the layer stack, and the
render-thread context. There is exactly one of these per process — do not try to construct your own.
Boot sequence
A LuckyEngine app starts identically on every platform. The client only fills the factory; the engine handles the rest.
| Step | Where it runs | What happens |
|---|---|---|
1. main() |
EntryPoint.h |
Engine-provided entry. Sets up logging, parses common args, and invokes CreateApplication(). |
2. CreateApplication() |
Client (e.g. LuckyEditor.cpp) |
Builds an ApplicationSpecification — window title, size, threading policy — and returns the concrete Application subclass. |
3. Application ctor |
Engine | Stores spec, creates the Window, initialises the render-thread context, builds default textures, primes AssetManager. |
4. OnInit hook |
Client | Optional override. This is where the client pushes its initial layers (editor pushes EditorLayer, runtime pushes its runtime layer). |
5. Run() |
Engine | Loops until WindowCloseEvent flips m_Running to false. |
6. Shutdown |
Engine | Detaches every layer in reverse, joins the render thread (if any), tears down NVRHI, destroys the window. |
The layer stack
Layers are the unit of feature composition. Each layer implements a small surface defined in Layer.h:
| Hook | Called when | Notes |
|---|---|---|
OnAttach() | Once, on PushLayer / PushOverlay | Allocate resources, register callbacks, subscribe to events. |
OnDetach() | Once, on pop or app shutdown | Release resources. Must be idempotent — layers are torn down in reverse. |
OnUpdate(Timestep ts) | Every frame | Frame-rate-bound work. Use TimeManager for fixed-rate work instead. |
OnImGuiRender() | Every frame, inside the ImGui pass | UI panels, debug overlays. |
OnEvent(Event&) | For every dispatched event | Mark Handled to stop propagation. |
PushLayer vs. PushOverlay
Regular layer
Inserted before the overlay region. Update order is bottom-up; event dispatch is top-down. Use for gameplay layers, editor body, debug systems that share priority with peers.
Overlay layer
Always sits on top of the regular layers. Reserved for things that must see events first and draw last — ImGui itself is an overlay.
Most new features want a panel (Editor), a component (Scene), or a TimeManager runner (fixed-rate sim work) — not a new layer. Layers exist for cross-cutting orchestration; piling layer-per-feature creates ordering puzzles.
The Run() frame loop
Run() is a fixed sequence per iteration. The diagram above shows it; the table below pins it down with the ordering invariants other subsystems rely on.
| Order | Stage | Why this position matters |
|---|---|---|
| 1 | Poll OS events (Window::OnUpdate) | Inputs and window state for the frame must arrive before any layer reads them. |
| 2 | Dispatch events to layers (top → bottom) | Overlays consume input first; Handled stops the chain. |
| 3 | Layer::OnUpdate (bottom → top) | Gameplay runs before UI so panels read consistent state. |
| 4 | Layer::OnImGuiRender | UI build phase — ImGui draw lists are collected here. |
| 5 | TimeManager::Tick | Steps every registered runner (physics, control, recording …) at its own fixed rate. |
| 6 | Drain render command queue | On the render thread (MT) or the main thread (ST). See pitfall below. |
| 7 | Present | NVRHI swapchain present + frame index advance. |
Threading policy
ApplicationSpecification::CoreThreadingPolicy is the single switch that decides whether m_RenderThread is a real
std::thread or a same-thread queue drain. It is set once at construction and never changes during the lifetime of the app.
| Policy | Used by | Render thread | Queue drain |
|---|---|---|---|
MultiThreaded |
Runtime, HeadlessApplication |
Dedicated worker thread | Render thread drains while main builds frame N+1 |
SingleThreaded |
Editor (LuckyEditor.cpp) |
Same as main | Main thread drains at end of frame |
In the editor, main and render are the same thread. Lambdas pushed via Renderer::Submit(...) do not cross a thread boundary,
do not need extra synchronisation, and can capture editor-side state directly. In the runtime, the same call does cross a thread boundary —
capture by value and treat shared state as needing a lock. See .claude/docs/Threading.md § "Editor vs. runtime".
TimeManager integration
Application::Run calls TimeManager::Tick exactly once per frame. Everything that wants to run at a fixed cadence — physics at 500 Hz,
control at 100 Hz, recording at 30 Hz — registers a callback with TimeManager rather than piggy-backing on OnUpdate.
Phases in execution order: Acquisition → Control → Physics → Validation → Export.
A free-update phase (FreePhaseID::PostExport) runs once per refresh-rate frame for UI-paced work. The full contract lives in
.claude/docs/Threading.md.
Don't add per-frame logic to Application::Run or to Scene::OnUpdate. Both are protected files. Register with TimeManager instead — you pick the phase and the frequency, and the loop already calls you.
Extending
Most new work fits one of these shapes:
| You want… | Do this |
|---|---|
| Code that runs every visual frame | Implement Layer::OnUpdate on an existing layer. |
| A fixed-rate system (physics, recording, telemetry) | TimeManager::RegisterWithRunner with the right phase. |
| UI in the editor | A new EditorPanel — not a new layer. See 2.10 Editor. |
| A cross-cutting feature (input, agent, debug UI) | A new layer pushed in the relevant client's OnInit. |
| Process-wide state | Reach for it via Application::Get(); do not introduce another singleton. |
Pitfalls
If layer B was pushed after layer A and B's OnAttach registered a callback on A, B's OnDetach must unregister before A goes away. The stack tears down top-to-bottom so this is the natural order — don't fight it with manual ordering.
Application& by reference in a workerApplication::Get() is fine on the main thread. From a std::jthread, prefer marshalling back via a dispatcher — see Threading.
CoreThreadingPolicy is read once during construction. There is no "go MT for this frame". If you need both behaviours, you need two apps (e.g. runtime and editor share Hazel; they don't share an Application instance).
Key types
| Type | Header | Role |
|---|---|---|
Application | Hazel/src/Hazel/Core/Application.h | Singleton root. Owns window, layers, render-thread context, event queue. |
ApplicationSpecification | Application.h | POD config — title, size, threading policy, working directory. |
Layer | Layer.h | Base class for the layer stack hooks. |
LayerStack | LayerStack.h | Manages layer + overlay regions and iteration order. |
RenderThread | RenderThread.h | Real worker thread when policy is MultiThreaded, same-thread shim when SingleThreaded. |
TimeManager | TimeManager.h | Fixed-rate phase scheduler. The home for all sim-rate work. |
EntryPoint.h | Core/EntryPoint.h | Defines main(). Included by exactly one TU per executable. |