It's State Machines, All The Way Down (Friction devlog 2)
I am a game developer now.
created - changed 0ca8830I have a confession to make. Strap in, because it’s pretty grim, and I don’t reveal it lightly.
I like Object-Oriented Programming in the abstract. One day I’ll experience the pain of a 6-layer deep class hierarchy and I’ll curse Alan Kay’s name with every fiber of my being. Until then, though, I do appreciate the idea of a bundle of data that has unique behaviors attached to it.
One thing that OOP guys love is a good design pattern. Factories. Builders. Functional options! There’s literally a whole book of these that is considered, even outside of Object-Oriented circles, to be required reading in the field of software architecture. One of these patterns, though, is so common that I think every developer will encounter it many times in their career (heck, I’m already writing about it, and I don’t even have a career yet). That pattern is the state pattern.
Let’s Make a Really Ugly, Bad State Machine
Let’s say you were writing a modal text editor. One of the hallmarks of such a program is that it has different modes which cause it to behave in different ways. Instead of reaching down for your arrow keys in order to navigate a document, you keep your hands in the same place they would live for typing, switch modes, and use the same keys for different functions. The simple caveman way of doing this might look something like this:
type EditorMode int
const (
Normal EditorMode = iota
Insert
Visual
Command
)
type Editor struct {
mode EditorMode
// other information about the editor's state
}
func (e Editor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return e, nil
}
switch keyMsg.Type {
case tea.KeyEscape:
switch e.Mode {
case Normal:
return e, tea.Quit
default:
e.mode = Normal
return e, tea.Quit
}
case tea.KeyRune:
switch r := keyMsg.String() {
// okay yeah we get itI’m sure you can see how this would get really ugly to work with. We’re checking all kinds of things in all kinds of places. Now, you and I are obviously so smart and talented that we don’t ever have to worry about writing a bug in this thicket of logic checks, but it makes life a nightmare for anyone who wants to read our code1. How do we avoid this combinatorial explosion of inscrutable nested switch statements? The answer, as I’m sure you’ve guessed, is the state pattern.
The State Pattern
Our code is already a state machine, albeit an ugly one. We either have to check what state we’re in and then prescribe a behavior for every possible input into that state, or we have to check what the input was and then check what state we’re in for every possibility. Instead, what if we just designed an interface so that each state could define its own behavior?
type Editor struct {
State EditorState
// other information about the editor's state
}
type EditorState interface {
Input(tea.Msg) (EditorState, tea.Cmd)
SetBuffer(*buffer.Buffer) EditorState
}In this case, the way we’ve designed this interface has been shaped pretty
heavily by the TUI library Friction is based on, called bubbletea and
referenced as tea in these code examples. The API can be shaped any way we
want, though. What’s important is that it defines a sort of shape that many
different implementations could potentially fill!
Now that we’ve defined our interface, we can build up some implementations that fulfill this contract. Insert mode is so simple that I can pretty much put the entire body of the Input method from Friction in here, so let’s start with that:
type InsertMode struct {
Buffer *buffer.Buffer
Cursor buffer.Location
}
func (s InsertMode) Input(msg tea.Msg) (EditorState, tea.Cmd) {
keymsg, ok := msg.(tea.KeyMsg)
if !ok {
return s, nil
}
switch keymsg.Type {
case tea.KeyEscape:
// no spoilers!
case tea.KeyBackspace:
return s.backspace()
case tea.KeySpace:
return s.insertTyping(' ')
case tea.KeyTab:
return s.insertTyping('\t')
case tea.KeyRunes:
return s.insertTyping(keymsg.Runes[0])
}
return s, nil
}Okay, cool! That was really simple. backspace and insertTyping are just
helper functions that allow us to clean up this main input section of the code a
bit. They’re not that complicated, but they’re outside of the scope of this
post, so I’ll leave them as an exercise for the reader. Now, we can really clean
up the main update code in our editor:
func (e Editor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
keymsg, ok := msg.(tea.KeyMsg)
if !ok {
switch t := msg.(type) {
case tea.WindowSizeMsg:
e.height = t.Height
e.width = t.Width
}
return e, nil
}
var stateCmd tea.Cmd
e.State, stateCmd = e.State.Input(keymsg)
return e, stateCmd
}There are a few types of messages that we want the editor to handle on its own,
but most of the logic here can just be delegated to the EditorState
implementation. At this point, the editor code doesn’t even have to change at
all for us to be able to add another state. Let’s do that:
type NormalMode struct {
Buffer *buffer.Buffer
Selection buffer.Range
Keymaps map[string]NormalModeAction
ActionLHS string
}
func (s NormalMode) Input(msg tea.Msg) (EditorState, tea.Cmd) {
switch m := msg.(type) {
case tea.KeyMsg:
if m.Type == tea.KeyRunes {
s.ActionLHS = fmt.Sprintf("%s%c", s.ActionLHS, m.Runes[0])
} else {
s.ActionLHS = fmt.Sprintf("%s<%s>", s.ActionLHS, m.String())
}
s, action := s.parseAction()
if action != nil {
return action(s)
}
return s, nil
default:
return s, nil
}
}Woah there, cowboy. That’s a pretty beefy implementation! Well, yeah. If you’re
hoping for an explanation of what the hell any of this does, that makes two of
us, and hopefully I’ll be able to get into it in the next installment of these
devlogs. The point here is that this new EditorState can just replace the old
one, and the editor at large is none the wiser. We can finally replace that
missing fragment from before:
switch keymsg.Type {
case tea.KeyEscape:
return NormalMode{
Buffer: s.Buffer,
Selection: buffer.Range{
Start: s.Cursor,
End: s.Buffer.LocAdd(s.Cursor, 1),
},
Keymaps: DefaultKeymaps,
}, nilNow we’re switching modes all on our own, no parental permission required. Each mode handles its own sets of keybindings, possibly even in its own way2! This is not just possible but pretty easy to do, all because of the state pattern.
-
Including you and I, circa three days later. Or whatever Hot New Agentic Tool the kids are using these days. ↩︎
-
Yes, fully aware that this is probably not the right way to handle key bindings. If I was in it for the long haul and planning to end up with something really industrial grade, I would refactor this, and maybe I eventually will. Right now my main goal is to get this project across the finish line and never look at it again. I want to go do some game jams or something. ↩︎