wrote an undo/redo system for my Godot level editor — command pattern in GDScript

94 views 10 replies

Building a small level editor tool scene in Godot 4 for my top-down game. Drag/drop object placement, property tweaks, that kind of thing. The second I added a second placement action I knew I needed undo/redo, so I went with the command pattern. Nothing revolutionary but it's working well and I figured I'd share.

Base class is basically nothing:

class_name EditorCommand

func execute() -> void:
    pass

func undo() -> void:
    pass

History stack with a cursor for redo:

class_name EditorHistory

const MAX_HISTORY := 50

var _history: Array[EditorCommand] = []
var _cursor := -1

func execute(cmd: EditorCommand) -> void:
    if _cursor < _history.size() - 1:
        _history = _history.slice(0, _cursor + 1)
    cmd.execute()
    _history.append(cmd)
    if _history.size() > MAX_HISTORY:
        _history.pop_front()
    _cursor = _history.size() - 1

func undo() -> void:
    if _cursor < 0:
        return
    _history[_cursor].undo()
    _cursor -= 1

func redo() -> void:
    if _cursor >= _history.size() - 1:
        return
    _cursor += 1
    _history[_cursor].execute()

func can_undo() -> bool:
    return _cursor >= 0

func can_redo() -> bool:
    return _cursor < _history.size() - 1

Concrete example, placing a scene object:

class_name PlaceObjectCommand extends EditorCommand

var _parent: Node
var _prefab: PackedScene
var _transform: Transform3D
var _placed: Node3D

func _init(parent: Node, prefab: PackedScene, xform: Transform3D) -> void:
    _parent = parent
    _prefab = prefab
    _transform = xform

func execute() -> void:
    _placed = _prefab.instantiate()
    _placed.global_transform = _transform
    _parent.add_child(_placed)
    _placed.owner = EditorInterface.get_edited_scene_root()

func undo() -> void:
    if _placed:
        _placed.queue_free()
        _placed = null

The part I didn't think through ahead of time: compound commands. Dragging an object updates position and rotation simultaneously, and I don't want those as two separate undo steps. I ended up writing a CompositeCommand that just holds an array of commands and delegates to them in order. Works fine, but I can already see it getting unwieldy as the editor grows.

Anyone doing something smarter for batching related operations? Also wondering if it's worth serializing the history to disk for crash recovery, or if that's deep overkill for a level editor that auto-saves anyway.

Replying to CyberFlare: The thing that always catches me with command-pattern undo systems: compound ope...

another edge case that got me: what happens when a sub-command in a composite fails halfway through undo? undoing a batch "move 5 objects" and the third one errors because something external already deleted that node — do you silently skip it, halt the whole undo, or try a partial rollback? shipped both "silently skip" and "halt the undo" at different points. silently skip leaves the editor in a state the user can't reason about. halt is technically correct but the error message is usually cryptic enough that they just stare at it.

ended up logging a visible warning in the editor output for any skipped sub-commands and treating the partial undo as valid. not clean, but better than a silent mystery state.

this is fine dog surrounded by fire

One addition that made a real difference in editor feel: command merging. When the user drags an object, you don't want to push a new MoveCommand on every mouse-move event. That fills the history stack with dozens of entries for what the user experienced as one action.

Give each command a can_merge(other) and a merge(other) method. On push, check if the incoming command can merge with whatever's on top of the stack and absorb it instead of appending. For a move command it's simple: two moves on the same object merge by just updating the destination. The stack entry ends up representing the whole drag as a single undo step, which is exactly what users expect. Took maybe 20 lines to add and made the undo history actually readable.

Replying to StormLeap: yeah, and silent drops feel broken even when they're intentional. if undo just s...

Completely agree. Silent failure is indistinguishable from a bug regardless of intent. I went with a small toaster-style notification in the bottom corner of my editor viewport: "Undo limit reached (100 steps)." Fades after three seconds. Small enough not to be intrusive, but visible enough that nobody opens a bug report thinking undo broke.

Also learned the hard way: if this editor might ever get handed off, document the limit somewhere accessible. "Why did undo stop working?" is exactly the kind of question that ends up in a bug tracker even when the behavior is fully intentional and correctly implemented.

Replying to CosmicWren: One addition that made a real difference in editor feel: command merging. When t...

the merging threshold is what bit me hardest here. too tight and you get thousands of micro-commands in the stack. too loose and undo jumps back further than the user expects and feels broken. what worked for me: time-based window (200ms for drag ops felt right) combined with checking that command type and target both match. both conditions have to pass to merge, otherwise push a new command.

seems obvious in retrospect but i spent a genuinely embarrassing amount of time diagnosing why my undo history "felt wrong" before landing on it.

Replying to CyberFlare: The thing that always catches me with command-pattern undo systems: compound ope...

Property changes are the sneaky edge case here. With placement you cleanly diff old position vs new, no problem. But slider-driven edits, dragging a light's falloff radius or tweaking a spawn rate, generate a ton of intermediate values during the drag. Naive implementation pushes a command on every change event and you end up with 80 history entries for a single interaction.

I handled it by capturing the value at drag-start and only committing to history on drag-end, so the whole interaction is one undo step. Works well in principle, but you're at the mercy of whatever events your UI framework actually exposes. Some controls don't have clean drag-start/drag-end semantics and you end up faking it with a debounce timer, which then introduces its own edge cases around what counts as "done."

The thing that always catches me with command-pattern undo systems: compound operations. Single actions are easy. But "move 5 selected objects at once" needs to either be one composite command that undoes all 5 in a single Ctrl+Z, or 5 individual commands, and if you go individual, multi-select operations feel completely broken to users.

Worth building a CompoundCommand wrapper early, before you have 20 actions implemented. Retrofitting it later is annoying. Also worth deciding now whether you want to serialize the command history. It makes "undo past the last save" possible, but the complexity jumps fast once you start thinking about what counts as serializable state.

One thing worth thinking about before your history gets deep: bounding the stack size. An editor that records every command indefinitely is fine for a short session, but after a couple hours of work you're holding onto a lot of state for no real benefit. Most people never undo more than 20-30 steps anyway.

The simplest approach is a fixed-size ring buffer where the oldest entry falls off once you hit the cap. The tricky part is commands that hold references to scene nodes or resources that would otherwise be freed. In Godot, a deleted node still referenced anywhere in the undo stack won't get garbage collected. Keep that in mind if your commands capture scene objects directly rather than just storing data needed to reconstruct the operation.

Replying to QuantumPulse: another edge case that got me: what happens when a sub-command in a composite fa...

For partial undo failures in composites, I gave each composite command a rollback list: as each sub-command undoes successfully, it gets pushed onto a completed list. If any sub-command fails mid-way, immediately re-execute everything in completed to restore pre-undo state. You either fully undo the batch or you don't. No half-applied intermediate states. Not elegant, but it makes the behavior predictable, which in an editor tool is what actually matters.

Replying to CyberFlare: The thing that always catches me with command-pattern undo systems: compound ope...

Composite command wraps this cleanly. Build up a batch of commands, push the composite as one entry to the history stack, and from the outside it's a single undo step:

class_name CompositeCommand extends UndoCommand

var _commands: Array[UndoCommand] = []

func add(cmd: UndoCommand) -> void:
    _commands.append(cmd)

func execute() -> void:
    for cmd in _commands:
        cmd.execute()

func undo() -> void:
    for i in range(_commands.size() - 1, -1, -1):
        _commands[i].undo()

The tricky part isn't the pattern itself, it's deciding when to "close" the composite. I ended up keying it to the end of a drag operation rather than individual move events, so you don't end up with 200 micro-commands per drag. Works for the "move 5 selected objects" case since you just add one MoveCommand per selected object into the composite before pushing.

Replying to VaporLynx: One thing worth thinking about before your history gets deep: bounding the stack...

yeah, and silent drops feel broken even when they're intentional. if undo just stops working at some point the user assumes it's a bug, because it behaves exactly like a bug.

i added a small status label that shows remaining undo depth when you're within ~10 of the cap, something like undo: 4 remaining. cheap to add, and it makes the limit feel like a designed constraint rather than a mystery failure. also gives you a natural hook to surface the cap as a configurable setting if you ever want to go there.

Moonjump
Forum Search Shader Sandbox
Sign In Register