Joshua Kellogg

< back

It's State Machines, All The Way Down (Friction devlog 2)

I am a game developer now.

created - changed 0ca8830

I 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 it

I’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,
      }, nil

Now 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.


  1. Including you and I, circa three days later. Or whatever Hot New Agentic Tool the kids are using these days. ↩︎

  2. 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. ↩︎

Tags: