← Trevor Waldorf

Flatland

Implementing the Command Pattern

Summary

Taking inspiration from game programming, I implemented the Command design pattern to enable undo/redo, create replayable state, and speed development in Flatland.

Inspiration

I learned about Commands after hearing Bob Nystrom, the author of Crafting Interpreters, talk at Handmade Conf in Seattle in 2022 and which led me to an earlier talk by him on data oriented programming called Is There More to Game Architecture than ECS? It is also worth mentioning Mike Acton's evergreen CPPCon talk Data-Oriented Design and C++ which introduced me to the idea of data-oriented programming when I was in undergrad. My takeaway from this content was that if I'm ever struggling to detangle the logic of an operation I should consider using the command pattern. This supports my worldview that all programming is the manipulation of data and that remembering such will serve us well.

The pattern is widely discussed online and although Nystrom wrote a section on it in Game Programming Patterns, it's not a universal reference and my implementation diverges from the example implementation architecturally.

The Command Pattern

All changes to state are packaged into Command objects (classes which extend the Command class), added to a queue, and then processed in the order they were received. Commands all implement do() and undo() functions which effect and revert their state changes. The data involved in a command is passed to its constructor, meaning each command carries everything the program needs to know to execute and revert the command within its object. The queue executes commands whenever it is asked, currently on a tick managed by the rendering loop. Although this may change, it does ensure that things look exactly how they are in state and as the developer it is immediately clear when performance issues arise.

Product impact

For the user, command enables undo/redo functionality, which is important because creating perfect shapes is a process of experimentation, evaluation and revision even with the most precise tools. Undo/redo is an essential part of a drawing app and is expected by all users.

Advantages for development

The structure provided by Commands usually makes coding functionality straightforward. Large operations naturally split into several ordered commands based on the data that needs to be changed. This is one of the advantages of a more "data-oriented" approach: if you store data where it is needed, life is better.

For example, if a user closes a path by connecting the first point to the last point, a number of state changes must be made: the _to_ and _from_ properties of the first and last points must be set to the correct coordinates to allow the Bezier curve to be drawn, a best guess at the intended handles of the last point must be set for the curve segment, the shape must be added to the list of complete geometries, a corresponding Piece must be filed with the React UI store, and the tool should swap to the Select tool and finally the new Piece should be selected to indicate to the user that their shape closure was successful.

Thus, path closure breaks down well into a specialized ClosePathCommand, an AddPieceCommand, and a ChangeToolCommand which are much easier to reason about than a large procedure with async components (communicating with the Zustand store). Furthermore, undo is free: to undo, ChangeTool reapplies the previous tool and tool state, AddPiece calls RemovePiece on the same id string it was initialized with (implemented by the Zustand store), and ClosePath sets an entry in the geometry map to the geometry state it stored on construction, which is just an array of a few vectors and flags.

Drawbacks for development

Vector- and matrix-heavy commands are the least natural operations as they often rely on mutating large amounts of data instead of initializing a set of vectors for performance reasons. At its worst, a set of delta vectors is stored in the command and applied or inverse applied to the relevant vectors directly to allow undo, such as in Move commands for multiple selections. But for most functionality, the undo is trivial, and for every command, the state should be the same before and after a do-undo sequence–coverable with a simple unit test.

Because of the widespread use of vectors, state is not atomic, but even the most involved operations are easy to reason about and easy to debug by watching how commands are submitted and processed within the queue. Plus, undo!

Home