26 Interactive Games as Reactive Systems
In this tutorial we’re going to write a little interactive game. The game won’t be sophisticated, but it’ll have all the elements you need to build much richer games of your own.
Albuquerque Balloon Fiesta
Imagine we have an airplane coming in to land. It’s unfortunately trying to do so amidst a hot-air balloon festival, so it naturally wants to avoid colliding with any (moving) balloons. In addition, there is both land and water, and the airplane needs to alight on land. We might also equip it with limited amounts of fuel to complete its task. Here are some animations of the game:
animate the airplane to move autonomously;
detect keystrokes and adjust the airplane accordingly;
have multiple moving balloons;
detect collisions between the airplane and balloons;
check for landing on water and land; and
account for the use of fuel.
26.1 About Reactive Animations
We are writing a program with two important interactive elements: it is an animation, meaning it gives the impression of motion, and it is reactive, meaning it responds to user input. Both of these can be challenging to program, but Pyret provides a simple mechanism that accommodates both and integrates well with other programming principles such as testing. We will learn about this as we go along.
26.2 Preliminaries
import image as I
import reactors as R
I
and R
. Thus, all image
operations are obtained from I
and animation operations from
R
.26.3 Version: Airplane Moving Across the Screen
We will start with the simplest version: one in which the airplane moves horizontally across the screen. Watch this video.
First, here’s an image of an airplane:Have fun finding your preferred airplane image! But don’t spend too long on it, because we’ve still got a lot of work to do.
http://world.cs.brown.edu/1/clipart/airplane-small.png
AIRPLANE-URL =
"http://world.cs.brown.edu/1/clipart/airplane-small.png"
AIRPLANE = I.image-url(AIRPLANE-URL)
AIRPLANE
, it will always refer to
this image. (Try it out in the interactions area!)Now look at the video again. Watch what happens at different points in time. What stays the same, and what changes? What’s common is the water and land, which stay the same. What changes is the (horizontal) position of the airplane.
The World State consists of everything that changes. Things that stay the same do not need to get recorded in the World State.
We can now define our first World State:
The World State is a number, representing the x-position of the airplane.
Observe something important above:
When we record a World State, we don’t capture only the type of the values, but also their intended meaning.
Ask to be notified of the passage of time.
As time passes, correspondingly update the World State.
Given an updated World State, produce the corresponding visual display.
26.3.1 Updating the World State
As we’ve noted, the airplane doesn’t actually “move”. Rather, we can ask Pyret to notify us every time a clock ticks. If on each tick we place the airplane in an appropriately different position, and the ticks happen often enough, we will get the impression of motion.
AIRPLANE-X-MOVE = 10
check:
move-airplane-x-on-tick(50) is 50 + AIRPLANE-X-MOVE
move-airplane-x-on-tick(0) is 0 + AIRPLANE-X-MOVE
move-airplane-x-on-tick(100) is 100 + AIRPLANE-X-MOVE
end
fun move-airplane-x-on-tick(w):
w + AIRPLANE-X-MOVE
end
If you have prior experience programming animations and reactive programs, you will immediately notice an important difference: it’s easy to test parts of your program in Pyret!
26.3.2 Displaying the World State
WIDTH = 800
HEIGHT = 500
BASE-HEIGHT = 50
WATER-WIDTH = 500
BLANK-SCENE = I.empty-scene(WIDTH, HEIGHT)
WATER = I.rectangle(WATER-WIDTH, BASE-HEIGHT, "solid", "blue")
LAND = I.rectangle(WIDTH - WATER-WIDTH, BASE-HEIGHT, "solid", "brown")
BASE = I.beside(WATER, LAND)
BACKGROUND =
I.place-image(BASE,
WIDTH / 2, HEIGHT - (BASE-HEIGHT / 2),
BLANK-SCENE)
BACKGROUND
in the interactions area
to confirm that it looks right.Do Now!
The reason we divide by two when placing
BASE
is because Pyret puts the middle of the image at the given location. Remove the division and see what happens to the resulting image.
I.place-image(AIRPLANE,
# some x position,
50,
BACKGROUND)
fun place-airplane-x(w):
I.place-image(AIRPLANE,
w,
50,
BACKGROUND)
end
26.3.3 Observing Time (and Combining the Pieces)
Finally, we’re ready to put these pieces together.
We create a special kind of Pyret value called a reactor, which creates animations. We’ll start by creating a fairly simple kind of reactor, then grow it as the program gets more sophisticated.
anim
:
anim = reactor:
init: 0,
on-tick: move-airplane-x-on-tick,
to-draw: place-airplane-x
end
on-tick
tells Pyret to run a clock and, every time the clock
ticks (roughly thirty times a second), invoke the associated
handler. The to-draw
handler is used by Pyret to refresh the
visual display.R.interact(anim)
That’s it! We’ve created our first animation. Now that we’ve gotten all the preliminaries out of the way, we can go about enhancing it.
Exercise
If you want the airplane to appear to move faster, what can you change?
26.4 Version: Wrapping Around
When you run the preceding program, you’ll notice that after a while, the airplane just disappears. This is because it has gone past the right edge of the screen; it is still being “drawn”, but in a location that you cannot see. That’s not very useful!Also, after a long while you might get an error because the computer is being asked to draw the airplane at a location beyond what the graphics system can manage. Instead, when the airplane is about to go past the right edge of the screen, we’d like it to reappear on the left by a corresponding amount: “wrapping around”, as it were.
Here’s the video for this version.
Do Now!
What needs to change?
Clearly, we need to modify the function that updates the airplane’s location, since this must now reflect our decision to wrap around. But the task of how to draw the airplane doesn’t need to change at all! Similarly, the definition of the World State does not need to change, either.
move-airplane-x-on-tick
. The
function num-modulo
does exactly what we need. That is, we want
the x-location to always be modulo the width of the scene:
fun move-airplane-wrapping-x-on-tick(x):
num-modulo(x + AIRPLANE-X-MOVE, WIDTH)
end
fun move-airplane-wrapping-x-on-tick(x):
num-modulo(move-airplane-x-on-tick(x), WIDTH)
end
Well, that’s a proposed re-definition. Be sure to test this function thoroughly: it’s tricker than you might think! Have you thought about all the cases? For instance, what happens if the airplane is half-way off the right edge of the screen?
Exercise
Define quality tests for
move-airplane-wrapping-x-on-tick
.
It is possible to leave
move-airplane-x-on-tick
unchanged and perform the modular arithmetic inplace-airplane-x
instead. We choose not to do that for the following reason. In this version, we really do think of the airplane as circling around and starting again from the left edge (imagine the world is a cylinder...). Thus, the airplane’s x-position really does keep going back down. If instead we allowed the World State to increase monotonically, then it would really be representing the total distance traveled, contradicting our definition of the World State.
Do Now!
After adding this function, run your program again. Did you see any change in behavior?
26.5 Version: Descending
Of course, we need our airplane to move in more than just one dimension: to get to the final game, it must both ascend and descend as well. For now, we’ll focus on the simplest version of this, which is an airplane that continuously descends. Here’s a video.
Let’s again consider individual frames of this video. What’s staying the same? Once again, the water and the land. What’s changing? The position of the airplane. But, whereas before the airplane moved only in the x-dimension, now it moves in both x and y. That immediately tells us that our definition of the World State is inadequate, and must be modified.
data Posn:
| posn(x, y)
end
The World State is a
posn
, representing the x-position and y-position of the airplane on the screen.
26.5.1 Moving the Airplane
move-airplane-wrapping-x-on-tick
. Previously our airplane moved
only in the x-direction; now we want it to descend as
well, which means we must add something to the current y
value:
AIRPLANE-Y-MOVE = 3
check:
move-airplane-xy-on-tick(posn(10, 10)) is posn(20, 13)
end
check:
p = posn(10, 10)
move-airplane-xy-on-tick(p) is
posn(move-airplane-wrapping-x-on-tick(p.x),
move-airplane-y-on-tick(p.y))
end
Which method of writing tests is better? Both! They each offer different advantages:
The former method has the benefit of being very concrete: there’s no question what you expect, and it demonstrates that you really can compute the desired answer from first principles.
The latter method has the advantage that, if you change the constants in your program (such as the rate of descent), seemingly correct tests do not suddenly fail. That is, this form of testing is more about the relationships between things rather than their precise values.
There is one more choice available, which often combines the best of both worlds: write the answer as concretely as possible (the former style), but using constants to compute the answer (the advantage of the latter style). For instance:check: p = posn(10, 10) move-airplane-xy-on-tick(p) is posn(num-modulo(p.x + AIRPLANE-X-MOVE, WIDTH), p.y + AIRPLANE-Y-MOVE) end
Exercise
Before you proceed, have you written enough test cases? Are you sure? Have you, for instance, tested what should happen when the airplane is near the edge of the screen in either or both dimensions? We thought not—
go back and write more tests before you proceed!
move-airplane-xy-on-tick
. You
should end up with something like this:
fun move-airplane-xy-on-tick(w):
posn(move-airplane-wrapping-x-on-tick(w.x),
move-airplane-y-on-tick(w.y))
end
fun move-airplane-y-on-tick(y):
y + AIRPLANE-Y-MOVE
end
26.5.2 Drawing the Scene
place-airplane-x
. Our
earlier definition placed the airplane at an arbitrary
y-coordinate; now we have to take the
y-coordinate from the World State:
fun place-airplane-xy(w):
I.place-image(AIRPLANE,
w.x,
w.y,
BACKGROUND)
end
26.5.3 Finishing Touches
big-bang
! If we’ve changed the definition
of World State, then we need to reconsider this parameter, too. (We
also need to pass the new handlers rather than the old ones.)
INIT-POS = posn(0, 0)
anim = reactor:
init: INIT-POS,
on-tick: move-airplane-xy-on-tick,
to-draw: place-airplane-xy
end
R.interact(anim)
Exercise
It’s a little unsatisfactory to have the airplane truncated by the screen. You can use
I.image-width
andI.image-height
to obtain the dimensions of an image, such as the airplane. Use these to ensure the airplane fits entirely within the screen for the initial scene, and similarly inmove-airplane-xy-on-tick
.
26.6 Version: Responding to Keystrokes
Now that we have the airplane descending, there’s no reason it can’t ascend as well. Here’s a video.
We’ll use the keyboard to control its motion: specifically, the up-key
will make it move up, while the down-key will make it descend even
faster. This is easy to support using what we already know: we just
need to provide one more handler using on-key
. This handler
takes two arguments: the first is the current value of the
world, while the second is a representation of which key was
pressed. For the purposes of this program, the only key values we care
about are "up"
and "down"
.
KEY-DISTANCE = 10
fun alter-airplane-y-on-key(w, key):
ask:
| key == "up" then: posn(w.x, w.y - KEY-DISTANCE)
| key == "down" then: posn(w.x, w.y + KEY-DISTANCE)
| otherwise: w
end
end
Do Now!
Why does this function definition contain| otherwise: w
as its last condition?
Notice that if we receive any key other than the two we expect, we leave the World State as it was; from the user’s perspective, this has the effect of just ignoring the keystroke. Remove this last clause, press some other key, and watch what happens!
No matter what you choose, be sure to test this! Can the airplane drift off the top of the screen? How about off the screen at the bottom? Can it overlap with the land or water?
anim = reactor:
init: INIT-POS,
on-tick: move-airplane-xy-on-tick,
on-key: alter-airplane-y-on-key,
to-draw: place-airplane-xy
end
26.7 Version: Landing
Remember that the objective of our game is to land the airplane, not to keep it airborne indefinitely. That means we need to detect when the airplane reaches the land or water level and, when it does, terminate the animation.
true
if the animation should halt,
false
otherwise. This requires a little arithmetic based on the
airplane’s size:
fun is-on-land-or-water(w):
w.y >= (HEIGHT - BASE-HEIGHT)
end
anim = reactor:
init: INIT-POS,
on-tick: move-airplane-xy-on-tick,
on-key: alter-airplane-y-on-key,
to-draw: place-airplane-xy,
stop-when: is-on-land-or-water
end
Exercise
When you test this, you’ll see it isn’t quite right because it doesn’t take account of the size of the airplane’s image. As a result, the airplane only halts when it’s half-way into the land or water, not when it first touches down. Adjust the formula so that it halts upon first contact.
Exercise
Extend this so that the airplane rolls for a while upon touching land, decelerating according to the laws of physics.
Exercise
Suppose the airplane is actually landing at a secret subterranean airbase. The actual landing strip is actually below ground level, and opens up only when the airplane comes in to land. That means, after landing, only the parts of the airplane that stick above ground level would be visible. Implement this. As a hint, consider modifying
place-airplane-xy
.
26.8 Version: A Fixed Balloon
Now let’s add a balloon to the scene. Here’s a video of the action.
Notice that while the airplane moves, everything else—
When does the game halt? There are now two circumstances: one is contact with land or water, and the other is contact with the balloon. The former remains unchanged from what it was before, so we can focus on the latter.
posn
s are good for. As for the
former, we can decide where it is:
BALLOON-LOC = posn(600, 300)
BALLOON-LOC = posn(random(WIDTH), random(HEIGHT))
Exercise
Improve the random placement of the balloon so that it is in credible spaces (e.g., not submerged).
fun are-overlapping(airplane-posn, balloon-posn):
distance(airplane-posn, balloon-posn)
< COLLISION-THRESHOLD
end
COLLISION-THRESHOLD
is some suitable constant computed
based on the sizes of the airplane and balloon images. (For these
particular images, 75
works pretty well.)distance
? It consumes two posn
s and determines
the Euclidean distance between them:
fun distance(p1, p2):
fun square(n): n * n end
num-sqrt(square(p1.x - p2.x) + square(p1.y - p2.y))
end
fun game-ends(w):
ask:
| is-on-land-or-water(w) then: true
| are-overlapping(w, BALLOON-LOC) then: true
| otherwise: false
end
end
anim = reactor:
init: INIT-POS,
on-tick: move-airplane-xy-on-tick,
on-key: alter-airplane-y-on-key,
to-draw: place-airplane-xy,
stop-when: game-ends
end
Do Now!
Were you surprised by anything? Did the game look as you expected?
BALLOON-URL =
"http://world.cs.brown.edu/1/clipart/balloon-small.png"
BALLOON = I.image-url(BALLOON-URL)
BACKGROUND =
I.place-image(BASE,
WIDTH / 2, HEIGHT - (BASE-HEIGHT / 2),
I.place-image(BALLOON,
BALLOON-LOC.x, BALLOON-LOC.y,
BLANK-SCENE))
Do Now!
Do you see how to write
game-ends
more concisely?
fun game-ends(w):
is-on-land-or-water(w) or are-overlapping(w, BALLOON-LOC)
end
26.9 Version: Keep Your Eye on the Tank
Now we’ll introduce the idea of fuel. In our simplified world, fuel
isn’t necessary to descend—
In the past, we’ve looked at still images of the game video to determine what is changing and what isn’t. For this version, we could easily place a little gauge on the screen to show the quantity of fuel left. However, we don’t on purpose, to illustrate a principle.
You can’t always determine what is fixed and what is changing just by looking at the image. You have to also read the problem statement carefully, and think about it in depth.
It’s clear from our description that there are two things changing: the position of the airplane and the quantity of fuel left. Therefore, the World State must capture the current values of both of these. The fuel is best represented as a single number. However, we do need to create a new structure to represent the combination of these two.
The World State is a structure representing the airplane’s current position and the quantity of fuel left.
data World:
| world(p, f)
end
Exercise
We could have also defined the World to be a structure consisting of three components: the airplane’s x-position, the airplane’s y-position, and the quantity of fuel. Why do we choose to use the representation above?
We can again look at each of the parts of the program to determine
what can stay the same and what changes. Concretely, we must focus on
the functions that consume and produce World
s.
fun move-airplane-xy-on-tick(w :: World):
world(
posn(
move-airplane-wrapping-x-on-tick(w.p.x),
move-airplane-y-on-tick(w.p.y)),
w.f)
end
fun alter-airplane-y-on-key(w, key):
ask:
| key == "up" then:
if w.f > 0:
world(posn(w.p.x, w.p.y - KEY-DISTANCE), w.f - 1)
else:
w # there's no fuel, so ignore the keystroke
end
| key == "down" then:
world(posn(w.p.x, w.p.y + KEY-DISTANCE), w.f)
| otherwise: w
end
end
Exercise
Updating the function that renders a scene. Recall that the world has two fields; one of them corresponds to what we used to draw before, and the other isn’t being drawn in the output.
Do Now!
What else do you need to change to get a working program?
You should have noticed that your initial world value is also incorrect because it doesn’t account for fuel. What are interesting fuel values to try?
Exercise
Extend your program to draw a fuel gauge.
26.10 Version: The Balloon Moves, Too
Until now we’ve left our balloon immobile. Let’s now make the game more interesting by letting the balloon move, as this video shows.
Obviously, the balloon’s location needs to also become part of the World State.
The World State is a structure representing the plane’s current position, the balloon’s current position, and the quantity of fuel left.
data World:
| world(p :: Posn, b :: Posn, f :: Number)
end
The background image (to remove the static balloon).
The drawing handler (to draw the balloon at its position).
The timer handler (to move the balloon as well as the airplane).
The key handler (to construct world data that leaves the balloon unchanged).
The termination condition (to account for the balloon’s dynamic location).
Exercise
Modify each of the above functions, along with their test cases.
26.11 Version: One, Two, ..., Ninety-Nine Luftballons!
Finally, there’s no need to limit ourselves to only one balloon. How many is right? Two? Three? Ten? ... Why fix any one number? It could be a balloon festival!
Similarly, many games have levels that become progressively harder; we could do the same, letting the number of balloons be part of what changes across levels. However, there is conceptually no big difference between having two balloons and five; the code to control each balloon is essentially the same.
We need to represent a collection of balloons. We can use a list to represent them. Thus:
The World State is a structure representing the plane’s current position, a list of balloon positions, and the quantity of fuel left.
Apply the same function to each balloon in the list.
Determine what to do if two balloons collide.
Exercise
Introduce a concept of wind, which affects balloons but not the airplane. After random periods of time, the wind blows with random speed and direction, causing the ballooons to move laterally.