Building a Cross-Platform Lisp Binary

Tagged as lisp, game-dev, mac, windows, linux

Written on 2019-08-06 19:00:00

Let's say you've created an awesome desktop game using Common Lisp. Good job. Now, how will you share your creation?

Unfortunately lisp has yet to take over the world, so if you want widespread use of your game you must produce a binary for each platform you want to support.

This post will cover:

  • How binaries are generally built in Common Lisp
  • Building a binary for a sample-game which includes an external library (SDL2)
  • How to set up CI (Azure Pipelines) to produce binaries for a sample-game on Linux, Mac, and Windows (using Pipelines' free plan).

Huge thanks to /u/gluaxspeed for providing his azure-pipelines yaml. This post would not have been possible without him.

The "Game"

Our sample game is not actually a game, but a function which prints some info about its runtime and runs the cl-sdl2 example app.

(in-package :cl-sample-game)

(defun main ()
  ;; osx drawing calls must occur on the main thread or the app will crash
  (sdl2:make-this-thread-main
   (lambda ()
     (format t
             "Sample app running.~%Lisp info: ~A : ~A~%"
             (lisp-implementation-type)
             (lisp-implementation-version))
     (format T "SDL info: ~D.~D.~D~%"
             sdl2-ffi:+sdl-major-version+
             sdl2-ffi:+sdl-minor-version+
             sdl2-ffi:+sdl-patchlevel+)
     (format t
             "Platform: ~A~%"
             (cond #+darwin(t "OSX")
                   #+win32(t "Windows")
                   #+linux(t "Linux")))
     (sdl2-examples:basic-test))))

Additionally we'll also define an asdf system.

(in-package :asdf-user)

(defsystem cl-sample-game
  :name "cl-sample-game"
  :version "0.1"
  :author "Ark"
  :pathname "src/"
  :serial T
  :components ((:file "packages")
               (:file "cl-sample-game"))
  :depends-on (:sdl2/examples))

Building our Game

The Common Lisp spec does not include building binaries. Each CL implementation provides its own means to accomplish this. The specifics vary, but in practice the approaches are very similar. The implementation usually exposes a function which dumps a binary for the platform you're running on and exits the program. This post uses sbcl's save-lisp-and-die function. For other implementations consult your manual, or try one of the cross-lisp libraries such as asdf:make, buildapp, or roswell.

With that in mind here's a script to build our game.

(ql:quickload :cl-sample-game)

(in-package :cl-sample-game)

(sb-ext:save-lisp-and-die
 (or
  #+win32"sample-game-windows.exe"
  #+linux"sample-game-linux"
  #+darwin"sample-game-osx"
  (error "Unsupported OS for building. Got: ~A" *features*))
 :purify T
 :toplevel
 #'main
 :executable t
 :save-runtime-options t)

To run the script:

~/prog/cl-sample-game $ sbcl --load build-game.lisp
To load "cl-sample-game":
  Load 1 ASDF system:
    cl-sample-game
; Loading "cl-sample-game"
..................................................
[package cl-sample-game]
~/prog/cl-sample-game $ ls | grep sample-game-
sample-game-linux

This produces a binary for the platform the script runs on (in my case, linux). Building for other platforms requires running the build script on the target platforms (or some kind of emulator).

Why CI?

Ultimately, supporting a platform requires direct testing on the OS with real hardware. Video game bugs are too subtle to be 100% dealt with by automation. That being said, setting up CI will greatly simplify your life.

Without CI, testing and building your game may look like this:

For every platform:

  • Ensure you're running the correct branch of your code
  • Ensure all quicklisp dependencies are the expected versions
  • Ensure all external libraries are the expected versions
  • Run your tests
  • Run the build script

It may not seem like much, but repeating it on every platform is a chore, and bugs may creep in if your dependencies are not the same across every platform. For example, if a third-party quicklisp library is not updated when you make your OSX build you could be running with a very different codebase than the one you developed on. Not good!

Without CI, you'll tend to build frequently on your development OS and rarely for everything else. You might find yourself hunting a subtle bug that broke your Windows build sometime between now and 50 commits ago. Not fun!

Setting up CI with Azure Pipelines

Azure Pipelines offers a free runners with OSX, Windows, and Linux hosting. I assume there's some limit before they start charging you, but so far I've been able to produce builds for our sample game without any hassle (other than generally trying to figure out Azure's yaml settings).

I won't post the full code here, but here's the full yaml: https://github.com/realark/cl-sample-game/blob/master/azure-pipelines.yaml

Generally the process looks like this:

  • Install a pre-built sbcl on the host machine
  • Download the sbcl source and use the pre-built sbcl to compile and install a specific version of sbcl (in our case 1.5.5)
  • Install quicklisp
  • Set up the cl-sample-game project as a local quicklisp project
  • Run the build-game.lisp script
  • Save the artifacts

There are a lot of specific details for each platform. Notably, I'm running the linux build using Azure's container option. This is because SDL2 on linux requires a later version of glib. This may not be required for your needs (especially if your application is not a game or has no libraries).

Source Code and Links

Source code: https://github.com/realark/cl-sample-game

Azure Pipeline: https://dev.azure.com/arktheprogrammer/cl-sample-game

Cl-Sample-Game Build: https://dev.azure.com/arktheprogrammer/_apis/resources/Containers/371573?itemPath=drop&$format=zip

comments powered by Disqus

Unless otherwise credited all material Creative Commons License by Ark