Editor Plan

SafiEngine already ships a MicroUI debug overlay with a Scene hierarchy, an editable Inspector (numbers, vec3s, checkboxes, dropdowns), physics components, and an engine-owned render stage. The editor is the natural extension: the same in-process UI grows into a tool that lets you build, play, and ship a game without writing C bootstrap code for every scene.

The editor is not a separate application. It is a set of systems and panels that run in the same process as the game, behind the same enable_debug_ui flag that already gates the Inspector.

Design principles

  • One binary, two modes — "Edit" and "Play". Edit mode pauses fixed update and game systems; Play mode runs them. No separate editor process, no IPC.
  • Data in ECS, not in the editor — every piece of scene state lives in flecs components. The editor only reads/writes components; it never keeps a parallel model of the world.
  • Undo = snapshot — undo/redo restores a serialized snapshot of affected entities rather than reverse-applying ops. Simpler, fewer edge cases, and it composes with physics state.
  • Stay in C — the editor is implemented in the same engine C code, using MicroUI and the existing render stack. No ImGui, no C++ bindings, no second UI toolkit.
  • Author scenes as files — the source of truth for a level is a scene file on disk, not code. Games ship as engine_binary + assets/ + scenes/.

Current foundation (already shipped)

  • Scene hierarchy panel — entities listed by SafiName, nested by EcsChildOf, click-to-select. (Debug UI)
  • Inspector panel — collapsible sections per component, editable number / vec3 / checkbox / dropdown widgets. Covers Transform, Camera, MeshRenderer, Spin, RigidBody, Collider, and all 5 light types. Drops stale selection (ecs_is_alive) across scene reloads.
  • Engine-owned render stage — the render pipeline is a set of ECS systems on the render phase; editor systems can insert overlays (gizmos, wireframes) without forking the renderer. (Render Overview)
  • Fixed timestepSafiFixedUpdate is separate from the variable update, so pausing physics is just "don't run fixed update". (Scheduler)
  • Physics integration — Jolt bodies driven by SafiRigidBody + SafiCollider, transform sync in both directions. (Physics)
  • Scene serializationsafi_scene_save / safi_scene_load / safi_scene_clear plus in-memory safi_scene_snapshot_all / safi_scene_restore_snapshot for non-destructive Play→Stop round-trips. (Scene)
  • Editor-mode singletonSafiEditorState { mode, selected_tool, selected_entity } with Edit / Play / Paused. The safi_app_tick loop gates the fixed + game pipelines on mode == PLAY; variable + render always run. (Editor State)
  • Dedicated game phase — user gameplay opts into SafiGamePhase (distinct from EcsOnUpdate) so it freezes in Edit mode while engine-owned variable systems keep ticking. (Scheduler)
  • Editor fly-camSafiEditorCamera entity with WASD/QE translate + right-mouse rotate. Installed automatically with the debug UI; arbitrates the SafiActiveCamera tag with the gameplay camera on every Edit ↔ Play switch. (Editor Camera)
  • Gizmo draw listsafi_gizmo_draw_line / _box_wire / _aabb / _sphere_wire, drained once per frame inside the main render pass. The substrate M4 gizmo handles sit on. (Gizmo Draw List)
  • Camera pose + screen-raySafiCamera carries explicit eye/forward/up; safi_camera_screen_ray unprojects a mouse click into a world-space ray so picking and gizmo drag share one convention. (Camera Math)
  • Editor keybinds — F1 toggles Edit ↔ Play, F6 snapshots the world, F7 restores the snapshot, Q/W/E/R picks the tool. Skipped while a MicroUI field has focus or RMB is held. (Editor Shortcuts)
  • Editor toolbar — chromeless MicroUI strip at the top with Play/Pause/Stop + Select/Translate/Rotate/Scale buttons. Auto-snapshots on Play, auto-restores on Stop. (Editor Toolbar)
  • Translate / Rotate / Scale gizmos — three-axis handles driven by the Toolbar/Q-W-E-R tool picker; hit-test in screen space, drag mutates SafiTransform directly. (Editor Gizmo)
  • Stable entity IDs — every named entity carries a 128-bit random SafiStableId. Scene save/load and snapshot restore key on the id, so renames, duplicate-named entities, and future prefab references all stay consistent. (Components)
  • Project-root-relative asset pathsSafiAppDesc.project_root anchors every relative path a scene file stores (e.g. "models/player.glb"). Scenes round-trip across machines without absolute-path baggage. (Assets)
  • Hot-reload — the engine polls mtimes under the project root at ~4 Hz and swaps any model or texture whose file changed. Handle ids stay valid across swaps. (Assets)
  • SafiPrimitive.texture handle — the procedural-primitive component now references a SafiTextureHandle instead of a raw path, so textures dedup, hot-reload, and never leak; inspector string edits resolve through the asset registry. (Primitives)
  • Component default-init + construct-by-name — every registered component has a default_init callback that sets sensible non-zero defaults; safi_component_registry_construct(world, e, "SafiPointLight") is the M5 "+ Add Component" entry point. (Registry)
  • Hierarchy helperssafi_entity_set_parent rejects cycles, safi_entity_detach_from_parent drops the EcsChildOf edge, safi_entity_children enumerates. Scene load uses them, so hand-edited JSON with a cycle gets a warning instead of a wedged propagation pass. (ECS Overview)
  • Change bus — one flecs EcsOnSet observer per serializable component emits SafiChange events to subscribers. safi_change_bus_begin_group / _end_group coalesce gizmo drags into a single undo step; M6 undo reads from this stream.
  • Entity presetssafi_preset_empty/_mesh/_primitive/_camera/_*_light/_static_box/_dynamic_sphere return a ready-to-edit entity with stable id, transform, and sensible component defaults. The M5 Create menu calls these instead of hand-rolling ecs_set blocks.
  • Audio pause / resumesafi_audio_pause(voice) / _resume(voice) preserves playback position. Used by the gltf_viewer's music gate so mid-track Stop→Play continues where it left off.
  • Editor Hub + safi_editor binary — a dedicated editor binary that boots into a project launcher (recent projects, create-from-template, open-folder) and loads a project into the same world in place. Single process: the Hub is a view, not a second app. (Editor Hub guide, Project I/O, Project Session, Hub UI)

Milestones

M0 — Editor Hub (project launcher) — ✅ shipped

The entry point: launch safi_editor, pick or create a project, and start editing — no C bootstrap per game.

  • One binary, Hub as a viewsafi_editor with no --project shows the Hub; picking a project re-roots the asset system and loads its default scene into the same window. --project <dir> skips the Hub. "Close Project" returns to it. No second process, no IPC.
  • Project format — a directory with project.safi.json + assets/ + scenes/. Asset paths are project-root-relative so a project folder is portable. (Project I/O)
  • Recent projects — persisted under SDL_GetPrefPath, capped at 32, corruption-tolerant.
  • Templatesempty and 3d_starter, authored as scene JSON with no external assets, copied verbatim on create.
  • Implementationsafi/project/project.{h,c} + recents.{h,c} (file I/O), safi/editor/project_session.{h,c} (the Hub/editor switch), safi/ui/hub_ui.{h,c} (the launcher screen). The debug overlay draws the Hub when no project is open; the gltf_viewer sample is unaffected. See the Editor Hub guide.

M1 — Play / Pause / Stop (foundation) — ✅ shipped

The single most important editor feature. Without it, nothing else feels like an editor.

  • Toolbar panel with three buttons: Play, Pause, Stop — plus the M4 tool picker (Select / Translate / Rotate / Scale) on the right. The active button darkens to indicate current state.
  • Edit mode (default): fixed update is skipped; user gameplay on SafiGamePhase is skipped; render + transform propagation still run so the scene stays visible; physics bodies are paused but their components stay editable in the Inspector.
  • Play mode: the toolbar auto-snapshots every named entity (safi_scene_snapshot_all) before switching, then enables the fixed and game pipelines.
  • Stop: restores the auto-snapshot (non-destructive — entity ids stay stable), frees it, flips back to Edit. Pause just toggles the schedule without snapshotting.

What landed

  1. A SafiEditorState singleton with { mode, selected_tool, selected_entity }. ✅ (SafiEditorState)
  2. Gate SafiFixedUpdate and SafiGamePhase on state.mode == Play. ✅ (Scheduler)
  3. Snapshot / restore primitives. ✅ (safi_scene_snapshot_all / safi_scene_restore_snapshot)
  4. Toolbar buttons in the debug UI, auto-snapshot on Play, auto-restore on Stop. ✅ (Editor Toolbar)

Keyboard alternatives stay available: F1 toggles Edit/Play, F6 / F7 are a developer scratchpad snapshot/restore distinct from the toolbar's automatic capture.

M2 — Scene serialization

Save / load what's in the world to disk so work survives a restart.

  • Format: JSON, one file per scene. Each entry = { name, parent?, components: { SafiTransform: {...}, SafiMeshRenderer: { model: "asset://meshes/box.glb" }, ... } }.
  • Mesh / material references are string asset URIs, resolved through the asset handle system (M3).
  • Writer: walks every entity with a SafiName, emits components via a per-component registry { id → to_json / from_json }.
  • Reader: creates entities, looks up asset URIs, attaches components in dependency order (Transform before GlobalTransform, RigidBody before Collider registration, etc.).
  • Not in scope: cross-scene references, binary format, versioning. A schema bump = "bump the version field and rewrite the loader".

Menu: File → New Scene / Open… / Save / Save As… in a top menu bar.

M3 — Asset handle system & asset browser

Scenes must reference assets by a stable id, not by a live pointer.

  • SafiAssetHandle<T> — opaque 64-bit id, one slot per asset type (mesh, material, texture, shader). Handles are resolvable through a central registry that caches loaded assets and deduplicates by path. ✅ shipped (Assets)
  • Project-root-relative paths — scene files store paths like "models/player.glb" anchored to SafiAppDesc.project_root. ✅ shipped
  • Texture handle on primitivesSafiPrimitive.texture is a SafiTextureHandle, not a string; drag-drop from the asset browser (UI TBD) is a one-line assignment. ✅ shipped
  • Hot-reload hook — poll-based mtime watcher calls safi_assets_reload* at ~4 Hz. Handle ids stay valid across reloads. ✅ shipped
  • Asset browser panel — grid or tree view of the assets/ directory. Double-click to preview (mesh in a mini viewport; texture as a swatch). Drag a row onto an Inspector field to assign. (UI TBD)

M4 — Viewport gizmos & editor camera — ✅ shipped

Direct manipulation in the 3D viewport — the part that makes the editor feel like an editor rather than a form filler.

  • Editor camera — a fly-cam with WASD + right-mouse-look, owned by a dedicated SafiEditorCamera entity. Arbitrates the SafiActiveCamera tag on Edit ↔ Play switches. ✅ (Editor Camera)
  • Translate gizmo — three axis-aligned arrows at the selected entity's world position. Drag solves closest-point-on-line-to-line and writes SafiTransform.position through the pre-inverted parent-world matrix (parented entities translate in their local frame).
  • Rotate gizmo — three orthogonal rings. Drag intersects the cursor ray with the ring plane and computes a signed atan2 delta angle around the axis.
  • Scale gizmo — three axis handles ending in cubes. Drag maps axis-distance to a multiplicative factor clamped to 0.01.
  • Mode switch: Q / W / E / R for select / translate / rotate / scale, matching Unity/Unreal muscle memory. ✅ (Editor Shortcuts, RMB-guarded so they don't fire while flying)
  • Rendering — gizmos go through the shared line-list draw list, drained by the render system inside the main pass with depth test on / write off. ✅ (Editor Gizmo)

Still to polish (carried into M4.1 / M6):

  • Undo snapshots on drag-start / drag-end — will fold into the M6 undo ring buffer.
  • Multi-select manipulation — M7.
  • Exact ray/line intersection picking (the current screen-space hit-test is Unity-style and "good enough"); world-vs-local toggle.
  • Snap-to-grid / angle snap.

M5 — Entity creation & component add/remove

Authoring entities from scratch without C code.

  • Create menu (+ button in the Scene panel or right-click context menu):
    • Empty — just SafiTransform + SafiName + auto SafiStableId. ✅ safi_preset_empty
    • Mesh — empty + SafiMeshRenderer (prompts for a mesh asset). ✅ safi_preset_mesh
    • Light → Directional / Point / Spot / Rect / Sky. ✅ safi_preset_*_light
    • CameraSafiTransform + SafiCamera. ✅ safi_preset_camera
    • Physics Body → Static Box / Dynamic Sphere / … — transform + rigidbody + collider presets. ✅ safi_preset_static_box / _dynamic_sphere
  • Component panel — each Inspector component gets an × in its header to remove it; bottom of Inspector has + Add Component… that opens a searchable list of every registered component. ✅ safi_component_registry_construct(world, e, name) handles construction (UI TBD)
  • Parent assignment — drag an entity onto another in the Scene panel to re-parent; drag to the root area to unparent. ✅ safi_entity_set_parent / _detach_from_parent handle the mutation (UI TBD)

M6 — Undo / redo

Snapshot-based, scoped to a small window of recent changes.

  • Every Inspector edit, gizmo drag, entity create/delete, and parent change pushes a snapshot of the affected entities onto a bounded ring buffer (say 64 steps).
  • Cmd/Ctrl-Z / Cmd/Ctrl-Shift-Z pop/push between stacks.
  • Gizmo drags coalesce — one snapshot at drag-start, one at drag-end, nothing in between.

Backing APIs (shipped, waiting on the undo ring + keybinds):

  • SafiStableId guarantees entity identity across snapshot restore even after a rename.
  • Change bus observes every serializable component; gizmo drags already wrap in safi_change_bus_begin_group / _end_group so the coalescer has its signal.
  • safi_scene_snapshot_entities(world, ids, count) serializes an arbitrary slice of the world for per-step snapshots.

M7 — Prefabs / multi-select / polish

  • Multi-select in the Scene panel with Cmd/Shift; Inspector shows shared fields only, edits apply to all.
  • Prefabs — save a subtree as *.prefab.json; instantiate by drag-and-drop into a scene. Changes to the prefab propagate unless overridden.
  • Debug physics wireframe — hook Jolt's debug renderer into the editor render system.
  • Performance panel — frame time, draw call count, physics step time, entity count.

M8 — Build & ship

The part that closes the loop from "I edited something" to "I can hand this to a player".

  • File → Build… packages: the engine binary, the assets/ folder (only files referenced by any scene), the scenes/ folder, and a tiny bootstrap.json naming the first scene.
  • The produced binary launches with enable_debug_ui = false, loads bootstrap.json, and plays immediately.
  • Platforms in scope for MVP: macOS (Intel + ARM). Windows and Linux follow the same pattern through SDL3.

Non-goals (for now)

  • Visual scripting / node graphs. Gameplay stays in C. The editor is a level/entity tool, not a logic tool.
  • Terrain, animation timeline, particle editors. These are subsystems that need to exist in the engine first; the editor gets them when the engine does.
  • Multi-user / remote editing. Single user, local files.
  • A separate "Game" binary. Games ship as the same engine binary in non-debug mode plus data.

Open questions

  • Gizmo interaction math — whether to use screen-space handle picking (simple, slightly imprecise) vs. ray/line intersection (more code, exact).
  • Undo granularity for physics — if the user scrubs RigidBody.mass during Play, should that be snapshotted? Probably yes in Edit, never in Play.
  • Asset URI schemeasset://meshes/box.glb vs. plain relative paths. Asset URIs are more flexible (multiple roots) but add indirection.
  • Which component registry? A macro-based one that every component opts into, or one centralized file that hand-lists components. Macro scales better but complicates the public header.

See also

  • Debug UI — the foundation panels the editor builds on
  • Scheduler — edit/play mode is implemented as a phase gate
  • Physics — what needs to pause/resume cleanly
  • Feature Roadmap — status of all engine subsystems