Andrey Listopadov

Interactive game development with LÖVE

@programming @gamedev fennel ~7 minutes read

This is a continuation of the previous post on game development with the LÖVE game engine. I’m slowly appreciating the freedom it gives, compared to the TIC-80 experience. One of such freedoms is the fact, that LÖVE is a well-behaving console application.

When you launch TIC, it boots up into a console:

If you do so from a terminal emulator, you can see that the same console is there too:


 TIC-80 tiny computer
 version 1.1.2837 Pro (be42d6f)
 https://tic80.com (C) 2017-2024

 hello! type help for help

>ls

[tic80.com]
mtd.lua

>

However, writing in this prompt doesn’t do anything - it’s not sent back into TIC. And unfortunately, there’s no way of reading from standard IN, because there’s no io namespace provided. One could, theoretically, fiddle with sockets, if it is even possible to load them into TIC, which I’m not so sure about.

In LÖVE, however, you get full Lua experience and more! As you may know, Lua’s io.read() is blocking, and there’s no real way of making it non-blocking or checking the input buffer. However, that’s not a problem for LÖVE, as it has threads! So we can set up a separate thread that reads from the stdin.

Why would we want that? Well, you see, when it comes to Lua, I usually do my programming in Fennel, and Fennel has a decent REPL for doing interactive development. I love the idea of programming with a REPL, I do it a lot for my hobby projects, and at work with Clojure. Interactive development makes iterations much faster, and, in my experience, minimizes the amount of bugs in the resulting program. And we can leverage that during game development too.

Adding the REPL

The way we’re going to set this up is as follows:

We create a new, asynchronous handler for the REPL, and spawn it on the main thread. Then, we’re going to spawn another thread, that will listen on stdin, and send everything it reads to the main thread. Finally, once the main thread finishes the evaluation, it sends the data back to the IO thread for printing. After that, I’m going to spice things up by using the Fennel Proto REPL package I made for Emacs for a better REPL experience.

Let’s look at the REPL code:

(require :love.event)

(local fennel
  (require :lib.fennel))

(fn read [cont?]
  (io.write (if cont? ".." ">> "))
  (io.flush)
  (.. (tostring (io.read)) "\n"))

(case ...
  (event-type channel)
  (while true
    (match (channel:demand)
      :write (-> (channel:demand)
                 (table.concat "\t")
                 (io.stdout:write "\n"))
      :error (-> (channel:demand)
                 (io.stderr:write "\n"))
      :read (->> (channel:demand)
                 read
                 (love.event.push event-type)))))

(let [chan (love.thread.newChannel)
      repl (coroutine.wrap #(fennel.repl $...))]
  (repl
   {:readChunk (fn [{: stack-size}]
                 (chan:push :read)
                 (chan:push (< 0 stack-size))
                 (coroutine.yield))
    :onValues (fn [vals]
                (chan:push :write)
                (chan:push vals))
    :onError (fn [errtype err]
               (chan:push :err)
               (chan:push err))
    :moduleName "lib.fennel"})
  (-> (love.filesystem.read "lib/repl.fnl")
      (#(fennel.compile-string $))
      (love.filesystem.newFileData "repl")
      love.thread.newThread
      (: :start :eval chan))
  (set love.handlers.eval repl))

Simply requiring this file would start a REPL. You may be confused, about how’s it going to work, the while true may look especially concerning.

When we require this file, the ... is bound to the module name, so the case doesn’t match, as it expects two values an event-type and the channel. Then we fall into the let block, which defines the channel chan, the REPL handler repl, starts the REPL coroutine, which pushes the :read message to the channel. Finally, we load this file again, using love.filesystem.read, compile it to Lua, and start a new thread with :eval for the event-type, and chan for the channel. This makes case match and starts the while loop, whose sole purpose is to take values off the channel and act accordingly.

Finally, we set the love.handlers.eval handler, matching our :eval event type to call the repl as a callback when the event occurs. The event occurs only when we get the :read message from the channel, then we use read to get data from the stdio, and we push it back to the eval handler so the repl callback can be called.

That’s pretty much it! Now, let’s put it to the test!

Changing code at runtime

Here, I have a simple player controller and a bunch of values that correspond to how the player behaves:

(local fall-vel 300)
(local jump-vel -300)
(local run-vel 200)

(local player
  {:name "player"
   :color [0.3 0.7 0.3 1]
   :xvel 0
   :yvel 0
   :x 50
   :y 50
   :flip? false
   :width 16
   :height 24
   :state :idle
   :extra-jump 10
   :draw
   (fn [p]
     ;; omitted
     )
   :update
   (fn [{: xvel : yvel : extra-jump : flip? : x : y &as p} dt]
     ;; omitted
     )})

We can try and change the run-vel and see if it is picked up by the game:

Seems it’s not. Perhaps, we need to recompile the game module?

Aw, snap!

By recompiling the entire module, we undefined some variables, and the game crashed! By the way, the reason changing the run speed value didn’t apply is that we were actually defining a completely new speed value, that is not part of the module we’re editing. That’s a quirk of how Lua modules work and how Fennel REPL works. So let’s fix this!

Modules and state

While Lua has some lisp characteristics, it is understandably not an actual lisp. So, unfortunately, we can’t expect the same level of interactivity from it.

In Lua, a module is an ordinary table. When you write Lua, you return the value at the bottom of the file, usually a table, or sometimes a function. Any local variables, defined in that module are locals, stored as closures for the functions that reference them in the exported value.

Because of that, there’s no such thing as entering a module like in many other lisps. The REPL has its own kind of module space, where all locals, defined in the REPL are stored. The same goes for any code that we send to the REPL. So, while we do have a REPL, we can’t enter a specific module and change it partially - the only way to do that is to recompile the module and put the updated version in the package.loaded table. Which we can do.

However, there’s another problem lurking around - state. The video above shows that the error message was:

./game.fnl:68: attempt to index upvalue 'world' (a nil value)

When we’ve recompiled the module, world became nil, why? Well, because world is defined as a nil, and later set in the love.load function:

(var world nil)

;; ---- 8< ----

(fn love.load []
  (set world (bump.newWorld 64))
  (each [_ wall (pairs level)]
    (world:add wall wall.x wall.y wall.width wall.height))
  (world:add player player.x player.y player.width player.height))

However, the love.load runs only when the game starts, and hence after recompiling the module, world becomes nil again, and our game doesn’t really expect that. The same would happen to any other state we’re setting up when the game loads.

Unfortunately, Fennel doesn’t have a defonce or anything like it. The defonce macro is available in many lisps, and its purpose is to ensure that when we’re redefining something in the REPL or re-evaluating the whole module/namespace/package (whatever you guys call it) the value doesn’t get redefined. Fennel lacks this because there’s no way of doing this in Lua, because of how locals are stored (as closed variables).

But there’s a way around this, we can move the definitions to another module, and use require to load them. In Lua require caches everything it loads, so when we run require again, it doesn’t really do anything except get the value from the package.loaded table.

Here’s how we can define the world module:

(local bump (require :lib.bump))

(bump.newWorld 64)
Code Snippet 1: state/world.fnl

That’s it. Now, we can bring it to the main game module using require:

(local world (require :state.world))

This way, when we recompile the module, we get the same world as before, unless we were to recompile the world module.

I moved most of the game state in such modules, and now we can use our REPL.

Using the REPL

Here’s an example of tweaking the player’s motion speed again:

I’m sending the code to the REPL, instead of reloading the module, just because it’s a bit more convenient. This way tweaking the game is much nicer and frictionless, although it doesn’t reach the same heights as that one video from Bret Victor.

Here’s me using a REPL to make an ad-hoc level editor:

Unfortunately, there’s no way of using the REPL on Android, since there’s no way of running LÖVE from the same environment as Emacs, without installing X11 and running everything through Termux. Probably doable, but I don’t want to go this route. Android development is more backup route anyway, since I have access to my PC again.