- Protocol handshake
- Login
- Receive table lists
- Join game table
- Participate in play
- Leave table
Joining a game
Joining a game is another state transition, and if we reuse the same connection it looks like this:
Connect (TCP) -> Handshake -> Login -> Entered Game
What’s the problem with this? – Well, what about playing on two tables at the same time?
There are various ways around this, for example:
- Make a new TCP connection for each game
- Make each game packet add its game-id
- Use virtual connections
Slightly abbreviated, using different connections looks like this:
Lobby Connection: 1. Connect+Handshake+Login 2. Retrieve game list Game 1 Connection: 3. Connect+Handshake+Login 4. Join game <game 1 id> -> returns <game state 1> Game 2 Connection: 5. Connect+Handshake+Login 6. Join game <game 2 id> -> returns <game state 2>
With game-ids:
1. Connect+Handshake+Login 2. Retrieve game list 3. Join game <game 1 id> -> returns <game 1 id, game state 1> 4. Join game <game 2 id> -> returns <game 2 id, game state 2>
And using virtual connections:
1. Connect+Handshake+Login 2. Retrieve game list <Lobby Channel> 3. Join game <Game channel A>, <game 1 id> -> returns <game state 1> on channel A 4. Join game <Game channel B>, <game 2 id> -> returns <game state 2> on channel B
The advantage of virtual connections and game-ids (virtual connections are actually the generalized version of using game-ids) is that we don’t need to bother with multiple logins.
For each method there are advantages and disadvantages. For example, game-ids are simple, but force you to push that id with every packet, and while managing multiple connections adds complexity, it also means less packets to handle unsubscribing or leaving tables.
There are many nuances that depend on the requirements of your particular game, but we don’t really have the time to explore them here. For now we’re just going to pretend that you can only join a single game (even though in practice we usually don’t want that restriction).
Creating our packets
The client packet is simple, it only needs to hold the game id, since we’ve already logged in and the server knows our player id.
The response though, will need to return the entire state of the table in order to render it on the client:
CLIENT SERVER JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE JOIN_GAME_REQUEST { "game_id": <game_id> } JOIN_GAME_RESPONSE { "result": <result>, ["game_state": GAME_STATE] } (The [] here stands for an optional value)
The problem sending the game state is similar to the creating our lobby list. When joining the game we need the full game state, but afterwards we only want the changes.
How do we handle this need to synchronize between client and server data?
Often what you see is ad-hoc solutions, where the server code attempts to send various “event” packets, and the client tries to make sense of it:
CLIENT SERVER JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE with full game state <- PLAYER_BET { player_id, amount } <- SHOW_PLAYER_TIMER { player_id, timer_value } <- PLAYER_HURRY { player_id } <- PLAYER_FOLD { player_id }
Here the client needs to keep a lot of logic in order to make sense of the incoming data. For example, when the PLAYER_BET
is received, the client needs to understand that the current player should no longer be active, and the next active player should be the next player that is still in the game, same with PLAYER_FOLD
.
This is extremely error prone. What if the protocol had looked like this instead:
JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE with full game state <- NOTIFY_PLAYER_BET { player_id, amount } <- UPDATE_ACTIVE_PLAYER { player_id } <- UPDATE_PLAYER_BET_TOTAL { player_id, amount } <- UPDATE_PLAYER_TIMER { player_id, timer_value } <- NOTIFY_PLAYER_HURRY { player_id } <- NOTIFY_PLAYER_FOLD { player_id } <- UPDATE_ACTIVE_PLAYER { player_id } <- UPDATE_PLAYERS_IN_ROUND { [player_id, ...] }
This way the client receives notifications (triggering text and/or animations) separated from updates (which update specific properties in the client model).
Working like this is especially useful if you have a rich state with many types of updates.
Handling updates
To automate the update packets we can use something similar to the object views discussed in this Gamasutra article. The most straightfoward thing is to keep an object with a data structure identical to the full GAME_STATE sent at connect.
Whenever we update the server model, we make sure we send an update to this object, which then can broadcast the delta changes to everyone viewing the game.
In code it could look something like:
//In PlayerGameState (holding the player-visible state) void setActivePlayer(int playerId) { this.activePlayer = playerId; queueUpdate(UPDATE_ACTIVE_PLAYER, playerId) } //In TableGameState - the actual state object void setActivePlayer(int playerId) { this.activePlayer = playerId; this.playerGameState.setActivePlayer(playerId); }
One problem we encounter though, is that we get a lot of different update packet types this way. However, nothing prevents us from generalizing them (this is slightly less pleasant with static packets):
JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE with full game state <- NOTIFY { type:PLAYER_BET, player_id, amount } <- UPDATE { type:ACTIVE_PLAYER, player_id } <- UPDATE { type:BET_TOTAL, player_id, amount } <- UPDATE { type:PLAYER_TIMER, player_id, timer_value } <- NOTIFY { type:HURRY, player_id } <- NOTIFY { type:FOLD, player_id } <- UPDATE { type:ACTIVE_PLAYER, player_id } <- UPDATE { type:PLAYERS_IN_ROUND, player_ids }
What we’ve essentially done here is to split packets into sub-packets, how is this useful?
Because this allows us to bundle updates:
JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE with full game state <- NOTIFY { type:PLAYER_BET, player_id, amount } <- UPDATE { updates: [ { type:ACTIVE_PLAYER, player_id }, { type:BET_TOTAL, player_id, amount }, { type:PLAYER_TIMER, player_id, timer_value } ] } <- NOTIFY { type:HURRY, player_id } <- NOTIFY { type:FOLD player_id } <- UPDATE { updates: [ { type:ACTIVE_PLAYER, player_id }, { type:PLAYERS_IN_ROUND, player_ids } ]}
We can even bundle the update and notification together, when they occur together:
JOIN_GAME_REQUEST -> <- JOIN_GAME_RESPONSE with full game state <- UPDATE { notifications: [ { type:PLAYER_BET, player_id, amount } ], updates: [ { type:ACTIVE_PLAYER, player_id }, { type:BET_TOTAL, player_id, amount }, { type:PLAYER_TIMER, player_id, timer_value } ] } <- UPDATE { notifications: [ { type:HURRY, player_id } ] } <- UPDATE { notifications: [ { type:FOLD, player_id } ], updates: [ { type:ACTIVE_PLAYER, player_id }, { type:PLAYERS_IN_ROUND, player_ids } ] }
The advantage here is that all changes to the state are “atomic”, and the client never needs to stay in an inconsistent state. It also keeps our outer protocol simple.
This approach allows us to write our code in this manner (error handling not included):
void processPacket(Packet packet) { this.notifications.clearNotifications(); this.gameState.clearUpdates(); handlePacket(packet); List<Notifications> notifications = this.notifications.getAll(); List<Update> updates = this.gameState.getUpdates(); sendResult(notifications, updates); }
There are obviously more details to this implementation, but the main idea is to defer update delta and notifications to when the packet have processed and send it as a single update. Interestingly, this allows us to surpress the queued notifications and updates if we want to. This can be useful for when errors occur during processing.
When is the the ad-hoc solution better?
Removing the need to duplicate game logic in the client is the greatest advantage to pure update deltas. This makes updates very simple in the client, and this is appropriate to use when it is important and expected that the game model is always in sync with the server.
If you have a fairly rich, but well defined, state – like in poker – updates of a player view makes good sense, but that doesn’t mean it’s universally applicable. Starting out with an ad-hoc solution can often be a good idea while prototyping or starting out.
The player game state
For our poker table we’ll want to know what players are participating, the cards on the table, the pot, active players etc. (It’s likely this object is missing a few fields, but you get the general idea)
GAME_STATE { "hand_id": <int>, "dealer_seat": <seat>, "main_pot": <amount>, "betting_round": <round>, "table_cards": [ <card>, ... ], "name": <table name>, "id": <game id>, "game_type": <holdem, omaha etc>, "max_players": <int>, "limit": <limit type>, "smallest_unit": <amount>, "rake": <amount>, "players": { <seat>: PLAYER, ... }, "may_sit_in": <boolean>, "buy_in": { "min": <amount>, "max": <amount> }, "active_player": <player_id>, "button_seat": <seat>, "available_actions": [ ACTION, ... ] } PLAYER { "state": <sitting in, waiting to sit in etc>, "slow": <boolean>, "bet": <amount>, "money": <amount>, "nick": <nickname>, "id": <player_id>, "timer": <timestamp>, "cards": [ <card>, ... ] } ACTION { "type": <bet, fold, etc> "values": { "min": <amount>, "max": <amount>, "smallest_unit": <amount> } } Card = -1 means a hidden card.
We’ve already looked at some of the play protocol, but in the next entry we’ll go over it in detail.