July 12, 2015

React.js for incremental text games

I have a small weakness for silly text games like Candy Box and A Dark Room, and most recently Kittens Game (“a Dark Souls of incremental gaming”). The common feature of these games is that, for the most part, the UI is just numbers on a web page, and you perform certain actions to increase those numbers to purchase upgrades that further your ability to increase the numbers.

One of the great things about this style of game is that the barrier to entry is very low (which is probably why there are approximately a brazilian of these things around), and the games in the genre are differentiated almost solely by the quality of their mechanics (without silly things like “art” to get in the way). You, the reader, might even be interested in dipping your toe in the world of incremental games. If you want to add one to the pile, I recommend you use React to do it.

Why React for this?

Much is made of React being all fast and stuff, but this isn’t going to be a major obstacle for any modern Javascript framework, since there’s likely not much to render. The standout feature for this purpose lies in React’s philosophy of automatically re-rendering based on some root state. The state in text-based resource management games can be really, really easily represented as an object, and not having to worry about updating the UI yourself simplifies matters a lot.

Here, I’ll show you.

Our silly game.

Let’s lay out our text game. The formula should be pretty familiar:

  • You can click a button to get 1 mud
  • With 10 mud, you can make a brick
  • With 100 bricks, you can make a shack, which produces mud over time
  • With 1000 bricks, you can make a mansion, which produces mud over time at a more favorable rate
  • With 500 bricks, you can make a brickyard, which reduce the cost of shacks and mansions by 5% each

I suspect most of these games start with a short list like this and then expand from there, but that’s enough for demonstration purposes. You can see how it turned out here. So how does this translate into React?

A wee code sample

My preferred wrapper around React is Om. I like Reagent too, but om cursors can come in handy in larger apps. This is not one of those, but let’s use om anyhow.

Yeah, yeah, I know that Clojurescript ain’t Javascript, but stick with me for a bit. React’s root-app-state paradigm meshes very well with Clojurescript’s immutable data types, and since you’re likely running some preprocessor workflow to get your JSX on anyhow, why not make it this one.

Anyhow, Om is all about having a single, global app state, and re-rendering the UI based on it. Here’s the app state I started with:

(defonce app-state (atom {:resources {:mud 0
                                      :bricks 0}
                          :buildings {:shacks 0
                                      :mansions 0
                                      :brickyards 0}
                          :flags #{}}))

Our game mechanics are simple: shacks and mansions add to our per-second mud count, and brickyards discount the purchase of those two.

We represent the automated digging of mud (and hopefully more, eventually) in a central event loop:

(defn tick [data]
  (-> data
      (update-in [:resources :mud] + (* (-> data :buildings :shacks) 0.01)
                                     (* (-> data :buildings :mansions ) 0.12))))

(js/setInterval #(swap! app-state tick) 20)

All we have to do is increase the mud count. Simple!

Skipping to the end, here’s the component that renders the UI. I used sablono to allow for a hiccup-like syntax over Om. If you have no idea what that meant, just pay attention to the nesting of [vectors], which represent html elements and their contents.

(defn widget [data]
  (om/component
   (html
     [:div.container
      [:ul.resources
       [:li "Mud: " (-> data :resources :mud)]
       [:li "Bricks: " (-> data :resources :bricks)]
       ]
      [:ul.buildings
       [:li "Shacks: " (-> data :buildings :shacks)]
       [:li "Mansions: " (-> data :buildings :mansions)]
       [:li "Brickyards: " (-> data :buildings :brickyards)]
       ]
      [:div.controls
       (action-button data (fn [data] true) pick-mud! "Dig Mud" :mud)
       [:br]
       (purchase-button data [:resources :mud] :mud->brick [:resources :bricks] 1 "Make Brick" :1b)
       (purchase-button data [:resources :mud] :mud->brick [:resources :bricks] 10 "10" :10b)
       (purchase-button data [:resources :mud] :mud->brick [:resources :bricks] 100 "100" :100b)
       (purchase-button data [:resources :mud] :mud->brick [:resources :bricks] 1000 "1000" :1000b)
       [:br]
       (purchase-button data [:resources :bricks] :bricks->shack [:buildings :shacks] 1 "Build Shack" :1s)
       (purchase-button data [:resources :bricks] :bricks->shack [:buildings :shacks] 10 "10" :10s)
       (purchase-button data [:resources :bricks] :bricks->shack [:buildings :shacks] 100 "100" :100s)
       [:br]
       (purchase-button data [:resources :bricks] :bricks->mansion [:buildings :mansions] 1 "Build Mansion" :1m)
       (purchase-button data [:resources :bricks] :bricks->mansion [:buildings :mansions] 10 "10" :10m)
       (purchase-button data [:resources :bricks] :bricks->mansion [:buildings :mansions] 100 "100" :100m)
       [:br]
       (purchase-button data [:resources :bricks] :bricks->brickyard [:buildings :brickyards] 1 "Build Brickyard" :1by)
       (purchase-button data [:resources :bricks] :bricks->brickyard [:buildings :brickyards] 10 "10" :10by)
       (purchase-button data [:resources :bricks] :bricks->brickyard [:buildings :brickyards] 100 "100" :100by)
       ]
      ])))

(om/root widget app-state {:target js/document.body})

There, that wasn’t so bad.

This widget will be re-rendered every time the incoming data changes (so, once per tick plus whenever someone clicks a button). Since it’s mounted as the root component, it receives the entire app-state, and updates when the app state changes. For a larger state map, we might consider using cursors to separate out things that change every tick from things that don’t, but it’s honestly no big deal.

Data-binding flows entirely downwards, so when we need to change the app state, we use a special function, om/transact!, which changes the app state and triggers a re-render. Like React, Om has that nice virtual dom in place, so re-rendering is much less costly than it sounds.

The purchase-button helper is just a function that we use to deduct from the mud count and add to the proper building count. We pass some keys to look up the correct numbers for this. Here are all the helpers associated with costs and purchasing:

(def cost {:mud->brick (fn [_] 10)
           :bricks->shack (fn [data] (* 100 (js/Math.pow 0.95 (-> data :buildings :brickyards))))
           :bricks->mansion (fn [data] (* 1000 (js/Math.pow 0.95 (-> data :buildings :brickyards))))
           :bricks->brickyard (fn [_] 500)})

(defn check-cost [selector cost-key n]
  (fn [data]
    (>= (get-in data selector) (* n ((cost cost-key) data)))))


; Purchasing

(defn pick-mud! [data]
  (update-in data [:resources :mud] inc))

(defn buy-item! [data in-selector cost-key out-selector n]
  (-> data
      (update-in in-selector - (* n ((cost cost-key) data)))
      (update-in out-selector + n)))

(defn action-button [data enabled-fn? action-fn! button-text id]
  (let [enabled? (enabled-fn? data)
        previously-enabled? (-> data :flags id)
        ]
    (when (and enabled? (not previously-enabled?))
      (om/transact! data #(update-in % [:flags] conj id)))
    (html/submit-button {:style (if-not (or enabled? previously-enabled?) {:display "none"} {})
                         :disabled (if-not enabled? "disabled")
                         :on-click (fn [_] (om/transact! data action-fn!))}
                        button-text)))

(defn purchase-button [data in-selector cost-key out-selector n text id]
  (action-button data
                 (check-cost in-selector cost-key n)
                 #(buy-item! % in-selector cost-key out-selector n)
                 text id))

Ok, so, if you’re not comfortable with lisp syntax, this is the part that’s going to look like nonsense to you. To save you the trouble, here are the cliff’s notes for each function.

  • pick-mud! is a one-off function that just increments our mud count.
  • buy-item! is a function that accepts the state map and some selectors, removes cost of the resource in and adds n of the resource out. You can see the costs in the cost map near the top, along with the check-cost function.
  • action-button takes care of rendering a button with a certain action. It also accepts a test to see if the action is available (a handly place for a cost function), and make sure to hide any buttons that have never been enabled (that’s what the :flags set is for).
  • purchase-button just ties action-button to buy-item!, taking care of the common bits.

That’s it! Those 100-or-so lines of code compile (with dependencies) into a javascript file that can more-or-less be dropped onto any page to transform it into an exciting mud-digging simulator. Of course, if you were more into this sort of thing than I apparently am, you probably would have gotten farther into the mechanics before getting bored and blogging about it, so I hope I’ve inspired you to do so (if you’re into that sort of thing)!

Once again, you can see how it turned out at http://adambard.github.io/silly-mud-game/. Good times.

A special message

This is where the affiliate links live, but hear me out! I use these two services every day, and I wouldn't recommend them if I wasn't satisfied.

DigitalOcean - Purveyors of fine (and inexpensive) virtual servers. I use DigitalOcean to host Address Bin and a few others; it's my go-to host. Use this referral link for a $10 credit.

AirBnb - I've been living in AirBnbs for over a year now, and plan to for many more. If you've ever wanted to try them out, you can get a $25 discount from this referral link.