Skip to content
All devlog entries

Undo/Redo Is Harder Than a Command Stack

editorecsarchitecture

Eresh Editor

Undo/redo sounds like a solved problem: record every command, keep an inverse for each one, replay them backwards on Ctrl+Z. That works for a text editor. In an ECS scene editor it doesn’t, because undo has to put back entities, hierarchies and prefab edits, not just flip a value between two states.

The command side of Eresh’s undo stack is the easy part:

enum EditorCommand {
    SpawnEntities(EntityTreeSnapshot),
    DespawnEntities(EntityTreeSnapshot),
    InsertComponent { entity: Entity, component_type: String, data: String },
    RemoveComponent { entity: Entity, component_type: String, data: String },
    SetField { entity: Entity, component_type: String, old_val: String, new_val: String },
    ReparentEntity {
        entity: Entity,
        old_parent: Option<Entity>,
        old_before: Option<Entity>,
        new_parent: Option<Entity>,
        new_before: Option<Entity>,
    },
}

Every variant has an inverse. SpawnEntities undoes into DespawnEntities, SetField swaps old_val and new_val, and so on. That’s exactly what “replay backwards” expects.

The hard parts are the two questions the enum doesn’t answer: what counts as one undo step, and what applying a command actually has to touch.

Undo has to bring back the same entity, not a copy

Say you delete a chair parented to a table. The obvious undo is to re-create the chair from the data you captured before deleting it.

That gets you a chair back, but a different one. It gets a new id. The table’s child list still points at the old id, and so does anything else that referenced that chair: another entity’s target reference, a prefab’s internal entity map. You’ve restored the data and broken every reference to it.

Eresh captures the whole subtree as a snapshot keyed by the real engine handles, id and generation included. Restoring goes through World::spawn_empty_at, which puts the entity back at that exact id instead of allocating a fresh one. The chair that comes back is the chair, so nothing needs patching up afterwards.

The snapshot carries the shape of the tree too, not just the entities in it: parent/child links and sibling order. Undo the deletion of the table and its chairs come back with it, in the same order, still attached.

One drag should be one undo step

Drag a gizmo for two seconds and the editor writes that object’s transform dozens of times. If every write were its own undo entry, Ctrl+Z would walk the object back one frame at a time.

So the unit of undo isn’t a command, it’s a transaction: a group of commands that get undone together. While you keep editing the same component on the same entity, each new value folds into the open transaction instead of starting a new one. Switch to a different component, switch to a different entity, or just pause for 600ms, and the next edit opens a fresh undo step.

The grouping key is the component, not the field. SetField stores the whole component as a RON string rather than a field path, so the stack can’t tell “position” from “rotation” in the first place. Nudging an object and then spinning it is one Transform, and therefore one Ctrl+Z.

Multi-selection uses the same machinery from the other direction. Deleting ten entities produces ten commands, but they’re pushed as a single transaction, so one Ctrl+Z brings all ten back rather than one per entity. Those grouped transactions never merge anything into themselves afterwards, so an unrelated edit can’t get quietly swallowed into the batch.

An edit to a prefab child can vanish

Here’s a bug that looks impossible. You move a child of a prefab instance. The inspector shows the new position. You save the scene, reopen it, and the object is back where it started.

The reason is that prefab children are deliberately left out of the scene file. Their data belongs to the prefab asset, so the scene serializer skips them on purpose. Which means that when you edit one, changing the component in the world isn’t enough. The edit also has to be recorded as an override, stored on the prefab instance’s root, because the root is saved.

Every command touching a prefab-managed entity writes that override. The catch is that there are two ways into the world, and both need to do it:

  • Undo and redo go through the command’s apply.
  • A live edit never touches apply at all. The inspector or the gizmo mutates the component directly, and only afterwards hands the undo stack a record of what it did.

The override authoring used to live only in apply. Undo and redo were fine; the original edit, the one that started everything, was the one slipping through. That’s why the bug is so disorienting: the value really is correct in the world, so the inspector isn’t lying to you. Nothing had recorded it as an override, so it evaporated on the next save. There’s a test in the codebase that pins this exact path and calls it the chokepoint.

Where things stand today

Working today: creating, deleting and reparenting entities, plus adding, removing and editing components. All of it runs through the transaction model above, with gesture merging, multi-selection grouping and prefab overrides kept in sync.

Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y are wired up, alongside an Edit menu that names the step it’s about to undo. Each undo raises a toast and re-selects the entity involved, or clears the selection when that entity no longer exists, which is what you get undoing a create.

The scope is deliberately narrow: undo covers the world, not the disk. Saving a scene or a prefab, applying or reverting a prefab, and content-browser file operations all sit outside the history. The file on disk is treated as a fixed point rather than something to step back through. The stack itself holds the last 100 transactions and lives only for the current session.

What’s next

Making that history survive a restart, so a session’s undo log could double as a lightweight backup. Field edits already serialize to plain strings; the create/delete snapshots would need the same treatment. Not started, no date attached.