Constructing Games

Game Loop and Updates


Learning Objectives

  • You know of the game loop.
  • You know how to add functionality that updates game components periodically.

Contrary to many of our prior Flutter applications, we did not explicitly create a stateful variable, or ask the game to update what was shown. Regardless, the box moved. Let’s look at why this happened.

Game loop

Games are often built around a loop — “game loop” — that updates the game state and renders the game to the user (Fig. 1). Although in the last chapter, we did not explicitly tell the game to render the box after its x-axis position was changed, the box did move on the screen. This is due to the game loop that continuously re-renders the content to the user.

Fig 1. — A game loop that loops between updating game state and rendering game content.

Sufficiently frequent rendering of content is necessary to create the illusion of smooth motion, so the game loop typically runs at a high frame rate. For example, a frame rate of 60 frames per second (FPS) is often used as a target for games.

As some updates can take longer than others, the game loop typically has a built-in functionality for providing information on how long it has been since the last update.

The game loop may also include a wait to aim for a specific target frame rate such as the above mentioned 60 FPS (Fig. 2). With the wait, the game loop will not run faster than the target frame rate, even if the updates are faster due to e.g. differences in the processing power of the computer.

Fig 2. — A game loop that contains updating game state, rendering game content, and waiting to avoid unnecessarily fast updates.

Updates to game state

In Flame, we can override the update method of our components (including the game) to create functionality that should be executed each frame. The method has a parameter dt that provides the time since the last update in microsecond precision.

As an example, if we would wish that the player has a total of ten seconds to tap the box, we could extend the update method of our TapGame to decrement the time left by the time since the last update. Finally, when the time left is less than or equal to zero, we could end the game and show the score to the player.

class TapGame extends FlameGame {
  var timeLeft = 10.0;
  var score = 0;

  TapGame() {
    add(
      TapBox(),
    );
  }

  @override
  void update(double dt) {
    super.update(dt);
    timeLeft -= dt;

    if (timeLeft <= 0) {
      Get.offAll(() => ResultScreen(score: score));
    }
  }

  incrementScore() {
    score++;
  }
}

The above example is problematic, however, as the update method is called at a high frame rate. This leads to a situation, where Get.offAll is called multiple times, and the result screen is added to the view multiple times. To solve this, we add a check to ensure that the game is not already over.

class TapGame extends FlameGame {
  var gameFinished = false;
  var timeLeft = 10.0;
  var score = 0;

  TapGame() {
    add(
      TapBox(),
    );
  }

  @override
  void update(double dt) {
    super.update(dt);
    timeLeft -= dt;

    if (timeLeft <= 0 && !gameFinished) {
      gameFinished = true;
      Get.offAll(() => ResultScreen(score: score));
    }
  }

  incrementScore() {
    score++;
  }
}

Now, in the current version of the game, the TapBox moves marginally on each click. To change the behavior and to move the box to a random location, we could update the onTapDown method of the TapBox component. For randomness, we could use the Random class from the dart:math library, and for limiting the new position to the game size, we could use the size of the game screen.

// new import:
import 'dart:math';

class TapBox extends RectangleComponent with HasGameRef<TapGame>, TapCallbacks {
  final random = Random();
  TapBox()
      : super(
          position: Vector2(100, 300),
          size: Vector2(50, 50),
        );

  @override
  void onTapDown(TapDownEvent event) {
    gameRef.incrementScore();

    position.x = random.nextDouble() * (gameRef.size.x - size.x);
    position.y = random.nextDouble() * (gameRef.size.y - size.y);
  }
}

Now, the box moves to a random location on the screen whenever clicked.

Updating individual components

Above, we are updating the game state in the update method of the game. The same update method can as well be updated for individual components. As an example, we could add a bit of challenge to the game, moving the TapBox to a new location every two seconds when it is not tapped.

class TapBox extends RectangleComponent with HasGameRef<TapGame>, TapCallbacks {
  final random = Random();
  var timeSinceLastMove = 0.0;
  TapBox()
      : super(
          position: Vector2(100, 300),
          size: Vector2(50, 50),
        );

  @override
  void onTapDown(TapDownEvent event) {
    gameRef.incrementScore();

    changeLocation();
  }

  @override
  void update(double dt) {
    super.update(dt);

    timeSinceLastMove += dt;
    if (timeSinceLastMove > 2.0) {
      changeLocation();
    }
  }

  void changeLocation() {
    position.x = random.nextDouble() * (gameRef.size.x - size.x);
    position.y = random.nextDouble() * (gameRef.size.y - size.y);
    timeSinceLastMove = 0.0;
  }
}

Similarly, if we wish to add some visual movement to the TapBox, we could adjust the transform property of the TapBox that allows transforming the object. As an example, we could continuously update the angle of the TapBox, showing that the TapBox rotates, as shown below.

  @override
  void update(double dt) {
    super.update(dt);
    transform.angle += dt;

    timeSinceLastMove += dt;
    if (timeSinceLastMove > 2.0) {
      changeLocation();
    }
  }

The transform property comes from the PositionComponent that RectangleComponent inherits. The transform is an instance of Transform2D, which allows e.g. adjusting the rotation and scale of the component.

When you try out the above example, you notice that the TapBox rotates continuously and moves to a new location every two seconds when it is not tapped. The rotation is not centered, however, as the default pivot point is the top-left corner of the TapBox. To adjust the pivot point, we can set the anchor property of the TapBox to the center of the TapBox — this is a property that we can pass in the constructor.

  TapBox()
      : super(
          position: Vector2(100, 300),
          size: Vector2(50, 50),
          anchor: Anchor.center,
        );

Now, the TapBox rotates around its center. You can try out the present version of the game below.

Run the program to see the output

Gravity and velocity

Depending on the game, the updates in the game loop can also include physics simulation such as simulating gravity and velocity. Gravity is the force that pulls an object towards the ground (or some other object), while velocity is the speed at which an object moves. A simple way to simulate them is to update the position of an object by adding its velocity to its current position, and then update the velocity by adding the gravity to it.

When thinking of our tap game, we could add gravity as a final constant to the game, and add velocity as a property to the TapBox component.

class TapGame extends FlameGame {
  final gravity = Vector2(0, 100);
  // ...
class TapBox extends RectangleComponent with HasGameRef<TapGame>, TapCallbacks {
  var velocity = Vector2(0, 0);
  // ...

Now, the concrete update method of the TapBox component could be updated to include the physics simulation. In the physics simulation, we update the velocity of the TapBox by adding the gravity to it, and then update the position of the TapBox by adding the velocity to it.

    velocity += gameRef.gravity * dt;
    position += velocity * dt;

The above works as Flame’s Vector2 has a + operator that allows adding two vectors, and a * operator that allows multiplying a vector by a scalar.

Some notes on performance

Although the above + and * operations work, they are not very efficient. When multiplying a vector with a scalar, a new vector is created. Similarly, when adding two vectors, a new vector is created. If there are many components that are updated every frame and that use the operations that create new objects, the creation can end up being computationally expensive. Writing the operations out explicitly is more efficient.

With the above example, the TapBox disappears from the screen when it falls below the screen. To prevent this, we could add a check to the update method to keep the TapBox within the screen bounds. The check could verify whether the TapBox has fallen below the screen, and if it has, set the position of the TapBox to the bottom of the screen and set the velocity to zero (this works only if the box only falls downwards).

    if (position.y > gameRef.size.y - size.y) {
      position.y = gameRef.size.y - size.y;
      velocity = Vector2(0, 0);
    }

Now, once the TapBox reaches the bottom of the screen, it stops falling and stays at the bottom of the screen. It continues to rotate though, as there are no checks for the rotation, or other movements. The final code is as follows.

Run the program to see the output

Loading Exercise...