I wanted to play with some graphics. I don't know much about graphics, so I wanted to start with the basics. I played around with a couple of demos and I found that easiest to get reliably working was SDL2.
After downloading the SDL binary library and installing the FFI bindings with Quicklisp, I was off and running. You can find numerous SDL demos and tutorials on line and I tried a number of them. After I felt confident I decided to try something simple.
One thing I've noticed about graphics programs is the ubiquity of mutable state. Everything seems mutable and is freely modified and global variables abound. As a mostly functional programmer, I am alarmed by this. I wanted to see where we'd get if we tried to be more functional in our approach and avoid mutable data structures where practical.
Now the pixels on the screen had best be mutable, and I'm not
trying to put a functional abstraction over the drawing primitives.
We'll encapsulate the rest of the state in a state machine that is
driven by the SDL event loop. The state machine will keep track of
time and the current world. The current world is simply an
immutable list of immutable objects. The state machine can
transition through a
render! phase, where it renders
all the objects in the current world to a fresh frame. It attempts
to do this about 75 times a second. The state machine can also
transition through a
next-world phase, where the
current world and a delta-
t are used to compute a new
version of the world.
run program will take the initial list of objects.
We'll start by initializing SDL, creating a window, and allocating a
renderer for that window:
(defun run (initial-world) (sdl2:with-init (:video) (sdl2:with-window (window :h +window-height+ :w +window-width+ :flags '(:shown)) (sdl2:with-renderer (renderer window :index -1 :flags '()) ... )))
Now we need the event loop state.
records the value from
sdl2:get-ticks from the last
time we processed the
:idle event. This will be used
to compute the elapsed time in ticks.
will record how many ticks have elapsed since the last time we
rendered a frame to the screen. When
exceeds a certain amount, we'll call
current-world) and reset the ticker to
title-ticker will record how many ticks have
occurred since the last time the window title was updated.
title-ticker exceeds a certain amount, we'll
sdl2:set-window-title to update the window title
with some stats.
sim-count is simply the number of
times we've iterated
frame-count is the number of times we've
render!. These are reset to zero every time we
refresh the window title, so we'll have the frames per second and
the world steps per second in the window title.
(let ((last-ticks 0) (render-ticker 0) (title-ticker 0) (sim-count 0) (frame-count 0) (world initial-world)) (flet ((title-tick! (dticks) (incf title-ticker dticks) (when (>= title-ticker 1000) (decf title-ticker 1000) (sdl2:set-window-title window (format nil "Sim rate: ~d, Frame rate: ~d" sim-count frame-count)) (setq sim-count 0) (setq frame-count 0))) (world-tick! (dticks) (incf sim-count) (setq world (next-world world (/ dticks 1000)))) (render-tick! (dticks) (incf render-ticker dticks) (when (>= render-ticker 13) (incf frame-count) (decf render-ticker 13) (render-world! renderer world))))
Now we can run the event loop. The idle event is where the action happens:
(sdl2:with-event-loop (:method :poll) (:idle () (let ((this-ticks (sdl2:get-ticks))) (if (= this-ticks last-ticks) (sdl2:delay 1) (let ((dticks (- this-ticks last-ticks))) (setq last-ticks this-ticks) (title-tick! dticks) (world-tick! dticks) (render-tick! dticks))))) (:keydown (:keysym keysym) (case (sdl2:scancode keysym) (:scancode-escape (sdl2:push-quit-event)) (:scancode-x (sdl2:push-quit-event)))) (:quit () t))
Now that's a bunch of state, but it's more or less under control because what we have is a state machine and the state variables aren't accessible to anything.
render-world! is straightforward. It clears the
render! on every object in the world,
and presents the renderer for display.
(defun render-world! (renderer world) (sdl2:set-render-draw-color renderer #x00 #x00 #x00 #xFF) (sdl2:render-clear renderer) (mapc (lambda (object) (render! renderer object)) world) (sdl2:render-present renderer) )
next-world is a function that maps the current world
to the next. It basically calls
next on each object in
the world and accumulate the results. We want objects to be able to
go away, so if
nil, we don't accumulate anything in the new
next returns the object unchanged, it will
be accumulated unchanged in the next
(next object) returns a new version of an
object to simulate an update to the object. We want to be able to
increase the amount of objects, so we
(next object) to return a list of objects
to be accumulated.
(defun next-world (previous-world dt) (fold-left (lambda (items item) (let ((more (next item dt))) (cond ((null more) items) ((consp more) (append more items)) (t (cons more items))))) '() previous-world))
We'll start with a user-controlled player.
(defclass player () ((x :initarg :x :reader get-x) (y :initarg :y :reader get-y)))
Everything that is to be displayed needs a
method. This one just draws a little green triangle facing up.
(defmethod render! (renderer (player player)) (let ((x (floor (get-x player))) (y (floor (get-y player)))) (sdl2:set-render-draw-color renderer #x00 #xFF #x00 #xFF) (sdl2:render-draw-line renderer (- x 8) (+ y 8) (+ x 8) (+ y 8)) (sdl2:render-draw-line renderer (- x 8) (+ y 8) (- x 1) (- y 16)) (sdl2:render-draw-line renderer (+ x 8) (+ y 8) x (- y 16)) (sdl2:render-draw-point renderer x y) ))
next method computes the player in the next
(defun x-input () (- (if (sdl2:keyboard-state-p :scancode-right) 1 0) (if (sdl2:keyboard-state-p :scancode-left) 1 0))) (defun y-input () (- (if (sdl2:keyboard-state-p :scancode-down) 1 0) (if (sdl2:keyboard-state-p :scancode-up) 1 0))) (defparameter +player-speed+ 200.0) ;; pixels per second (defmethod next ((player player) dt) (let ((new-x (max 8 (min (- +window-width+ 8) (+ (get-x player) (* (x-input) +player-speed+ dt))))) (new-y (max 16 (min (- +window-height+ 8) (+ (get-y player) (* (y-input) +player-speed+ dt)))))) (make-instance 'player :x new-x :y new-y)))
Once we've defined a
render! method and
next method, we're ready to go. If we
run on a list containing a player object, we'll
have our little player on the screen controllable with the arrow
An enemy ship can be defined.
(defclass enemy () ((x :initarg :x :reader get-x) (y :initarg :y :reader get-y) (dx :initarg :dx :reader get-dx) (dy :initarg :dy :reader get-dy))) (defmethod next ((enemy enemy) dt) (let ((new-x (+ (get-x enemy) (* (get-dx enemy) dt))) (new-y (+ (get-y enemy) (* (get-dy enemy) dt)))) (when (and (>= new-x 8) (< new-x (+ +window-width+ 8)) (>= new-y 8) (< new-y (- +window-height+ 16))) (make-instance 'enemy :x new-x :y new-y :dx (get-dx enemy) :dy (get-dy enemy))))) ;;; Render method omitted
As given, enemy ships will drift at constant speed until they run off the screen. We'd like to replenish the supply, so we'll make an enemy spawner:
(defclass enemy-spawner () ((timer :initarg :timer :initform 0 :reader get-timer))) (defmethod next ((spawner enemy-spawner) dt) (let ((new-time (- (get-timer spawner) dt))) (if (> new-time 0) (make-instance 'enemy-spawner :timer new-time) (list (make-instance 'enemy-spawner :timer (+ 1 (random 4))) (make-instance 'enemy :x (random (+ (- +window-width+ 32) 16)) :y 16 :dx (- 25 (random 50)) :dy (+ (random 100) 50)))))) (defmethod render! (renderer (enemy-spawner enemy-spawner)) nil)The
render!method doesn't do anything so a spawner doesn't have an image. It simply has a timer. To compute the next spawner, we subtract
dtand create a new spawner with the reduced amount of time. If that's not a positive amount of time, though, we create two objects: a new spawner with somewhere between 1 and 5 seconds time and a new enemy ship.
We'll modify our player to allow him to shoot at the enemy:
(defclass player () ((x :initarg :x :reader get-x) (y :initarg :y :reader get-y) (fire-cycle :initarg :fire-cycle :initform 0 :reader get-fire-cycle))) (defmethod next ((player player) dt) (let ((new-x (limit 8 (- +window-width+ 8) (+ (get-x player) (* (x-input) +player-speed+ dt)))) (new-y (limit 16 (- +window-height+ 8) (+ (get-y player) (* (y-input) +player-speed+ dt)))) (next-fire-cycle (- (get-fire-cycle player) dt))) (if (and (sdl2:keyboard-state-p :scancode-space) (< next-fire-cycle 0)) (list (make-instance 'player :x new-x :y new-y :fire-cycle .1) (make-instance 'bullet :x (- (get-x player) 8) :y (- (get-y player) 16) :dx 0 :dy (- +bullet-speed+)) (make-instance 'bullet :x (+ (get-x player) 8) :y (- (get-y player) 16) :dx 0 :dy (- +bullet-speed+))) (make-instance 'player :x new-x :y new-y :fire-cycle next-fire-cycle))))
A bullet is a simple moving object:
(defclass bullet () ((x :initarg :x :reader get-x) (y :initarg :y :reader get-y) (dx :initarg :dx :reader get-dx) (dy :initarg :dy :reader get-dy))) (defmethod next ((bullet bullet) dt) (let ((new-x (+ (get-x bullet) (* (get-dx bullet) dt))) (new-y (+ (get-y bullet) (* (get-dy bullet) dt)))) (when (and (>= new-x 0) (< new-x +window-width+) (>= new-y 0) (< new-y +window-height+)) (make-instance 'bullet :x new-x :y new-y :dx (get-dx bullet) :dy (get-dy bullet)))))
At this point we can move around the screen and shoot at enemies
that spawn periodically. The problem is that the bullets go right
through the enemy. We need to handle object collisions. We'll
next-world function. As it loops over the
objects in the world, it will perform an inner loop that checks for
collisions with other objects. If two objects collide, a function
is called to get the collision results and those results are added
to the list of objects in the world. If an object doesn't collide
with anything, the
next method is called to get the
next version of the object.
(defun next-world (previous-world dt) (let outer ((tail previous-world) (next-world '())) (cond ((consp tail) (let ((this (car tail))) (let inner ((those (cdr tail))) (cond ((consp those) (let ((that (car those)) (others (cdr those))) (if (collides? this that) (outer (append (collide this that) (delete that (cdr tail))) next-world) (inner others)))) ((null those) (outer (cdr tail) (let ((more (next this dt))) (cond ((consp more) (append more next-world)) ((null more) next-world) (t (cons more next-world)))))) (t (error "Bad list.")))))) ((null tail) next-world) (t (error "Bad list.")))))
collides? as a generic function that
nil by default
(defgeneric collides? (this that) (:method ((this t) (that t)) nil) )so that most objects don't collide. In the case where something does collide, we'll define
collideas a generic function that returns
(defgeneric collide (this that) (:method ((this t) (that t)) nil) )so when two objects collide, they simply disappear.
collides? will be called on pairs of objects in no
particular order, so method pairs will be needed to handle both
orders. We'll define
collides? methods on bullets and
enemies that checks if the bullet is within the bounding box of the enemy:
(defmethod collides? ((this bullet) (that enemy)) (and (> (get-x this) (- (get-x that) 8)) (< (get-x this) (+ (get-x that) 8)) (> (get-y this) (- (get-y that) 8)) (< (get-y this) (+ (get-y that) 8)))) (defmethod collides? ((this enemy) (that bullet)) (collides? that this))
At this point, we can shoot enemy ships. The default method
collide between an enemy and a bullet
nil so the enemy and the bullet simply
disappear. If we were fancy, we could arrange for it to return an
explosion object or several debris objects.
It would be nice to keep a tally of the number of
enemy ships we have shot. We don't have to add any extra machinery
for this. We create a
score class and a point class:
(defclass score () ((value :initarg :value :reader get-value))) (defmethod next ((score score) dt) score) ;;; Render method prints score on screen. (defclass point () ((value :initarg :value :reader get-value))) (defmethod next ((point point) dt) point) (defmethod render! (renderer (point point)) nil)Scores and points are immutable objects without positions, but we'll define methods so that when a score and a point collide, the result is a higher score.
(defmethod collides? ((this point) (that score)) t) (defmethod collides? ((this score) (that point)) t) (defmethod collide ((this point) (that score)) (list (make-instance 'score :font (get-font that) :value (+ (get-value this) (get-value that))))) (defmethod collide ((this score) (that point)) (collide that this))Now we'll define a bullet colliding with an enemy to produce a point:
(defmethod collide ((this bullet) (that enemy)) (list (make-instance 'point :value 1) ;; add explosion object here )) (defmethod collide ((this enemy) (that bullet)) (collide that this))So when you shoot an enemy, the bullet and enemy disappear to be replaced by a point. On the next update, the point will collide with the score to be replaced with an updated score.
At this point we've got a little demo game where we can fly a ship around and shoot enemies and a running score is kept. The world model is immutable and worlds are functions of previous worlds. I'll call it a successful proof of concept.
But did this buy us anything? We don't have mutable state per se, but we've kind of cheated. When we create new versions of an object, each version is immutable, but the sequence of versions taken as a whole seem to be evolving over time. For example, consider the score. At each time step, there is an immutable score object, but over time what is considered the current score changes. We've eliminated the direct problems of mutation, but we've introduced the problem of keeping track of what series of immutable versions correspond to a single evolving instance.
In this small example, we're not keeping track of the evolving objects. For instance, each bullet, as it is updated from step to step, is actually created anew at its new position on each step. The old bullet instance is dropped and the new instance really has no idea how it got there. Bullets are such simple objects that this doesn't matter, but the current score is different. It makes sense for there to be a singleton score object that increases over time, but we haven't built that in to our model. Instead, we've designed a set of collision interactions that drive the score.
We've eliminated the direct mutable state in our objects and our world, but sometimes we want to model stateful objects. We therefore create objects that represent state transitions (e.g. points) and then use the collision mechanism to combine the transition objects with the objects that represent physical entities. That seems a bit convoluted, and I don't think it will scale.
On the other hand, we do gain the immediate benefits of the world and the objects being immutable. Saving and restoring a world is trivial. Reasoning about objects at each update is easy because the objects don't change, but we now have to reason about how objects appear to change in the long run.
The tradeoff of immutable objects is increased allocation. But although a lot more consing is happening, most of it can be quickly reclaimed by the generational collector, so noticable GC pauses are infrequent. I haven't measured the performance, but it is certainly adequate for the little example I wrote. If you had a mechanism to reason about the objects as linear types (Sufficiently Smart Compiler™), you could determine when you can update objects in place and avoid reallocating.
The world model, simply a list of objects, is flexible, but not well structured. For instance, the current score and the bullets are among the elements mixed together in this list. You'd have to search this list to find specific elements or filter this list to find elements of a certain type.
The simple collision model is O(n2), so it can't handle a ton of objects. A more sophisticated world model would be needed to keep track of different classes of collidable objects to avoid the O(n2) search. For example, if bullets were kept separately, we could avoid checking if they collide with each other.
The point of this exercise was to play with graphics and see what you can do without the mutable state that is alarmingly ubiquitous. It turns out that you can go pretty far, but it's kind of strange.