Stop Thinking with IF Statements!

Chander Ramesh
7 min readJan 1, 2024

--

Over winter break I built Hexagonal chess, as a way to learn all about Lit, and WebComponents. Rather than come up with my own UX, I decided to emulate Lichess and Chess.com.

It certainly seemed simple enough at the time; users can click to select a piece, and click to move it. This set up only requires a single nullable variable — selectedPiece . Easy enough, just highlight the selected piece in yellow and show the legal moves with a yellow dot.

A hexagonal chess board with the selected piece shown in yellow.

But as I began to play test, it became obvious that most people had no idea what the rules of Hexagonal chess were, and often times they’d click on a piece just to see what the legal moves are, but then change their mind and want to move another piece instead.

Ok no problem — whenever selected piece is not null, the next time they mouse down check to see if they’re selecting an empty space or a piece; if it’s an empty space, try and make a move, otherwise set selectedPiece to the new piece.

Oh, wait. What about captures? Ok fine, if selectedPiece is not null and the new square cannot be captured, set selectedPiece to the new square, otherwise perform the capture; and if it’s an empty sell, just move the piece. Annoying, but ok we’re done — back to learning Lit and Web Components.

A GIF showing first one piece being selected, then another.

Then I began to play test some more, and it turns out, most people actually preferred to drag the pieces from one square to another. So I added a couple new variables called dragPiece and currentlyDraggedOverSquare. If dragPiece was null, just ignore all pointermove events. If it was not null, then every time the mouseenter event fired, update currentlyDraggedOverSquare. Easy!

A GIF of a piece being dragged around the board, with the currently dragged over square being highlighted in a different color.

But what if they mouseup-ed on an illegal square? Or outside the board? Or on top of another piece? Well, Chess.com keeps the initial selection, letting the user try again. Ok no problem — if mouseup fires on an illegal move, don’t set selectedPiece to null. But now we have another problem. Until now, pointerdown has only ever been fired when selectedPiece is null… but now the user can pointerdown and drag it, even though there’s already a selectedPiece.

This is just with 3 events and 3 state variables. Lichess actually allows users to rewind and fast forward the game to see prior moves. What happens if the user clicks on a piece when they’re rewinding? What happens if the user clicks on a piece when it’s not their turn? What if they have a piece selected but click outside the board? And actually, turns out users want to be able to right click on square and drag to draw arrows and make commentary on other games…

Are you starting to see the problem?

How do we allow the software flexibility to add more events and more states without breaking the previous ones? The problem with if statements like these is that the complexity grows exponentially. Since the code isn’t organized or well thought out, the only way to guarantee correctness is to test every combination of variables and assert their behavior. Every new state variable introduces 2^n new states!

This amount of work is crippling for velocity, and nobody can keep this many states in their head. So you rapidly get to a state where nobody can describe how the system is supposed to behave, let alone how it does behave.

What’s the alternative?

Enter state machines. Here is the full, complete state machine for HexChess when making moves and rewinding / fast forwarding. This doesn’t include resigning, offering a draw, or drawing arrows.

A state machine diagram describing my HexChess game.

There’s three main benefits to this way of thinking.

  1. Velocity

Perhaps most obviously, it’s extremely easy to add new states and transitions because you’re allowed to compartmentalize the complexity away. When you’re dealing with pawn promotions, the only state variables you have to care about are which pawn is being promoted. When you break down the problem like that, it’s actually quite easy to know what to do when a mousedown or mouseup event happens.

It’s not uncommon to realize that, when adding a new state for a new feature, the old states also need a new variable added. Halfway through I decided I needed to start keeping score, so I can show who has captured more pieces. And actually track all the moves that had been made thus far, so we can rewind and fast forward.

This is precisely what state machines were made for. Simply add the new variables and add a couple lines to each unit test.

2. No (business logic) bugs

Second, because all of this has nothing to do with how the component is being rendered — it’s all pure business logic — you can ensure high test coverage with basically no effort!

Test coverage for my HexagonChess, showing between 80 and 94% test coverage depending on the file.

This screenshot is from when I was only halfway through the project; I was simply writing tests as I was going along, precisely because the state machine made it so easy.

Too many codebases muddle business and rendering logic in the same file or the same component. This is particularly easy to do with React codebases. But creating a state machine is a forcing function to separate that out. Now, there’s a single variable called state which holds all the complexity, and any transition I want to make is quite simple. I just tell the state machine to give me a new state please, and then set my current state to that new one. All of the rendering then updates automatically to reflect that.

private _handleMouseUp(event: MouseEvent | PointerEvent) {
const square = this._getSquareFromClick(event);
let newState;
if (!square) {
newState = getNewState(this._state, {
name: 'MOUSE_UP_OUTSIDE_BOARD',
});
} else {
newState = getNewState(this._state, {
name: 'MOUSE_UP',
square,
});
}

// Set new position of piece
const state = this._state
}

You no longer have to deal with 2^n states anymore. The fact is, most state machines don’t have nearly so many edges. So rather than checking combinations of state variables, I can simply assert that if the game is not currently in one of the 3 states that has such an edge, I can ignore any event.

private _handleMouseMove(event: MouseEvent | PointerEvent) {
if (
this._state.name !== 'DRAG_PIECE' &&
this._state.name !== 'MOUSE_DOWN_PIECE_SELECTED' &&
this._state.name !== 'CANCEL_SELECTION_SOON'
) {
return;
}

// Rest of method
}

3. Deal with complexity up front

When you’re thinking in if statements, from the beginning your thought process is an incremental one. You think of A and B, and given those, you’re trying to see how you can jam in C in this existing system. It’s alluring for the same reason it’s dangerous — it leads you to believe there is no complexity here. So every time product comes back with a new requirement D or E or F, you think “Oh, no problem! I’ll just add one more small condition here and that will solve the problem.”

And that’s great, because product or design will never have one more ask of us. After this, we’ll be done forever! Or perhaps we just won’t be on this team anymore or at this company, so it won’t be our problem anyway... And slowly but surely, the frog boils.

When I say we’re forced to deal with the complexity up front, I don’t mean to say that all of those features must be implemented. We still live in the real world with very real deadlines and business pressures. Ultimately we are paid for the value we deliver our customers, not the fancy diagrams we draw. But when we envision all the various states our system can have and all the possible ways the product could evolve, we naturally structure our code in a more maintainable and elastic way. If product someday decides to sunset a feature? No problem; simply delete the handful of states and the edges leading to those states.

This also results in far more realistic deadlines. When your approach to every feature is to just add a few if statements, then of course every feature will just take an hour or two… and it’s a total mystery why we’re facing so many bugs all the time, and even the most simple things suddenly cause issues in seemingly unrelated aspects of the product.

State machines are everywhere! Perhaps you’ve seen this famous example from Slack:

Again, this isn’t to say all of this has to be implemented at once (Slack certainly didn’t)! But it is vital to be able to allow your code and your product to scale with complexity — should your users require it — while maintaining velocity and reliability.

And here is the final Hexchess client, in all its glory.

A longer GIF showing game play of moving pieces, canceling moves, and capturing.

Stay tuned for the server post! :)

--

--