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 byEcsChildOf, 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 timestep —
SafiFixedUpdateis 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 serialization —
safi_scene_save/safi_scene_load/safi_scene_clearplus in-memorysafi_scene_snapshot_all/safi_scene_restore_snapshotfor non-destructive Play→Stop round-trips. (Scene) - Editor-mode singleton —
SafiEditorState { mode, selected_tool, selected_entity }withEdit / Play / Paused. Thesafi_app_tickloop gates the fixed + game pipelines onmode == PLAY; variable + render always run. (Editor State) - Dedicated game phase — user gameplay opts into
SafiGamePhase(distinct fromEcsOnUpdate) so it freezes in Edit mode while engine-owned variable systems keep ticking. (Scheduler) - Editor fly-cam —
SafiEditorCameraentity with WASD/QE translate + right-mouse rotate. Installed automatically with the debug UI; arbitrates theSafiActiveCameratag with the gameplay camera on every Edit ↔ Play switch. (Editor Camera) - Gizmo draw list —
safi_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-ray —
SafiCameracarries explicit eye/forward/up;safi_camera_screen_rayunprojects 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
SafiTransformdirectly. (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 paths —
SafiAppDesc.project_rootanchors 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.texturehandle — the procedural-primitive component now references aSafiTextureHandleinstead 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_initcallback that sets sensible non-zero defaults;safi_component_registry_construct(world, e, "SafiPointLight")is the M5 "+ Add Component" entry point. (Registry) - Hierarchy helpers —
safi_entity_set_parentrejects cycles,safi_entity_detach_from_parentdrops theEcsChildOfedge,safi_entity_childrenenumerates. 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
EcsOnSetobserver per serializable component emitsSafiChangeevents to subscribers.safi_change_bus_begin_group/_end_groupcoalesce gizmo drags into a single undo step; M6 undo reads from this stream. - Entity presets —
safi_preset_empty/_mesh/_primitive/_camera/_*_light/_static_box/_dynamic_spherereturn 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 / resume —
safi_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_editorbinary — 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 view —
safi_editorwith no--projectshows 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. - Templates —
emptyand3d_starter, authored as scene JSON with no external assets, copied verbatim on create. - Implementation —
safi/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
SafiGamePhaseis 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
A✅ (SafiEditorStatesingleton with{ mode, selected_tool, selected_entity }.SafiEditorState)Gate✅ (Scheduler)SafiFixedUpdateandSafiGamePhaseonstate.mode == Play.Snapshot / restore primitives.✅ (safi_scene_snapshot_all/safi_scene_restore_snapshot)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 toSafiAppDesc.project_root. ✅ shipped - Texture handle on primitives —
SafiPrimitive.textureis aSafiTextureHandle, 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✅ (Editor Camera)SafiEditorCameraentity. Arbitrates theSafiActiveCameratag on Edit ↔ Play switches.Translate gizmo — three axis-aligned arrows at the selected entity's world position. Drag solves closest-point-on-line-to-line and writes✅SafiTransform.positionthrough 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✅atan2delta 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:✅ (Editor Shortcuts, RMB-guarded so they don't fire while flying)Q / W / E / Rfor select / translate / rotate / scale, matching Unity/Unreal muscle memory.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— justSafiTransform + SafiName+ autoSafiStableId. ✅safi_preset_emptyMesh— empty +SafiMeshRenderer(prompts for a mesh asset). ✅safi_preset_meshLight → Directional / Point / Spot / Rect / Sky. ✅safi_preset_*_lightCamera—SafiTransform + SafiCamera. ✅safi_preset_cameraPhysics 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_parenthandle 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-Zpop/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):
SafiStableIdguarantees 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_groupso 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, theassets/folder (only files referenced by any scene), thescenes/folder, and a tinybootstrap.jsonnaming the first scene.- The produced binary launches with
enable_debug_ui = false, loadsbootstrap.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.massduring Play, should that be snapshotted? Probably yes in Edit, never in Play. - Asset URI scheme —
asset://meshes/box.glbvs. 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