I was asked to give a presentation about something fun I do with Mathematica. It struck me that I make a lot of interfaces, so why not make a game? As a first attempt I decided to port the game Flappy Bird to run in a Mathematica notebook. This is really a purely academic exercise, as I didn't stop to ask myself "should I do this?", but rather "how well would this run?".
Step 1: Basic Strategy
I chose an aspect ratio for the game area roughly that of a phone. I also followed the strategy of Luc Barthelet's Introduction to Dynamics: Learn to Build a Game where a ScheduledTask
is used to update the screen at a set frame rate. I then calculated all movement units w.r.t. the frames per second. That way if the game runs poorly you can adjust this value for better performance.
Step 2: get gravity right
The game mechanic is to keep Flappy in the air against gravity as well as gracefully maneuver through gaps in pipes. Every time you press a key, Flappy moves upward against gravity. Gravity is straightforward to implement as it is a change in downward velocity, and velocity is a change in position. Thus, every frame we update the velocity, we subsequently update the position based on the new velocity. Jumping is implemented as a sudden change in the upward velocity.
I made an interface to get the feel of gravity correct:
DynamicModule[{task, pos = 0, vel = 0, fps = 30, gravity = 100, kick = 10},
Column[{
Graphics[{Line[{{-5, -1}, {5, -1}}], Dynamic[Circle[{5, pos}, 0.5]]},
Frame -> True, PlotRange -> {{0, 10}, {0, 14}}, ImageSize -> 485
],
Button["JUMP", vel = kick/fps],
Dynamic[{pos, vel}],
LabeledSlider[Dynamic[gravity], {100, 3000}],
LabeledSlider[Dynamic[kick], {1, 1500}]
}],
Initialization :> (
task = CreateScheduledTask[
vel -= gravity/fps/fps;
pos += 1/2 vel/fps;
If[pos < 0, vel = pos = 0];
,
1./fps
];
StartScheduledTask[task];)
]
The ScheduledTask
must go in the Initialization
section in order to scope the variables correctly. I also set that Flappy cannot fall through the ground. Playing with this interface, I eventually settled on a gravity value of 2500 and a velocity kick of 600.
Step 3: Use the keyboard as a control
I don't like the extra button needed to flap, so I added CurrentValue["ControlKey"]
as the predicate in an If
statement for the velocity. If that's all I did, then you could hold down the control key to send Flappy off the top of the screen. To add the effect that one key-press equals one wing-flap, I included a switch to check if the key was still pressed.
DynamicModule[{task, pos = 0, vel = 0, fps = 30, gravity = 2500, kick = 600, previousButtonState = False, buttonHeld = False},
Graphics[
{Dynamic[Circle[{5, pos}, 0.5]]},
Frame -> True, PlotRange -> {{0, 10}, {0, 14}}, ImageSize -> 485
],
Initialization :> (
task = CreateScheduledTask[
Switch[{previousButtonState, CurrentValue["ControlKey"]},
{False, True}, previousButtonState = True; vel = kick/fps,
{True, False}, previousButtonState = False,
_, Null
];
vel -= gravity/fps/fps;
pos += 1/2 vel/fps;
If[pos < 0, vel = pos = 0];
,
1./fps
];
StartScheduledTask[task];
)
]
Step 4: Adding Obstacles (Pipes)
I didn't know how big to make a pair of pipes, or what gap to use, so I made an interface to play with the dimensions:
Manipulate[
Graphics[{
Line[{{0, 0}, {0, h}, {w2, h}, {w2, 0}, {0, 0}}],
Line[{{0, h + w}, {0, 50 + h}, {w2, 50 + h}, {w2, h + w}, {0,
h + w}}]}, Frame -> True, PlotRange -> {{0, 10}, {0, 14}},
ImageSize -> 485],
{{h, 4.5}, 0, 14},
{{w, 3.4}, 1, 15},
{{w2, 2}, 2, 10}]
The lines are used just for visualization. In the final version of this project I'm going to only track the line vertices to make hit boxes, but for instructional purposes I keep these as lines.
Eventually I can create a set of random obstacles that look reasonable wide and spaced such that only two are ever on the screen at any one time.
createPipe[h_, w_, w2_, p0_] := {
Line[{{p0, 0}, {p0, h}, {w2 + p0, h}, {w2 + p0, 0}, {p0, 0}}],
Line[{{p0, h + w}, {p0, 20 + h}, {w2 + p0, 20 + h}, {w2 + p0, h + w}, {p0, h + w}}]
}
Graphics[Table[createPipe[RandomReal[{1, 10}], 3.4, 2, i], {i, 15, 100, 7.5}]]
I give the pipes some horizontal velocity and update their horizontal positions every frame. Once a pipe's right edge is out of the PlotRange
, I move it to the right of the PlotRange
with a new random height value. To see this in action (without the dynamic "Flappy" circle):
DynamicModule[{task, pos = 0, vel = 0, fps = 30, gravity = 2500, kick = 600, pipeVel = 4, pipeSpacing = 3.4, pipeWidth = 2, pipeList = {}},
Column[{
Graphics[
{Dynamic[pipeList]},
Frame -> True, PlotRange -> {{0, 10}, {0, 14}}, ImageSize -> 485
],
Dynamic[pipeList, UpdateInterval -> 1]
}],
Initialization :> (
pipeList = {
createPipe[RandomReal[{1, 10}], pipeSpacing, pipeWidth, 15],
createPipe[RandomReal[{1, 10}], pipeSpacing, pipeWidth, 22.5]
};
task = CreateScheduledTask[
(* update all existing pipes *)
pipeList = pipeList /. {x_?NumericQ, y_?NumericQ} :> {x, y} - {pipeVel/fps, 0};
(* if pipe moves off screen, remake it randomly on the right *)
pipeList =
If[Max[#[[1, 1, All, 1]]] <= 0,
createPipe[RandomReal[{1, 10}], pipeSpacing, pipeWidth, 14],
Identity[#]] & /@ pipeList;
,
1./fps
];
StartScheduledTask[task];)
]
Step 5: Tracking Collisions
I use RegionIntersection
to check the intersection of a circle with the lines that define the pipes. If there is no intersection, then the function returns an EmptyRegion[2]
. Collisions are checked for with this code snippet:
(* check for collisions *)
If[AnyTrue[Flatten[pipeList], RegionIntersection[Circle[{5, pos}, 0.5], #] =!= EmptyRegion[2] &], StopScheduledTask[task];];
At this point the game mechanics are complete. The rest is just icing on the cake.
Step 6: Adding Prettier Graphics
The Wolfram Language lets me cheat a little bit when it comes to graphics. I can use Inset
to put any image I want into the graphics object.
I screen captured an image of Flappy and a pipe. I then used RemoveBackground
to isolate the sprites. The pipe I had was not long enough, so I used ImageTake
to get the stem of the pipe, and then ImageAssemble
to stack additional lengths onto it. To get the Inset
images correctly sized and positioned, I made a quick interface:
pipeList = {createPipe[RandomReal[{1, 10}], 3.4, 2, 5], createPipe[RandomReal[{1, 10}], 3.4, 2, 12.5]};
Manipulate[
Graphics[{
Line[{{-5, -1}, {45, -1}}],
{Red, Thick, pipeList},
Inset[invertPipeImage, pipeList[[1, 2, 1, 1]], Scaled[{0, 0}], size],
Inset[pipeImage, pipeList[[1, 1, 1, 2]], Scaled[{0, 1}], size],
{Red, Thick, Circle[{5, pos}, 0.5]},
Inset[< flappy >, {5, pos}, Scaled[{0.5, 0.5}], a]
},
Frame -> True, PlotRange -> {{0, 10}, {0, 14}}, ImageSize -> 485,
Background -> RGBColor[{0.44313725490196076`, 0.7803921568627451`, 0.8156862745098039`}],
Epilog -> {Inset[ground, {-10, -0.6}, Scaled[{0, 1}], a]}
],
{pos, 0, 15}, {{a, 1}, 1, 3}, {{size, 2.05}, 1, 3},
ControlPlacement -> Bottom]
Here, <Flappy>
represents a copy of the image of Flappy. I adjusted the sizes dynamically until they fit within the hit boxes and hit circle. The inverted pipe is anchored to the bottom left corner (pipeList[[1, 2, 1, 1]], Scaled[{0,0}]
) of the upper hit box, while the regular pipe is anchored to the upper left corner (pipeList[[1, 1, 1, 2]], Scaled[{0,1}]
) of the lower hit box. Flappy is circular enough that I anchored its image to the center of the circle ({5, pos}, Scaled[{0.5,0.5}]
). The background color was determined using ImageData[<Flappy>,{1,1}]
taken from a screen capture.
Step 7: Add Animations
I took additional screen captures of the ground, background city, and other positions of Flappy. The ground has the illusion of constant left motion because it has the same velocity as the pipes. Like the pipes, when it moves far enough to the left, I re-position it a set distance to the right.
I'm a little lazy when it comes to animating Flappy. It has only 4 frames, and I cycle through them at the same speed as the number of frames per second. I really should slow the animation, but even so, it had the desired effect of apparent motion. All animations stop if Flappy hits an obstacle.
If[pipeVel > 0,
gif = 1 + Mod[++gif, 4];
bird = gifSequence[[gif]];
groundPosition -= pipeVel/fps;
]
Step 8: Adding a Scoring Mechanism, Create a Start Condition
The score is the count of each pipe that Flappy successfully navigates. The ScheduledTask
would attempt to update the score every frame, so a switch is included to only update once, and then wait until the "right time" to score is approaching again.
(* increase score *)
If[Apply[And, # > 5. & /@ Min /@ pipeList[[All, All, All, 1]]], readyToScore = True];
If[Min[Max /@ pipeList[[All, All, All, 1]]] < 5 && readyToScore,
scoreValue += 1;
readyToScore = False;
];
The score "font" is just another screen capture, aligned in a Row
. Zero is put as the 10th item in the list of sprites.
digits = RemoveBackground /@ First[ImagePartition[ImageCrop[ImageTake[Import["Sprites/digits.jpg"],{100,250}]],{42.76,60.2}]];
makeScore[n_Integer] := Row[digits[[IntegerDigits[n] /. (0 -> 10)]]]
I chose to use draw the score using Overlay
. I also make the entire display a Button
that must be clicked in order for the ScheduledTask
to start. The button also makes the initial instructions become invisible (again I cheat because the instructions are large enough to cover the score).
Button[
Overlay[{
Graphics[{
(* pipes *)
Inset[invertPipeImage, Dynamic[First[{pipeList[[1, 2, 1]]}]], Scaled[{0, 0}], 2.05],
Inset[pipeImage, Dynamic[First[{pipeList[[1, 1, 2]]}]], Scaled[{0, 1}], 2.05],
Inset[invertPipeImage, Dynamic[First[{pipeList[[2, 2, 1]]}]], Scaled[{0, 0}], 2.05],
Inset[pipeImage, Dynamic[First[{pipeList[[2, 1, 2]]}]], Scaled[{0, 1}], 2.05],
(* Flappy *)
Inset[Dynamic[First[{bird}]], {5, Dynamic[First[{pos}]]}, Scaled[{0.5, 0.5}], 1.325],
(* ground *)
Inset[ground, {Dynamic[First[{groundPosition}]], -2}, Scaled[{0, 0}], 43.7]
},
Frame -> False, PlotRange -> {{0, 10}, {0, 14}}, ImageSize -> 350,
Background -> RGBColor[0.44313725490196076`, 0.7803921568627451`, 0.8156862745098039`],
Prolog -> Inset[city, {-0.07, 1.88}, Scaled[{0, 0}], 10.08]
],
(* score is an overlay over the main graphic to help with formatting *)
Dynamic[makeScore[scoreValue]],
Dynamic[instructions]
},
Alignment -> {0, 0.6}
]
instructions = Invisible[instructions]; StartScheduledTask[task],
Appearance -> "Frameless"
]
Step 9: Add Sound Effects
I found some sound effects that seemed appropriate. They were easy to add using EmitSound
in the appropriate places, such as if control is pressed, if a pipe or the ground is hit, or if the score increases.
Step 10: Optimize the Game Code
This version of the game is playable, but was very slow and clunky. I realized that the bottleneck was the RegionIntersection
. That function is great, but it's just too powerful for this simple game. Thus I calculated the equation for the distance from a point to a rectangle:
RegionDistance[Rectangle[{xmin, xmax}, {ymin, ymax}], p]
(* Sqrt[(-Clip[Indexed[p, {1}], {xmin, ymin}] + Indexed[p, {1}])^2 + (-Clip[Indexed[p, {2}], {xmax, ymax}] + Indexed[p, {2}])^2] *)
I then compiled this function along with whatever other code that I could. I went so far as re-writing my "createPipe" function to not return a pair of graphic primitives, but rather just the vertices of a rectangle.
You can find the final code on my GitHub account: Flappy Bird in Mathematica which includes all of the modified graphics and the sound effects.
Last Step: Play the Game!
You load the game's source code with <<FlappyBird.wl
and then load the game with playFlappyBird[]
.
Here's a short video of the final result: