Exploring the Gamespace in Scheme
I had been looking forward to the recent Lisp Game Jam and thought I would participate (unfortunately I had a minor health emergency followed by surgery so I was unable). Leading up to that I looked into how I would go about doing this. I looked at Racket first, remembering some of my Racket courses had involved movings images around on screen. Ultimately I moved on from that, wanting something a bit more tailored specifically to gaming; with sprite-loading, tilemaps, audio, etc.
Along the way I came across Chickadee. It had a clean and modularized API and I knew this because it actually had docs! (Also, I had never used Guile, and I love trying out new Schemes.) Beyond that it had first-class support for Emacs + Geiser, allowing the evaluation of code in the buffer to affect the running game instance. I was intrigued to say the least. I'm very glad to have finally gotten around to this post as it is truly what inspired the creation of this blog. As I spoke with friends and fellow schemers during the creation of this prototype, many asked if any of this was being logged anywhere, especially the features of Chickadee that may be under documented.
GUIX and I didn't get along
The hardest part of using Chickadee was getting it up and running. Needless to say I ended up using
apt install guile and building the dependencies, GUIX is a river to cross another day. I wish all the necessary packages were simply on Akku ¯_ (ツ)_/¯.
Why Flappy Bird
I was inspired both by my friend David who had recently made a Flappy Bird clone using Godot as well as this clojure video from Bruce Hauman which featured many of the interaction niceties touted in Chickadee's Geiser support. Additionally, it is a very straightforward game to implement with just a single input and very basic rules. I won't go through all the code, the main file is here, instead I'll focus on a few highlights and the overall flow.
FlapPyBird was my source for the game assets themselves, beyond that I hit the docs to get started. Honestly, it's impressive how clear the library is, without explanation the following codes purpose should be fairly clear regardless of ones familiarity with Guile or even Scheme.
(run-game #:load load #:draw draw #:update update #:window-title "Flappy Bird!" #:window-width WINDOW-WIDTH #:window-height WINDOW-HEIGHT #:mouse-press handle-mouse-press)
The heavy reliance on
#:) is always new to me when I step away from my SoC (Scheme of Choice) but there is no debating the clarity it provides. "Put your load function here, your draw function here, your update function here, etc"
Let's start at the top, looking at the scaffolding of a Chickadee game.
Like much of the library, not much needs to be said about this process. One thing that seemed inevitable however was having to
set! the loaded textures onto existing declarations as they needed to be accessed in several other scopes. Managing large global state in scheme is something I would love to understand more about and will likely use this game as a test bed for exploration in that space.
(set! background-sprite (load-image "./assets/sprites/background-day.png")) (set! ground-sprite (load-image "./assets/sprites/base.png"))
Couldn't be easier.
Unsurprisingly, drawing a sprite is equally straight-forward:
(draw-sprite background-sprite #v(0.0 0.0))
Sprites are drawn from the back to the front unless otherwise specified, so the background is drawn first. There is some logic around the drawing of the tubes and the bird, but ultimately they are
draw-sprite calls as well.
I deviate slightly in this function from the docs when it comes to drawing the ground texture, and use an undocumented function,
draw-sprite* to enable scrolling of the ground texture:
(draw-sprite* ground-texture ground-tex-rect IDENTITY-MATRIX #:texcoords ground-tex-coords)
I have to thank David Thompson, not only for this great library, but also for taking the time to answer questions!
draw-sprite* is a low-level function that is used by
draw-sprite. It gives granular control over the application of a texture and texture coordinate system to the target coordinate system. We'll come back to this shortly, what matters now is that we have our
ground-text-coords available to change. The other thing to note, is this is a pretty common pattern: many functions have asterisk-suffixed equivalents doing the lower-level lifting.
For now, lets move onto the update function.
Some Update Magic
As you may have guessed, this function runs every tick updating the game state before the draw function gets called to redraw the game.
Two little snippets power the Emacs + Geiser magic. One, is the creation of a cooperative repl server, started with a function imported from
(system repl coop-server):
(define repl (spawn-coop-repl-server))
Later, at the top of the function passed to
;; Update REPL and Agenda (poll-coop-repl-server repl))
This is all it takes for the cooperative repl server to work. At that point I can run the game script and from Geiser,
connect-to-guile will find and connect to the running instance. The cooperative repl pauses at each update loop to poll the server for new messages, this means Emacs runs unblocked and everything can be kept in sync.
I can't understate how much fun it is to develop a game with everything able to be manipulated from a REPL or even directly from your source, evaluating s-expressions as you code.
Reacting to Events The last function passed to the run-game call, handle-mouse-press, fires on each mouse event:
(define (handle-mouse-press button clicks x-pos y-pos) (if (eqv? button 'left) (flap/c)) (if (eqv? button 'right) (init-new-game)))
Once more, this function should be a bit self-evident. It's worth pointing out that
flap/c is a continuation which we'll revisit.
Hold Up At this point the most obvious question should be, "Okay, but where is... everything?". This goes through a game render cycle and dictates actions handles but where is the game state itself updated?
Chickadee has a concept of 'scripts' that is really neat. Take for example the simple logic for pulling the bird down with gravity:
(define gravity (script (forever (set-rect-y! flappy-bird-rect (- (rect-y flappy-bird-rect) flappy-bird-drop-velocity)) (sleep 1))))
This script is registered to run forever, it makes a state change, then says 'okay, I'm asleep until the next tick', in fact if we look back at the update function it really only has one job outside of polling the cooperative server-repl.
(update-agenda (if paused 0 1))
So here we get exposed to our agenda, every script belongs to an agenda and the update loop can update the current agenda. In this simple game we only use one agenda; the only need is to advance our tick counter.
Remember the ground-texture coordinates mentioned earlier? Here's how the scrolling ground texture is accomplished:
(define offset-ground-texture (script (forever (begin (rect-move-by! ground-tex-coords 10 0.0) (sleep 1)))))
This ability to create new agendas, set and advance agendas and control timing in decoupled scripts is fantastic. The scripting system also includes a few useful features seen in the
(define flap-bird (script (yield (lambda (c) (set! flap/c c))) (set! flappy-bird-sprite flappy-bird-down-flap-tex) (tween 10 (rect-y flappy-bird-rect) (+ flappy-bird-flap-velocity (rect-y flappy-bird-rect)) (lambda (y) (set-rect-y! flappy-bird-rect y))) (set! flappy-bird-sprite flappy-bird-up-flap-tex)))
This script is registered, but this script doesn't dictate
forever at the start. Instead, it has a syntax for capturing a continuation,
c, via a yield function. Now, calling
flap/c as we did in the mouse handler, executes the current-continuation.
We also see the
helpful tween function in use here which gradually, over a specified number of ticks, changes a value to another value, allowing for smooth flaps.
Reading through the scripts should give a clear image of each component that makes up the games rules:
- We advance every tick.
- Pairs of pipes spawn and advance in intervals.
- Gravity pulls us down every tick.
- A flap can occur to lift us a certain number of pixels.
- A collision with the ground ends the round.
- A collision with a pipe ends the round.
- Any issue in any component of the game should be easily traced back to a corresponding script.
In April, David Thompson let me know audio support was coming soon and I'm pleased to see it's already in the library and documented. I'm looking forward to adding a bit more detail to the bird animation, (an additional, unused flap asset, as well as tilting of the bird during flight), a countdown timer, a score indicator, and audio on the next iteration. Outside of Flappy Bird, I would like to explore Chickadee's support for Tiled as well. I recommend those who are interested in game dev with Scheme to try Chickadee.