Building An Asynchronous Multiuser Web App For Fun ... and Maybe Profit

Download as pdf or txt
Download as pdf or txt
You are on page 1of 80

Building an Asynchronous

Multiuser Web App for Fun


... and Maybe Profit
Luke Welling
[email protected]
Laura Thomson
[email protected]
Introduction
2
Today’s task:
Texas Hold ‘Em, OSCON Variant
• In this tutorial we’ll step through designing and building a
multiplayer game
• UI will be web based, which presents special challenges.
• The tools we will use are:
• HTML/CSS/JavaScript
• AJAX
• PHP
• MySQL

3
Speakers
• Luke Welling is a senior software engineer from
Hitwise in Melbourne, Australia
• Laura Thomson is Director of Web Development at
OmniTI in Columbia, Maryland
• We wrote “PHP and MySQL Web Development” (3/e,
Sams Publishing, 2004)
• The most popular parts of the book are the projects
• (This talk is a work in progress of one of the projects
from the 4/e.)

4
Motivation
• Today’s talk should give you insight into:

–Asynchronous web apps


–Multiuser web apps and the challenges thereof
–Using these technologies together
• But we were only joking about the profit part, sorry

5
Audience
• You’ll get the most out of this talk if you:
– Know PHP, but are not an expert
– Know a little about the other technologies.

• If you want to save your eyes, the slides are on


http://lukewelling.com

6
Overview
• Introduction (You are here)
• The rules
• The goal
• The architecture
• The components

7
The Goal

8
The rules
9
Texas Hold ‘Em OSCON Variant:
Basics
• Each player is dealt two pocket or hole cards.
• There are also 5 community cards.
• Each player’s hand consists of the best hand they can
make out of those seven cards. (7C5, or 21 possible
hands)
• Up to eight players

10
Sequence of Play
• Before any cards are dealt, first player posts a bet, called a
small blind, and then second player posts a bigger bet,
called a big blind
• Basically a tax on sitting at the table

11
Sequence of play - continued
• Each player is then dealt two cards
• A round of betting ensues
• Three community cards are dealt followed by a round of
betting
• A fourth community card is dealt followed by a round of
betting
• The fifth community card is dealt followed by a fourth and
final round of betting

12
Betting
• Each player can either:
– Call (sometimes referred to as “see”) – either match the
existing bet, or go “all in” if they don’t have enough money to
match it
– Raise – bet an increased amount
– Fold – quit
• Betting rounds occur at several points throughout the
game
• Each round may go through the players several times and
ends when each player has either bet the same amount,
folded, or gone all in
13
Ranking of hands
• Winners are then determined according to the standard ranking of poker
hands:
– Straight flush: Five cards in sequence and of the same suit. (Q♦ J♦ 10♦ 9♦ 8♦)
– Four of a kind: A hand with four cards of the same rank. ( 4♣ 4♦ 4♥ 4♠ 9♥)
– Full house: A hand with three of one rank and two of another. ( 8♣ 8♦ 8♠ K♥ K♠)
– Flush: Five cards of the same suit. (K♠ J♠ 8♠ 4♠ 3♠)
– Straight: Five cards in sequence. (7♦ 6♥ 5♠ 4♦ 3♦)
– Three of a kind: Three cards of the same rank. ( 7♣ 7♥ 7♠ K♦ 2♠)
– Two pair: Two cards of one rank, two of another. ( A♣ A♦ 8♥ 8♠ Q♠)
– One pair: Two cards of the same rank. (9♥ 9♠ A♣ J♠ 4♥)
– High card: Also known as a "no pair" hand. The following example is considered
"Ace high." ( A♦ 10♦ 9♠ 5♣ 4♣)
(Source: http://en.wikipedia.org/wiki/Poker_hand)

14
Payout
• If you win you get the pot
• If there’s a draw then it’s split between people that draw

• (Handling “all in” is more complicated and not on today’s


agenda)

15
The goal
16
The goal
• Build a system that allows people to play OSCON Texas
Hold ‘Em over the web without using Flash/Java applets
etc, just using HTML and Ajax.

• Up to eight players at once

• No AI players (easier in many respects), AS instead

• On the way, we might discover why Flash and Java are


more appropriate popular for this task

17
The architecture
18
Basic architecture
Behaviors,
Validity checking
Rules
Engine
Ajax requests

Game
UI Controller
Serialization
Game state
changes, HTML
fragments Database
Initial
HTML
Renderer

19
Overall architecture
• This is an MVC (Model – View – Controller) patterned
architecture
– UI + Renderer is the view
– Game controller is the controller
– Rules engine + DB is the model

20
UI
• UI is vanilla HTML + CSS + JavaScript
• Changes occur via Ajax updates on a per player basis

• UI sends requests to the controller


– Create Game
– Join Game
– Start Game
– Poll
– Play (Call, Raise, Fold)

21
Controller
• Controller processes requests from the UI, and returns
HTML for the portions of the UI that have changed.

• Controller processes the 4.2 different kinds of UI request

22
Create Game
• Triggers these events in the rule engine:

– Creates the game object


– Creates and shuffles the deck
– Creates empty arrays and objects to hold the game objects
– Serializes the Game for the first time

23
Game start
• Triggers these events in the rule engine:

– Unserializes the empty Game


– Adds dummy players
– Deals with small and big blinds
– Deals player cards
– Serializes again so other players can load the same data

24
Poll
• UI gets state changes by polling the game controller
• Several different choices in how to implement game state
updates in these kind of systems. Polling is the most
common, and certainly the easiest to implement.
• Games are tracked via a gamestate (like a revision
number). If a particular player is at revision x and the
current gamestate is at y, we need to update their view to
version y.
• This is the Periodic Refresh design pattern
(http://ajaxpatterns.org/Periodic_Refresh)

25
Clever Polling
• By tracking state number, we save processing and
bandwidth
• You could refresh the whole screen every few seconds
with plain HTML
• We can check every few seconds, but ignore most polls
• We only have to refresh if there has been a change
• We can just refresh the volatile parts
• We could fairly easily be a lot more clever and track at
which state each UI item last changed, and only refresh
the few that have changed recently
26
Plays
• There are three possible play actions:
– Raise
– Call (including all in)
– Fold
• Check the validity of the play with the rules engine:
whether a play is valid at any point depends where we are
in the sequence of the game

27
Rules engine
• Rules engine is OO PHP, consisting of the following
classes:
– Game: representing the whole game
– Card: a single card
– CardCollection: a collection, strangely enough, of cards
– Deck, a CardCollection, representing the deck to be used in
the game, including card ordering for dealing purposes
– Hands, are CardCollections, representing a player’s possible
hands
– HandCollection, includes features like sorting
– CommunityCards, a CardCollection

28
Database
• The database contains the following tables:
–players
–games

29
mysql> describe players;
+----------+------------------+------+-----+---------+--------------+
| Field | Type | Null | Key | Default | Extra |
+----------+------------------+------+-----+---------+--------------+
| id | int(10) unsigned | | PRI | NULL |auto_increment|
| password | varchar(40) | YES | | NULL | |
| login | varchar(50) | YES | MUL | NULL | |
| gameid | int(11) | YES | | 0 | |
| name | varchar(50) | YES | | NULL | |
+----------+------------------+------+-----+---------+--------------+
5 rows in set (0.00 sec)

30
mysql> describe games;
+-------+------------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+------------------+------+-----+---------+----------------+
| id | int(10) unsigned | | PRI | NULL | auto_increment |
| state | int(11) | | | 0 | |
| data | blob | YES | | NULL | |
+-------+------------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

31
Database Serialization
• In general we are storing serialized objects from the Rules Engine
as blobs, plus a game state version number
• Why not more granular data?
• For the purpose of this app we only need to access the above
information. This is a pretty fast way to store and retrieve data for
what we want to do.
• If we required any reporting or auditing functionality we would
need to do more serious ORM

32
Issues and alternatives
• Sending changes: ghetto version control

• Server overhead / scalability

• Serialization

• Security

33
Version control
• About 75% of the way through implementation, complaints
were heard: ‘Aren’t we just re-implementing Subversion?”
• There is a PHP extension for this: svn
• The difficulty would lie in writing a JavaScript client
capable of doing svn update
• Nice idea though, and given more time and JS-Fu…

34
Scalability
• Polling is easy to implement.
• For one eight player game it’s pretty trivial for each browser to poll
the server every 3 seconds
• If this became wildly successful this introduces a lot of load
• Not difficult load to manage: it’s just HTML, and small pieces at
that. (Note we’re not resending the whole page, just portions of it)
• Some alternatives exist:
– Keep connections open between requests
– mod_pubsub to push from server to client
– Implement the HTTP Streaming pattern
(http://ajaxpatterns.org/HTTP_Streaming)

35
Serialization
• ORM done in this app is extremely simplistic (using PHP’s
serialize() and unserialize() functions)
• Could be done in a more granular way using an
ActiveRecord pattern implementation

36
Security
• This particular implementation is for fun. If we were
playing for actual money we would need:
– Better session management
– A complete non-volatile audit trail of all plays (times, players,
originating IPs)
– SSL
– More careful implementation of timing (or terms and conditions
at least) to avoid litigation
– To make it harder to read the HTML into a bot

37
The components
38
JavaScript
// trigger a poll every three seconds, but don't
// do one immediately
// on page load as that would increase the risk of the
// script being only partially loaded
function periodicallyUpdate(first)
{
if(!first)
{
sendRequest("poll");
}
setTimeout('periodicallyUpdate(0)',3000)
}

39
Recipe
• 1 JavaScript file
• 1 CSS file
• 2 simple database tables
• Mostly PHP

40
Card Renderer
Object Model

Game Controller
CardCollection

Deck Hand Player

HandCollection
41
// create a cross browser object to make our ajax requests through
function createHttpRequestObject()
{
// use feature sniffing to find out what type of object to create
var httpRequest;
if (window.XMLHttpRequest)
{ // Mozilla, Safari, ...
httpRequest = new XMLHttpRequest();
}
else if (window.ActiveXObject)
{ // IE
httpRequest = new ActiveXObject("Microsoft.XMLHTTP");
}
return httpRequest;
}
// make an instance of it
var http = createHttpRequestObject();

42
//make an Ajax request
function sendRequest(action)
{
// append what the client thinks the current state is to
every request
var state =
parseInt(document.getElementById('clientState').value);
http.open('get',
'gameController.php?clientState='+state+'&action='+action);
http.onreadystatechange = handleResponse;
http.send(null);
}

43
// when we get an answer we need to parse it and do something
with the chunks
// in this case, the pipe delimited format is very simple, and
needs limited processing
function handleResponse()
{
if(http.readyState == 4)
{
var response = http.responseText;
var updates = new Array();
var count = 0;
if(response.indexOf('|') != -1)
{
updates = response.split('|');
// make sure we have an even number of items, for pairs of
id and text
count = Math.floor((updates.length)/2)*2;
for(var i=0; i<count; i+=2)
{

44
if('status' == updates[i])
{
document.getElementById('statusBox').value +=
"\n"+updates[i+1];
}
else if('clientState' == updates[i])
{
document.getElementById(updates[i]).value =
updates[i+1];
}
else
{
document.getElementById(updates[i]).innerHTML =
updates[i+1];
}
}
}
}
}

45
Data Format
communityCards|<div class= "cardSpace"> </div><div class= "cardSpace">
</div><div class= "cardSpace"> </div><div class= "cardSpace"> </div><div
class= "cardSpace"> </div>|playerName|<h2>Not Playing This
Game</h2>|playerCards|<div class= "cardSpace"> </div><div class=
"cardSpace"> </div>|playerText|<p class = "importantText">Balance:
$0.00</p> <p class = "importantText">Stake: $0.00</p> <p class =
"importantText">Total Pot: $0.00</p>|player0Cards|<div class= "cardSpace">
</div><div class= "cardSpace"> </div>|player0Text|<h2>1: Empty Seat</h2>
<p>Balance: $0.00</p> <p>Stake: $0.00</p>|player1Cards|<div class=
"cardSpace"> </div><div class= "cardSpace"> </div>|player1Text|<h2>2:
Empty Seat</h2> <p>Balance: $0.00</p> <p>Stake:
$0.00</p>|player2Cards|<div class= "cardSpace"> </div><div class=
"cardSpace"> </div>|player2Text|<h2>3: Empty Seat</h2> <p>Balance:
$0.00</p> <p>Stake: $0.00 …

46
CSS Makes some things very easy
// render a placeholder for a card so the
//layout does not collapse
public static function renderCardSpace()
{
return '<div class= "cardSpace"> </div>';
}

// you see the backs of other players' cards


public static function renderCardBack()
{
return '<div class="cardBack"> </div>';
}

47
… and some not so

48
Card CSS
.card, .cardBack, .cardSpace
{
background-color: #fff;
margin: 0.2em;
float: left;
border-color: #000;
border-width: .05em;
border-style: solid;
position: relative;
width: 11em;
height: 14em;
-moz-border-radius:0.75em;
border-radius:0.75em;
}

49
Same code – Same result
public function renderCommunityCards($game = null)
{
if(is_a($game, 'Game'))
{
return $game->renderCommunityCards();
}
else
{
// we are not in an active game
return Card::renderCardSpace().
Card::renderCardSpace().
Card::renderCardSpace().
Card::renderCardSpace().
Card::renderCardSpace();
}
}

50
OO Delegation
// render the collection, or with a count, blanks spaces to pad
public function render($count=0)
{
if($count)
{
for($i=0; $i<$count; $i++)
{
if(is_a($this->cards[$i], 'Card'))
{
$return .= $this->cards[$i]->render();
}
else
{
$return .= Card::renderCardSpace();
}
}
}
else
{
foreach($this->cards as $card)
{
$return .= $card->render();
}
}
return $return;
}
51
public function render()
{
$colour = $this->getColour();
$entity = $this->getEntity();
$locations = $this->getLocations();
$return = "<div class=\"card $colour\">
<div class=\"rankArea $colour\">
<div class=\"rank $colour\">{$this->rank}</div>
<div class=\"sideSuit $colour\">$entity</div>
</div>";

if('J'==$this->rank||'Q'==$this->rank||'K'==$this->rank)
{
$return .= "<div class=\"locFace\">
<img src = 'images/".$this->getLongRank().".gif' class =
\"locFace\">
</div>";
}
else if ('A' == $this->rank)
{
$return .= "<div class=\"locFace $colour\">
<span class=\"face $colour\">$entity</span>

52
</div>";
}
else
{
foreach($locations as $location)
{
if('Face' != $location)
{
$return .= "<div class=\"loc{$location} $colour\">
<div class=\"suit $colour\">{$entity}</div>
</div>";
}
}
}
$return .= " <div class=\"bottomRankArea $colour\">
<div class=\"sideSuit $colour\">$entity</div>
<div class=\"rank $colour\">$this->rank</div>
</div>
</div>";
return $return;
• }

53
Player
function load($id)
{
// the id will be passed in since it's been retrieved from the player's session
// comes from authentication.php
$this->db = new mysqli('localhost', 'poker', 'pokerpass', 'poker');
$id = intval($id);
$query = "select name, gameid from players
where id = $id";
$res = $this->db->query($query);

if($res)
{
$name = mysqli_fetch_array($res);
$name = $name[0];
$this->setName($name);
$this->id = $id;

}
else
{
unset($this);
}
}

54
More Player
function setStatus($status)
{
switch($status)
{
case 'fold' :
$this->cards = new CardCollection();
// fallthrough ...
case 'raise' :
case 'call' :
case 'allIn' :
$this->status = $status;
break;
default :
trigger_error("invalid status", E_USER_WARNING);
}
}

55
Game::serialize()
function serialize()
{
global $db;
$db->autocommit(false);

// serialize this object


$query1 = "select state from games where id =". $this->id;
$res1 = $db->query ($query1);

$row = $res1->fetch_assoc();
$this->state = $row['state'];
//update it in the object before serializing
$this->state++;

$query = "update games set data= '".mysqli_real_escape_string($db, serialize($this))."' where id =


".$this->id;
$db->query($query);

// update the state in the db too


$query = "update games set state = (state+1) where id = ".$this->id;
$db->query($query);

$db->commit();
}

56
Game::unserialize()
public static function unserialize($id)
{
global $db;
$query = "select * from games where id = $id";
$result = $db->query($query);
$row = $result->fetch_assoc();
$old = unserialize($row['data']);
return $old;
}

57
function bestHand($player)
{
if($player->getStatus()=='fold')
{
return null;
}
$stored = $this->bestHands->getHandByOwnerId($player-
>getId());
if($stored)
{
return $stored;
}
// note this code is assuming 2 hole cards and 5
community cards
// if you want to make another variant you will have
to rewrite it
$cards = new CardCollection();

58
$playerHands = new HandCollection();

$cards->add($this->communityCards->peek(0));
$cards->add($this->communityCards->peek(1));
$cards->add($this->communityCards->peek(2));
$cards->add($this->communityCards->peek(3));
$cards->add($this->communityCards->peek(4));
$cards->add($player->peekCard(0));
$cards->add($player->peekCard(1));

if($cards->count()!=7)
{
trigger_error("Cards missing", E_USER_WARNING);
return null;

59
}

// generate all possible hands of 5 from 7 cards


for($i = 0; $i<6; $i++)
{
for($j = $i+1; $j<7; $j++)
{
$hand = array();

for($k=0; $k<7; $k++)


{
if($k!=$i&&$k!=$j)
{
$hand[]=$cards->peek($k);
}

60
}
$handObject = new Hand($hand, $player);
$playerHands->add($handObject);

}
}
// 7C5 is 21, so if we are missing some possibilities,
we have a problem
if($playerHands->count()!=21)
{
trigger_error("Hands missing", E_USER_WARNING);
}
// get the best from the 21 possibles.
return $playerHands->getBestHand();
}

61
Hand
function handCompare($a,$b)
{
$a = $a->getNumericRank();
$b = $b->getNumericRank();

if($a==$b)
{
return 0;
}
else if ($a<$b)
{
return -1;
}
else // ($a>$b)
{
return 1;
}
}

62
// calculate completely arbitrary numbers to rank hands
// Yes, 15 is a magic number,
// As Aces are being ranked as 14, rank/15 will give a
number less than one
public function getNumericRank()
{
if($this->isARoyalFlush())
{
return 9;
}
if($this->isAStraightFlush())
{
return 8+$this->getHighCard(1)->getNumericRank();
}

63
if($this->isAFourOfAKind())
{
return 7+Card::getNumericRank($this->isAFourOfAKind())/15;
}
if($this->isAFullHouse())
{
return 6+
Card::getNumericRank($this->isAThreeOfAKind())/15+
Card::getNumericRank($this->getHighCard(4))/150;
}
if($this->isAFlush())
{
return 5 + $this->getHighCard()->getNumericRank()/15 +
$this->getHighCard(1)->getNumericRank()/150 +

64
$this->getHighCard(2)-
>getNumericRank()/1500 +
$this->getHighCard(3)-
>getNumericRank()/15000 +
$this->getHighCard(4)-
>getNumericRank()/150000;
}
if($this->isAStraight())
{
return 4+$this->getHighCard()->getNumericRank()/15;
}
if($this->isAThreeOfAKind())
{
return 3+Card::getNumericRank($this-
>isAThreeOfAKind())/15;
}
if($this->isATwoPairs())
{

65
$pairRanks = $this->isATwoPairs();
$pairRanks[0] = Card::getNumericRank($pairRanks[0]);
$pairRanks[1] = Card::getNumericRank($pairRanks[1]);

// the hand was sorted, so the second rank is going


to be the higher one
return 2+$pairRanks[1]/15+($pairRanks[0]/150);
}
if($this->isAPair())
{
return 1+Card::getNumericRank($this->isAPair())/15;
}
return $this->getHighCard()->getNumericRank()/15;
}

66
Specific hand functions
public function isAThreeOfAKind()
{
if(
($this->peek(0)->getRank() == $this->peek(1)->getRank() &&
$this->peek(1)->getRank() == $this->peek(2)->getRank()
) ||
($this->peek(1)->getRank() == $this->peek(2)->getRank() &&
$this->peek(2)->getRank() == $this->peek(3)->getRank()
) ||
($this->peek(2)->getRank() == $this->peek(3)->getRank() &&
$this->peek(3)->getRank() == $this->peek(4)->getRank()
)
)
{
// Three of a kind sorted into order will always have a card at position 2
return $this->peek(2)->getRank();
}
else
{
return false;
}
}

67
GameController
/* This is the AJAX based controller that runs the game */

// get the overall action


$action = $_GET['action'];
$clientState = intval($_GET['clientState']);

switch ($action)
{
case 'createGame':
{
$game = new Game();
$game->serialize();
break;
}

68
GameController
case 'startGame':
{
// temporary code until I write some join up code for players

$game->postSmallBlind();
$game->postBigBlind();

$game->dealPlayerCards(2);

$game->serialize();

echo 'status|OK, game started|';


}
else
{
echo 'status|Game already started|';
}
}
break;

69
GameController

case 'fold' :
case 'call' :
case 'raise' :
{
$game = Game::unserialize($gameId);
$userId = intval($_SESSION['userId']);

switch ($action)
{
case 'fold':
// mark player as folded
$game->fold($game->getPlayerById($userId));

break;

70
GameController
case 'call' :
$game->call($game->getPlayerById($userId));

//all the robo-players can call too, we want to go home one day
$game->call($game->getPlayer(1));
$game->call($game->getPlayer(2));
$game->call($game->getPlayer(3));
$game->call($game->getPlayer(4));
break;
case 'raise':
$amount = floatval($_GET['$raiseAmount']);
$game->raise($game->getPlayerById($userId), $raiseAmount);
break;
default:
}

71
GameController
// have we rolled over to a new betting round?
{
if($game->getBettingRound()>1 && $game->countCommunityCards()<3)
{
$game->dealCommunityCards(3);
}
else if($game->getBettingRound()>2 && $game-
>countCommunityCards()<4)
{
$game->dealCommunityCards(1);
}
else if($game->getBettingRound()>3 && $game-
>countCommunityCards()<5)
{
$game->dealCommunityCards(1);
}

72
GameController
else if($game->isGameOver())
{
$winningHand = $game->getWinningHand();
$winningPlayer = $winningHand->getOwner();
$winningLocation = $game->getPlayerLocation($winningPlayer-
>getId());
echo "status|Winner is ".$winningPlayer->getName()." with ".
$winningHand->getRank().'|';
}
}

$game->serialize();
}
break;
default:
}

73
GameController
if(!$game)
{
$game = Game::unserialize($gameId);
}
// update all - poll command, and after others
if($clientState<$game->getState())
{
echo
'communityCards|'.SectionRenderer::renderCommunityCards($game).'|';

// update player data


echo 'playerName|'.SectionRenderer::renderPlayerName($game).'|';
echo 'playerCards|'.SectionRenderer::renderPlayerCards($game).'|';
echo 'playerText|'.SectionRenderer::renderPlayerText($game).'|';

74
GameController

// update small player cards and text


for($i = 0; $i<MAX_PLAYERS; $i++)
{
echo 'player'.$i.'Cards|'.SectionRenderer::renderPlayerCards($game,
$i).'|';
echo 'player'.$i.'Text|'.SectionRenderer::renderPlayerText($game,
$i).'|';
}
echo 'clientState|'.$game->getState().'|';
}
else
{
// echo "status|no updates from state $clientState|";
}

75
Conclusions
76
Lessons learned
• JavaScript Libraries?
• FireBug is great
• Serialization in PHP is simple

77
Questions?

• Slides are online at


• http://lukewelling.com
• http://omniti.com/resources/talks

78
The J, Q, K Images
• Nicu Buculei
• http://www.nicubunu.ro/cards/
• Full sets of SVG cards
• Public Domain

79
A word from our sponsor…

• PHP Lightning talks: [email protected]

• Book launch and signing at Powell’s onsite bookstore,


12.30 Thursday

80

You might also like