in which a game jam is recounted further

This is the second part continuing my previous post about creating the game EXO_encounter 667 using the Fennel programming language and the LÖVE game framework for the Lisp Game Jam 2018; you'll probably want to read the first installment if you haven't already. I wrote about the game design and art, but in this post I'd like to dive into the more technical aspects of the game.

exo encounter terminal

The voting for the game jam just closed, and EXO_encounter 667 came in ranked first! Three out of the top four winners are LÖVE games; one other in Fennel and one in Urn.

Libraries

I pulled in a couple libraries on top of LÖVE to help out in a few areas. First and foremost I would dread to do any work on the Lua runtime without lume, which I like to think of as Lua's "missing standard library". It brings handy things like filter, find, reduce, etc. It's mostly sequence-related functions, but there are a few other handy functions as well like split, a bizarre omission from the standard library, or hotswap which I'll get to below.

The bump.lua library is used for collision detection, and as long as you only need to operate in terms of axis-aligned rectangles, it is very easy to use and gets the job done with no fuss.1 But one of the nicest things about bump is that it's integrated into Simple Tiled Implementation, which handles maps exported from Tiled. On its own the Tiled library just handles drawing them (including their animations and layering), but it can automatically integrate with bump if you set properties on a layer or object to flag it as collidable.

The documentation for the Tiled library unfortunately leaves quite a bit to be desired; it's one of those projects that just dumps a list of all functions with a line or two describing what each one does and considers that "the documentation". Fortunately the source is pretty readable, but figuring out how to handle opening and closing of doors was definitely the roughest spot when it came to 3rd-party libraries. The readme does describe how to implement a custom drawing routine for a layer, which allows us to draw a door differently based on whether it's closed or open. The problem is there's no easy way to do the same thing for the collision detection side of the story.

The Tiled library handles setting up the "world" table from bump by seeding it with all the collidable things from the map. The problem is it doesn't actually use the same tables from the map when adding them to the bump table; it wraps them in bump-specific tables stripping it down to just the fields relevant to collision detection. This is fine until have a door you need to open. Normally you'd do this by calling bump.remove with the door table to make the door no longer take part in collision detection, but bump doesn't know about the door table; it only knows about the wrapper table, which we no longer have access to.

I ended up hacking around this by making the Tiled library save off all the wrapper tables it created, and introducing a new bump_wrap function on the map which would intercept methods on the bump world, accept a regular table and look up the wrapped table and use it instead in the method call. It got the job done quickly, but I couldn't help but feel there should be a better way. I've opened an issue with the Tiled library to see if maybe I missed an undocumented built-in way of doing this. But as far as the coding went, this was really the only hiccup I encountered with any of the libraries I used.

Interactive Development

As a lisp, of course Fennel ships with a REPL (aka interactive console, often mistakenly called an "interpreter") which allows you to enter code and see the results immediately. This is absolutely invaluable for rapid game development. There's a bit of a hiccup though; the REPL reads from standard in, and LÖVE doesn't ship with a method for reading from standard in without blocking. Since Lua doesn't have concurrency, this means reading repl input would block the whole game loop until enter was pressed! LÖVE saves the day here by allowing you to construct "threads" which are really just completely independent Lua virtual machines that can communicate with each other over queues but can't share any data directly. This turns out to be just fine for the repl; one thread can sit and block on standard in, and when it gets input send it over a queue to the main thread which evaluates and sends the response back.

(defn start-repl []
  (let [code (love.filesystem.read "stdio.fnl")
        lua (love.filesystem.newFileData (fennel.compileString code) "io")
        thread (love.thread.newThread lua)
        io-channel (love.thread.newChannel)]
    ;; this thread will send "eval" events for us to consume:
    (: thread :start "eval" io-channel)
    (set love.handlers.eval
         (fn [input]
           (let [(ok val) (pcall fennel.eval input)]
             (: io-channel :push (if ok (view val) val)))))))

As I use Emacs, I've configured fennel-mode to add a key combo for reloading the module for the current buffer. This only works if the current file is in the root directory of the project; it won't work with subdirectories as the module name will be wrong, but it's pretty helpful. It also requires lume be defined as a global variable. (Normally I avoid using globals, but I make two exceptions; one for lume and another for pp as a pretty-print function.) I haven't included this in fennel-mode yet because of these gotchas; maybe if I can find a way to remove them it can be included as part of the mode itself in the future.

Simply run C-u M-x run-lisp to start your game, and use love . as your command. Once that's started, the code below will make C-c C-k reload the current module.

(eval-after-load 'fennel-mode
  '(define-key fennel-mode-map (kbd "C-c C-k")
     (defun pnh-fennel-hotswap ()
       (interactive)
       (comint-send-string
        (inferior-lisp-proc)
        (format "(lume.hotswap \"%s\")\n"
                (substring (file-name-nondirectory (buffer-file-name)) 0 -4))))))

Update: I added first-class support for reloads to fennel-mode, though you will still need the stdin hack described above when using it inside LÖVE.

The other gotcha is that currently an error will crash your whole game. I really wanted to add an error handler which would allow you to resume play after reloading the module that crashed, but I didn't have time to add that. Hopefully I'll have that ready in time for the next jam!

Tutorial

From a usability perspective, one of the most helpful things was adding a tutorial to explain the basic controls and mechanics. The tutorial displays instructions onscreen until the point at which the player carries out those instructions, at which point it moves on to the next instructions. There are various ways you could go about doing this, but I chose to implement it using coroutines, which are Lua's way of offering cooperative multitasking.

(defn tutorial [state world map dt]
  (echo "Press 2 to select rover 2; bring it near the"
        "main probe and press enter to dock.")
  (while (not (. state.rovers 2 :docked?))
    (coroutine.yield))

  (echo "With at least 3 rovers docked, the main" "probe has mobility."
        "" "Now do the same with rover 3.")
  (while (not (. state.rovers 3 :docked?))
    (coroutine.yield))

  (echo "The probe's communications laser can be"
        "activated by holding space. Comma and"
        "period change the aim of the laser.")
  (while (not (or (and state.laser (~= state.selected.theta math.pi))
                  (> (: world :getRect state.selected) 730)
                  (sensor? map "first")))
    (coroutine.yield))

  ...)

The tutorial function runs inside a coroutine started with coroutine.wrap; it echoes the first message and then suspends itself with coroutine.yield which returns control to the caller. On every tick, the love.update function coroutine.resumes it which allows it to check whether the conditions have been fulfilled. If so it can move on to the next instruction; otherwise it just yields back immediately. Of course, it would be possible to do something like this using only closures, but coroutines allow it to be written in a very linear, straightforward way.

exo encounter laser screenshot

Distribution

With LÖVE you get portability across many operating systems; however it does not actually handle creating the executables for each platform. I used an old version of love-release2 to create zip files which include everything you need to run on Windows and Mac OS. This was a huge help; I could run my entire build from my Debian laptop without even touching a Windows machine or a Mac.

For the jam I just published a .love file for other platforms, which requires you to manually install LÖVE yourself. This is a bit of a drag since most package managers don't include the correct version of LÖVE, and even if they did today, in the future they'd upgrade to a different one, so this is one place where relying on the package manager is definitely not going to cut it. Soon after the jam I discovered AppImages which are a way of bundling up all a program's dependencies into a single executable file which should work on any Linux distribution. While I think this is a really terrible idea for a lot of software, for a single-player game that doesn't load any data from untrusted sources, I believe it to be the best option. The love-release tool doesn't currently support creating AppImages, but I am hoping to add support for this. I also didn't get around to automating uploading of builds to itch.io using butler, but I'm hoping to have that working for next time.

Play my game!

Now that the jam is over, I've gotten some great feedback from players that resulted in a nice todo list of items that can be improved. I hope to release a "special edition" in the near future that includes all the things I wasn't able to get to during the jam. But in the mean time, I hope you enjoy EXO_encounter 667!


[1] LÖVE ships with a physics engine built-in, but the API it uses is much more complicated. It's capable of more sophisticated behavior, but unless you really can't work in terms of rectangles, I'd recommend sticking with the much simpler bump.lua.

[2] The love-release project has since been rewritten in Lua instead of being a shell script as it was at the time I downloaded the version I used. I haven't tried the new version but it looks promising.

« older | 2018-05-05T17:05:03 | newer »