The state pattern is very similar to the Strategy Pattern as it allows the behavior of objects to be altered at runtime. Objects behavior will be based on its internal state. The behavior of these states are broken down into separate classes.
Example Problem
I’ll use an example implementation of a pinball machine. The machine accepts user actions insertQuarter()
, returnQuarter
and startButtonPressed()
. These are the represented as methods. The machine has three states NO_CREDIT
, HAS_CREDIT
and GAME_STARTED
. This is what a simple implementation might look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | public class PinballMachine { // States final static int NO_CREDIT = 0; final static int HAS_CREDIT = 1; final static int GAME_STARTED = 2; int state = NO_CREDIT; int credit = 0; public PinballMachineBad() { } // Action methods public void insertQuarter() { credit += 1; if (state == NO_CREDIT) state = HAS_CREDIT; } public void returnQuarter() { if (state == HAS_CREDIT && credit >= 1) { // doEject(); state = NO_CREDIT; credit -= 1; } else if (state == GAME_STARTED) { System.out.println("Please wait for the game to end before requesting a quarter return."); } else { System.out.println("No quarter inserted, cannot return."); } } public void startButtonPressed() { if (state == HAS_CREDIT) { state = GAME_STARTED; credit -= 1; // startGame(); } else { System.out.println("No credits, please insert a quarter."); } } } |
Reviewing the example
This class should work as expected. The user should receive an additional credit for each quarter inserted. Pressing the start button should use a credit and start the game (details omitted). Pressing the return quarter button should return a quarter (if present) and subtract a credit from the machine (I realize this isn’t a great real world example, most pinball machines won’t operate this way). This is just a simple implementation and already the code is a bit messy. What happens when you want to add a new state? Most pinball machines have a chance to win a free credit at the game over screen. Adding a new state such as this will involve modifying some or all the if
statements increasing their complexity and lowering readability.
Implementing the State Pattern
To implement the state pattern the existing state logic (from above) will be encapsulated into state objects
in their own classes. Let’s start by adding the “action” methods to an interface
:
1 2 3 4 5 | public interface PinballState { public void insertQuarter(); public void returnQuarter(); public void startButtonPressed(); } |
PinballMachine
will now be responsible for owning the current PinballState
. Let’s update PinballMachine
to get
and set
the PinballState
and credits:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public class PinballMachine { PinballState noCreditState; PinballState creditState; PinballState gameStartedState; PinballState state; int credits; public PinballMachine() { noCreditState = new NoCreditState(this); creditState = new CreditState(this); gameStartedState = new GameStartedState(this); } // Actions public void insertQuarter() { state.insertQuarter(); } public void returnQuarter() { state.returnQuarter(); } public void startButtonPressed() { state.startButtonPressed(); } // States public PinballState getNoCreditState() { return noCreditState; } public PinballState getCreditState() { return creditState; } public PinballState getGameStartedState() { return gameStartedState; } public void setPinballState(PinballState pinballState) { state = pinballState; } // Credits public void setCredits(int credits) { this.credits = credits; } public int getCredits() { return credits; } } |
Notice all the PinballMachine
“actions” now just invoke the appropriate state
methods. PinballMachine
is no longer concerned about what PinballState
the machine is in, the PinballState
implementations handle that. Now we can add the PinballState
implementations for NoCreditState
, CreditState
and GameStartedState
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | // the machine has no credits public class NoCreditState implements PinballState { private PinballMachine pinballMachine; public NoCreditState(PinballMachine pinballMachine) { this.pinballMachine = pinballMachine; } @Override public void insertQuarter() { // the machine has no credits, add one and set the new state pinballMachine.setCredits(1); pinballMachine.setPinballState(pinballMachine.getCreditState()); } // the machine has no credits, just print an error @Override public void returnQuarter() { printNoCreditError(); } @Override public void startButtonPressed() { printNoCreditError(); } private void printNoCreditError() { System.out.println("0 Credits. Please insert a quarter to play."); } } // the machine has credit(s) public class CreditState implements PinballState { private PinballMachine pinballMachine; public CreditState(PinballMachine pinballMachine) { this.pinballMachine = pinballMachine; } @Override public void insertQuarter() { updatePinballMachineCreditsBy(1); } @Override public void returnQuarter() { updatePinballMachineCreditsBy(-1); if (pinballMachine.getCredits() == 0) pinballMachine.setPinballState(pinballMachine.getNoCreditState()); } @Override public void startButtonPressed() { updatePinballMachineCreditsBy(-1); pinballMachine.setPinballState(pinballMachine.getGameStartedState()); } private void updatePinballMachineCreditsBy(int creditValue) { pinballMachine.setCredits(pinballMachine.getCredits() + creditValue); } } public class GameStartedState implements PinballState { private PinballMachine pinballMachine; public GameStartedState(PinballMachine pinballMachine) { this.pinballMachine = pinballMachine; } @Override public void insertQuarter() { pinballMachine.setCredits(pinballMachine.getCredits() + 1); } @Override public void returnQuarter() { printGameInProgressError(); } @Override public void startButtonPressed() { if (pinballMachine.getCredits() >= 1) { pinballMachine.setCredits(pinballMachine.getCredits() - 1); // pinballMachine.addPlayer(); omitting details } else printGameInProgressError(); } private void printGameInProgressError() { System.out.println("Game in progress, please wait until the game is finished."); } } |
Summary
All of the PinballMachine
logic is now encapsulated in well described classes. Each PinballState
describes one state. State classes can be shared across context instances if needed. Following the “one class, one responsibility” design principle can result in several classes containing somewhat duplicated code. An approach to reduce duplicated code would be to use an abstract
instead of an interface
. The pinball machine example doesn’t really have any shared code so I just used an interface.
Game Over?
I’ve left out the GameOver
state. This is important because it should reset the machine to the appropriate state when the game has ended. Let’s see how adding a new PinballState
looks after implementing the State Pattern:
1 2 3 4 | // PinballMachine.java new method public void gameOver() { setPinballState(new GameOverState(this)); } |
Instead of storing GameOverState
I’m creating it on the fly. This is because GameOverState
invokes match()
, generates a random number and sets a new PinballState
based on the credit count.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | public class GameOverState implements PinballState { private PinballMachine pinballMachine; public GameOverState(PinballMachine pinballMachine) { this.pinballMachine = pinballMachine; match(); } @Override public void insertQuarter() { pinballMachine.setCredits(pinballMachine.getCredits() + 1); } // Will never happen, the constructor invokes match and sets a new state @Override public void returnQuarter() { } @Override public void startButtonPressed() { } private void match() { int range = (10 - 1) + 1; int match = (int)(Math.random() * range) + 1; if (match == 5) { pinballMachine.setCredits(pinballMachine.getCredits() + 1); System.out.println("Match!"); } pinballMachine.setPinballState( pinballMachine.getCredits() >= 1 ? pinballMachine.getCreditState() : pinballMachine.getNoCreditState() ); } } |