The One Where Buttons are Made


This post is a primer on how not to do UI in Love2d. Read at your own peril. You can follow along with the code examples using the project library on gitlab.

Love2d is a great game framework. It provides all the tools you need to make a game wrapped up in a series of easy to use lua modules. But, it leaves the development of the UI to the reader.

There have been several UI libraries and wrappers written to fill this void, both in pure lua, like SUIT and wrappers around c libraries, like nuklear. These libraries are extensive and, I’d argue for most hobby games, overkill.

In my personal projects I’ve taken several approaches to implementing UI, including using bump a library that handles axis aligned rectangular collisions and an ill advised implementation of a UI library of my own.

For my entry to this year’s lisp game jam, Frozen Horizon I wanted to take a simpler path. My goal was to stick with the tools love2d provides out of the box and try and create an immediate mode GUI as simply as possible, with minimal state.

Example from Frozen Horizon

My assumptions objectives and constraints when setting up the UI were:

  1. Assume that interactable elements are not going to overlap
  2. Limit the interface to text and buttons (no sliders windows etc)
  3. Play sound effects when an interactable is hovered and clicked
  4. Play a single sound / action per hover or click (i.e. don’t spam the hover sound effect when your over a button)

Before we begin lets check out love.run, the main function used by love2d, containing the main loop. This is modifiable by the game developer, but its defaults are sensible (I have never bothered to alter it). What we need to remember is the order in which the love callback functions we define in our game are called. In the main loop of the game we:

  1. Iterate through events (including input events like love.mousereleased)
  2. Increment dt and call love.update
  3. Call love.draw

Step 1 - Drawing a Button

Lets begin. The first step is to get a button to show up on screen. You could load a button as a png but in this post we will be using Love2d’s built in geometry procedures.

To draw a rectangle at point 0 0 that is 100px wide and 200 px tall we can call the love.graphics.rectangle.

(love.graphics.rectangle :fill 0 0 100 200)

The keyword :fill specifies that the rectangle should be filled in. The other option is :line which specifies that only the outline should be drawn.

To set the colour of the button we can use love.graphics.setColor, which takes the red, green, blue and alpha args, ranging from 0 to 1 either as individual arguments or as part of a table. Note, this will change the colour of everything you draw, including images.

;; Set the colour to white
(let [(r g b a) (values 1 1 1 1)]
    (love.graphics.setColor r g b a))

;; Set the colour to black
(let [(r g b a) (values 0 0 01)]
    (love.graphics.setColor r g b a))

To draw text we can use the function love.graphics.print or love.graphics.printf. The later can be used to center text horizontally.

(let [(x y w) (values 0 100 300)]
    (love.graphics.printf "Some Text" x y w :center))

Lets choose some colours and put it all together. Check out a UX colour pallet site for some inspiration like colorsinspo. I’ll be using:

(local colours
       {
        :text       [ 0.2109375 0.30859375 0.41796875 0.99609375 ]
        :button     [ 0.9375 0.9375 0.9375 0.99609375 ]
        :background [ 0.26171875 0.86328125 0.8984375 0.99609375 ]
        :highlight  [ 0.984375 0.31640625 0.51953125 0.99609375 ]
        })

Now lets go about drawing a button. We will use the functions discussed above. From here on out I will be using lg as an alias for love.graphics in codeblocks. I also wrap love callbacks draw, update and mousereleased. Wherever you see these functions you can replace them with love.draw, love.update and love.released.

(local lg love.graphics)

(local (bw bh) (values 200 50))

(fn draw []
  ;; clear frame
  (lg.clear colours.background)
  (lg.setColor colours.button)
  ;; draw button
  (lg.rectangle :fill 0 0 bw bh)
  (lg.setColor colours.text)
  (lg.rectangle :line 0 0 bw bh)
  (lg.printf "Button" 0 10 bw :center))

You should now have a button in the top left. Really captivating stuff.

The most simple button

The full source can be seen here.

Step 2 - Centring the Button

Before going any further lets play around with this button. We can set the font of the text using love.graphics.newFont, we can center the button using love.graphics.translate and we can make the outline thicker using love.graphics.setLineWidth.

;; to help center the button lets get the window's
;; height and width
(local (window-w window-h) (love.window.getMode))

(local button-font (lg.newFont "inconsolata.otf" 24))

(fn draw []
  ;; clear frame
  (lg.clear colours.background)
  (lg.setColor colours.button)
  ;; move to position
  (lg.translate (-> window-w (- bw) (/ 2) (math.floor))
                (-> window-h (- bh) (/ 2) (math.floor)))
  ;; draw button
  (lg.rectangle :fill 0 0 bw bh)
  (lg.setColor colours.text)
  (lg.setLineWidth 6)
  (lg.rectangle :line 0 0 bw bh)
  (lg.setFont button-font)
  (lg.printf "Button" 0 10 bw :center))

For those unfamiliar with the threading operator ->, each function takes the output of the previous function as its first argument. Its a helpful means of extracting nested logic into a piped flow.

(assert (= (-> 1 (+ 1) (* 2)) (* 2 (1 + 1))))

A centred button

The full source can be seen here.

Step 3 - Hovering the Button

So now we have a halfway decent looking button that we can move anywhere on the screen. Its time to interact with it.

The first step to interacting is determining when we are hovering over the button. To test if a point px py is within an axis aligned box we can use the following function.

(fn point-within [px py x y w h]
  ;; px < x + w  and px > x and py < y + h and py > y
  (and (< px (+ x w))
       (> px x)
       (< py (+ y h))
       (> py y)))

Additionally, to account for any transformations in screen space we can translate our mouse coordinates using love.graphics.inverseTransformPoint.

(local (mx my) (love.mouse.getPosition))
;; some transformation
(local (screen-x screen-y) (lg.inverseTransformPoint mx my))

Putting it all together, here is our new draw function. Remember, call inverseTranformPoint after the transformations of the button are finished.

(fn draw []
  ;; get mouse position
  (local (mx my) (love.mouse.getPosition))
  ;; clear frame
  (lg.clear colours.background)
  (lg.setColor colours.button)
  ;; move to position
  (lg.translate (-> window-w (- bw) (/ 2) (math.floor))
                (-> window-h (- bh) (/ 2) (math.floor)))
  ;; transform mouse to screen space
  (local (screen-x screen-y) (lg.inverseTransformPoint mx my))
  ;; determine if mouse is within button
  (local hover (point-within screen-x screen-y 0 0 bw bh))
  ;; draw button
  (lg.setColor colours.button)
  (lg.rectangle :fill 0 0 bw bh)
  ;; set text and outline colour to pink when hovered
  (if hover
      (lg.setColor colours.highlight)
      (lg.setColor colours.text))
  (lg.setLineWidth 6)
  (lg.rectangle :line 0 0 bw bh)
  (lg.setFont button-font)
  (lg.printf "Button" 0 10 bw :center))

A hovered button

The full source can be seen here.

Step 4 - Clicking the Button

Cool, so we can draw a neat button on screen and have it highlight whenever the mouse is over it. Great. Now, how can we interact with it, i.e. click it. This is the first point where we will have to introduce some sort of external state. In order to distinguish which button we are over when the mouse is clicked we will need some sort of over variable that love.mousereleased has access to.

Referring back to the order of operations in love.run we can recall that events are called before update, and update is called before draw. So, if we set our variable over to the handle of the button we are hovering in draw, it will be available next frame in love.mousereleased. Once we are done using it in love.mousereleased we can set it back to nil in love update and begin the process again.

Lets start with love.mousereleased. When this event is called lets have a variable count incremented when we are over the button.

(var over nil)
(var count nil)

(fn mousereleased [x y button]
  (match over
    :button (set count (+ count 1))))

To ensure the value of over is nil in the case where the mouse is not over anything we can set it to nil in update each frame. We can also spice up our demo by rotating the button, we can update the rotation in update as well.

(var rotation 0)
(fn update [dt]
  (set over nil)
  (set rotation (+ rotation  dt)))

Finally in our draw function we only need one small addition to enable clicking. After we determine if the button is hovered or not we need to set over to the handle of the button.

;; in draw
(local hover (point-within screen-x screen-y 0 0 bw bh))
(when hover (set over :button))

Additionally, lets rotate the button as well. To rotate around the center point of the button, translate to the center of the button before rotating and then translate back.

(lg.translate (/ bw 2) (/ bh 2))
(lg.rotate (* rotation 5))
(lg.translate (/ bw -2) (/ bh -2))

We can also replace the text of the button with a count of the number of times its been clicked.

(lg.printf (.. "Clicked " count) 0 10 bw :center)

Clicking the button

The full source can be seen here.

Step 5 - The Sound Effects

So now we have a functional button. We can use love.graphics.push and love.graphics.pop to make several of these and spread them all over our game. Before we do that however lets implement the remaining constraint outlined above: making the buttons have sounds.

To load a sound in love2d we use love.audio.newSource. We can then use the method play to play the sound.

(local beep (love.audio.newSource "beep.ogg" :static))
(beep:play)

To ensure that we only hear the beep the first time we hover over the button we’re going to keep track of the last button we hovered over. For this lets use the simple function first-only. This is the second required piece of state in the ui.

(var first-previous nil)
(fn first-only [x]
  (let [first (~= x first-previous)]
    (set first-previous x)
    (when first x)))

We can now use (first-previous over) to determine if we should play a sound. To do this we could create our own event handler or check for a change in state in update. The easiest place to stick this logic for our example would be in update right before we set over to nil.

(match (first-only over)
  nil :no-hover-sfx
  _ (beep:play))

The full source can be seen here.

Step 6 - Putting it all together

Now that we have the full implementation of a button, as alluded to above, we can use love.push and love.pop to reset the transformation state between button draws. Doing this allows us to draw buttons wherever we want on the screen with full interactivity.

A complete setup

The full source can be seen here.

Limitations of the Approach

As can be seen in the completed example, while we will always select the last button drawn and hovered to be clicked, we will still show all the buttons hovered as highlighted. This is a non issue if your buttons are not expected to overlap, or if you’re drawing stacked menus where the hovered buttons below will be obscured by the background of the menus above.

There are a couple of solutions to this limitation.

  1. Have a separate render pass for the highlighted interactable. This will require additional state (i.e. how to render the highlight) but works well if you are, for example, using this immediate draw approach for characters in a world map.
  2. Use a shader / depth buffer to draw forward back instead of back forward (i.e. the painters algorithm)

Points of Interest

Here are just an extra couple points of interest for the avid reader. For those wondering how love.graphics.getInverseMatrix works, fundamentally it takes the object transform * view matrix and performs the inverse transformation on it. Since the matrix we are inverting is a 4x4 we can use a specified matrix inversion.

love::Vector2 Transform::inverseTransformPoint(love::Vector2 p)
{
	love::Vector2 result;
	getInverseMatrix().transformXY(&result, &p, 1);
	return result;
}

For those that would like to use point-within in their own projects, but don’t want to use fennel here is an implementation in lua

function pointWithin(px,py,x,y,w,h)
  return px < x + w  and px > x and
         py < y + h and py > y
end

Get Frozen Horizon

Download NowName your own price

Leave a comment

Log in with itch.io to leave a comment.