Building An Asynchronous Multiuser Web App For Fun ... and Maybe Profit
Building An Asynchronous Multiuser Web App For Fun ... and Maybe Profit
Building An Asynchronous Multiuser Web App For Fun ... and Maybe Profit
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:
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.
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
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.
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
21
Controller
• Controller processes requests from the UI, and returns
HTML for the portions of the UI that have changed.
22
Create Game
• Triggers these events in the rule engine:
23
Game start
• Triggers these events in the rule engine:
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
• 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
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>';
}
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);
$row = $res1->fetch_assoc();
$this->state = $row['state'];
//update it in the object before serializing
$this->state++;
$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
}
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]);
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 */
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();
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).'|';
74
GameController
75
Conclusions
76
Lessons learned
• JavaScript Libraries?
• FireBug is great
• Serialization in PHP is simple
77
Questions?
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…
80