Aug 30, 2022

REPL Driven Minecraft

A tale of optimization during the ClojureD Minecraft workshop

author picture
Fraser Crossman
Software Engineer

In June, JUXT attended ClojureD, the fantastic annual Clojure conference in Berlin. In the middle of the day, a number of workshops were run to teach you about a specific idea or tool, and get you working with it. A group of us decided to attend the ‘Change the (Minecraft) World with Code’ workshop run by Arne Brasseur, Ariel Alexi, and Felipe Barros. This post shows how we used what we learnt in the workshop to generate images in the game and optimized the code using Tufte.

The ‘Change the (Minecraft) World with Code’ workshop at ClojureD 2022 (Gallery

Arne showed us how to interact with the Minecraft world through the Clojure REPL! He started by showing off how we could use code to move the player around in the world and add items to their inventory. Using the expressive power of Clojure he was able to quickly start generating structures in the world with only a small set of smartly composed instructions. For his final trick, he showed us how to make chickens explode!

The Witchcraft project provides a convenient API for interacting with Bukkit-based Minecraft servers and is what was used in the workshop.

Having a go ourselves

After showing off his REPL-driven Minecraft-ing skills, Arne left it up to us to have a go and see what we could design. Once we had everything setup and installed we were able to start up the server, connect to it with the client, and jack-in to the REPL with CIDER. The workshop repo provides four namespaces which demonstrate a variety of ways to utilise Witchcraft to orchestrate the server and Minecraft world.

One of the more interesting functions provided by Witchcraft is nearest-material which finds the Minecraft material which most closely matches a given RGB color. Witchcraft provides a file of mappings between materials and the two most prominent colors in their textures.

To determine the most appropriate material to represent a given RGB value the nearest-material function calculates the color distance to every prominent color, and selects the material for which this value is lowest, and is therefore closest to in color space.

Will Caine had the brilliant idea of reading the pixels of an image and mapping them into the Minecraft world, using this function to determine the most appropriate materials to use. With a little investigation we managed to get ImageIO to read our file and before long we had RGB values for each pixel in the file!

Generating images in the Minecraft world

The Minecraft world is only 319 blocks high, from bedrock to the top of the map, so we had to scale the image so that it would fit.

Our first implementation mapped each pixel from the image to an RGB vector lazily using for. From there we could specify a scale and sample RGB values from the large array of values.

(ns gen-image
  (:require [lambdaisland.witchcraft :as wc]
            [lambdaisland.witchcraft.palette :as palette])
  (:import (javax.imageio ImageIO)
           ( File)
           (java.awt Color)))

(defn img2world
  [filename coords mc-width]
  (let [buff (. ImageIO (read (File. filename)))
        img-width (.getWidth buff)
        img-height (.getHeight buff)
        (for [x (range 0 img-width)]
          (for [y (range 0 img-height)]
            (let [rgbint (.getRGB buff x y)
                  color (Color. rgbint true)]
              [(.getRed color) (.getGreen color) (.getBlue color)])))
        scale-factor (/ img-width mc-width)
        mc-height (quot img-height scale-factor)]
    (for [x (range 0 mc-width)
          y (range 0 mc-height)]
       (-> coords
           (update :x + x)
           (update :y + y)
           (assoc :material
                   (nth (nth rgbvec (* x scale-factor)) (* y scale-factor)))))))))

After hacking our solution together we finally managed to generate an image in the world. But it was upside down!

(nth (nth rgbvec (* x scale-factor)) (* y scale-factor)))
(nth (nth rgbvec (* x scale-factor)) (* (- mc-height 1 y) scale-factor)

The origin coordinate of an image read into an ImageIO buffer is located in the top left corner of the image, not the bottom left as we had expected. This meant that as we iterated through the y-axis of the image we were descending towards the bottom. By inversing the y coordinates we were able to correctly flip the image.


Optimising for speed

Although our solution worked it was painfully slow to generate the resulting image. Our first thought was that all those repeated calls to wc/set-block might be slowing us down, so we refactored the code to make use of wc/set-blocks to set all the blocks at once. Using wc/set-blocks also has the added benefit that it is much easier to undo generated images using wc/undo! as it will remove the whole image rather than just one generated block at a time.

(defn img2world
  [filename coords mc-width]
  (let [buff (. ImageIO (read (File. filename)))
        img-width (.getWidth buff)
        img-height (.getHeight buff)
        scale-factor (/ img-width mc-width)
        mc-height (quot img-height scale-factor)]
        (for [x (range 0 mc-width)
              y (range 0 mc-height)]
          (let [rgbint (.getRGB buff (* x scale-factor) (* y scale-factor))
                color (Color. rgbint true)
                rgb [(.getRed color) (.getGreen color) (.getBlue color)]]
            [x (- mc-height 1 y) 0 (palette/nearest-material rgb)]))
        {:anchor coords})))

But still our solution was slow. Had we hit a hard limit? To find out what was really going on we needed to profile the code. Tufte is a simple profiler for both Clojure and ClojureScript, so we added the dependency to the server deps.edn and wrote some profiling code. To make use of Tufte you must identify the forms you would like to profile by wrapping them with p. Then call the function inside profile and observe the results.

(ns gen-image
  (:require ...
            [taoensso.tufte :as tufte :refer (defnp p profile)])

(defn img2world
  [filename coords mc-width]
  (let [buff (p :new-buff (. ImageIO (read (File. filename))))
        img-width (.getWidth buff)
        img-height (.getHeight buff)
        scale-factor (/ img-width mc-width)
        mc-height (quot img-height scale-factor)]
       (p :set-blocks (wc/set-blocks
        (for [x (range 0 mc-width)
              y (range 0 mc-height)]
          (let [rgbint (p :get-rgb (.getRGB buff (* x scale-factor) (* y scale-factor)))
                color (p :new-color (Color. rgbint true))
                rgb (p :rgb-vec [(.getRed color) (.getGreen color) (.getBlue color)])]
            [x (- mc-height 1 y) 0 (p :near-mat (palette/nearest-material rgb))]))
        {:anchor coords}))))

{:format-pstats-opts {:columns [:n-calls :min :max :mean :clock :total]}})

 (p :img2world (img2world "juxt-logo.png" {:x 0 :y 150 :z 0} 200)))
    pId             nCalls        Min        Max       Mean      Clock  Total

    :img2world           1    34.15s     34.15s     34.15s     34.15s    100%
    :set-blocks          1    34.14s     34.14s     34.14s     34.14s    100%
    :near-mat       15,400     1.47ms    28.95ms     2.18ms    33.62s     98%
    :get-rgb        15,400   963.00ns     6.83ms     7.39μs   113.86ms     0%
    :rgb-vec        15,400   124.00ns    42.28μs     1.14μs    17.49ms     0%
    :new-buff            1     7.26ms     7.26ms     7.26ms     7.26ms     0%
    :new-color      15,400    19.00ns    74.82μs   361.67ns     5.57ms     0%

    Accounted                                                   1.70m    299%
    Clock                                                      34.15s    100%

The results show that 98% of the time spent in the function is spent in nearest-material. As we know that there is a small and constrained range of possible values for the inputs and outputs of this function, memoize can be used to effectively cache the results, mitigating the need to perform the same calculations repeatedly. This optimization is particularly performant in this case as the image contains only a small range of different colors.

[x (- mc-height 1 y) 0 (p (palette/nearest-material rgb))]
(def memo-nearest-material (memoize palette/nearest-material))
[x (- mc-height 1 y) 0 (p (memo-nearest-material rgb))]
    pId             nCalls        Min        Max       Mean      Clock  Total

    :img2world           1   322.64ms   322.64ms   322.64ms   322.64ms   100%
    :set-blocks          1   317.40ms   317.40ms   317.40ms   317.40ms    98%
    :near-mat       15,400   395.00ns     2.55ms    10.79μs   166.12ms    51%
    :get-rgb        15,400   518.00ns    27.75μs   736.34ns    11.34ms     4%
    :new-buff            1     5.10ms     5.10ms     5.10ms     5.10ms     2%
    :rgb-vec        15,400    72.00ns    11.60μs   119.87ns     1.85ms     1%
    :new-color      15,400    20.00ns    17.50μs    43.02ns   662.46μs     0%

    Accounted                                                 825.10ms   256%
    Clock                                                     322.76ms   100%

We can now generate the image in under a third of a second, down from 34 seconds, which is a 100x improvement. Of course, on subsequent calls the image is generated even faster as the color-to-material mappings are already cached. wc/set-blocks is now the bottleneck, so we will leave it there.

Have a go yourself

If you want to have a go yourself you can easily work through it on your own by reading through the detailed workshop instructions available here. There are also some YouTube videos to help you get inspired.

Thank you to Arne, Ariel, and Felipe for the brilliant workshop, to the organizers of ClojureD for running such a great conference, and to JUXT for taking us to the event.

Recommended Resources
Head Office
Norfolk House, Silbury Blvd.
Milton Keynes, MK9 2AH
United Kingdom
Company registration: 08457399
Copyright © JUXT LTD. 2012-2024
Privacy Policy Terms of Use Contact Us
Get industry news, insights, research, updates and events directly to your inbox

Sign up for our newsletter