Building an Asynchronous Multiuser Web App for Fun... and Maybe Profit Luke Welling...

Preview:

Citation preview

Building an Asynchronous

Multiuser Web App for

Fun ... and Maybe Profit Luke Wellingluke.welling@hitwise.comLaura Thomsonlaura@omniti.com

2

Introduction

3

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

4

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.)

5

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

6

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.

7

• Introduction (You are here)

•The rules

•The goal

•The architecture

•The components

Overview

8

The Goal

9

The rules

10

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

11

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

12

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

13

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

14

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)

15

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)

16

The goal

17

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

18

The architecture

19

Basic architecture

UI

RulesEngine

Game Controller

Database

Ajax requests

Game statechanges, HTML

Serialization

Behaviors,Validity checking

RendererInitialHTML

fragments

20

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

21

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)

22

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

23

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

24

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

25

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)

26

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

27

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

28

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

29

Database

• The database contains the following tables:–players

–games

30

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)

31

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)

32

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

33

Issues and alternatives

• Sending changes: ghetto version control

• Server overhead / scalability

• Serialization

• Security

34

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…

35

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)

36

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

37

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

38

The components

39

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)

}

40

Recipe

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

41

Object Model

HandCollection

Controller

Hand

Game

Player

Renderer

Deck

CardCollection

Card

42

// 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();

43

//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);

}

44

// 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) {

45

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];}

} } }}

46

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 …

47

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>';

}

48

… and some not so

49

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;}

50

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(); } }

51

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; }

52

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>

53

</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;• }

54

Playerfunction 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); } }

55

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); } }

56

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(); }

57

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;

}

58

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();

59

$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;

60

}

// 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);

}

61

} $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(); }

62

Handfunction 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; } }

63

// 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();

}

64

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 +

65

$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()) {

66

$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;

}

67

Specific hand functionspublic 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; } }

68

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; }

69

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;

70

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;

71

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: }

72

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); }

73

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: }

74

GameController

if(!$game){ $game = Game::unserialize($gameId);}// update all - poll command, and after othersif($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).'|';

75

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|";}

76

Conclusions

77

Lessons learned

• JavaScript Libraries?• FireBug is great• Serialization in PHP is simple

78

Questions?

• Slides are online at

• http://lukewelling.com

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

79

The J, Q, K Images

• Nicu Buculei

• http://www.nicubunu.ro/cards/

• Full sets of SVG cards

• Public Domain

80

A word from our sponsor…

• PHP Lightning talks: lightningtalks-oscon2006@omniti.com

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

Recommended