Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Creating a live blackjack game

  • Graham Cox
December 13th, 2018
You will need Go 1.11+ and Create React App installed on your machine.

Introduction

In this tutorial, we are going to create an online, realtime blackjack game. Players will be able to observe and join tables in order to play, and can chat with other players at the table they are at.

Blackjack is an interesting game. It’s single player between the player and the house, but the way that the game plays out varies depending on the number of other players at the same table.

Playing blackjack

Blackjack plays in rounds, where all of the players who were sat at the table at the start of the round get to play. Firstly all of the players put in a bet - anywhere between one token and their entire pot. The croupier then deals two cards to each player and one to himself, all face up.

Starting from seat 1, each player then gets to play - either opting to stick on their current hand or to hit, to draw a new card, until they go bust or reach five cards. Each player gets their entire turn before the next player gets to act.

Note: we will not be allowing more advanced playing rules such as splitting, doubling or insurance.

Once the last player has gone, the croupier also gets to act. This involves drawing cards until they reach 16 or higher.

Once the croupier has finished, any players who did not go bust and have a hand worth more than the croupiers will double their stake, and any players who have a hand worth equal to the croupiers will get their stake back. Players who got exactly 21 will triple their stake instead of doubling it. At this point the next round begins, and play continues.

Prerequisites

We will be using Go and React. As such, it is important that you have a recent version of Go - at least version 1.11 - already installed and configured on your system, including having the GOPATH set up correctly. If you do not know how to do this then the Go documentation can explain this all. A certain level of understanding of Go is assumed to follow along with this article. The “A Tour of Go” tutorial is a fantastic introduction if you are new to the language.

In order to develop and run our UI you will need to have a recent version of Node.js installed and correctly set up, as well as the Create React App tool. A certain level of understanding of JavaScript is also assumed to follow along with this article.

Finally, in order to complete this you will need a free account at Pusher, and then create a free Pusher Channels and a Pusher Chatkit instance to use, making sure to note down the credentials provided.

Creating initial Chatkit details

Before we can continue, we need to create some basic Chatkit details. This is done by logging in to the Chatkit dashboard for the instance we are using, and navigating to the Instance Inspector.

Once here, we need to create a Croupier user. The Instance Inspector will start out blank with a single button to create a user:

Press this button, and fill out the form. It is recommended to use a secure User ID that will not be guessed, for example a UUID:

We will then create a number of rooms. Each room equates directly to a table in our game, so the room names will be the table names.

In the Instance Inspector, open the Rooms tab and press the Create New Room button:

Select our Croupier user as the owner of the room, and give it a name to represent the table and then hit Create Room. Repeat this for as many tables as you want to create.

Building the backend service

We are going to write our backend service using the Go language, using the Gin library to power our HTTP service.

Every one of our Blackjack tables will be represented by a Chatkit room. Each table can have up to six players, and everyone else is a spectator.

As such, our backend service will offer the following endpoints:

  • POST /chatkit/auth - this will allow a user to authenticate with Chatkit.
  • GET /games/:id - this will get the current state of a given table.
  • PUT /games/:id/:player - this will allow a player to join a table.
  • DELETE /games/:id/:player - this will allow a player to leave a table.
  • PUT /games/:id/:player/bet - this will allow a player to place a bet.
  • POST /games/:id/:player/action - this will allow a player to perform an action.

To start with, we need to create an area to work with. Create a new directory under your GOPATH in which to work:

    # Mac and Linux
    $ mkdir -p $GOPATH/src/pusher/blackjack
    $ cd $GOPATH/src/pusher/blackjack

    # Windows Powershell
    mkdir -path $env:GOPATH/src/pusher/blackjack
    cd $env:GOPATH/src/pusher/blackjack

Next we’re going to set up the Go Modules support introduced in Go 1.11.

    # Mac and Linux
    $ export GO111MODULE=on
    $ go mod init pusher/blackjack

    # Windows Powershell
    set GO111MODULE=on
    go mod init pusher/blackjack

We also need to get some modules that are going to be needed for our service:

    $ go get github.com/pusher/chatkit-server-go github.com/pusher/pusher-platform-go/auth github.com/pusher/pusher-http-go github.com/gin-contrib/cors github.com/gin-gonic/gin

These modules are:

  • pusher/chatkit-server-go - the server component for working with Pusher Chatkit.
  • pusher/pusher-platform-go/auth - the server component for authenticating users for Pusher Chatkit.
  • pusher/pusher-http-go - the server component for working with Pusher Channels.
  • gin-contrib/gin - the Gin web server.
  • gin-contrib/cors - the component for allowing CORS support in Gin.

Next we can start developing the service itself. Firstly we will write a package for interacting with Chatkit. This goes in internal/chatter/chatter.go as follows:

    // internal/chatter/chatter.go
    package chatter
    import (
            "context"
            "github.com/pusher/chatkit-server-go"
            "github.com/pusher/pusher-platform-go/auth"
    )
    const CROUPIER = "CROUPIER_ID"
    type Chatter struct {
            client *chatkit.Client
    }
    func New() Chatter {
            client, _ := chatkit.NewClient(
                    "CHATKIT_INSTANCE_LOCATOR",
                    "CHATKIT_KEY",
            )
            return Chatter{client}
    }
    func (chatter *Chatter) Authenticate(user string) (*auth.Response, error) {
            chatter.client.CreateUser(context.Background(), chatkit.CreateUserOptions{
                    ID:   user,
                    Name: user,
            })
            return chatter.client.Authenticate(chatkit.AuthenticatePayload{
                    GrantType: "client_credentials",
            }, chatkit.AuthenticateOptions{
                    UserID: &user,
            })
    }
    func (chatter *Chatter) Message(table string, message string) {
            chatter.client.SendMessage(context.Background(), chatkit.SendMessageOptions{
                    RoomID:   table,
                    SenderID: CROUPIER,
                    Text:     message,
            })
    }

Note: make sure to replace the values of CHATKIT_INSTANCE_LOCATOR and CHATKIT_KEY with the ones obtained from your actual Chatkit application instance.

Note: make sure to replace the value of CROUPIER_ID with the ID of the Croupier user you created earlier

This package creates a type that has two methods on it - one for processing authentication of a user, and the other for allowing us to send a message to a table on behalf of the Croupier.

Note: the value of CROUPIER needs to match the ID generated for that user earlier on.

We also want a package for integrating with Pusher Channels. This goes in internal/notifier/notifier.go as follows:

    // internal/notifier/notifier.go
    package notifier
    import (
        "github.com/pusher/pusher-http-go"
    )
    type Notifier struct {
        notifyChannel chan<- string
    }
    func notifier(notifyChannel <-chan string) {
        client := pusher.Client{
            AppId:   "PUSHER_APP_ID",
            Key:     "PUSHER_KEY",
            Secret:  "PUSHER_SECRET",
            Cluster: "PUSHER_CLUSTER",
            Secure:  true,
        }
        for {
            table := <-notifyChannel
            client.Trigger("table-"+table, "update", table)
        }
    }
    func New() Notifier {
        notifyChannel := make(chan string)
        go notifier(notifyChannel)
        return Notifier{notifyChannel}
    }
    func (notifier *Notifier) Notify(table string) {
        notifier.notifyChannel <- table
    }

Note: make sure to replace the values of PUSHER_APP_ID, PUSHER_KEY, PUSHER_SECRET and PUSHER_CLUSTER with the values obtained from creating your Pusher Channels application instance.

This package simply sends a Pusher Channels message indicating that some table has had some update, which will then cause the client to re-request the table details. Doing this rather than sending the entire table details in the message is more efficient in terms of the data sent, and avoids any problems with the client receiving and rendering stale data - it will always need to get the exact data from the server every time.

Here we actually run the Notifier in a separate goroutine, and queue messages to it when they come in. This means that the request to send a message will not cause the rest of the HTTP request to be blocked, but it does potentially open up the risk that the update message is delayed slightly.

The next package we need is the actual game. This is the actual core of our backend service, so will be split over several files that Go then automatically makes available as a single package.

Firstly we’ll create a file containing out type definitions for the package. This goes in internal/game/types.go as follows:

    // internal/game/types.go
    package game
    import "pusher/blackjack/internal/chatter"
    var CARD_VALUES = [...]string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
    var CARD_SUITS = [...]string{"clubs", "diamonds", "hearts", "spades"}
    type Card struct {
        Suit string `json:"suit"`
        Face string `json:"face"`
    }
    type Player struct {
        ID    string `json:"id"`
        Bet   uint16 `json:"bet"`
        Stack uint16 `json:"stack"`
        Cards []Card `json:"cards"`
    }
    type Game struct {
        ID       string    `json:"id"`
        State    string    `json:"state"`
        Turn     uint16    `json:"turn"`
        Players  [6]Player `json:"players"`
        Croupier []Card    `json:"croupier"`
        deck     []Card
        chatter  *chatter.Chatter
    }
    type Games struct {
        games   []*Game
        chatter *chatter.Chatter
    }

Here we define a struct for a single Card, a single Player, a single Game and for the entire set of all Games. Every player has a current bet, a stack of chips to bet with, and a set of cards they are currently playing with. Every game has the details of the current players and the croupier, and also as an unexported variable the entire deck of cards for that table.

Next we want to be able to obtain the appropriate game to work with for any request. This goes in internal/game/games.go as follows:

    // internal/game/games.go
    package game
    import "pusher/blackjack/internal/chatter"
    func New(chatter *chatter.Chatter) Games {
        return Games{
            games:   []*Game{},
            chatter: chatter,
        }
    }
    func (g *Games) newGame(id string) *Game {
        return &Game{
            ID:      id,
            State:   GAME_STATE_BETTING,
            Turn:    0,
            Players: [6]Player{},
            deck:    []Card{},
            chatter: g.chatter,
        }
    }
    func (g *Games) GetGame(id string) *Game {
        var result *Game
        for _, v := range g.games {
            if v.ID == id {
                result = v
                break
            }
        }
        if result == nil {
            result = g.newGame(id)
            g.games = append(g.games, result)
        }
        return result
    }

This gives us the ability to get a game by it’s unique ID, and if it doesn’t already exist then we will create it. This allows us to add tables on the fly and they will automatically be handled.

Note: there is no mechanism here for removing tables.

We also need a mechanism for working with cards - both in terms of shuffling the deck that we are working with and in dealing out the cards from this deck for the players. This goes in internal/game/cards.go as follows:

    // internal/game/cards.go
    package game
    import (
        "math/rand"
        "time"
    )
    const NUMBER_OF_DECKS = 6
    func (g *Game) shuffle() {
        cards := []Card{}
        for i := 0; i < NUMBER_OF_DECKS; i++ {
            for _, suit := range CARD_SUITS {
                for _, value := range CARD_VALUES {
                    cards = append(cards, Card{
                        Face: value,
                        Suit: suit,
                    })
                }
            }
        }
        r := rand.New(rand.NewSource(time.Now().Unix()))
        g.deck = make([]Card, len(cards))
        perm := r.Perm(len(cards))
        for i, randIndex := range perm {
            g.deck[i] = cards[randIndex]
        }
    }
    func (g *Game) deal() Card {
        card, remainder := g.deck[0], g.deck[1:]
        g.deck = remainder
        return card
    }

Our shuffle routine will build a new deck out of six complete packs of cards randomly shuffled together. Our deal routine will then simply take the first of these cards out of the deck, removing it from that slice, and return that one card.

Note: this uses the simple random number generation in the standard library. It is not of good enough quality for real casino use, but is good enough for our game.

The next thing to do is to allow players to join and leave the tables in our games. This is handled in internal/game/joinTable.go as follows:

    // internal/game/joinTable.go
    package game
    import "errors"
    func (g *Game) JoinTable(seat uint16, id string) (*Player, error) {
        if g.Players[seat].ID == "" {
            g.Players[seat] = Player{
                ID:    id,
                Bet:   0,
                Stack: 100,
                Cards: []Card{},
            }
            return &(g.Players[seat]), nil
        } else {
            return nil, errors.New("That seat is already taken")
        }
    }
    func (g *Game) LeaveTable(id string) {
        for index, s := range g.Players {
            if s.ID == id {
                g.Players[index] = Player{}
            }
        }
    }

Players joining a table are given a free 100 chips in their stack to play with, and no cards until they are dealt some in the next round.

Finally we need to have the actual gameplay logic. This goes in internal/game/game.go as follows:

    // internal/game/game.go
    package game
    import (
        "errors"
        "fmt"
        "strconv"
    )
    const GAME_STATE_BETTING = "betting"
    const GAME_STATE_PLAYING = "playing"
    func (g *Game) Bet(id string, amount uint16) error {
        if g.State != GAME_STATE_BETTING {
            return errors.New("The betting phase is over")
        }
        for index, s := range g.Players {
            if s.ID == id {
                if g.Players[index].Bet != 0 {
                    return errors.New("You have already placed a bet")
                }
                if amount > g.Players[index].Stack {
                    return errors.New("You can not bet that much")
                }
                g.Players[index].Bet = amount
                g.Players[index].Stack -= amount
                g.Players[index].Cards = []Card{}
                g.Croupier = []Card{}
                g.chatter.Message(g.ID, fmt.Sprintf("%s has bet %d", id, amount))
            }
        }
        stillBetting := false
        players := 0
        for _, player := range g.Players {
            if player.Bet == 0 && player.ID != "" {
                stillBetting = true
                players++
            }
        }
        if stillBetting == false {
            g.State = GAME_STATE_PLAYING
            if len(g.deck) < 5*(players+1) { // We need at least 5 cards per player to be able to play
                g.shuffle()
            }
            for index, player := range g.Players {
                if player.ID != "" {
                    g.Players[index].Cards = append(g.Players[index].Cards, g.deal())
                    g.Players[index].Cards = append(g.Players[index].Cards, g.deal())
                }
            }
            g.Croupier = append(g.Croupier, g.deal())
            for index, player := range g.Players {
                if player.ID != "" {
                    g.Turn = uint16(index)
                    g.chatter.Message(g.ID, fmt.Sprintf("%s's turn to act", player.ID))
                    break
                }
            }
        }
        return nil
    }
    func (g *Game) Hit(id string) error {
        if g.State != GAME_STATE_PLAYING {
            return errors.New("We are not currently acting")
        }
        player := &(g.Players[g.Turn])
        if player.ID != id {
            return errors.New("It is not your turn")
        }
        player.Cards = append(player.Cards, g.deal())
        newScore := calculateScore(player.Cards)
        if newScore > 21 {
            // Bust. Next players turn
            g.chatter.Message(g.ID, fmt.Sprintf("%s is bust", player.ID))
            return g.Stick(id)
        } else if len(player.Cards) == 5 {
            // 5 Cards. Next players turn
            g.chatter.Message(g.ID, fmt.Sprintf("%s has 5 cards", player.ID))
            return g.Stick(id)
        }
        return nil
    }
    func (g *Game) Stick(id string) error {
        if g.State != GAME_STATE_PLAYING {
            return errors.New("We are not currently acting")
        }
        player := g.Players[g.Turn]
        if player.ID != id {
            return errors.New("It is not your turn")
        }
        foundNext := false
        for index, player := range g.Players {
            if uint16(index) > g.Turn && player.ID != "" {
                g.Turn = uint16(index)
                g.chatter.Message(g.ID, fmt.Sprintf("%s's turn to act", player.ID))
                foundNext = true
                break
            }
        }
        if foundNext == false {
            // The round has finished
            g.State = GAME_STATE_BETTING
            for calculateScore(g.Croupier) < 16 {
                g.Croupier = append(g.Croupier, g.deal())
            }
            croupierScore := calculateScore(g.Croupier)
            if croupierScore > 21 {
                g.chatter.Message(g.ID, "The house is bust")
            }
            for index, player := range g.Players {
                if player.ID != "" {
                    playerScore := calculateScore(g.Players[index].Cards)
                    if playerScore == 21 {
                        // Blackjack
                        g.chatter.Message(g.ID, fmt.Sprintf("%s got Blackjack!", player.ID))
                        g.Players[index].Stack += g.Players[index].Bet * 3
                    } else if playerScore <= 21 {
                        if croupierScore > 21 || playerScore > croupierScore {
                            // Player has won
                            g.chatter.Message(g.ID, fmt.Sprintf("%s has won", player.ID))
                            g.Players[index].Stack += g.Players[index].Bet * 2
                        } else if playerScore == croupierScore {
                            // Scores match
                            g.chatter.Message(g.ID, fmt.Sprintf("%s has drawn. Money back.", player.ID))
                            g.Players[index].Stack += g.Players[index].Bet
                        }
                    }
                    g.Players[index].Bet = 0
                }
            }
            g.chatter.Message(g.ID, "Bets please.")
        }
        return nil
    }
    func calculateScore(cards []Card) uint16 {
        var score uint64
        aces := 0
        for _, card := range cards {
            switch card.Face {
            case "A":
                score += 11
                aces++
            case "J", "Q", "K":
                score += 10
            default:
                parsedScore, _ := strconv.ParseUint(card.Face, 10, 16)
                score += parsedScore
            }
        }
        for i := 0; i < aces; i++ {
            if score > 21 {
                score -= 10
            }
        }
        return uint16(score)
    }

There is a lot going on here, so lets take it one method at a time.

The Bet method is used when a player wants to place a bet. Each player that is sat at the table can place a bet during the “betting” stage. Once every player currently sat at the table has placed a bet we automatically move on to the “playing” stage. When we do this, we ensure that the deck has enough cards in it, shuffling a new deck if not, then we deal two cards to each player that has bet and one card to the croupier. Then we work out which seat is the first to play, and we get the croupier to announce this to the table.

The Hit method is used when a player wants to perform a “hit” - meaning that they want to be given another card. This ensures that the player is allowed to act, and then gives them another card. If they have either reached five cards or else they have gone bust - by exceeding a score of 21 - then we trigger the Stick method which will pass control on to the next player.

The Stick method will ensure that the player is allowed to act, and then will work out which player to pass control to. If there is no next player - in other words the last player sat at the table has acted - then we resolve the end of the round. This is done by dealing cards to the croupier until they reach a score of 16, and then comparing the scores of each player to the croupier to work out how much to pay out. Once done we then announce to the table what the actions have been and move back to the “betting” stage to begin the next round.

Finally the calculateScore method allows us to calculate the score for a set of cards. This simply adds up the face values of the cards - treating aces as 11 - and then reduces the score by 10 for each ace that we counted until we either get below 21 or else we run out. This effectively allows us to treat aces as 1 or 11 and get the maximum score value.

Once all of this is done, we need to have the actual web server to run the backend with. This goes in internal/webapp/webapp.go as follows:

    // internal/webapp/webapp.go
    package webapp
    import (
        "net/http"
        "pusher/blackjack/internal/chatter"
        "pusher/blackjack/internal/game"
        "pusher/blackjack/internal/notifier"
        "strconv"
        "github.com/gin-contrib/cors"
        "github.com/gin-gonic/gin"
    )
    func StartServer(chatter *chatter.Chatter, notifier *notifier.Notifier, games *game.Games) {
        r := gin.Default()
        corsConfig := cors.DefaultConfig()
        corsConfig.AllowAllOrigins = true
        corsConfig.AllowMethods = []string{"GET", "PUT", "POST", "DELETE"}
        r.Use(cors.New(corsConfig))
        r.POST("/chatkit/auth", func(c *gin.Context) {
            userID := c.Query("user_id")
            authRes, err := chatter.Authenticate(userID)
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{})
            } else {
                c.JSON(http.StatusOK, authRes.TokenResponse())
            }
        })
        r.GET("/games/:id", func(c *gin.Context) {
            game := games.GetGame(c.Param("id"))
            c.JSON(http.StatusOK, game)
        })
        r.PUT("/games/:id/:player", func(c *gin.Context) {
            game := games.GetGame(c.Param("id"))
            player := c.Param("player")
            seat, _ := strconv.ParseUint(c.PostForm("seat"), 10, 16)
            _, err := game.JoinTable(uint16(seat), player)
            if err == nil {
                notifier.Notify(game.ID)
                c.JSON(http.StatusOK, game)
            } else {
                c.JSON(http.StatusBadRequest, gin.H{
                    "message": err.Error(),
                })
            }
        })
        r.DELETE("/games/:id/:player", func(c *gin.Context) {
            game := games.GetGame(c.Param("id"))
            player := c.Param("player")
            game.LeaveTable(player)
            notifier.Notify(game.ID)
            c.JSON(http.StatusOK, game)
        })
        r.PUT("/games/:id/:player/bet", func(c *gin.Context) {
            game := games.GetGame(c.Param("id"))
            player := c.Param("player")
            amount, _ := strconv.ParseUint(c.PostForm("amount"), 10, 16)
            game.Bet(player, uint16(amount))
            notifier.Notify(game.ID)
            c.JSON(http.StatusOK, game)
        })
        r.POST("/games/:id/:player/action", func(c *gin.Context) {
            game := games.GetGame(c.Param("id"))
            player := c.Param("player")
            action := c.PostForm("action")
            var err error
            if action == "hit" {
                err = game.Hit(player)
            } else if action == "stick" {
                err = game.Stick(player)
            }
            if err == nil {
                notifier.Notify(game.ID)
                c.JSON(http.StatusOK, game)
            } else {
                c.JSON(http.StatusBadRequest, gin.H{
                    "message": err.Error(),
                })
            }
        })
        r.Run()
    }

All this does is to create a new Gin web server, add routes as described earlier on for all of our needed handlers, and start listening.

At this point, the only thing missing is our main method to tie it all together. This goes in blackjack.go as follows:

    // blackjack.go
    package main
    import (
        "pusher/blackjack/internal/chatter"
        "pusher/blackjack/internal/game"
        "pusher/blackjack/internal/notifier"
        "pusher/blackjack/internal/webapp"
    )
    func main() {
        chatter := chatter.New()
        notifier := notifier.New()
        games := game.New(&chatter)
        webapp.StartServer(&chatter, &notifier, &games)
    }

This just creates our various components and starts the server listening.

We can now run our server with go run blackjack.go and we’re ready for the UI.

Building the UI

Now that we’ve got a backend service that can manage our games, we need a UI for the players to interact with. This is going to be a simple Create React App application, using Semantic UI to give us some structure to the page.

Firstly, we need to actually create the application. This is done by executing:

    $ create-react-app webapp
    $ cd webapp
    $ npm install

Note: you can use “yarn” instead of “npm” if you prefer.

We then want to add some dependencies that we need for the system:

    $ npm add --save semantic-ui-css semantic-ui-react axios @pusher/chatkit pusher-js

Our UI will allow the player to enter a name, after which they will be given a list of tables they can play at, and the ability to play at a selected table.

Firstly we want our login screen. This is in src/Login.js as follows:

    // src/Login.js
    import React from 'react';
    import { Segment, Button, Form } from 'semantic-ui-react';
    export default class Login extends React.Component {
        state = {
            username: ''
        };
        render() {
            return (
                <Segment>
                    <Form onSubmit={this.handleFormSubmit.bind(this)}>
                        <Form.Field>
                            <label>Username</label>
                            <input placeholder='Username'
                                    value={this.state.username}
                                    autoFocus
                                    onChange={this.handleUsernameChange.bind(this)} />
                        </Form.Field>
                        <Button type='submit'>Log in</Button>
                    </Form>
                </Segment>
            );
        }
        handleUsernameChange(e) {
            this.setState({
                username: e.target.value
            });
        }
        handleFormSubmit() {
            if (this.state.username) {
                this.props.login(this.state.username);
            }
        }
    }

This simply renders a Username field and triggers a callback when the player submits the form. The wrapping component will use this later to allow the player to log in to the game.

Next we want a component to render our list of tables. This will go to the side of the main screen. The code for this can go in src/TableList.js as follows:

    // src/TableList.js
    import React from 'react';
    import { List, Label } from 'semantic-ui-react';
    export default class Tables extends React.Component {
        render() {
            const { currentTable } = this.props;
            const joinedRooms = this.props.joined.map(room => ({
                id: room.id,
                name: room.name,
                size: room.userIds.length,
                member: true
            }));
            const joinableRooms = this.props.joinable.map(room => ({
                id: room.id,
                name: room.name,
                size: room.userIds.length,
                member: false
            }));
            const rooms = [].concat(joinedRooms, joinableRooms)
                .sort((a, b) => {
                    var nameA = a.name.toUpperCase();
                    var nameB = b.name.toUpperCase();
                    if (nameA < nameB) {
                      return -1;
                    }
                    if (nameA > nameB) {
                      return 1;
                    }
                    return 0;
                });
            const tables = rooms.map(table => (
                <List.Item as="a" key={table.id} onClick={() => this.props.onJoinTable(table.id)}>
                    <List.Content>
                        <Label ribbon={table.id === currentTable} color={table.member ? 'red' : 'grey'}>
                            {table.name}
                            <Label.Detail>
                                ({table.size})
                            </Label.Detail>
                        </Label>
                    </List.Content>
                </List.Item>
            ));
            return (
                <List>
                    { tables }
                </List>
            );
        }
    }

This simply takes the list of all Chatkit rooms - both currently joined and joinable ones - sorted in alphabetical order and then renders them. The currently joined ones are rendered in red, and the currently active one is rendered in a ribbon effect.

Next we want to be able to render the actual playing tables. Firstly we need a component to render a set of cards. This goes in src/Cards.js as follows:

    // src/Cards.js
    import React from 'react';
    import { List } from 'semantic-ui-react';
    const cardStyles = {
        clubs: {
            color: 'green'
        },
        diamonds: {
            color: 'blue'
        },
        hearts: {
            color: 'red'
        },
        spades: {
            color: 'black'
        }
    };
    const cardNames = {
        clubs: '♣',
        diamonds: '♦',
        hearts: '♥',
        spades: '♠'
    };
    export default function Cards({cards, horizontal}) {
        const cardElements = cards.map((card, index) => (
            <List.Item style={cardStyles[card.suit]} key={`card-${index}`}>
                {card.face} {cardNames[card.suit]}
            </List.Item>
        ));
        return (
            <div>
                <List horizontal={horizontal}>{cardElements}</List>
            </div>
        )
    }

Note: we’ve used the more accessible four color cards here, not the traditional two color cards. This means that the suits stand out more.

We then want to render the actual game board. This goes in src/Game.js as follows:

    // src/Game.js
    import React from 'react';
    import { Grid, Container, List } from 'semantic-ui-react';
    import Cards from './Cards';
    import axios from 'axios';
    import Pusher from 'pusher-js';
    var pusher = new Pusher('PUSHER_KEY', {
        cluster: 'PUSHER_CLUSTER',
        forceTLS: true
      });
    export default class Game extends React.Component {
        state = {
            croupier: [],
            seats: [{}],
            gameMode: "",
            turn: 0
        };
        componentDidMount() {
            var channel = pusher.subscribe('table-' + this.props.activeTable);
            channel.bind('update', () => {
                this._fetchGame();
            });
            this._fetchGame();
        }
        render() {
            const userSatAtTable = this.state.seats.find(player => player.name === this.props.currentUser.id);
            const players = this.state.seats.map((player, index) => {
                if (player.name) {
                    let leave;
                    let bet;
                    let actions;
                    if (player.name === this.props.currentUser.id) {
                        if (this.state.gameMode === 'betting' || player.bet === 0) {
                            leave = <List.Item><button onClick={() => this._leaveTable()}>Leave Table</button></List.Item>
                        }
                        if (player.bet === 0 && this.state.gameMode === 'betting') {
                            bet = (
                                <List.Item>
                                    <button onClick={() => this._placeBet()}>Place Bet</button>
                                </List.Item>
                            );
                        } else {
                            bet = (
                                <List.Item>
                                    Bet: {player.bet}
                                </List.Item>
                            );
                            if (player.bet > 0 && this.state.turn === index) {
                                actions = [
                                    <List.Item><button onClick={() => this._action('hit')}>Hit</button></List.Item>,
                                    <List.Item><button onClick={() => this._action('stick')}>Stick</button></List.Item>,
                                ];
                            }
                        }
                    }
                    return (
                        <Grid.Column textAlign="center" key={`seat-${index}`}>
                            <List>
                                <List.Item>{player.name}</List.Item>
                                <List.Item><Cards cards={player.cards || []} /></List.Item>
                                <List.Item>Current Stack: {player.stack}</List.Item>
                                { bet }
                                { actions }
                                { leave }
                            </List>
                        </Grid.Column>
                    );
                } else {
                    return (
                        <Grid.Column textAlign="center" key={`seat-${index}`}>
                            <i>Empty Seat</i>
                            { !userSatAtTable && <button onClick={() => this._joinTable(index)}>Sit At Table</button> }
                        </Grid.Column>
                    );
                }
            });

            return (
                <Container>
                    <Grid>
                        <Grid.Row columns="1">
                            <Grid.Column textAlign="center">
                                Croupier
                                <Cards cards={this.state.croupier} horizontal/>
                            </Grid.Column>
                        </Grid.Row>
                        <Grid.Row columns={this.state.seats.length}>
                            {players}
                        </Grid.Row>
                    </Grid>
                </Container>
            );
        }
        _fetchGame() {
            axios({
                baseURL: 'http://localhost:8080',
                url: `/games/${this.props.activeTable}`,
            }).then((response) => {
                this.setState({
                    gameMode: response.data.state,
                    croupier: response.data.croupier || [],
                    turn: response.data.turn,
                    seats: response.data.players.map(player => {
                        if (player.id) {
                            return {
                                name: player.id,
                                bet: player.bet,
                                stack: player.stack,
                                cards: player.cards
                            };
                        } else {
                            return {};
                        }
                    })
                });
            });
        }
        _joinTable(seat) {
            const formData = new FormData();
            formData.set('seat', seat);
            axios({
                baseURL: 'http://localhost:8080',
                url: `/games/${this.props.activeTable}/${this.props.currentUser.id}`,
                method: 'PUT',
                data: formData,
            });
        }
        _leaveTable() {
            axios({
                baseURL: 'http://localhost:8080',
                url: `/games/${this.props.activeTable}/${this.props.currentUser.id}`,
                method: 'DELETE',
            });
        }
        _placeBet() {
            const bet = prompt('How much to bet?');
            if (bet) {
                const formData = new FormData();
                formData.set('amount', bet);
                axios({
                    baseURL: 'http://localhost:8080',
                    url: `/games/${this.props.activeTable}/${this.props.currentUser.id}/bet`,
                    method: 'PUT',
                    data: formData,
                });
            }
        }
        _action(action) {
            const formData = new FormData();
            formData.set('action', action);
            axios({
                baseURL: 'http://localhost:8080',
                url: `/games/${this.props.activeTable}/${this.props.currentUser.id}/action`,
                method: 'POST',
                data: formData,
            });
        }
    }

Note: make sure to replace the values of PUSHER_KEY and PUSHER_CLUSTER with the values obtained from creating your Pusher Channels application instance.

Note: in several places we refer to http://localhost:8080. This is the address our backend server is running on, and will need to be updated to point to the live one.

This renders the croupier, all of the players seats and allows for the current user to perform the appropriate actions as needed.

Now we can render the actual table, including the game board, list of players and the chat window. This goes in src/Table.js as follows:

    // src/Table.js
    import React from 'react';
    import { Grid, List, Header, Label, Form, Input } from 'semantic-ui-react';
    import Game from './Game';
    export default class Table extends React.Component {
        state = {
            users: [],
            messages: [],
            newMessage: "",
        };
        constructor(props) {
            super(props);
            props.currentUser.subscribeToRoom({
                roomId: props.activeTable,
                messageLimit: 100,
                hooks: {
                    onUserJoined: () => {
                        this._updateUsers();
                    },
                    onUserLeft: () => {
                        this._updateUsers();
                    },
                    onNewMessage: (message) => {
                        const messages = this.state.messages;
                        console.log(message);
                        messages.unshift({
                            id: message.id,
                            user: message.senderId,
                            message: message.text
                        });
                        this.setState({
                            messages: messages
                        });
                    }
                }
            }).then(() => this._updateUsers());
        }
        _updateUsers() {
            const currentRoom = this.props.currentUser.rooms.find(room => room.id === this.props.activeTable);
            this.setState({
                users: currentRoom.users
            });
        }
        _handleNewMessageChange(e) {
            this.setState({
                newMessage: e.target.value
            });
        }
        _handleSubmit() {
            const { newMessage } = this.state;
            const { currentUser, activeTable } = this.props;
            currentUser.sendMessage({
                text: newMessage,
                roomId: activeTable
            });
            this.setState({
                newMessage: ''
            });
        }
        render() {
            const users = this.state.users.map((user) => (
                <List.Item key={user.id}>
                    <List.Content>
                        {user.name}
                    </List.Content>
                </List.Item>
            ));
            const messages = this.state.messages
                .map((message) => {
                    const user = this.state.users.find(user => user.id === message.user) || {};
                    return (
                        <List.Item key={message.id}>
                            <List.Content>
                                <Label ribbon>{ user.name || message.user }</Label>
                                { message.message }
                            </List.Content>
                        </List.Item>
                    );
                });
            return (
                <Grid>
                    <Grid.Row>
                        <Grid.Column width="12">
                            <Game activeTable={this.props.activeTable} currentUser={this.props.currentUser} />
                        </Grid.Column>
                        <Grid.Column width="4">
                            <Header>Players</Header>
                            <List>{users}</List>
                        </Grid.Column>
                    </Grid.Row>
                    <Grid.Row>
                        <Grid.Column width="16">
                            <List style={{height: '20em', overflow: 'auto'}}>
                                { messages }
                            </List>
                        </Grid.Column>
                    </Grid.Row>
                    <Grid.Row>
                        <Grid.Column width={16}>
                            <Form onSubmit={this._handleSubmit.bind(this)}>
                                <Input action='Post'
                                       placeholder='New Message...'
                                       value={this.state.newMessage}
                                       fluid
                                       autoFocus
                                       onChange={this._handleNewMessageChange.bind(this)} />
                            </Form>
                        </Grid.Column>
                    </Grid.Row>
                </Grid>
            );
        }
    }

Next we want a component to show the overall game structure. This includes the list of tables and the active table, and goes in src/Tables.js as follows:

    // src/Tables.js
    import React from 'react';
    import { Container, Grid, Header, Segment} from 'semantic-ui-react';
    import { TokenProvider, ChatManager } from '@pusher/chatkit';
    import TableList from './TableList';
    import Table from './Table';
    export default class Tables extends React.Component {
      state = {
        joined: [],
        joinable: [],
      };
      _onJoinTable = this._handleJoinTable.bind(this);
      constructor(props) {
        super(props);
        this.chatManager = new ChatManager({
            instanceLocator: 'CHATKIT_INSTANCE_LOCATOR',
            tokenProvider: new TokenProvider({
                url: "http://localhost:8080/chatkit/auth",
            }),
            userId: props.username
        });
        this.chatManager.connect().then(currentUser => {
            this.setState({
                currentUser: currentUser
            });
            setInterval(this._pollRooms.bind(this), 5000);
            this._pollRooms();
        }).catch((e) => {
            console.log('Failed to connect to Chatkit');
            console.log(e);
        });
      }
      _pollRooms() {
        const { currentUser } = this.state;
        return currentUser.getJoinableRooms()
            .then((rooms) => {
                this.setState({
                    joined: currentUser.rooms,
                    joinable: rooms
                })
            });
      }
      _handleJoinTable(id) {
        const { currentUser } = this.state;
        currentUser.joinRoom({roomId: id})
            .then(() => this._pollRooms())
            .then(() => {
                this.setState({
                    activeTable: id
                });
            });
      }
      render() {
        const { currentUser, activeTable } = this.state;
        return (
            <Container>
                <Segment padded>
                <Grid divided>
                    <Grid.Row>
                    <Grid.Column width="4">
                        <Header>Tables</Header>
                        <TableList joined={this.state.joined}
                                   joinable={this.state.joinable}
                                   currentTable={activeTable}
                                   onJoinTable={this._onJoinTable} />
                    </Grid.Column>
                    <Grid.Column width="12">
                        { activeTable && <Table key={activeTable} currentUser={currentUser} activeTable={activeTable} /> }
                    </Grid.Column>
                    </Grid.Row>
                </Grid>
                </Segment>
            </Container>
        );
      }
    }

Note: make sure to replace the values of CHATKIT_INSTANCE_LOCATOR with the value obtained from creating your Pusher Chatkit application instance.

Note: we refer to http://localhost:8080. This is the address our backend server is running on, and will need to be updated to point to the live one.

This also handles the connection to Chatkit, and provides the current user and the lists of tables to the child components.

Finally we need the actual core application container to be updates. Firstly we need to update the existing src/App.js as follows:

    // src/App.js
    import React from 'react';
    import 'semantic-ui-css/semantic.min.css';
    import { Container } from 'semantic-ui-react';
    import Login from './Login';
    import Tables from './Tables';
    class App extends React.Component {
      state = {};
      render() {
        let contents;
        if (this.state.username) {
          contents = <Tables username={this.state.username} />
        } else {
          contents = <Login login={this.enterGame.bind(this)} />
        }
        return (
          <Container>
            { contents }
          </Container>
        );
      }
      enterGame(username) {
        this.setState({
          username: username
        });
      }
    }
    export default App;

This simply renders either the Login or the Tables component as needed.

And then the existing src/index.js as follows:

    // src/index.js
    import React from 'react';
    import ReactDOM from 'react-dom';
    import 'semantic-ui-css/semantic.min.css';
    import App from './App';
    ReactDOM.render(<App />, document.getElementById('root'));

This ensures that the Semantic UI CSS is pulled in to the application.

At this point, our UI is ready to use, and can be started with npm start. Ensure that the backend is running as well and we’re ready to go.

Conclusion

This tutorial shows how you can use the power of Go and React to create an entertaining online game, including chat between players as well as the game itself talking back to the user.

The source code for this example is available on GitHub. Why not try extending it to add more complicated rules?

Clone the project repository
  • Chat
  • Gaming
  • Go
  • JavaScript
  • React
  • Social Interactions
  • Social
  • Channels
  • Chatkit

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.