Edgardo Carreras | Blog

Tic-Tac-Toe - Player vs Player Network Messages

September 22, 2021


👋!!

Alright now lets get to the good stuff, lets make both peers interface with our game through our pubsub.

This is the message protocol I have in mind.

The first one to join will be the host opponent.

When playing on a board space.

{ "type": "play",
  "board-space": "<space>"
}

When a player plays we want to update the game state on each peer.

We will only accept plays from the player who has turn active.

When resetting the board.

{
 "type": "reset"
}

After a lot of refactoring here is the production code.

First our both join-game component:

(defn join-game [peer-address room-id]
  (let [network-state (atom {:node nil :my-addresses [] :peer-ids [] :opponent nil})
        on-play #(handle-play (:node @network-state) room-id %)
        on-reset #(handle-reset (:node @network-state) room-id)
        on-join #(handle-connection network-state %1 %2 %3)]
    (reagent/create-class
      {:display-name
       "join-game"
       :component-did-mount
       (fn []
         (join-room peer-address room-id on-join))
       :component-will-unmount
       (fn []
         (unsubscribe-game (:node network-state) room-id))
       :reagent-render
       (fn []
         [:div
          (cond
            (nil? (:opponent @network-state))
            [:p {:aria-label "join-room-loading"} "Joining Room..."]
            :else
            [:div "Playing as O"
             [tic-tac-toe-board
              #(%)
              {:online
               {:play     on-play
                :reset    on-reset
                :player   O
                :node     (:node @network-state)
                :room-id  room-id}}]])])})))

First our both host-game component:

(defn host-game [room-id go-back]
  (let [network-state (atom {:node nil :my-addresses [] :peer-ids [] :opponent nil})
        on-play #(handle-play (:node @network-state) room-id %)
        on-reset #(handle-reset (:node @network-state) room-id)
        on-host #(handle-connection network-state %1 %2 %3)]
    (reagent/create-class
      {:display-name
       "host-game"
       :component-did-mount
       (fn []
         (host-room room-id on-host))
       :component-will-unmount
       (fn []
         (unsubscribe-game (:node network-state) room-id))
       :reagent-render
       (fn []
         [:div {:aria-label "loading-room"}
          (cond
            (empty? (:my-addresses @network-state))
            [:p "Creating Room..."]
            (nil? (:opponent @network-state))
            [:div
             [:p "Waiting for opponent.."]
             [:p
              {:aria-label "room-id"}
              (str "Share this address with your opponent ")]
             [:span (create-join-link (first (:my-addresses @network-state)) room-id)]]
            :else
            [:div "Playing as X"
             [tic-tac-toe-board
              go-back
              {:online
               {:play    on-play
                :reset   on-reset
                :player  X
                :node    (:node @network-state)
                :room-id room-id}}]])])})))

They seem very similar we might just remove some duplication maybe with a strategy pattern.

Here is our board module:

(defn on-play [online game space]
  (if (nil? online)
    (swap! game play space)
    (if (= (:active-player @game) (:player online))
      (do
        (swap! game play space)
        ((:play online) space)))))


(defn handle-reset [online reset-game]
  (do
    (if (not (nil? online))
      ((:reset online)))
    (reset-game)))


(defn reset-game [game new-game]
  (reset! game new-game))

(defn subscribe-to-game [online game new-game]
  (if (not (nil? online))
    (subscribe-to-topic
      (:node online)
      (:room-id online)
      (fn [msg]
        (let [payload (js->clj (.parse js/JSON (. (. msg -data) (toString)) :keywordize-keys true))]
          (js/console.log "payload" payload)
          (cond
            (= (payload "type") "reset")
            (reset-game game new-game)
            (= (payload "type") "play")
            (swap! game play (payload "board-space"))
            :else
            nil))))))

(defn tic-tac-toe-board [& [on-back options]]
  (let [{:keys [first-player ai-difficulty online]} options
        new-game (create-game-factory
                   {:first-player  first-player
                    :ai-difficulty ai-difficulty})
        game (atom new-game)
        handle-play #(on-play online game %)
        subscription (subscribe-to-game online game new-game)]
    (fn []
      [:div.game
       (let [board (:board @game)
             spaces (sort (keys board))]
         [:div.board
          (for [space spaces]
            (board-space board space #(handle-play space)))
          [:div
           [player-turn @game]
           [game-over @game]
           [reset-button #(handle-reset online reset-game)]
           [play-options-menu on-back]]])])))

Here’s our network util function:

(def room-state (atom {:node nil :my-addresses [] :peer-ids [] :opponent-address nil :msg-input nil}))

(defn get-peer-ids [node topic]
  (. (. node -pubsub) (peers topic)))

(defn create-ipfs-node []
  (.create js/Ipfs
           (clj->js {:config
                     {:Addresses
                      {:Swarm
                       ["/dns4/wrtc-star1.par.dwebops.pub/tcp/443/wss/p2p-webrtc-star"
                        "/dns4/wrtc-star2.sjc.dwebops.pub/tcp/443/wss/p2p-webrtc-star"]}}
                     })))


(defn connect-to-peer [node multiaddr]
  (. (. node -swarm) (connect multiaddr)))

(defn subscribe-to-topic [node topic handle-message]
  (. (. node -pubsub) (subscribe topic handle-message)))

(defn publish-msg [node topic msg]
  (. (. node -pubsub) (publish topic msg)))

(defn create-join-link [address room-id]
  (str (.. js/window -location -href) "#join-game/" room-id "?address=" address))

(defn handle-play [node room-id space]
  (publish-msg node room-id (js/JSON.stringify (clj->js {:type "play" :board-space space }))))

(defn handle-reset [node room-id]
  (publish-msg node room-id (js/JSON.stringify (clj->js {:type "reset" }))))

(defn log-messages [msg]
  (js/console.log (. (. msg -data) (toString))))

(defn get-my-addresses [id]
  (map #(.toString %) (. id -addresses)))

(defn create-ipfs-util [topic handle-messages]
  (go
    (let [node (<p! (create-ipfs-node))
          my-addresses (get-my-addresses (<p! (.id node)))
          subscription (<p! (subscribe-to-topic node topic handle-messages))
          interval (js/setInterval #(do
                                      (go
                                        (let [peer-ids (<p! (get-peer-ids node topic))]
                                          (swap! room-state assoc :node node :my-addresses my-addresses :peer-ids peer-ids))))
                                   3000)]
      node)))

(defn connect-to-peer-form [node address]
  (fn []
    [:div
     [:label "Opponents Address"]
     [:input {:type        "text"
              :value       (:opponent-address @room-state)
              :on-change   #(swap! room-state assoc :opponent-address (-> % .-target .-value))
              :placeholder "Opponents Address"}]
     [:button {:on-click #(connect-to-peer node address)} "Connect to Opponent"]]))

(defn send-msg-form [topic]
  (fn []
    [:div
     [:label "Send Message"]
     [:input {:type        "text"
              :value       (:msg-input @room-state)
              :on-change   #(swap! room-state assoc :msg-input (-> % .-target .-value))
              :placeholder "Chat..."}]
     [:button {:on-click #(publish-msg (:node @room-state) topic (:msg-input @room-state))} "Send Message"]]))

(defn peers-list [peer-ids]
  [:ul
   (for [peer peer-ids]
     [:li {:key peer} peer])])

(defn network-utils []
  (let [topic "clean-tic-tac-toe"
        node (create-ipfs-util topic log-messages)]
    (fn []
      [:div
       [:div "My Address: "
        [:ul
         (for [address (:my-addresses @room-state)]
           [:li {:key address} address])]
        [:div "Connected Peers: "
         [peers-list (:peer-ids @room-state)]
         [:div [connect-to-peer-form node (:opponent-address @room-state)]]
         [:div [(send-msg-form topic)]]]]])))

(defn handle-connection [network-state node my-addresses peer-ids]
  (cond
    (empty? peer-ids)
    (swap! network-state assoc :node node :my-addresses my-addresses :peer-ids [] :opponent nil)
    (nil? (:opponent @network-state))
    (swap! network-state assoc :node node :my-addresses my-addresses :peer-ids peer-ids :opponent (first peer-ids))
    :else
    (swap! network-state assoc :node node :my-addresses my-addresses :peer-ids peer-ids)))


(defonce interval (atom 0))


(defn unsubscribe-game [node room-id]
  (go
    (try
      (js/clearInterval @interval)
      (<p! (.stop node))
      (<p! (. (. node -pubsub) (unsubscribe room-id))))))

(defn join-room [peer-address room-name on-join]
  (go
    (let [node (<p! (create-ipfs-node))
          peer-connection (<p! (connect-to-peer node peer-address))
          subscription (<p! (subscribe-to-topic node room-name log-messages))]
      (reset!
        interval
        (js/setInterval
          #(do
             (go
               (let [peer-ids (<p! (get-peer-ids node room-name))
                     my-addresses (get-my-addresses (<p! (.id node)))]
                 (on-join node my-addresses peer-ids))))
          2000)))))

(defn host-room [room-name on-host]
  (go
    (let [node (<p! (create-ipfs-node))]
      (reset!
        interval
        (js/setInterval
          #(do
             (go
               (let [peer-ids (<p! (get-peer-ids node room-name))
                     my-addresses (get-my-addresses (<p! (.id node)))]
                 (on-host node my-addresses peer-ids))))
          2000)))))

I feel like this should be a class. Next we’ll look on how to make this code easier to maintain, by creating the class implementing strategy pattern for creating a networked game, and mocking our network util for use of tests.

<3!


Want to hear more from me?

Signup to my newsletter!

CarrerasDev Newsletter

A free email newsletter on how to create high-performing development teams.


Written by Edgardo Carreras.

© 2023, Edgardo Carreras