Constructing Games

Physics Engines and Forge2D


Learning Objectives

  • You know of physics engines for games and you know of Forge2D for the Flame engine.
  • You know how to create a game with physics using Forge2D and Flame.

Games often feature more complex physics than what we saw at the end of the TapGame example or even in the Brick Breaker tutorial. Complex physics involve advanced calculations, which can take some time to implement. As physics functionality is similar across games, there are also a number of ready-made physics engines.

For example, Flame has a physics engine called Forge2D, which is a Dart-based port of the popular Box2D physics engine. To take Forge2D for Flame into use, we need to add the flame_forge2d library as a dependency to the pubspec.yaml file.

dependencies:
  flame_forge2d: 0.18.2+1
  # other dependencies

Then, we need to import flame_forge2d in your Dart file.

import 'package:flame_forge2d/flame_forge2d.dart';

When using Forge2D, instead of extending FlameGame, we extend Forge2DGame. Similarly, our components extend BodyComponent instead e.g. RectangleComponent that we used earlier.

Next, we reimplement our earlier TapGame example using Forge2D. The game is a simple tap game where the player has to tap a box as many times as possible within a time limit. When the time runs out, the game ends and the player is shown their score.

Forge2D, coordinates, and zoom

In Forge2D, the center of the game area is located at (0, 0), but otherwise the coordinates work similarly than with Flame. That is, x-axis increases to the right, and y-axis increases to the bottom. In addition, the default zoom level of the game is 10.

Extending Forge2DGame

First, we modify the TapGame class to extend Forge2DGame instead of FlameGame, removing the property gravity as it is now handled by the physics engine.

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

Next, we redefine the constructor, having it call the constructor of the superclass, setting the camera property to use a specific aspect ratio defined with the withFixedResolution constructor of CameraComponent.

  TapGame()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: 800,
            height: 600,
          ),
        );

The above creates a fixed resolution for the game, after which the sizes of the components are automatically adjusted to match the size of the screen.

You could think of this as similar to Flutter’s AspectRatio widget.

When working with Forge2D, we add components to the game by overriding the onLoad method. In the onLoad method, we add a TapBox component to the game, using the method add of the property world. The world property is an instance of Forge2DWorld from Forge2D.

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    world.add(TapBox());
  }

We can keep the update and incrementScore methods as they are.

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

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

  incrementScore() {
    score++;
  }
}

As a whole, the TapGame class looks like this:

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

  TapGame()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: 800,
          height: 600,
        ),
      );

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    world.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++;
  }
}

Extending BodyComponent

Next, we need to modify the TapBox class so that it extends BodyComponent. When extending BodyComponent, we also gain access to the game that the component belongs to through the property game. However, to provide information about the game class, we need to provide type information to the BodyComponent class. In our case, the type of the game is TapGame.

class TapBox extends BodyComponent<TapGame> {
}

A component that extends BodyComponent has a body that consists of a body definition and a fixture definition. The body definition provides information about the location of the component and on whether it is dynamic or static, and the fixture definition provides information about the shape of the component and its physical properties.

The body of a component is defined in the createBody method of the component, which is called in the onLoad method of the game.

class TapBox extends BodyComponent<TapGame> {

  @override
  Body createBody() {
    // create body here
  }
}

The body is defined using BodyDef, while the fixture is defined using FixtureDef. The body definition is given a position and information on whether the component is dynamic or static. In our case, we could start with a location at (0, 0) and a dynamic body. The location (0, 0) corresponds to the center of the screen in Forge2D.

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
    );

    // create body here
  }

The fixture definition is created based on an instance of Shape. In our case, as the TapBox is a box, we can use the PolygonShape class. A box with the side length of five units can be created with the help of the setAsBoxXY method of the PolygonShape class.

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);

    // create body here
  }

The above call PolygonShape()..setAsBoxXY(2.5, 2.5) leads to a shape that consists of four vertices, with corners at [(-2.5, -2.5), (2.5, -2.5), (2.5, 2.5), (-2.5, 2.5)]. This also means that the centerpoint of the shape is at (0, 0).

Dark trickery

The above call PolygonShape()..setAsBoxXY(2.5, 2.5) is a Dart trick that allows us to call a method on an object and return the object itself. This way, we can chain method calls together. The call is equivalent to:

final shape = PolygonShape();
shape.setAsBoxXY(2.5, 2.5);

Now that we have the shape, we can use the shape to create a fixture definition.

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);
    final fixtureDef = FixtureDef(shape);

    // create body here
  }

And finally, we can create the concrete body using createBody method of the world, and provide the fixture to the body using createFixture method of the body. Once the body has been created and the fixture attached to it, the body is returned from the method.

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);
    final fixtureDef = FixtureDef(shape);

    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }

At this point, the TapBox class looks like this:

class TapBox extends BodyComponent<TapGame> {

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);
    final fixtureDef = FixtureDef(shape);
    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }
}

Now, when we start the game, we can see a box that falls downwards. This stems from setting the type of the body as dynamic in the body definition — due to this, the physics engine simulates gravity acting on the box.

Try adjusting the screen size when you run the game. You’ll notice that the size of the box changes as the screen size changes. This is due to the fixed resolution that we set for the game.

The box does not yet rotate, however. To add rotation, we can add a property angularVelocity to the body definition. The angular velocity is the rate of change of the angle of the body. For example, setting the angular velocity to 1.5 would mean that the body rotates one point five radian (approximately 86 degrees) per second.

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
      angularVelocity: 1.5,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);
    final fixtureDef = FixtureDef(shape);
    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }

Now, the box rotates as it falls downwards.

On the size of the box

The size of the box is defined in the setAsBoxXY method of the PolygonShape class. The method takes two parameters, which define the half-width and half-height of the box. In our case, the box has a width of 5 units and a height of 5 units. So, what does this mean in terms of the game window size?

The default zoom level of the game is 10, which means that when we set the resolution of the game to 800x600, this created a camera that looks at an area that is 80 units wide and 60 units high. As the box has a width of 5 units and a height of 5 units, the box is 6.25% of the width and 8.3% of the height of the screen.

Even if the screen size is changed as the game is running, the size of the box remains the same. This is because the box is defined in terms of units, not pixels. The box is always 5 units wide and 5 units high, regardless of the screen size.

This holds also for all other components that we add to the game.

Reacting to taps

In the earlier TapGame, the player could tap the box to increase their score. We already have access to the TapGame instance through the property game of the TapBox as it extends BodyComponent, so we need to just react to taps.

Reacting to taps can be done by adding the same TapCallbacks mixin that we used earlier, and then overriding the method onTapDown to increment the score.

class TapBox extends BodyComponent<TapGame> with TapCallbacks {
  @override
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
      angularVelocity: 1.5,
    );

    final shape = PolygonShape()..setAsBoxXY(2.5, 2.5);
    final fixtureDef = FixtureDef(shape);
    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }

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

Now, the player can tap the box to increase their score.

At the moment, when the box is tapped, the score is incremented, but the box does not react to the tap in a way that can be perceived visually. In the previous version of the game, the box was moved to a new location after a tap. This can be achieved in Forge2D by moving the body of the component to a new location when the box is tapped.

To move the component, we can use the setTransform method of the body. The method takes a position (as a Vector2) and an angle (in radians) as parameters. As an example, we can move the box to a new location at (0, 0) and reuse the existing angle as follows.

  @override
  void onTapDown(TapDownEvent event) {
    game.incrementScore();
    body.setTransform(Vector2(0, 0), body.angle);
  }

Now, tapping the box moves it to a new location, keeping the rotation, and the player’s score is increased. The velocity of the box is not however reset on click, and the pace at which the box falls continues to increase.

To modify the velocity, we can modify the linearVelocity property of the body. The property is a Vector2 object, and it has x and y properties. Setting the velocity of the box to 0 would work as follows.

  @override
  void onTapDown(TapDownEvent event) {
    game.incrementScore();
    body.setTransform(Vector2(0, 0), body.angle);
    body.linearVelocity.x = 0;
    body.linearVelocity.y = 0;
  }

Game bounds

At the moment, the box falls downwards, and the player has the possibility of tapping the box to increase their score. Tapping the box moves the box back to the center of the screen. If the box falls out of the screen, it continues to fall, and the player can no longer interact with it.

To prevent the box from falling out of the screen, let’s add bounds to the game. We can do this by creating a component with a static body that does not move and that the box can collide with. Let’s create that creates a floor and a ceiling to the game. For this, we can create two instances of an EdgeShape, one for the ceiling, and one for the floor.

To find what the bounds of the game are, we can use the visibleWorldRect property of the camera that we can ask from the game. The property returns a Rect object that contains the bounds of the game as offsets, which we can turn into points. Then, we can use the points to create the bounds.

A component that creates the bounds of the game could look like this:

class GameBounds extends BodyComponent {
  @override
  Body createBody() {
    final bodyDef = BodyDef(
      type: BodyType.static,
      position: Vector2.zero(),
    );

    Body gameBoundsBody = world.createBody(bodyDef);

    final gameRect = game.camera.visibleWorldRect;
    final gameBoundsVectors = [
      gameRect.topLeft.toVector2(),
      gameRect.topRight.toVector2(),
      gameRect.bottomRight.toVector2(),
      gameRect.bottomLeft.toVector2(),
    ];

    for (int i = 0; i < gameBoundsVectors.length; i += 2) {
      gameBoundsBody.createFixture(FixtureDef(
        EdgeShape()
          ..set(
            gameBoundsVectors[i],
            gameBoundsVectors[(i + 1) % gameBoundsVectors.length],
          ),
      ));
    }

    return gameBoundsBody;
  }
}

In the above version, the bounds are at the edges of the screen, which means that they are really visible to the player. To move the bounds a bit inwards, we can add a small offset to the points that define the bounds.

    final gameBoundsVectors = [
      gameRect.topLeft.toVector2() + Vector2(0, 1),
      gameRect.topRight.toVector2() + Vector2(0, 1),
      gameRect.bottomRight.toVector2() + Vector2(0, -1),
      gameRect.bottomLeft.toVector2() + Vector2(0, -1),
    ];

Now, the bounds are a bit inwards from the edges of the screen, making them visible. When you start the game, you see the bounds, and the box collides with the bounds when it falls to the bottom of the screen. The collision is somewhat dull, however, as the box does not really bounce back up when it collides with the bounds.

To make the box bounce back up when it collides with the bounds, we can modify the property restitution of the fixture definition of the box. Restitution is a value between 0 and 1 that defines how much energy is conserved in a collision. A value of 0 means that the object does not bounce back at all, while a value of 1 means that the object bounces back with the same speed as it hit the object.

To make the box bounce back up a bit when it collides with the bounds, we can set the restitution property of the fixture definition of the box to 0.25.

    // in createBody of TapBox
    final fixtureDef = FixtureDef(shape)
      ..restitution = 0.25;

Now, the box bounces back up when it collides with the bounds.

When the box falls to the bottom and stops bouncing, there’s an interesting effect, however. When the box falls to the bottom and we tap it, the box does not move. This stems from a Forge2D optimization, where the physics engine has set the body asleep to avoid unnecessary calculations.

To explicitly set the body awake again, we need to use the setAwake method of the body when moving the box back to the center of the screen.

  @override
  void onTapDown(TapDownEvent event) {
    game.incrementScore();
    body.setTransform(Vector2(0, 0), body.angle);
    body.linearVelocity.x = 0;
    body.linearVelocity.y = 0;
    body.setAwake(true);
  }

Now, the box starts moving again when tapped, even if it has fallen to the bottom, and the player can continue to interact with it.

Applying forces

Body objects can also be influenced by applying forces to them. For example, we can apply a force to the box when it is tapped, which would cause the box to move. Force can be applied, for example, with the method applyLinearImpulse of body. The method is given a Vector2 object that represents a force. As an example, we could change the box so that the user can tap the box to make it jump upwards.

  @override
  void onTapDown(TapDownEvent event) {
    game.incrementScore();
    body.applyLinearImpulse(Vector2(0, -500));
  }

Now, when you run the game, you can tap the box to make it jump upwards. The box jumps upwards because the force applied is negative in the y-direction. Due to the gravity, the box falls back down, but the player can continue to tap the box to make it jump upwards again.

Collisions between objects

In the present version of the game, collision between objects is handled by the physics engine. When two objects collide, the physics engine calculates the collision and moves the objects accordingly. However, the game does not react to the collision in any other way.

Forge2D comes also with a ContactCallbacks mixin that allows explicitly defining functionality that is called when two objects collide. To react to the collision, we can add the mixin to the TapBox, and override the beginContent method, which is called when two objects collide.

class TapBox extends BodyComponent<TapGame>
    with TapCallbacks, ContactCallbacks {
  // other functionality

  @override
  void beginContact(Object other, Contact contact) {
    // do something
  }
}

In addition, we need to explicitly state that the TapBox and the GameBounds are interested in collisions by setting their userData property, as a null value for the property dictates that the contact events are ignored. That is, the beginning of the createBody method of TapBox is changed as follows:

  // ...
  Body createBody() {
    final bodyDef = BodyDef(
      position: Vector2(0, 0),
      type: BodyType.dynamic,
      angularVelocity: 1.5,
      userData: this,
    );
    // ...

And the part that creates the body definition in GameBounds is changed as follows:

  // ...
  Body createBody() {
    final bodyDef = BodyDef(
      type: BodyType.static,
      position: Vector2.zero(),
      userData: this,
    );
    // ...

Now, when the box collides with the bounds, the beginContact method of the TapBox is called. We can use this to react to the collision in some way. As an example, we could adjust the game so that it immediately finishes when the box collides with the bounds. To do this, we can add a method gameFinished to the TapGame class that ends the game and shows the score screen to the user.

class TapGame extends Forge2DGame {
  // other functionality

  finishGame() {
    gameFinished = true;
    Get.offAll(() => ResultScreen(score: score));
  }
}

And, then modify the beginContact method of the TapBox to call finishGame of the game on collision.

  @override
  void beginContact(Object other, Contact contact) {
    game.finishGame();
  }

Now, the game ends on collision, but the player can still tap the box to make it jump upwards, keeping the game alive.

Adding obstacles

When we think of the game at the moment, it is quite easy. One only has to keep the box in the air by tapping it every now and then. To make the game more challenging, we can add obstacles to the game. As an example, the game could feature obstacles that move from right to left that the user has to avoid. One possible implementation for such an ostacle is as follows — the component is placed at the top or at the bottom randomly, and moves from right to left.

class MovingObstacle extends BodyComponent {
  final random = Random();
  final obstacleHeight = 25.0;
  final edgeBuffer = 2;

  @override
  Body createBody() {
    final halfObstacleHeight = obstacleHeight / 2;
    final worldRect = game.camera.visibleWorldRect;

    final positionY = random.nextBool()
        ? worldRect.top + edgeBuffer + halfObstacleHeight
        : worldRect.bottom - edgeBuffer - halfObstacleHeight;

    final bodyDef = BodyDef(
      position: Vector2(worldRect.right + edgeBuffer, positionY),
      gravityOverride: Vector2.zero(),
      linearVelocity: Vector2(-10, 0),
      type: BodyType.dynamic,
      userData: this,
    );

    final shape = PolygonShape()
      ..setAsBoxXY(
        2,
        halfObstacleHeight,
      );

    return world.createBody(bodyDef)..createFixture(FixtureDef(shape));
  }
}

There are a few interesting features in the above component. First, the randomly picked y axis position is calculated based on the screen size, a buffer from the sides, and the obstacle height. Second, the component overrides gravity using the gravityOverride property of the body definition, setting gravity for the component as 0. This means that the component does not fall downwards due to gravity. Third, the component moves from right to left due to the linearVelocity property of the body definition. The velocity is set to -10 in the x-direction, which means that the component moves 10 units to the left per second.

To add the obstacle to the game, we can add the component to the game in the onLoad method of the game.

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    world.add(TapBox());
    world.add(GameBounds());
    world.add(MovingObstacle());
  }

With this, the component is added to the game once, and the player has to avoid the obstacle while keeping the box in the air. To make the game a bit more challenging, we can add a timer that adds a new obstacle to the game every few seconds. This can be done by adding a timer to the game that adds a new obstacle to the game every few seconds.

To do this, we can import Timer functionality from Dart’s async library. For it to work, we need to name the import, as Flame also has a class called Timer.

import 'dart:async' as dart_async;

With the above import, we can use the Timer class with the dart_async prefix. The following outlines a modified onLoad method for the TapGame, indicating that a new obstacle is added every 2.5 seconds.

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    world.add(TapBox());
    world.add(GameBounds());
    world.add(MovingObstacle());

    dart_async.Timer.periodic(const Duration(milliseconds: 2500), (timer) {
      if (gameFinished) {
        timer.cancel();
      }

      world.add(MovingObstacle());
    });
  }

With this in place, the game has a stream of obstacles that the player has to avoid while keeping the box in the air. With the change, the game can be adjusted so that it no longer keeps track of the time until the end of the game, meaning that the way to finish the game is to collide with an obstacle.

In addition, for final polishes, let’s also modify the game so that obstacles that are out of the screen are removed from the game. This can be done by adding a method removeOutOfBoundsObstacles to the TapGame class that removes obstacles that are out of the screen.

  removeOutOfBoundsObstacles() {
    world.children.whereType<MovingObstacle>().forEach((obstacle) {
      if (obstacle.body.position.x < camera.visibleWorldRect.left) {
        world.remove(obstacle);
      }
    });
  }

And calling it e.g. in the function that is used to periodically add obstacles.

As a whole, the TapGame class would look like this:

class TapGame extends Forge2DGame {
  var gameFinished = false;
  var score = 0;

  TapGame()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: 800,
            height: 600,
          ),
        );

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    world.add(TapBox());
    world.add(GameBounds());
    world.add(MovingObstacle());

    dart_async.Timer.periodic(const Duration(milliseconds: 2500), (timer) {
      if (gameFinished) {
        timer.cancel();
      }

      world.add(MovingObstacle());

      removeOutOfBoundsObstacles();
    });
  }

  incrementScore() {
    score++;
  }

  finishGame() {
    gameFinished = true;
    Get.offAll(() => ResultScreen(score: score));
  }

  removeOutOfBoundsObstacles() {
    world.children.whereType<MovingObstacle>().forEach((obstacle) {
      if (obstacle.body.position.x < camera.visibleWorldRect.left) {
        world.remove(obstacle);
      }
    });
  }
}
Loading Exercise...