Ludum Dare 34 Postmortem
Posted on December 15th, 2015.
This past weekend was Ludum Dare 34. Ludum Dare is a thrice-a-year event where a theme is chosen and people have 48 hours (for the competition) or 72 hours (for the "casual" jam) to create a game based on a theme.
I actually managed to have some free time for a change, so I decided to give it a go. I've got a lot of experience programming and a little bit in "sprinting" like this (my team placed second in Django Dash a few years ago), but I haven't made very many games. Still, by the end of the jam I managed to make something that's not quite a game but is pretty fun to play around with. I figured I'd write about the process while it's all still fresh in my mind.
My Game
The theme this year was actually tied in voting, so there were two:
- Growing
- Two Button Controls
Language & Code
I knew going in that I wanted to make something with ASCII graphics with Clojure and clojure-lanterna. Once I thought about the themes I decided to make a simulation of a world with plants and animals. I settled on the name "Silt" (the stuff at the bottom of riverbeds that slowly accumulates and reshapes the world) and got to work.
You can get the source for the game on BitBucket. There's also a mirror on GitHub if you prefer git.
Play
There's a prebuilt jar file on BitBucket if you want to play the game on your computer.
If you don't want to run some random code on your machine, I understand. I've spun up a server you can telnet into to play:
telnet silt.stevelosh.com
For best results use a terminal with a dark background. If there are a bunch of people playing the server may be slow, or fail entirely with out-of-memory errors. Sorry, it's just an 8gb Linode I'm funding out of my own pocket.
Gameplay
You are the god of a toroidal world. Initially it's inhabited by a small population of four hundred and one creatures. Time ticks by at a few ticks per second.
The creatures need energy to survive. They can get energy by eating fruit from shrubs or being near water.
If a creature has enough energy it might reproduce by splitting off a clone of itself. The clone will be identical to its parent (sibling?), though there is a slight chance of mutation.
The ideal body temperature for a creature is 0 degrees. The world starts at that temperature, creating a paradise. As the god of the world, the only way you can interact with it is to increase or decrease the temperature.
If the temperature of the world is different than the temperature of a creature, they will need to spend more energy to maintain their body heat. This makes it more difficult to survive. Swinging the temperature in large increments quickly is a sure way to kill off the population.
The creatures are not entirely at the mercy of the climate, however. Creatures have an "insulation" score that represents fur or skin. More insulation protects the creature from the outside climate, at the expense of a small amount of energy.
The mechanics of temperature/insulation and reproduction/mutation interact to cause evolution. If you slowly increase the temperature of the world over a few thousand ticks, children with more insulation are more likely to survive, and so your creatures will tend to evolve more insulation over time.
Creatures' movement patterns are also affected by mutation. Over time your creatures will likely evolve to wander instead of staying stationary, because fruit takes time to grow and so it's more effective to keep moving and gathering.
Creatures can also change their colors and glyphs through mutation, but this is much rarer. After letting the game run for a while you'll probably notice "gangs" of creatures with similar characteristics, all descended from a single parent.
Finally, there are eight mysterious objects scattered throughout the landscape. Each one does something, but discovering exactly what will be difficult (unless you read the source).
Controls
The controls are pretty basic:
arrow keys
to move your view of the world.R
to reset the world.escape
to quit the game.
Put your cursor over a creature to see their stats:
hjkl
orwasd
to move your cursor.
The world ticks along, but you can freeze time:
space
to pause/unpause time.
Those are the basic controls. To actually interact with the world you have only two options (in accordance with the "Two Button Controls" theme):
+
to make the world one degree hotter.-
to make the world one degree colder.
Good Choices
I managed to make a working, fairly interesting game within the time limit, so I'm pretty happy with most of my choices.
Familiar Environment
I decided to make Silt in Clojure. If you follow me on Twitch you might have noticed I've been getting more into Common Lisp these days. I don't really like writing in Clojure any more, but I used it for the jam because I wanted an environment I was familiar with.
This was a good decision. I wouldn't have finished if I tried to use Common Lisp — I'd probably still be struggling with whatever its Curses library is.
A 72-hour jam is not the time to be trying out a new language or framework you've never used before if you want to have a finished product at the end. Pick something you know well so you don't spend 30% of your time trying to Google things.
Streaming and Talking
I streamed a bit of the coding process a couple times over the weekend (look in past broadcasts and they might still be there). I didn't have a ton of viewers, but it was good to talk things out. Sometimes the stream was my rubber duck and it helped a bunch.
Releasing Before the End
Ludum Dare ends at a certain time, but there's a "release hour" afterword when you can take an hour to package up the final version of your game. I think it's better if you don't leave it until then to run through your packaging/release process at least once, though. Once I had the game in a runnable (if not yet very fun) state, I made it into a jar to make sure that everything works.
You don't want to find out your build process is fucked when you only have 40 minutes left, so do a dry run early and discover any snags while you have lots of time to fix them.
Getting Feedback
Once I had a playable game I asked my friend Hafdís to see if it would run on Windows. Luckily it did — Java and Lanterna are pretty nicely cross-platform.
She ran the game in the background for a while and showed me the results later. It's really helpful to get a second opinion about your game (or website or whatever you're making) as you're working on it. It's too easy to get too close to your own game and not realize what's fun or painful about it any more.
Asking in the IRC channel can work, but generally everyone there is busy working on their own games so you won't get a ton of takers. You can ask someone online, but I think in-person is better if at all possible. Seeing someone else enjoy something you've made is a big morale boost, which can be important when you're dealing with tricky bugs later.
Profiling and Performance
During the last day I spent an hour or so using a profiler to identify the hottest spots in the code and improving performance. I didn't focus too much on speed, but a little bit of attention can go a long way.
Ruthless Simplicity
One of the main reasons I managed to finish despite my lack of experience making games was being ruthless about making the game as simple as possible. I have a ton of ideas that I could potentially do, but cramming them all into the game would take far too long and would probably create a giant mess.
Focusing on one core mechanic (temperature) and building the things that interact with it got me pretty far. The fact that the theme was "Two Button Controls" really helped me a lot with this, because it forced me to come up with a mechanic simple enough to encode in two buttons.
Sleeping
I was hanging out in the official IRC channel over the weekend and noticed a bunch of people talking about how tired they were and how little sleep they were getting.
Because the time frame is so short there's a tendency to want to stay awake for as much time as possible to use as many hours as you can. I think this is usually a mistake. During this weekend, and also for the Django Dash weekend, I stuck to my normal sleeping/waking schedule and I think it was the right choice. You may get more hours of work in if you don't sleep, but you get better hours of work if you're rested, fed, and showered.
This is especially tricky if your timezone is offset with the event. The themes were announced at 2 AM my time, and while I would have loved to be awake and dive in right away I chose to go to sleep instead and just get started the next day. A screwed up sleep schedule is almost as bad as not enough sleep (for me at least).
Bad Choices
I made a few mistakes along the way too (luckily none of them prevented me from finishing).
Clinging to Failed Mechanics
One of the first mechanics I added to the game was aging — creatures would age over time and eventually die of old age. Immediately I had trouble balancing this against reproduction to avoid population explosions and extinctions, but I stuck with it for quite a while trying to make it work.
Eventually I tore the entire aging mechanic out and replaced it with hunger, which ended up working far, far better. In hindsight I should have abandoned aging much sooner, as soon as it was obvious that it was making things painful and unfun.
Last-Minute Additions
I added a couple of mysterious objects right before I packaged up the final version of the game and I didn't have time to playtest them very much. I tested that they didn't crash the game, but I assumed their effects would be minor curiosities and didn't worry too much.
Now that I've tested it a bit more, they have a bigger effect than I thought and drastically affect the world. I should have either tested them more or held off on them until I had time to test.
Working Alone
This one was unavoidable for me this time, but I'm sure Silt could have been a lot more interesting if I had worked with another programmer, an artist, or a sound designer.
Clojure
I'll end with a few notes on the experience of writing Silt in Clojure. Like I said earlier: I don't particularly like Clojure any more, but it was familiar so I ran with it. After doing a bunch of Common Lisp lately a few things jumped out at me about writing in Clojure, so I wanted to get them down on paper before I forget.
Destructuring is Nice
Clojure's ubiquitous destructuring is really nice. Common Lisp has
destructuring-bind
but it's not as powerful as it is in Clojure, and it's
certainly not threaded through the language nearly as much.
Destructuring in function arguments is really handy — I almost want to write
a defun-destructured
macro that will let me do it in CL.
Fewer Superfluous Parentheses
In Clojure's syntax there seems to be a general theme of using fewer
parentheses. I don't just mean using brackets and curly braces for other
literals either. Take the humble let
in Clojure:
(let [x (foo)
y (bar)]
(+ x y))
If you translate the vector literal to a Common Lisp list, it would look like this:
(let (x (foo)
y (bar))
(+ x y))
But Common Lisp actually makes you surround the binding pairs with another set of parentheses:
(let ((x (foo))
(y (bar)))
(+ x y))
This is annoying. There are always going to be pairs of binding forms, why do I need to wrap them in extra parens? Clojure's way is cleaner.
The Tooling is Good
Jvisualvm's statistical profiler is a bit homely, but it gets the job done. It's nice to have mature tools to introspect what your code is doing at runtime.
lein uberjar
just worked and produced a jar that runs fine on Windows and OS
X. This was a great relief. I haven't looked at packaging up a Common Lisp app
but I am not optimistic.
But the Compiler Is Not Helpful
SBCL warns me when I'm doing something stupid:
CL-USER> (defun square (x) (* x x))
SQUARE
CL-USER> (defun bad (x y)
(+ x (square y y)))
; in: DEFUN BAD
; (SQUARE Y Y)
;
; caught STYLE-WARNING:
; The function was called with two arguments, but wants exactly one.
;
; compilation unit finished
; caught 1 STYLE-WARNING condition
BAD
CL-USER>
Clojure happily lets me be stupid without a peep:
user=> (defn square [x] (* x x))
#'user/square
user=> (defn bad [x y]
#_=> (+ x (square y y)))
#'user/bad
user=>
I'm often stupid, so I prefer a compiler that helps prevent it.
Errors are Still Terrible
Clojure stack traces have historically been awful. When I ran into my first error this time I noticed that the stack trace was... nonexistent?
user=> (defn foo [] (/ 1 0))
#'user/foo
user=> (foo)
ArithmeticException Divide by zero clojure.lang.Numbers.divide (Numbers.java:158)
user=>
I guess they got tired of people complaining about how the stack traces look like shit, so they just... removed them? You can get them back with a bit of work:
user=> (use '[clojure.stacktrace :only [print-stack-trace]])
user=> (print-stack-trace *e)
java.lang.ArithmeticException: Divide by zero
at clojure.lang.Numbers.divide (Numbers.java:158)
clojure.lang.Numbers.divide (Numbers.java:3808)
user$foo.invoke (form-init5869611501548592997.clj:1)
user$eval1219.invoke (form-init5869611501548592997.clj:1)
clojure.lang.Compiler.eval (Compiler.java:6782)
clojure.lang.Compiler.eval (Compiler.java:6745)
clojure.core$eval.invoke (core.clj:3081)
clojure.main$repl$read_eval_print__7099$fn__7102.invoke (main.clj:240)
clojure.main$repl$read_eval_print__7099.invoke (main.clj:240)
clojure.main$repl$fn__7108.invoke (main.clj:258)
clojure.main$repl.doInvoke (main.clj:258)
clojure.lang.RestFn.invoke (RestFn.java:1523)
clojure.tools.nrepl.middleware.interruptible_eval$evaluate$fn__623.invoke (interruptible_eval.clj:58)
clojure.lang.AFn.applyToHelper (AFn.java:152)
clojure.lang.AFn.applyTo (AFn.java:144)
clojure.core$apply.invoke (core.clj:630)
clojure.core$with_bindings_STAR_.doInvoke (core.clj:1868)
clojure.lang.RestFn.invoke (RestFn.java:425)
clojure.tools.nrepl.middleware.interruptible_eval$evaluate.invoke (interruptible_eval.clj:56)
clojure.tools.nrepl.middleware.interruptible_eval$interruptible_eval$fn__665$fn__668.invoke (interruptible_eval.clj:191)
clojure.tools.nrepl.middleware.interruptible_eval$run_next$fn__660.invoke (interruptible_eval.clj:159)
clojure.lang.AFn.run (AFn.java:22)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1142)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:617)
java.lang.Thread.run (Thread.java:745)
Oh yeah, there's the garbagepile I remember.
Lisp also hides the traceback at first, but it's easier to get at, and once you do it's actually helpful:
CL-USER> (declaim (optimize (debug 3)))
NIL
CL-USER> (defun foo () (/ 1 0))
; in: DEFUN FOO
; (/ 1 0)
;
; caught STYLE-WARNING:
; Lisp error during constant folding:
; arithmetic error DIVISION-BY-ZERO signalled
; Operation was /, operands (1 0).
;
; compilation unit finished
; caught 1 STYLE-WARNING condition
FOO
CL-USER> (foo)
debugger invoked on a DIVISION-BY-ZERO in thread
#<THREAD "main thread" RUNNING {1002C74703}>:
arithmetic error DIVISION-BY-ZERO signalled
Operation was /, operands (1 0).
Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.
restarts (invokable by number or by possibly-abbreviated name):
0: [ABORT] Exit debugger, returning to top level.
(SB-KERNEL::INTEGER-/-INTEGER 1 0)
0] backtrace
Backtrace for: #<SB-THREAD:THREAD "main thread" RUNNING {1002C74703}>
0: (SB-KERNEL::INTEGER-/-INTEGER 1 0)
1: (/ 1 0)
2: (FOO)
3: (SB-INT:SIMPLE-EVAL-IN-LEXENV (FOO) #<NULL-LEXENV>)
4: (EVAL (FOO))
5: (INTERACTIVE-EVAL (FOO) :EVAL NIL)
6: (SB-IMPL::REPL-FUN NIL)
7: ((LAMBDA NIL :IN SB-IMPL::TOPLEVEL-REPL))
8: (SB-IMPL::%WITH-REBOUND-IO-SYNTAX #<CLOSURE (LAMBDA NIL :IN SB-IMPL::TOPLEVEL-REPL) {1004A790FB}>)
9: (SB-IMPL::TOPLEVEL-REPL NIL)
10: (SB-IMPL::TOPLEVEL-INIT)
11: ((FLET #:WITHOUT-INTERRUPTS-BODY-86 :IN SAVE-LISP-AND-DIE))
12: ((LABELS SB-IMPL::RESTART-LISP :IN SAVE-LISP-AND-DIE))
Notice how it actually warns me at compile time about the error, whereas Clojure merrily compiles it without a peep. Also notice how the SBCL backtrace gives me actual forms and arguments, instead of a line number (which is probably inaccurate if you've been editing and eval'ing a file piecemeal).
Old Bugs
Running into four-year old bugs was fun.
RIP Your Heap
Clojure's data structures and STM system make it pretty easy to write code that both:
- Runs correctly
- Generates enormous mountains of garbage
This can be a good or bad thing, depending on how you look at it and what your needs are.
Common Lisp has immutable data structures (via fset) and STM if you want them, but it also lets you use efficient mutable things just as easily if you want. It doesn't really encourage one specific way of doing things.
Conclusion
Ludum Dare 34 was a lot of fun. I'm definitely going to mark my calendar and do the next one too.
If making a game sounds interesting, you should do it too! You don't need a ton of programming experience, artistic skills, or anything but a love of video games and a free weekend. Get out there and make a game!