Tagged as programming, game-dev, lisp, syn
Written on 2020-05-16 19:00:00
My previous post explored why I chose Common Lisp to develop my Indie game. In this follow-up I'll tell you what makes Lisp special as a language and how that applies to game dev.
What makes Lisp different? Most languages maintain rigid boundaries between the compiler, the code, and the data being processed by the code. Sometimes those boundaries can be crossed, but usually in a limited manner (for example, some kind of reflection API). Lisp's philosophy is to merge those boundaries as much as possible. In doing so, Lisp hands ordinary programmers power usually reserved by the language compiler.
Of course, this extra power means you're much more capable of writing terrible code. If you're the sort of person who likes to wield tremendous programming power responsibly and gracefully, Lisp is the language for you.
Let's look at a small example (this is a bit contrived, but roll with it). Say you have a hash table of users. Keys are user-id integers and the values are strings of the user's name.
(defparameter *users* (make-hash-table)
"Hash USER-ID -> USER-NAME")
A common usage may be to check for a key in the hash. If the key is present, return the value. If the key is not present, add it to the hash then return the value. You'll probably end up writing a lot of code like this.
(let ((user-id 3))
(unless (gethash *users* user-id)
(setf (gethash *users* user-id)
(prompt-for-new-user "Please enter a new user name :> ")))
(gethash *users* user-id))
This is a common pattern. Check if a value is cached. If it's there, use it; if not create and cache. In many languages, it's hard to automate this process because a function would evaluate the "create" part. For example:
(defun get-or-create-user (user-id user-cache default-value)
(unless (gethash user-id user-cache)
(setf (gethash user-id user-cache)
default-value))
(gethash user-id user-cache))
This won't work well in practice because functions evaluate their arguments before the body is invoked.
(get-or-create-user 3
*users*
;; PROBLEM prompt-for-new-user will always be called, even if 3 is in the cache
(prompt-for-new-user "Please enter a new user name :> "))
For many languages, that's the end of the line. You either duplicate that code everywhere or use an ugly kludge to work around the limits of the language (such as passing a lambda into the default value and invoking if there's a cache miss).
Lisp's macro system, on the other hand, allows us to write our desired, idiomatic, code. Here's a macro which behaves like our previous function, but only evaluates default-value
when there's a cache miss. Don't worry about understanding exactly how macros work. Just understand that macros allow us to give special instructions which change the way the language is evaluated.
(defmacro get-or-create-user (user-id user-cache default-value)
(alexandria:once-only (user-id user-cache)
`(progn
(unless (gethash ,user-id ,user-cache)
(setf (gethash ,user-id ,user-cache)
,default-value))
(gethash ,user-id ,user-cache))))
Notice that our macro code is very similar to our function code. This is because the instructions we feed the Lisp Compiler to evaluate get-or-create-user
are written in Lisp itself. We're using Lisp to instruct the compiler on how to interpret other Lisp. It takes a while to wrap your head around this, but once you get the idea it's extremely powerful.
Now we can use our macro and it will do the right thing.
(get-or-create-user 3
*users*
;; This is safe now. `prompt-for-new-user` will only be invoked on a cache miss.
(prompt-for-new-user "Please enter a new user name :> "))
Lisp has many other mind-bending features. If you'd like a deeper dive, I highly recommend Practical Common Lisp.
Lisp's guiding philosophy is to change the programming language itself until it's a natural fit for your problem domain.
This brings us to game development.
In game development, long feedback cycles are creativity and productivity killers. Waiting seconds (or minutes) for your code to recompile so you can test your changes burns a lot of time and energy.
To get around this, popular game engines offer high level scripting languages so changes can be made without having to restart the engine.
Lisp engines have no need for this because the core language supports recompilation while the program is running. Let's say you have a function to control an entity's AI. Change your function, tell the lisp REPL to evaluate the change, and the new AI behavior will instantly take effect.
Another reason for using a scripting language is providing a high level DSL (Domain Specific Language) to control the game. Lisp's macro system makes it an excellent choice for reducing complex operations to a natural game-like language. Here's a real example from a game I'm working on. It's pretty readable even if you don't know how to program.
(make-cutscene ()
(cutscene-capture-input *player*)
(cutscene-change-speaker nash)
"Hi Roark."
(cutscene-ask-yes-no-question "Did you find anything down there?" 'yes 'no)
(cutscene-label 'yes)
"Excellent!"
(cutscene-goto 'done)
(cutscene-label 'no)
"Hmmm. Can you check again?"
(cutscene-label 'done))
Of course, Lisp is not the most popular game dev language for a reason. There are downsides.
Lisp game-dev lacks a large community. Even though the language itself is well-suited making games, there just aren't many tools or libraries compared to established languages. You'll spend a lot of time re-inventing the wheel. These time-sucks far outweigh the time saved using an established engine.
Common Lisp also carries few gotchas:
These things aren't deal-breakers, but using lisp has speed-bumps.
For most game devs, Lisp is not ready for prime-time, but it is well worth your while if you're interested in game programming. Developing a small game or game-jam submission with Common Lisp will make you a better programmer.
If you'd like to learn more, check out the lispgame's wiki or pop into #lispgames
on freenode.