author |
Steve Losh <steve@stevelosh.com> |
date |
Wed, 21 Aug 2024 09:10:34 -0400 |
parents |
f5556130bda1 |
children |
(none) |
(
:title "Ludum Dare 34 Postmortem"
:snip "I made a \"game\"!"
:date "2015-12-15T16:30:00Z"
:draft nil
)
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][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.
[Ludum Dare 34]: http://ludumdare.com/compo/
[Django Dash]: http://djangodash.com/judging/c2/results/team/49/
<div id="toc"></div>
## 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][bitbucket]. There's also
a mirror on GitHub if you prefer git.
### Play
There's a prebuilt jar file [on BitBucket][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.
[![Silt Initial World](/static/images/blog/2015/12/silt-initial.gif)](/static/images/blog/2015/12/silt-initial.gif)
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.
[![Silt Later World](/static/images/blog/2015/12/silt-later.gif)](/static/images/blog/2015/12/silt-later.gif)
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`** or **`wasd`** 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.
[clojure-lanterna]: https://sjl.bitbucket.io/clojure-lanterna
[bitbucket]: https://bitbucket.org/sjl/silt
[toroidal]: https://en.wikipedia.org/wiki/Torus
## 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.
[Twitch]: http://twitch.tv/stevelosh
### 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.
[rubber duck]: https://en.wikipedia.org/wiki/Rubber_duck_debugging
### 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.
[Hafdís]: http://havethis.info/
### 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:
```text
(let [x (foo)
y (bar)]
(+ x y))
```
If you translate the vector literal to a Common Lisp list, it would look like
this:
```text
(let (x (foo)
y (bar))
(+ x y))
```
But Common Lisp actually makes you surround the binding pairs with *another* set
of parentheses:
```text
(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:
```lisp
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:
```clojure
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?
```clojure
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:
```clojure
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*:
```lisp
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][clj-700] 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.
[clj-700]: http://dev.clojure.org/jira/browse/CLJ-700
## 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!