Animating Flappy Bird

This relates to the my post on using AI for Flappy Bird.

After reading this, you should be able to create your very own scrolling world! I’m also going to make you realise that you could have coded Flappy Bird and received the kudos if only you knew…

I doubt you have failed to notice the quality of modern games, some of which the graphics far exceed the underlying gameplay. Recent generations have grown up to expect more than Space Invaders. If you want to see the awesome 80’s home playable games, you can at this website.

If you were to remove textures/bitmaps from most modern games they would not be so appealing. The gameplay might still be fun, but people can be judgemental after years of being treated to amazing graphics.

I felt if I were to recreate an iconic game like Flappy Bird I would at least try to make it aesthetically appealing and similar to the original. But rather than expend effort on the graphics, I googled Flappy images and came across this website providing free images (like our Flappy PNG file): https://www.kindpng.com/downpng/iimbxmh_atlas-png-flappy-bird-transparent-png/. I have no affiliation with them, so please treat them with caution as I hope you do with all websites.

On web apps (CSS) you can reference parts of a bigger image, but this is fat-client. I went with cutting up the image into individual images using Paint so I could simply reference them using a Bitmap object.

For Flappy, we have three frames that I cut out, and a fourth one that is used when Flappy hits the pipe (a copy of the 3rd image converted to greyscale). I’ve edited them to have a green background because we need transparency.

Frame 1
Frame 2
Frame 3
Splat Frame

The Making Of Flappy AI

Let’s make the world, starting first with a Fixed-Single WinForm 575x420px (MaximizeBox+MinimizeBox = False). The horizontal size was chosen to be not too small, not too large; it just felt correct. Sorry if you feel differently.

I’ve added a timer to the form called “timerScroll” that will provide a consistent “frame rate”.

Within the form I have added two PictureBox’s, defined as follows. The top one pictureBoxFlappyGameScreen which we’ll animate, and a smaller panel for stats. You don’t have to split it, you could use one big image like my “user” version of Flappy Bird. I split the screen so that I could update statistics independently (less frequently), and reduce the overall area being painted.

pictureBoxStats.Dock = System.Windows.Forms.DockStyle.Bottom;
pictureBoxStats.Location = new System.Drawing.Point(0, 305);
pictureBoxStats.Name = "pictureBoxStats";
pictureBoxStats.Size = new System.Drawing.Size(559, 76);

pictureBoxFlappyGameScreen.Dock = System.Windows.Forms.DockStyle.Fill;
pictureBoxFlappyGameScreen.Location = new System.Drawing.Point(0, 0);
pictureBoxFlappyGameScreen.Name = "pictureBoxFlappyGameScreen";
pictureBoxFlappyGameScreen.Size = new System.Drawing.Size(559, 305);

We need to configure the timerScroll to call “TimerScroll_Tick”. I chose a 10ms interval which is 100 fps (you can lower the fps, but it will scroll slower, compensate for this if need be). Starting the timer is important, without nothing will happen!

timerScroll.Interval = 10;
timerScroll.Tick += new System.EventHandler(this.TimerScroll_Tick);   
timerScroll.Start();

We’d better add the event handler that the timer references.

You cannot run it yet, because it’s dependent on another class “ScrollingScenery” which we’ll discuss shortly. We separate the scenery “move” from the “draw” because we will move it, then move all the Flappies and only afterwards draw everything.

You don’t have to do it my way, but I would rather create one bitmap and graphics, paint then assign the frame to the PictureBox. Nothing stops you from drawing each part at a time per se.

private void TimerScroll_Tick(object sender, EventArgs e)
{
   ScrollingScenery.Move();

  Bitmap b = new(pictureBoxFlappyGameScreen.Width, pictureBoxFlappyGameScreen.Height);
  using Graphics graphics = Graphics.FromImage(b);

  ScrollingScenery.Draw(graphics);

  // switch the image over
  pictureBoxFlappyGameScreen.Image?.Dispose();
  pictureBoxFlappyGameScreen.Image = b;
}

Animating Scenery

Here’s our “empty” scenery class, with the Move() and Draw() methods.

using System.Drawing;
using System.Drawing.Imaging;
using System.Security.Cryptography;

namespace FlappyBird;

internal class ScrollingScenery
{
    /// <summary>
    /// Constructor.
    /// </summary>
    static ScrollingScenery()
    {
    }

    /// <summary>
    /// Draws the parallax scrolling scenery including pipes, but not Flappy.
    /// </summary>
    /// <param name="graphics"></param>
    internal static void Draw(Graphics graphics)
    {
    }

    /// <summary>
    /// Moves the scenery to the next point (progress game)
    /// </summary>
    /// <returns>true when Flappy has reached the end.</returns>
    internal static bool Move()
    {
    }

    /// <summary>
    /// Reset the scenery to the start (we do it when all Flappy's have gone splat). And create new random pipes.
    /// </summary>
    internal static void Reset()
    {
    }
}

It makes sense to consider what we are trying to do…

The game has Parallax scrolling (where things scroll at different speeds), with buildings moving slower than the pipes.

We have the background (buildings + shrubs) moving slower than the chevrons and pipes. We’ll look at the buildings first. To give the impression of scrolling we use a “view-port” approach. Below, we want to paint enough pixels to fill the width of the PictureBox, but if we always paint the image from its left, we won’t see scrolling just a static background.

Now you could make 500+ frames each with the image shifted. There might be a time when stupid as it sounds that is viable or makes sense but I can’t think of one. What we can do is utilise the fact that GDI supports a negative X position when painting. It clips the image at 0. So if we introduce a variable s_posBackground and with every frame we increment, and during paint use -s_posBackground, it will appear to scroll.

Now, what happens when s_posBackground goes beyond the PictureBox width?

Oops, we’ve lost part of the background! Given we’re scrolling 20,000 and the image is just 1118 pixels wide (we’ll get to how/why), there will be more white space than scenery. Not good.

That’s why you’ll notice s_posBackground has a “% 225” (modulo). That magic number is the width of the background image. The red outline below shows the form relative to the image at s_posBackground = 225.

As the image is constructed of something repeating whether s_posBackground = 0 or s_posBackground =225, as long as the image is wide enough (to cover the whole PictureBox width), you’ll see the same thing.

Therefore when it gets to 225px, we are treating it as 0.

Where does 1118px figure into it? Ignorance on my part. When I first wrote the scrolling I was using % 559, and the scenery would jump for obvious (or not) reasons. I didn’t notice at first, and after correcting I didn’t adjust the image size…

Our Draw() method now looks like this: “unscaled” should be used where we don’t need to scale it.

internal static int s_posBackground = 0;
internal static int s_pos = 0;

private static Bitmap s_bitmapSlowMovingBuildings;

internal static void Draw(Graphics graphics)
{
  graphics.DrawImageUnscaled(s_bitmapSlowMovingBuildings, -(s_posBackground % 225), 205);
}

Before you point out that I am referencing an image that isn’t initialised, I am just about to get to that.

Our background building/sky image is 225×94, far too small given the width of the screen is 559px. So we load the image into a Bitmap, then make a bigger Bitmap and repeatedly paste the image into it as follows:

private static void CreateScrollingBuildingsWithSkyImage(int width)
{
  // make the "scrolling" buildings area into twice the size of the screen.
  using Bitmap bitmapBackground = new(@"images\buildingsAndSky.png");

  s_bitmapSlowMovingBuildings = new Bitmap(width * 2, bitmapBackground.Height);

  using Graphics graphics = Graphics.FromImage(s_bitmapSlowMovingBuildings);

  for (int x = 0; x <= s_bitmapSlowMovingBuildings.Width; x += bitmapBackground.Width)
  {
    graphics.DrawImageUnscaled(bitmapBackground, x, 0);
  }
}

Because it’s declared as a static class, we can initialise it there

private readonly static int s_widthOfGameArea;

private readonly static int s_heightOfGameArea;

static ScrollingScenery()
{
  // store dimensions
  s_widthOfGameArea = 559;
  s_heightOfGameArea = 305;

  CreateScrollingBuildingsWithSkyImage(width);
}

That’s great but we’re missing a key part, the code that makes it seem to scroll. Why use 2 counters? We’ll get to that shortly. Move() returns TRUE when we’ve reached the end of the game.

internal const int c_numberOfPixelsScrolledBeforeReachingTheEndOfGame = 20000;

internal static bool Move()
{
  s_pos++;

  if (s_pos % 4 == 0) ++s_posBackground;

  return s_pos > c_numberOfPixelsScrolledBeforeReachingTheEndOfGame;
}

When we run it we get a scrolling background. The jump is the short GIF resetting, not a scrolling issue)!

Where’s the sky you’re wondering? Well, that might be because we never asked it to paint any. Let’s add a “.Clear” to reset it to sky blue. The RGB value came from using a colour-dropper on the image.

internal static void Draw(Graphics graphics)
{
  graphics.Clear(Color.FromArgb(78, 192, 202));

  graphics.DrawImageUnscaled(s_bitmapSlowMovingBuildings, -(s_posBackground % 225), 205);
}

We clear it all blue, even the part we will overlay an image. The alternative is a FillRectangle(). I would expect the Clear() to be quicker as it is a block fill of a DWORD (ARGB pixel), but I honestly haven’t checked. We can’t split the PictureBox into a narrow strip to save the paint because you’ll then make Flappy have to straddle two PictureBoxes.

Let’s overlay the chevron to see parallax in action. Again the jump is the GIF resetting, not a broken scroll.

The chevron is created in the same manner. It’s 264 pixels wide (so % 264), and a lot shorter height-wise but our approach doesn’t change. Let’s add it here.

private static Bitmap s_bitmapChevronFloor;

private static void CreateScrollingChevronAreaImage(int width)
{
  // make the "scrolling" brown area into twice the size of the screen
  using Bitmap bitmapBottom = new(@"images\chevronGround.png");

  s_bitmapChevronFloor = new(width * 2, bitmapBottom.Height);
	
  using Graphics graphics = Graphics.FromImage(s_bitmapChevronFloor);

  for (int x = 0; x <= s_bitmapChevronFloor.Width; x += bitmapBottom.Width)
  {
    graphics.DrawImageUnscaled(bitmapBottom, x, 0);
  }
}

static ScrollingScenery()
{
  // store dimensions
  s_widthOfGameArea = 559;
  s_heightOfGameArea = 305;

  CreateScrollingBuildingsWithSkyImage(width);
  CreateScrollingChevronAreaImage(width);
}

internal static void Draw(Graphics graphics)
{
  graphics.DrawImageUnscaled(s_bitmapSlowMovingBuildings, -(s_posBackground % 225), 205);
  
  graphics.DrawImageUnscaled(s_bitmapChevronFloor, -(s_pos % 264), 293);
}

If you’re wondering how come it is parallax scrolling, look back at our Move() method.

We’re incrementing s_pos every frame but incrementing the s_posBackground every 4 frames. Therefore the viewport on the two images is moved at differing rates giving the parallax feel.

Time to lay pipes

Making games that are impossible to win is the quickest way to become unpopular/go out of business. This is a perfect opportunity to do exactly that.

Is the following even possible? I don’t know, but you understand that point. It’s not just about the 2 pipes, it depends on the one before. Because if the one before requires Flappy to descend very fast, at the bottom it needs to arrest the rate of the descent and change acceleration to an opposite direction. You could derive a formula based on the relative distance in both directions, and the speed at which Flappy can accelerate in either vertical direction. I was too lazy, opting for a 100px maximum distance from the middle of the gaps. Given my AI Flappy managed 99.9% success, it is in theory playable…

The code to randomly is as follows. Notice I added a 300px run-up, it also makes the distance horizontally between pipes decrease to add difficulty as the game progresses. Again we must be careful that this does not make the game unbeatable, just harder.

private static List<Point> s_pointsWhereRandomPipesAppearInTheScenery;

private static void CreateRandomPipes()
{
  int height = 399 - 140;

  s_pointsWhereRandomPipesAppearInTheScenery = new();

  int horizontalDistanceBetweenPipes = 110;

  int lastY = height / 2;

  // 300 ensures Flappy gets a "run up"
  for (int i = 300; i < c_numberOfPixelsScrolledBeforeReachingTheEndOfGame; i += RandomNumberGenerator.GetInt32(94, 124) + horizontalDistanceBetweenPipes)
  {
    if (horizontalDistanceBetweenPipes > 0) horizontalDistanceBetweenPipes -= 3; // reduces the distance between pipes to make the level harder as it progresses

    int newY = RandomNumberGenerator.GetInt32(30, height);

    // ensure the gap between pipes isn't too excessive.
    if (newY > lastY && newY - lastY > 100) 
      newY = lastY + 100 - RandomNumberGenerator.GetInt32(0, 17);
    else
      if (newY < lastY && lastY - newY > 100)
        newY = lastY - 100 + RandomNumberGenerator.GetInt32(0, 17);
      
    s_pointsWhereRandomPipesAppearInTheScenery.Add(new Point(i, newY));

    lastY = newY;
  }
}

It would help if we call that code, otherwise Flappy won’t have any obstacles, so we add it to the constructor. We now have a list of “points”. You might be wondering if I have gone mad. Don’t we have two pipes per horizontal? Yes.

static ScrollingScenery()
{
  // store dimensions
  s_widthOfGameArea = 559;
  s_heightOfGameArea = 305;

  CreateScrollingBuildingsWithSkyImage(width);
  CreateScrollingChevronAreaImage(width);
  CreateRandomPipes();
}

Maybe if I show you the .Draw() method, you’ll believe I am not mad. I take “p.Y”, subtract the down-pipe height then subtract an additional 40 from it. This is means we draw the pipe off-screen vertically but so the pipe bottom ends up 40 pixels above our point.

We then do a similar thing for the pipe below. We take the “p.Y” and add 40, so that when the up pipe is drawn it starts 40 pixels down from that point. GDI will clip the part painting outside the “graphics”.

private static void DrawVisiblePipes(Graphics graphics)
{
  int leftEdgeOfScreenTakingIntoAccountScrolling = s_pos;
  int rightEdgeOfScreenTakingIntoAccountScrolling = s_pos + s_widthOfGameArea;
  int pipeWidth = s_upPipe.Width;
  int pipeHeight = s_downPipe.Height;

  foreach (Point p in s_pointsWhereRandomPipesAppearInTheScenery)
  {
    if (p.X > rightEdgeOfScreenTakingIntoAccountScrolling) break; // offscreen to the right, no need to check for more

    if (p.X < leftEdgeOfScreenTakingIntoAccountScrolling - pipeWidth) continue; // offscreen to the left

    // draw the top pipe
    graphics.DrawImageUnscaled(s_downPipe, p.X - leftEdgeOfScreenTakingIntoAccountScrolling, p.Y - pipeHeight - 40);

    // draw the bottom pipe
    graphics.DrawImageUnscaled(s_upPipe, p.X - leftEdgeOfScreenTakingIntoAccountScrolling, p.Y + 40);
  }
}

For it to do anything we need to add the call to draw pipes. The order of paint is important. We colour the sky, draw the buildings, overlay the pipes and then the chevron. That’s because if we do paint the chevron before the pipes will draw on top of it – we don’t want that.

internal static void Draw(Graphics graphics)
{
  graphics.Clear(Color.FromArgb(78, 192, 202));

  graphics.DrawImageUnscaled(s_bitmapSlowMovingBuildings, -(s_posBackground % 225), 205);
  
  DrawVisiblePipes(graphics);

  graphics.DrawImageUnscaled(s_bitmapChevronFloor, -(s_pos % 264), 293);  
}

We’ve conveniently ignored until now the “scrolling” of pipes that occurs from our drawing.

It would be stupidly inefficient to paint all the pipes regardless of whether they are on-screen. To know what we clip, let’s remind ourselves what the offset of the left edge is. It’s s_pos of course.

Therefore

  • if the right-hand edge of a pipe (p.X+pipeWidth) is before the left edge, we need not draw. It means we haven’t yet got to the onscreen pipes, so we “continue” (move to next).
  • if the left-hand edge of a pipe (p.X) is after the right edge, we clip but more to the point we don’t need to examine the remaining pipes as they are further right still (hence the “break” statement).

I wouldn’t describe this as my crowning moment of code optimisation. When we get 10,000 pixels in we will look at all pipes up to s_pos of 10,000. This is inefficient. Ideally, I should store the “index” of the left-most pipe on the screen, and each time we “continue” move that “index” along. If pipes are 80px apart, then we traverse 125 needlessly. If we’re near an offset of 19,500 it’s 243.

Another way to optimise might be to delete ones as they go offscreen, you cannot however delete items from a collection during the foreach. You’d need to store the ones to delete in a list, then foreach the list deleting from the main list. I don’t think that approach will be materially faster.

Teaching people to knowingly write inefficient code is wrong. I learnt in assembler to do things in the least clock ticks as well as least bytes. A foreach for 243 records process one “if” is immeasurable.

I leave it to the reader to correct my lazy optimisation.

Collision detection

For completeness, given we have to cope with scrolling, let’s discuss our approach. We unfortunately repeat our inefficient code (looking at pipes that are offscreen to the left; I really ought to fix it).

After doing this in a rudimentary form, I found Flappy resorted to cheating by cutting corners. After a while, my autism couldn’t tolerate the injustice of cheating any longer and forced me to rectify it. Flappy didn’t like it, taking longer to be successful but got over it. Hit points are in cyan.

The hit points are merely an array of points that we will check against the pipes.

internal static Point[] HitTestPoints = new Point[]
{
	new Point(1,8),
	new Point(14,0),
	new Point(26,11),
	new Point(13,18),
	new Point(23,16),
	new Point(21,3),
	new Point(8,2),
	new Point(4,15),
	new Point(9,2),
	new Point(23,8),
	new Point(25,15),
	new Point(17,17),
	new Point(7,16),
	new Point(2,12),
	new Point(3,5)
};

We are testing if hit-point X is between the pipe’s left edge and the pipe’s right edge. If it is not we’re ok, that hit point isn’t colliding. However, if it is within the pipe’s left/right, we need to check the vertical position is between the bottom of the top pipe and top of the bottom pipe. Don’t try saying that when drunk, it’s hard enough to write. We know those edges as the “Y” of our pipe is in the middle vertically between the top and bottom edges.

internal static bool FlappyCollidedWithPipe(Flappy flappy, out Rectangle rect)
{
  rect = new Rectangle();

  int left = s_pos-30;
  int right = Flappy.s_idthOfAFlappyBirdPX + s_pos + 12;

  int score = 0;

  bool collided = false;

  // traverse each point, and count how many Flappy has gone past, and also whether it has collided
  foreach (Point p in s_pointsWhereRandomPipesAppearInTheScenery)
  {
    if (p.X < left) // we haven't got to flappy (offscreen, already gone past)
    {
      ++score;
      continue;
    }

    if (p.X > right) break; // we are looking too far right of Flappy

    ++score;

    // this is the rectangle between the top and bottom pipe
    Rectangle rectangleAreaBetweenVerticalPipes = new(p.X, p.Y - 40, 39, 80);

    foreach (Point hp in HitTestPoints)
    {
	int hitPointX = (int)(hp.X + flappy.HorizontalPositionOfFlappyPX - 1)+s_pos; // real world (scrolled coords)
	int hitPointY = (int)(hp.Y + flappy.VerticalPositionOfFlappyPX - 11); // center is 11.

	if (hitPointX >= rectangleAreaBetweenVerticalPipes.Right) continue;
        if (hitPointX <= rectangleAreaBetweenVerticalPipes.Left) continue;
		 
        if (!rectangleAreaBetweenVerticalPipes.Contains(hitPointX,hitPointY))
        {
          rect = new(p.X - s_pos, p.Y - 39, 39, 80);
          collided = true;
          break;
        }
      }
    }

    flappy.Score = score; // provide a score
    
    return collided;
}

This assumes you have a “Flappy” object (which the game does, so we’re not describing it here). Lastly scoring is based on how many pipes you go through and so we calculate it here.

Animating Flappy

It wouldn’t be complete if we didn’t mention that Flappy uses his wings a lot and therefore deserved to have them animated.

We create take the images and store them as “frames”. They need to be transparent as they fly with sky, buildings or hedges behind them.

private int wingFlappingAnimationFrame = 1;

internal readonly static Bitmap[] s_FlappyImageFrames;
..

List<Bitmap> flappyImageFrameList = new()
{
	// load images for Flappy to save us having to paint pixels.
	new(@"images\FlappyFrame1.png"),
	new(@"images\FlappyFrame2.png"),
	new(@"images\FlappyFrame3.png")
};

// we need use the array to step thru
s_FlappyImageFrames = flappyImageFrameList.ToArray();

// make the 3 images transparent (green part around the outside of Flappy)
s_FlappyImageFrames[0].MakeTransparent();
s_FlappyImageFrames[1].MakeTransparent();
s_FlappyImageFrames[2].MakeTransparent();

The drawing is done using a DrawImageUnscaled, except we have to pick a “splat” image after hitting a pipe rather than a Flappy image.

If the bird is falling we don’t need to flap. If it’s going upwards, we increment the frame number used as the index to the frames, which leads to the image cycling 0,1,2,0,1,2,… and it appears to flap.

internal void Draw(Graphics graphics)
{
  // draw Flappy (bird)
  graphics.DrawImageUnscaled(FlappyWentSplat ? s_flappySplatFrame : s_FlappyImageFrames[wingFlappingAnimationFrame], (int)HorizontalPositionOfFlappyPX, (int)(VerticalPositionOfFlappyPX - 10));

  // if flapping, toggle frames to simulate, else keep wings still
  if (verticalAcceleration > 0) wingFlappingAnimationFrame = (wingFlappingAnimationFrame + 1) % s_FlappyImageFrames.Length; else wingFlappingAnimationFrame = 1;
}

I wrote a majority of Flappy in a few hours, it has taken longer to explain than to write the code!

With that, you should now be able to make something even cooler. I hope this post was useful, if so please comment below.

Leave a Reply

Your email address will not be published. Required fields are marked *