Beat Your Grandma at Cards - Chapter 4 - Game On
In this chapter, our game comes to life. We’ll cover our game architecture, and we’ll write up a simple randomized AI to play against.
The first thing we need is a representation of the game itself. We’ve discussed all the pieces already, but now we just need to put them all in one convenient place. We’ll call this thing a GameState:
enum GamePhase { first_bidding_round, second_bidding_round, discard, play, resolve_trick, finished } class GameState { final GamePhase phase; final TablePosition dealerPosition; final Map<TablePosition, List<PlayingCard>> players; final Kitty kitty; final TablePosition currentPlayer; final TablePosition bidWinner; final Suit trump; final Trick currentTrick; final Map<Team, List<Trick>> pastTricks; const GameState({ this.phase, this.dealerPosition, this.players, this.kitty, this.currentPlayer, this.bidWinner, this.trump, this.currentTrick, this.pastTricks }); // action methods }
You can see everything on this object is ‘final’. This means we can’t modify it, so every time something happens in the game, we have to create an entirely new GameState with the new information. It’s sort of like a snapshot of the game, and the game itself is represented by a series of GameStates with player actions in between.
Speaking of player actions, our GameState has methods for each action that players can take that I didn’t include in that code above. For example, when a player “orders up” the dealer card, we call this method:
GameState orderUp() => GameState( phase: GamePhase.discard, dealerPosition: dealerPosition, players: players, kitty: kitty, currentPlayer: dealerPosition, // dealer is the discarder bidWinner: currentPlayer, // current player won the bid trump: trump, currentTrick: currentTrick, pastTricks: pastTricks );
You can see that this method creates an entirely new GameState, moving the game to the next phase (discard), and setting the bid winner and current player.
What about when players play cards?
GameState handlePlay(PlayingCard play) { List<PlayingCard> playerCopy = List<PlayingCard>.from(players[currentPlayer]); playerCopy.remove(play); Trick trickCopy = currentTrick.play(currentPlayer, play); Map<TablePosition, List<PlayingCard>> newPlayers = players.map((pos, hand) => MapEntry<TablePosition, List<PlayingCard>>(pos, pos == currentPlayer ? playerCopy : hand)); return GameState( phase: trickCopy.finished ? GamePhase.resolve_trick : GamePhase.play, dealerPosition: dealerPosition, players: newPlayers, kitty: kitty, currentPlayer: currentPlayer.next, bidWinner: bidWinner, trump: trump, currentTrick: trickCopy, pastTricks: pastTricks ); }
This one is much more complicated, but the idea is the same: we create a new state with the new information. One important thing to call out in this method is that we need to be careful to never modify the data on our current state. So, for example, in order to remove the card being played from the current player’s hand, we first create a copy of the player, and then remove the card from the copy. Then, the next state uses that copied player.
There are a few more actions players can take, like discarding or calling trump, and they’re all represented on the GameState as functions. It’s a lot of code, mostly because we need to copy the entire GameState for every change, but the code tends to be simple. I won’t list all of the functions in this blog post, but check them out here.
Let’s have a brief interlude to discuss game architecture. We have a useful representation of the game itself, and we have a bag of functions that handle all the possible actions players can take, but we need some coordinator that maintains the state, shows the correct stuff on the screen, asks players to take their turns, and applies updates to the state when players are finished. It’ll look something like this:
Okay, so we just need to write that purple guy. Let’s start with just the first phase (the first bidding round), and move on from there. You’ll notice a few new concepts and classes in this code that we haven’t talked about, but don’t worry, we’ll break it all down.
class EuchreGameController extends StatefulWidget { @override State<StatefulWidget> createState() => EuchreGameControllerState(); } class EuchreGameControllerState extends State<EuchreGameController> { GameState _euchreGameState; @override void initState() { super.initState(); _euchreGameState = startNewGame(TablePosition.east); } @override Widget build(BuildContext context) { switch (_euchreGameState.phase) { case GamePhase.first_bidding_round: return _simpleTable(tableSize, BidFirstRoundWidget( cardSize: cardSize, state: _euchreGameState, ai: _ai, onResult: _onFirstRoundBidResult )); } } Widget _simpleTable(Size size, Widget child) => Container( color: Colors.green, child: Center( child: Container( width: size.width, height: size.height, child: child ), ), ); void _onFirstRoundBidResult(bool pickUp) { // we'll fill this in in a moment } }
The first big new thing here is the BidFirstRoundWidget. This is the widget responsible for showing the UI for the phase. Luckily, it’s incredibly simple because our EuchreTableWidget we designed in the last chapter does most of the heavy lifting:
typedef BidCallback = void Function(bool pickUp); class BidFirstRoundWidget extends StatelessWidget { final Size cardSize; final GameState state; final BidCallback onResult; const BidFirstRoundWidget({this.cardSize, this.state, this.onResult}); Widget _buttons() => Column(children: <Widget>[ RaisedButton(onPressed: () { this.onResult(true); }, child: Text("pick up"),), RaisedButton(onPressed: () { this.onResult(false); }, child: Text("pass"),), ],); @override Widget build(BuildContext context) => EuchreTableWidget( players: state.players.map((pos, v) => MapEntry<TablePosition, Widget>(pos, pos == TablePosition.south ? PlayerWidget(state: state, cardSize: cardSize, onSelected: null,) : OpponentWidget(position: pos, state: state, cardSize: cardSize))), tableCenter: state.currentPlayer == TablePosition.south ? _buttons() : WaitingForOtherPlayerWidget(state.currentPlayer) ); }
This will render a screen that looks like this:
Notice that our controller passes “_onFirstRoundBidResult” into this widget? When the player clicks one of the two buttons, it will call that method with the bid result. Let’s implement the method now. The logic here is pretty simple. If the player picks it up, we move to the dealer discard phase, if the player passes, we just move to the next player, and if the bidding round is over, we go to the next bidding round:
void _onFirstRoundBidResult(bool pickUp) { setState(() { if (pickUp) { _euchreGameState = _euchreGameState.orderUp(); } else { if (_euchreGameState.currentPlayer == _euchreGameState.dealerPosition) { _euchreGameState = _euchreGameState.withPhase(GamePhase.second_bidding_round); } else { _euchreGameState = _euchreGameState.nextPlayer(); } } }); }
You might be thinking at this point “alright, cool, but where does the AI fit into all this?” That’s a super pertinent question, and we’ll answer it now.
It’s probably clear that the Controller will be responsible for managing the AI. For example, if it’s the bidding phase, and it’s the AIs turn to bid, the Controller is the one to say “hey, AI player, let me know if you want to bid or not.” Then, when the AI responds, the controller will invoke “_onFirstRoundBidResult”. So let’s put a EuchreAI on our controller. We’ll start out with a fairly crummy AI named “DerpAI”:
class EuchreGameControllerState extends State<EuchreGameController> { GameState _euchreGameState; EuchreAI _ai; @override void initState() { super.initState(); _euchreGameState = startNewGame(TablePosition.east); _ai = DerpAI(); } // ... }
Our AI needs to handle the primary actions that a Euchre player can take: bidding, discarding, and playing. Let’s create an interface:
abstract class EuchreAI { Future<bool> desiresBid(GameState state); Future<CallResult> maybeCallSuit(GameState state); Future<PlayingCard> chooseDiscard(GameState state); Future<PlayingCard> play(GameState state); }
So when does the controller ask the AI to do things? Well, one reasonable place is whenever the GameState changes. When the State changes, that means it’s a new person’s turn. We check if that person is an AI, and if it is, we ask her what she wants to do. Let’s add that function to our GameController:
void _maybeProcessAITurn() { if (_euchreGameState.currentPlayer != TablePosition.south) { switch (_euchreGameState.phase) { case GamePhase.first_bidding_round: _ai.desiresBid(_euchreGameState).then(_onFirstRoundBidResult); break; case GamePhase.second_bidding_round: _ai.maybeCallSuit(_euchreGameState).then(_onSecondRoundBidResult); break; case GamePhase.discard: _ai.chooseDiscard(_euchreGameState).then(_onDiscard); break; case GamePhase.play: _ai.play(_euchreGameState).then(_onPlay); break; default: // ai does nothing for other phases break; } } }
And call the function in _onFirstRoundBidResult after our state changes:
void _onFirstRoundBidResult(bool pickUp) { setState(() { if (pickUp) { _euchreGameState = _euchreGameState.orderUp(); } else { if (_euchreGameState.currentPlayer == _euchreGameState.dealerPosition) { _euchreGameState = _euchreGameState.withPhase(GamePhase.second_bidding_round); } else { _euchreGameState = _euchreGameState.nextPlayer(); } } _maybeProcessAITurn(); }); }
And finally, we need an actual AI implementation! As noted, we’ll start with a “DerpAI”. This poor guy isn’t the best decision maker:
class DerpAI implements EuchreAI { final Random r = Random(); @override Future<bool> desiresBid(GameState state) { return Future.delayed(Duration(milliseconds: 200 + r.nextInt(500)), () => r.nextBool()); } @override Future<CallResult> maybeCallSuit(GameState state) { return Future.delayed(Duration(milliseconds: 200 + r.nextInt(500)), () => CallResult(r.nextBool(), Suit.values[r.nextInt(Suit.values.length)])); } @override Future<PlayingCard> chooseDiscard(GameState state) { List<PlayingCard> myHand = state.players[state.dealerPosition]; return Future.delayed(Duration(milliseconds: 200 + r.nextInt(500)), () => myHand[r.nextInt(myHand.length)]); } @override Future<PlayingCard> play(GameState state) { List<PlayingCard> myHand = state.players[state.currentPlayer]; List<PlayingCard> validPlays = state.currentTrick.empty ? List<PlayingCard>.from(myHand) : getValidPlays(myHand, state.currentTrick, state.trump); return Future.delayed(Duration(milliseconds: 200 + r.nextInt(500)), () => validPlays[r.nextInt(validPlays.length)]); } }
Alright, we’ve got our core components in place, and we’ve implemented one phase of the game in its entirety. In the next short chapter we’ll cover the remaining phases, then we’ll move on to a perfect information AI.