Actor-based Multiplayer Card Game

Oct 4, 2017

In previous articles we’ve talked about how we can implement a simple game using actors, and how we can use Akka’s remoting functions to build actor networks, so it’s time we put the pieces together and create a multiplayer game.

The game we’ll implement is called pişti or bastra, a card game that can be played with 2 to 4 people, and is pretty popular among people of all ages due to its simplicity.

At the start, each player is dealt 4 cards, and 4 other cards are placed in the middle of the table (also called the board), with one of them open. To ensure fairness, any jacks that are dealt on the board stack is randomly replaced with another from the deck so the players can get them later. On each turn, a player plays a card from their hand and places it on the board. If the card that is played is a jack, or if it matches the one on top of the board stack, the player collects the board (also called fishing) and earns points. If not, the card is left on the stack and the turn passes over to the next player. If the player runs out of cards they’re dealt 4 more, and the game lasts until all cards are played out. Once they are, whoever has the highest score wins the game.

Rules

There are different versions of bastra that have different scoring rules, but the one we’ll implement (possibly the most common one) goes like this:

Jacks or aces are worth 1 point

Two of clubs is worth 2 points

Ten of diamonds is worth 3 points

Performing a bastra (i.e. fishing a single card from the board using the same card) is worth 10 points

Planning

Before moving on to the implementation, let’s plan our approach.

Dedicated Servers or Peer-to-Peer

Whether or not a game should have dedicated servers to run the game logic on, or would P2P with a “master” peer suffice, is the age old problem for multiplayer games. The latter method is sometimes the go-to solution for even big companies since it basically induces no cost for server maintenance, but it’s susceptible to cheating and hacking, as was the case in many Call of Duty games. It’s not very difficult to alter the program’s memory and mess up with the game logic, and that’s how modern bots, wall hacks and aimbots work anyways.

Since we’re dealing with a game logic that depends heavily (solely?) on memory (i.e. the game state), there’s no way we can make our game cheat-proof if we run P2P. As such, we’ll create dedicated servers that keep the game’s state and run its logic, and verify clients’ commands as they come.

Communication Flow

The flow of communication is the main topic of this article, so we’ll delve more deeply into this in following sections, but let’s see the outline first.

Since we’re not running a P2P network, it’s our servers’ responsibility to peer clients together and manage the flow of information. So the first thing we need to do is to let clients find a game server and introduce themselves to it.

Once a client connects, the server put them in a matchmaking queue, and if there are at least two players, they will be put into a game room where the game will actually played. Once the game ends, they’ll be back in the matchmaking queue.

To summarize, here’s an overview of the client flow:

Scores and Matchmaking

This post is more about the client-server communication in a multiplayer game than other aspects of multiplayer gaming such as scoring and matchmaking, so we won’t cover these here. They are, however, among the main topic of future posts, so please stay tuned. Until then, we won’t score players and the matchmaking will simply take the first two player in the queue and put them in a game.

That said, players do have to wait until they’re placed in a game, so we’ll call this state “waiting in the lobby”, and implement a special actor to handle it.

User Interaction and UI

We need no user interaction on the server side (aside from logging stuff), and on the client side we’ll just go ahead and use the terminal for both input and output since we don’t have much visual data. Therefore, no UI.

Project Outline

We’ll have one project each for both the server and the client, and another project called commons to that we can share the same domain objects and messages. Since we don’t need a professional-grade project structure, we’ll simply create a multi-project setup.

Implementation

The codebase for this project is considerably larger than previous examples, so rather than going line by line over the code, I’ll try to explain it by going over flowcharts and samples of code. The codebase has a sufficient (hopefully) amount of comments as well, so we should be fine.

P.S. Since we have multiple actors running different state machines, I’ve colored their diagrams differently. Use the following legend if needed.

Domain Objects

Since we’re making a card game our domain objects are cards, decks, ranks and suits, and we can implement a few case classes and companion objects to represent them with convenience.

Suits

Suits basically represent the class or category of a card, and consists of a name and a symbol. The following is a list of suit names and their symbols.

Ranks

Ranks represent the value of a card, and have a special ordering such as A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, where A is sometimes larger than K and sometimes isn’t. Even so, we can just go ahead and implement this as follows:

Cards

We usually have two ways to refer to a card: “Ace of Spades” or “A♠”. We can call these the name and short names, and with suits and ranks implemented, it’s easy to create a class that can represent both.

A list of cards isn’t exactly a domain object, we can’t really build on that, so let’s abstract that into another class called CardStack. This will allow us to generate ordered or randomly ordered decks, and with or without a specific card.

Establishing the Connection

Now that we’ve created our domain objects, it’s time to implement the flow. Looking back at the overview in the previous section, our first task is to connect the client to a server. In order to do that, we’ll simply let the client connect to a specific actor system on a specific IP address and port. In the ideal world this is pretty much the same, only the IP address is replaced by a hostname, quite possibly prefixed by a region code. Here’s an example: eu.logon.worldofwarcraft.com

One thing to note here is that there are dozens of reasons that we can’t establish the connection in the first attempt (if the client doesn’t have an internet connection we may never connect at all!), so we’ll let the client retry connecting every 5 seconds.

Also, we need a name for each player since we want to introduce them to their opponents, so we ask the player their name before initiating the connection request. This also acts as a pseudo-login phase which we might need in future examples.

Matchmaking

The matchmaking is performed on the server’s end, and as stated in the previous section it’s really nothing special. For the sake of completeness, I’ve also included the initialization phase.

Game Logic

We’ve finally found a match, so it’s time to run the game logic now!

As always, our game room actor will be responsible for not only running the game logic, but also watching the players’ connections and terminating if one of them disconnects. Upon termination, it should also return whoever’s left in the room to the lobby so they can play another game.

As for the client, remember that in previous sections we left them in the lobby, and now it’s time to let them into a game. They’ll start out from the matchmaking phase and will then begin waiting for the game to start. As seen on the server’s flow, it’ll first prepare the deck and decide on who’s going first, announce these details and then give the turn to a random player. We’ll have anticipate each step on our client.

With the flows in place, let’s implement the actual game logic. We’ll do this by keeping the game’s current state and updating it based on the cards played by the players, assuming that a valid card was played, and we’ll also keep the game score so we can determine the winner in the end. Looking at the game rules in the first section, here’s our ruleset:

Only the player in turn can play a card

A player can only play a card that they have in their hand

When a card played, it’s removed from the player’s hand

When a card is played on an empty board it’s placed on the board

When a card is played on top of a card that it cannot match, it’s placed on top of the board stack

When a card is played on top of a card that it can match, it’s placed on the player’s bucket along with all the cards on the board, and the player earns points. This is called fishing.

If both players’ hands are empty upon removing a card, the game deals 4 cards to each player

If the deck and both players’ hands are empty upon removing a card, the game ends, and the player who has the higher score is elected winner

Ouch! This looks a bit messy and difficult to maintain, so we’d better write a few (albeit incomprehensive) unit tests for it. We’ll have three test cases; one for fishing a card, another for a regular round without fishing a card, and another to verify that the game successfully terminates when all cards are played out. So here they are, in order:

$ sbt "project server" test...[info] GameRoomActorSpec:[info] - should fish when necessary[info] - should not fish when not possible[info] - should should end the round and elect a winner when no cards remain[info] Run completed in 337 milliseconds.[info] Total number of tests run: 3[info] Suites: completed 1, aborted 0[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0[info] All tests passed....

Building and Running

The implementation phase is finally over, so it’s time to build the game and play it. To do that, we’ll use the same method as before (see the previous article), but this time, since we’re using a multi-project setup, we’ll need to specify the project we’re building when running our stage command.

Server

1

$ sbt "project server" stage

Client

1

$ sbt "project client" stage

And, voila! We now have two executables for each app, and therefore can play the game! Let’s run the server first:

123

$ ./bastra-server/target/universal/stage/bin/bastra-serverServer is now ready to accept connections...

Conclusion

So that’s pretty much it for the “basic” implementation of a turn based multiplayer game. While we do have a fully functional gameplay, the game can only be served and played on the same machine, and it simply lacks a UI, but at least we’ve made some progress.

In upcoming articles we’ll take care of these issues. We’ll deploy the server on a separate machine, implement a simple UI so the game can be played on the browser, and also a WebSocket-based web application to acts as a gateway between the front-end and the game server. So stay tuned!