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:
The airplane comes in to land succcessfully.
the airplane collides with a balloon!
the airplane lands in the water!
By the end, you will have written all the relevant portions of this program. Your program will: 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. Phew: that’s a lot going on! Therefore, we won’t write it all at once; instead, we’ll build it up bit-by-bit. But we’ll get there by the end.
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.
import image as I import reactors as R
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.
AIRPLANE-URL = "http://world.cs.brown.edu/1/clipart/airplane-small.png" AIRPLANE = I.image-url(AIRPLANE-URL)
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.
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!
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)
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
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 = reactor: init: 0, on-tick: move-airplane-x-on-tick, to-draw: place-airplane-x end
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.
If you want the airplane to appear to move faster, what can you change?
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:
Let’s think about what we need 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.
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?
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 in place-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.
After adding this function, run your program again. Did you see any change in behavior?
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 a 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.
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
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!
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
fun place-airplane-xy(w): I.place-image(AIRPLANE, w.x, w.y, BACKGROUND) end
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)
It’s a little unsatisfactory to have the airplane truncated by the screen. You can use I.image-width and I.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 in move-airplane-xy-on-tick.
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: was 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
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:
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
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.
Extend this so that the airplane rolls for a while upon touching land, decelerating according to the laws of physics.
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.
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.
BALLOON-LOC = posn(600, 300)
BALLOON-LOC = posn(random(WIDTH), random(HEIGHT))
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
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
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 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
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
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 Worlds.
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
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.
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?
Extend your program to draw a fuel gauge.
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).
Modify each of the above functions, along with their test cases.
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.
Introduce a concept of wind, which affects balloons but not the airplane. Afer random periods of time, the wind blows with random speed and direction, causing the ballooons to move laterally.