Multiplayer blackjack game using Tableau JS API



(this space intentionally left almost blank)

This namespace contains the game specific, non-tableau related functions required. It's designed for two players (at the moment) plus a dealer. The namespace has no external dependency: it can run on server or client side as required. The namespaced methods are invoked from the server namespace, from ring handlers.

Majority of these functions are fork from:

(ns data15-blackjack.blackjack
  (:require [data15-blackjack.utils :refer [other-player keywordize]]))

game is the main state container atom. It contains:

  • Deck and discard pile
  • Cards in hand (for players & dealer)
  • Status of hands (actual score & status info like ok, bust, blackjack)
  • Feedback (text status message)
(defonce game (atom {:deck             (into [] (shuffle (range 0 52)))
                 :dealer-hand      []
                 :dealer-status    nil
                 :player1-hand     []
                 :player1-name     nil
                 :player2-hand     []
                 :player2-name     nil
                 :player1-status   [0 :na]
                 :player2-status   [0 :na]
                 :player2-feeback  }))

Set player name and ensure that one player has only one user id

(defn set-player-name!
  [player name]
  (when (= (get @game (keywordize (other-player player) :name)) name)
    ;; Log out other player if the user-id is the same
    (swap! game assoc
           (keywordize (other-player player) :name) nil))
  (swap! game assoc
         (keywordize player :name) name))

Game Logic

Deal one card from the deck to a hand in the given position (face up or face down), possibly using the discard pile. Return a vector of the remaining deck, new hand, and discard pile

(defn deal
  [[deck hand discard-pile] position]
  (let [new-deck (if (empty? deck) (shuffle discard-pile) deck)
        new-discard (if (empty? deck) [] discard-pile)
        card [(first new-deck) position]]
    [(rest new-deck) (conj hand card) new-discard]))

Discard contents of a hand onto a pile; just the card number, not the up/down position. Return an empty hand and the new discard pile.

(defn discard
  [[hand pile]]
  [[] (vec (reduce (fn [acc x] (conj acc (first x))) pile hand))])

Helper function to total a hand. The accumulator is a vector giving the current total of the hand and the current value of an ace (1 or 11)

(defn accumulate-value
  [[acc ace-value] card]
  (let [card-mod (inc (mod (first card) 13))
        card-value (if (= card-mod 1)
                     (min 10 card-mod))]
    [(+ acc card-value) (if (= card-mod 1) 1 ace-value)]))

Get total value of hand. Return a vector with total and the status (:ok, :blackjack, :bust, :stand)

(defn evaluate-hand
  (let [[pre-total ace-value] (reduce accumulate-value [0 11] hand)
        total (if (and (> pre-total 21) (= ace-value 1)) (- pre-total 10) pre-total)]
    (vec [total (cond
                  (and (= total 21) (= (count hand) 2)) :blackjack
                  (<= total 21) :ok
                  :else :bust)])))

Given players hand and dealer hand, return true if dealer has blackjack, players have blackjack, false otherwise.

(defn immediate-win
  [dealer-hand player1-hand player2-hand]
  (let [[p1total _] (evaluate-hand player1-hand)
        [p2total _] (evaluate-hand player2-hand)
        [dtotal _] (evaluate-hand dealer-hand)]
    (or (and (= p1total 21) (= p2total 21)) (= dtotal 21))))

This function takes a player's hand and returns a new hand with all the cards in the :up position

(defn reveal
  (vec (map (fn [card] [(first card) :up]) hand)))

Store personalized feedback message, practically the result from end-game.

(defn feedback
  [player message]
  (swap! game assoc (keywordize player :feedback) message))

Evaluate the dealer's and player's hands when the game has ended.

(defn end-game
  [dealer-hand player-hand player]
  (let [[ptotal pstatus] (evaluate-hand player-hand)
        [dtotal dstatus] (evaluate-hand dealer-hand)]
      (> ptotal 21) (feedback player "Sorry, you are busted.")
      (> dtotal 21) (feedback player "Dealer goes bust. You win!")
      (= ptotal dtotal) (feedback player "Tie game.")
      (= pstatus :blackjack) (feedback player "You win with blackjack!")
      (= dstatus :blackjack) (feedback player "Dealer has blackjack.")
      (< ptotal dtotal) (feedback player "Dealer wins.")
      (> ptotal dtotal) (feedback player "You win!")
      :else (feedback player "Unknown result (Shouldn't happen.)"))))

Deal two cards to the player (both face up), and two to the dealer (one face down and one face up). Update the game atom, and check for an immediate win.

(defn start-game
  (let [{:keys [deck discard-pile dealer-hand player1-hand player2-hand]} @game
        [player1-1 pile0] (discard [player1-hand discard-pile])
        [player2-1 pile1] (discard [player2-hand pile0])
        [dealer1 pile2] (discard [dealer-hand pile1])
        [deck2 dealer2 pile3] (deal (deal [deck dealer1 pile2] :up) :down)
        [deck3 player1-2 pile4] (deal (deal [deck2 player1-1 pile3] :up) :up)
        [deck4 player2-2 pile-after-deal] (deal (deal [deck3 player2-1 pile4] :up) :up)
    (swap! game assoc :playing true :discard-pile pile-after-deal :player1-hand player1-2
           :player2-hand player2-2 :dealer-hand dealer2 :deck deck4
           :player1-feedback "" :player2-feedback ""
           :player1-status (evaluate-hand player1-2)
           :player2-status (evaluate-hand player2-2)
           :dealer-status nil)
    (if (immediate-win dealer2 player1-2 player2-2)
        (swap! game assoc :dealer-hand (reveal dealer2))
        (end-game dealer2 player1-2 :player1)
        (end-game dealer2 player2-2 :player2)))))

Deal a card face up to the player, and evaluate the hand. If the player went bust, end the game.

(defn hit-me
  (let [{:keys [deck discard-pile dealer-hand]} @game
        player-hand (@game (keywordize player :hand))
        [deck2 player2 discard2] (deal [deck player-hand discard-pile] :up)
        [total status] (evaluate-hand player2)]
    (swap! game assoc (keywordize player :hand) player2 (keywordize player :status) [total status]
           :deck deck2 :discard-pile discard2)
    (if (= status :bust)
      (end-game dealer-hand player2 player))))

Player is satisfied with hand. Reveal the dealer's hand, then deal cards one at a time until the dealer has to stand or goes bust.

(defn stand
  [role num-players]
  (swap! game assoc-in [(keywordize role :status) 2] :stand)
  (if (and (= num-players 2) (not= (get-in @game
                                           [(keywordize (other-player role) :status) 2])
    ; Other player is still playing
    (swap! game assoc (keywordize role :feedback) "Waiting for other player to stand.")
    ; We are done
    (let [{:keys [deck dealer-hand player1-hand player2-hand discard-pile]} @game
          dhand (reveal dealer-hand)]
      (swap! game assoc :dealer-hand dhand)
      (loop [loop-deck deck
             loop-hand dhand
             loop-pile discard-pile]
        (let [[total status] (evaluate-hand loop-hand)]
          (if (or (= status :bust) (>= total 17))
              (swap! game assoc :dealer-hand loop-hand :dealer-status [total status])
              (end-game loop-hand player1-hand :player1)
              (end-game loop-hand player2-hand :player2))
            (let [[new-deck new-hand new-discard] (deal [loop-deck loop-hand loop-pile] :up)]
              (swap! game assoc :dealer-hand new-hand)
              (recur new-deck new-hand new-discard))))))))

Web Server (Ring) functions

This namespace manage the web server functionalities including request dispatch, session management, websocket/ajax calls, logon in addition to blackjack related functionalities.

The namespace uses http-kit as web server, compojure as request routing engine, hiccup for generating html code, sente for async websocket messaging plus the basing ring middlewares.

(ns data15-blackjack.server
    [ring.middleware.resource :as resources]
    [clojure.string :as str]
    [compojure.core :as comp :refer (defroutes GET POST)]
    [compojure.route :as route]
    [hiccup.core :as hiccup]
    [ :refer (include-js include-css)]
    [clojure.core.async :as async :refer (<! <!! >! >!! put! chan go go-loop)]
    [taoensso.encore :as encore :refer ()]
    [taoensso.timbre :as timbre :refer (tracef debugf infof warnf errorf)]
    [taoensso.sente :as sente]
    [org.httpkit.server :as http-kit]
    [taoensso.sente.server-adapters.http-kit :refer (sente-web-server-adapter)]
    [data15-blackjack.blackjack :as blackjack])

Logging config. The more the better.

(sente/set-logging-level! :trace)

Start our fancy http-kit web server. Port is 3000 by default. This web server will serve our html pages, provide logon and session management and manage web socket based async channels.

(defn start-web-server!*
  [ring-handler port]
  (println "Starting http-kit...")
  (let [http-kit-stop-fn (http-kit/run-server ring-handler {:port port})]
    {:server  nil                                           ; http-kit doesn't expose this
     :port    (:local-port (meta http-kit-stop-fn))
     :stop-fn (fn [] (http-kit-stop-fn :timeout 100))}))

Server-side setup

Register async channels used for the communication. The directly used defs are:

  • ch-chsk - Receive channel
  • chsk-send! - Send API function
  • connected-uids - Watchable, readonly atom. This store all client ids. We use this structure to send broadcast messages to all clients.
(let [{:keys [ch-recv send-fn ajax-post-fn ajax-get-or-ws-handshake-fn
      (sente/make-channel-socket! sente-web-server-adapter {:packer :edn})]
  (def ring-ajax-post ajax-post-fn)
  (def ring-ajax-get-or-ws-handshake ajax-get-or-ws-handshake-fn)
  (def ch-chsk ch-recv)                                     ; ChannelSocket's receive channel
  (def chsk-send! send-fn)                                  ; ChannelSocket's send API fn
  (def connected-uids connected-uids))                      ; Watchable, read-only atom

Number of identified users connected

(defn number-of-connected-users
  (count (filter #(not= :taoensso.sente/nil-uid %) (:any @connected-uids))))

Ring Handlers

Login methods sets the user name. No real login, just associate the username with the websocket. Later on this user-id will be used to send messages to one client only

(defn login!
  (let [{:keys [session params]} ring-request
        {:keys [role user-id]} params]
    (debugf "Login request: %s" params)
    (blackjack/set-player-name! role user-id)
    {:status 200 :session (assoc session :role role :uid user-id)}))

Langing page containing the tableau JS API vizardry. This is the main HTML page serving requests hitting the / URL. HTML is generated thru hiccup library

(defn landing-pg-handler
     (include-js "")
     (include-css "css/page.css")]
     [:h2 "Set user user-id"]
     [:p [:input#input-login {:type :text :placeholder "Enter your name:"}]
      [:button#btn-player1 {:class "login-button" :type "button"} "I'm Player 1"]
      [:button#btn-player2 {:class "login-button" :type "button"} "I'm Player 2"]]]
     [:button#btn-hit {:class "game-button" :type "button"} "hit"]
     [:button#btn-stand {:class "game-button" :type "button"} "stand"]
     [:button#btn-reset {:class "game-button" :type "button"} "new"]]
    [:script {:src "js/client.js"}]))                       ; Include our cljs target

Basic endpoints:

       * `/`      langing page,
       * `/chsh`  sente channels,
       * `/login` to set user name
       * plus the usual static resources
(defroutes my-routes
           (GET "/" req (landing-pg-handler req))
           (GET "/chsk" req (ring-ajax-get-or-ws-handshake req))
           (POST "/chsk" req (ring-ajax-post req))
           (POST "/login" req (login! req))
           (route/resources "/")                            ; Static files, notably public/js/client.js (our cljs target)
           (route/not-found (hiccup/html
                              [:h1 "Invalid URL"])))

The ring handler is reponsible to start ring web server, setup routing and session management default

(def my-ring-handler
  (let [ring-defaults-config
        (assoc-in ring.middleware.defaults/site-defaults [:security :anti-forgery]
                  {:read-token (fn [req] (-> req :params :csrf-token))})]
    (ring.middleware.defaults/wrap-defaults my-routes ring-defaults-config)))

Routing handlers

Send message to all connected, authenticated users

(defn server->all-users!
  (doseq [uid (:any @connected-uids)]
    (when-not (= uid :sente/nil-uid)
      (chsk-send! uid message))))

Broadcast state: send blackjack game's state to connected users

(defn broadcast-state!
  (server->all-users! [:data15-blackjack/broadcast-state @blackjack/game]))

Define multifunction to dispach messages based on event-ids

(defmulti event-msg-handler

Log every incoming message and dispatch them.

(defn event-msg-handler*
  [{:as ev-msg :keys [id ?data event]}]
  (debugf "Event: %s" event)
  (event-msg-handler ev-msg))

default event handler: log (and optionally reply) for each unidentified event message

(defmethod event-msg-handler :default
  [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}]
  (let [session (:session ring-req)
        uid (:uid session)]
    (debugf "Unhandled event: %s" event)
    (when ?reply-fn
      (?reply-fn {:umatched-event-as-echoed-from-from-server event}))))

This is out main event, the click. The message contains the button name triggered on client side (or load which simply request the current state). The blackjack engine will be invoked after each message with the necessary additional parameters including player's role and number of connected users.

Finally we call broadcast-state! to tell the actual game state to the clients.

(defmethod event-msg-handler :data15-blackjack/click
  [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}]
  (let [{:keys [uid role]} (:session ring-req)]
    (debugf "Initalize request: %s uid: %s data: %s" event uid ?data)
    (condp = ?data
      "load" nil
      "reset" (blackjack/start-game)
      "stand" (blackjack/stand role (number-of-connected-users))
      "hit" (blackjack/hit-me role))

Init web server

Web server atom to store server information in

{:server _ :port _ :stop-fn (fn [])}


(defonce web-server_
         (atom nil))                                        ;

Invoke server's stop function.

(defn stop-web-server!
  [] (when-let [m @web-server_] ((:stop-fn m))))

Start web server on port. Stop first, if it's already running. Store the server info in web-server_ atom.

(defn start-web-server!
  [& [port]]
  (let [{:keys [stop-fn port] :as server-map}
        (start-web-server!* (var my-ring-handler)
                            (or port 3000))
        uri (format "http://localhost:%s/" port)]
    (debugf "Web server is running at `%s`" uri)
    (reset! web-server_ server-map)))

Atom to store message router state.

(defonce router_ (atom nil))

Stop router if we aware of any router stopper callback function.

(defn stop-router!
  [] (when-let [stop-f @router_] (stop-f)))

Stop and start router while storing the router stop-function in router_ atom.

(defn start-router!
  (reset! router_ (sente/start-chsk-router! ch-chsk event-msg-handler*)))

Start message web socket async message router and web server

(defn start!

start things from REPL immediately


In case you start the server from command line here is a nice main function

(defn -main
  [& args] (start!))

Client-side browser functions

This namespace responsible for the client side code running in the browser side. This includes the websocket/ajax communication channel management, sending, receiving and dispatching messages.

(ns data15_blackjack.client
    [data15-blackjack.tableau :as tableau]
    [clojure.string :as str]
    [cljs.core.async :as async :refer (<! >! put! chan)]
    [dommy.core :as dommy :refer-macros [sel sel1]]
    [taoensso.encore :as encore :refer ()]
    [taoensso.timbre :as timbre :refer-macros (tracef debugf infof warnf errorf)]
    [taoensso.sente :as sente :refer (cb-success?)])
    [cljs.core.async.macros :as asyncm :refer (go go-loop)]))

Logging config. We do love logs, so level is :trace

(sente/set-logging-level! :trace)

Client-side setup

Create a new sente socket (websocket if possible, ajax as a fallback option). The location handler on server side is /chsk, packer type (serialization) is edn based (which is like json for clojure). If the socket is ready, bind async channels and a watchable atom with the socket state.

(debugf "ClojureScript appears to have loaded correctly.")
(let [{:keys [chsk ch-recv send-fn state]}
      (sente/make-channel-socket! "/chsk"
                                  ; Note the same URL as before
                                  {:type   :auto
                                   :packer :edn})]
  (def chsk chsk)
  (def ch-chsk ch-recv)                                     ; ChannelSocket's receive channel
  (def chsk-send! send-fn)                                  ; ChannelSocket's send API fn
  (def chsk-state state)                                    ; Watchable, read-only atom)

Convert a ClickEvent object to string while removing the btn- prefix

Client-side logic and helpers

(defn- unbuttonize
  (-> (.-toElement click-event)
      (clojure.string/replace "btn-" "")))

Setup username and player role. Called from login buttons, thus, event is ClickEvent object. Basically:

  • Check user supplied name is blank? If yes, show an alert
  • Otherwise /login with user-id (textbox content) and role (player1 or player2) based on the pressed button.
  • Re-establish sente socket - with the new user-id
(defn login-event-handler
  (debugf "setting username to user-id")
  (let [user-id (dommy/value (sel1 :#input-login))]
    (if (str/blank? user-id)
      (js/alert "Please enter user name first")
        (debugf "Logging in with user-id %s" user-id)
        (sente/ajax-call "/login"
                         {:method :post
                          :params {:user-id    (str user-id)
                                   :role       (unbuttonize event) ; button name
                                   :csrf-token (:csrf-token @chsk-state)}}
                         (fn [ajax-resp]
                           (debugf "Ajax login response: %s" ajax-resp)
                           (sente/chsk-reconnect! chsk)))))))

Client-side UI

Call login-event-handler on login button click

(doseq [button (sel :.login-button)]
  (dommy/listen! button :click login-event-handler))

Send new/hit/stand events to the server. The message payload is simply the button's name without the btn- prefix

(doseq [button (sel]
  (dommy/listen! button :click
                 (fn [e] (chsk-send! [:data15-blackjack/click (unbuttonize e)]))))

Toggle buttons in game-button class based on show? param

(defn show-game-buttons!
  (doseq [button (sel]
    (dommy/toggle! button show?)))

Toggle tableau viz based on show? param

(defn show-tableau-viz!
  (dommy/toggle! (sel1 :div#tableau-viz) show?))

Toggle login box based on show? parameter

(defn show-login!
  (dommy/toggle! (sel1 :div#div-login) show?))

Message handlers

Watch for :viz-ready status in tableau/viz atom. If the viz status have changed and the new status is :viz-ready, then send load message to server.

(add-watch tableau/viz :ui
           (fn [_ _ _ new-state]
             (when (= :viz-ready (new-state :status))
               (chsk-send! [:data15-blackjack/click " load "]))))

Define new multi-function which will dispatch on event-id

(defmulti event-msg-handler :id)

Wrap for logging and dispatching. We will use a defmulti to deliver message based on event

(defn event-msg-handler* [{:as ev-msg :keys [id ?data event]}]
  (debugf " Event: % s " event)
  (event-msg-handler ev-msg))

Just log unhandled events (like :check-state, :ws-ping).

(defmethod event-msg-handler :default
  [{:as ev-msg :keys [event]}]
  (debugf " Unhandled event: % s " event))

We just received a new push notification from the server. If we already authenticated, update the tableau visualization with tableau/update-tableau.

(defmethod event-msg-handler :chsk/recv
  [{:as ev-msg :keys [?data]}]
  (when-not (= (get @chsk-state :uid) :sente/nil-uid)
    (tableau/update-tableau (get @chsk-state :uid) (second ?data)))
  (debugf " Push event from server: % s " ?data))

The handshare is the first callback from server side after establishing connection. Change visible div's according to user-id: if the user-id is nil-uid we should hide the viz and show the login.

(defmethod event-msg-handler :chsk/handshake
  [{:as ev-msg :keys [?data]}]
  (let [[?uid] ?data]
    (debugf " Handshake: % s " ?data)
    (let [logged-in? (not= ?uid :taoensso.sente/nil-uid)]
      (show-game-buttons! logged-in?)
      (show-tableau-viz! logged-in?)
      (show-login! (not logged-in?)))))

Atom to store stop function for stopping the router

Router Initalization

Stop the message router by calling the previously saved stop function

(def router_  (atom nil))
(defn stop-router!
  [] (when-let [stop-f @router_] (stop-f)))
(defn start-router! []
  " Stop router first, then start and save the result (which is a stop callback)
in `router_ `. "
  (reset! router_ (sente/start-chsk-router! ch-chsk event-msg-handler*)))

Start router by default, on-load


Tableau related functions

This namespace loads and changes the tableau visualisation.

(ns data15-blackjack.tableau
    [data15-blackjack.utils :refer [other-player keywordize select-values]]
    [dommy.core :refer-macros [sel sel1]]))

Initializing Viz

Map (atom) to store tableau viz load status & object reference

(def viz
  (atom {:status :not-loaded :vizobj nil}))

The public URL of the tableau viz.

(def viz-url

Tableau viz's div element. This is where the visualization will go

(def placeholder-div
  (sel1 :div#tableau-viz))

Javascript object to interface with Viz constructor. Hide tabs & toolbars. After initalizing the Viz change :status to :viz-ready in our viz atom. The clint UI watch this atom: when the status change the dom node will be visible to the gamer.

(def viz-options
    "hideTabs" true
    "hideToolbar" true
    "onFirstInteractive" #(swap! viz assoc :status :viz-ready)))

Initialize our visualization with viz-options and store it in viz atom. This swap! will trigger the watcher defined in client.cljs to show the dom element.

(swap! viz assoc :status :initalize :vizobj
       (js/tableau.Viz. placeholder-div viz-url viz-options))

Interaction with Viz

Get the Workbook object from the previously created viz.

(defn workbook
  [] (.getWorkbook (get @viz :vizobj)))

Get the Sheet object from the active sheet. Active sheet must be a Dashboard

(defn get-sheet-in-active-sheet
  (-> (workbook)
      (.get sheet)))

Filter supplied values for key on sheet. Filter change is asynchronous. Values can be CLJS or JS values and sequences.

(defn filter-update
  [sheet key values]
  (-> (get-sheet-in-active-sheet sheet)
      (.applyFilterAsync key (clj->js values) js/tableau.FilterUpdateType.REPLACE)))

Update key parameter to value in workbook.

(defn parameter-update
  [workbook key value]
  (.changeParameterValueAsync workbook key (clj->js value)))

Card related helper functions

We show always five cards for the players. If the player has less then five cards then we are padding his hand with "empty" cards. Empty cards are the ones with ID=52. Thus, Location-ID=452 stands for empty card on fifth position.

(defn pad-with-blank-cards
  (let [num-cards (count cards)]
    (concat cards (take (- 5 num-cards) [452 352 252 152 52]))))

Show hand in sheet, padded with empty cards to show at least five of them.

(defn update-cards
  [sheet cards]
  (filter-update sheet "Location-ID" (pad-with-blank-cards cards)))

Calculate cards unique ID in tableau. Basic calculation is: 99 for all face down cards, otherwise position in hand * 100 + card id.

(defn get-cards
    (fn [idx [card pos]]
      (if (= pos :down)
        99                                                  ; face down card
        (+ (* idx 100) card)))                              ; 100 * pos in hand + card ID

Synchronize Server state with Tableau

Synchronize Tableau report with the blackjack game state broadcasted by the server after every action:

  1. First deconstruct player name and dealer hand
  2. Find out my-role and other-user: who is player1 and player2
  3. Deconstruct status, feedback, name and hand information from state
  4. Call update-cards and parameter-update with these state information

    If values are not changed no re-rendering required. Tableau keeps track the filter and parameter information client side and invokes Server only when necessary

(defn update-tableau
  [uid state]
  (let [{:keys [player1-name dealer-hand]} state
        my-role (if (= uid player1-name) :player1 :player2)
        other-user (other-player my-role)
        [other-status your-status feedback other-name your-hand]
        (select-values state
                       [(keywordize other-user "status")
                        (keywordize my-role "status")
                        (keywordize my-role "feedback")
                        (keywordize other-user "name")
                        (keywordize my-role "hand")])]
    (update-cards "Player" (get-cards your-hand))
    (update-cards "Dealer" (get-cards dealer-hand))
    (parameter-update (workbook) "other-score" (first other-status))
    (parameter-update (workbook) "your-score" (first your-status))
    (parameter-update (workbook) "feedback" feedback)
    (parameter-update (workbook) "other-name" (or other-name "Player2"))))