We have all read articles about how to structure scalable Flutter applications (if you haven't, here is a very good recommendation on layered architecture in Flutter).
What if you want to write a game in Flutter? Writing games in Flutter is quite easy thanks to the Flame Engine, but designing your game and making sure your code is scalable and structured might be more of a challenge.
Building scalable games with the Flame Behaviors package
As we worked on these games, we had the same questions:
- How do we scale the game?
- How do we test the game's functionality?
- How do we structure the game?
These questions helped us come up with the Flame Behaviors package, which provides a structured way of writing game code that is both scalable and testable. It does this by applying separation of concerns in the form of Entities and Behaviors.
An entity is a visual representation of a game object. Each entity can have one or more behaviors to define how it should behave within your game. These behaviors handle a single aspect of an entity and can be made generic so they are reusable over different types of entities. By separating the entity's behavioral aspects into behaviors, we can then test each behavior without having to worry about the others.
By defining these entities, we can describe our game without having to touch code. The Flame Behaviors repository has a good breakdown of entities and behaviors that we'll address in this article:
Imagine you want to build an old school Pong game. At its very core there are two objects: a paddle and a ball. If you have a look at the paddle, you could say its game logic is: move up and move down. The ball has the simple game logic of: on collision with a paddle reverse the movement direction.
These objects, paddles and balls, are what we call entities and the game logic we just described are their behaviors. By applying these behaviors to each individual entity we get the core gameplay loop of Pong: hitting balls with our paddles until we win.
Building Pong with Flame Behaviors
To show how we can use Flame Behaviors to structure games, we are going to remake Pong.
The first step in creating any new game is analyzing the gameplay loop and determining our entities and behaviors. As noted above, these will be the entities:
- A ball that will move over the playfield.
- Two paddles that players use to try and reflect the ball away from them.
The gameplay loop also consists of the different behaviors each entity can hold. The paddle is the easiest to describe in behaviors, as it only needs to be able to move up and down the playfield. We can call this the moving behavior of the paddle.
The ball is, compared to the paddle, more complex in terms of its behaviors:
- Moving behavior: The ball needs to be able to move across the whole playfield.
- Colliding behavior: The ballneeds to be able to collide with any paddle to reverse its own velocity.
- Scoring behavior: When the ball hits either the left or the right side of the playfield, the opposite player scores a point.
Setting up our game project
Let's start by creating our project by running the following command:
Now that that the project files are set up we can prepare the lib directory for our game:
The main.dart will start up the pong game:
We can rewrite our pong_game.dart to reflect the following code:
There are a few things going on in the code. First we have a PongGame that uses the mixin HasKeyboardHandlerComponents. This detects keyboard input, which we will need later for one of the behaviors. The game also uses the mixin HasCollisionDetection, which enables collision within the game and checking for collisions on every frame.
In the onLoad method, we set the camera's viewport to a resolution of 512x256 pixels. Everything that is being rendered will be rendered within that resolution (while this is not the original Pong resolution, it does make it easier for us to position our paddles on the screen so we don't have to work with dynamic resolutions).
We also overwrite the onKeyEvent method from the HasKeyboardHandlerComponents mixin because on macOS if you don't return the KeyEventResult.handled value, the window will play an error sound (see this issue for more explanation).
Creating the paddle entity and its behaviors
The first entity that we are going to implement is the paddle. Let's edit the paddle.dart file to represent the following code:
We start by defining a Paddle class that extends an Entity, which is just a PositionComponent with some extra APIs related to behaviors. The paddle will be controlled through keyboard inputs. To make this possible, we define two public constructors, Paddle.wasd and Paddle.arrows. Each calls the private constructor Paddle._withKeys, which in turn calls the main private constructor. This constructor requires a behavior that will define its moving behavior. The Paddle._withKeys passes a KeyboardMovingBehavior with the up and down arrow keys set by either the Paddle.wasd or Paddle.arrows constructor.
By applying multiple constructors that eventually call the main private constructor, we can easily add a new type of moving behavior later without having to duplicate code. The main constructor defines how it looks by passing components as children and the kind of behaviors it has by passing default behaviors to the behaviors argument. One thing to note is that the paddle has a RectangleHitbox as one of its children, which allows for collision detection in Flame. Because the paddle does not have its own collision behaviors, we don't need to do anything else. When we get to the ball entity, we will showcase how collision behaviors work.
The moving behavior
As described above, the moving behavior will be controlled by keyboard input. For that we need to create a keyboard_moving_behavior.dart that will live in a behaviors directory within the lib/entities/paddle directory:
Then create a barrel file called behaviors.dart in which we export the newly created keyboard_moving_behavior.dart:
Let's edit the keyboard_moving_behavior.dart file and add the following code:
As you can see, this behavior is quite compact, simply because it only has one goal: move a paddle up or down based on which key is pressed. The class extends a Behavior that can only have a parent entity of the type Paddle. We add the KeyboardHandler mixin from Flame so that we can detect key events and overwrite the onKeyEvent to check if the upKey is pressed. If it is, we set the _movement to -1 to indicate that we are moving upwards. If the downKey is pressed we set it to 1, if neither keys are pressed, we reset it to 0.
Then in the update method, we update our parent position (the paddle) by multiplying the speed by our movement (to either move it up or down), and then multiply that by the delta value dt. Finally, we clamp the y-axis so that our paddle can't get out of the playfield bounds.
Creating the ball entity and its behaviors
As mentioned earlier, the ball entity consists of multiple behaviors. Behaviors may also require persistent data, which should live on the entity. For instance, if we talk about the ball's moving behavior, there is a velocity value that indicates which direction the ball is moving, but does not indicate anything about the moving behavior. This kind of data should always live on the entity.
Let's change the code in the ball.dart file:
Just like the paddle entity, the ball has a private constructor and an entry constructor. The private constructor defines how the ball looks by passing a CircleComponent as one of its children. In its behaviors, we define a PropagatingCollisionBehavior that has a CircleHitbox. This is a behavior that comes from Flame Behaviors and propagates any collision to the correct CollisionBehavior, which we will define later on.
We also define a property called maxSpeed, which will be used to set the velocity of the ball, and finally we have a range value. This value describes the range of angles that the ball can go when it starts moving. Let's use these values and create a method that allows us to reset the ball and move it in a random direction:
The reset method does four things:
- Randomly calculates an angle between -range..+range.
- Randomly indicates if the ball should go left or to the right.
- Sets the velocity based on the angle and direction and multiplied by the maxSpeed.
- Finally, resets the ball's position to the center of the screen.
The reset method allows us to start the game with the ball at the center and have it move in a random direction at an angle. We can call the method every time we need to reset the ball, for instance when the ball scores a point.
Before we start adding any of the ball behaviors, let's update the file structure to represent the following:
Inside the behaviors.dart, export the newly created behaviors files.
The moving behavior
The moving behavior for the ball is quite simple. We only need to update the ball's position based on its velocity and then confirm if we have hit either the top or the bottom of the playfield. If we do hit the top or bottom, the ball needs to invert its velocity on the y-axis. Let's update the moving_behavior.dart file:
We can now update our Ball entity and pass the newly defined MovingBehavior:
The paddle colliding behavior
Our ball needs to be able to collide with a paddle, and when it does, it should invert the velocity x-axis. To do this, we need to define a CollisionBehavior that has a Collider of the type Paddle. As mentioned before when we defined the Ball entity, there is a PropagatingCollisionBehavior that propagates any collision to the correct CollisionBehavior. By adding a CollisionBehavior for the Paddle entity, the PropagatingCollisionBehavior will then be able to confirm if we are colliding with a Paddle. If we do, it will call the onCollision methods with the entity with which we are colliding. This sounds complex, but it all boils down to the following code:
Thanks to the PropagatingCollisionBehavior we don't have to handle collision logic and we can just focus on writing what should happen if we do collide with a Paddle.
We can add our newly created PaddleCollidingBehavior to our ball constructor:
The scoring behavior
The scoring behavior has the same logic that we implemented for velocity inversion of the ball's moving behavior, but in this case, we have to check if the ball hits the left or right side of the playfield. If so, then we assign a point to the player opposite the side that was hit. Finally, we reset the ball to start a new round. The scoring_behavior can be updated like this:
Just like before, we can now add our ScoringBehavior to the ball's constructor:
Testing entities and behaviors
At VGV, we love testing, so it should not come as a surprise that the Flame Behaviors package is 100% testable. When writing tests for entities, we only have to focus on the entity's data and methods. Any behavior that an entity might have should contain their own test files. This allows us to focus on testing only the specific behaviors of entities and keeps our test files compact and ordered.
We can test the entities and behaviors in this Pong example by using the testing library from Flame. To test an entity, we can define a test constructor that exposes parameters to make testing easier. Let's add such a constructor to the ball entity:
This test constructor can now be used to test both the ball entity and any behavior that it might have. Here is an example of the test file for the ball entity:
As you can see, the tests are focused on the ball's functionality, not its behaviors, which are tested separately. A good example of a behavior test is the PaddleCollidingBehavior of the ball:
Both entities and behaviors are easy to test. Since a behavior always describes a single aspect of an entity, there should be a test file for each separate behavior. For more examples of testing entities and behaviors, see the test directory in the repository.
Games are so much more than entities and behaviors
We have mostly talked about entities and behaviors in this article, but a game consists of quite a lot of additional elements: backgrounds, title screens, game modes, options, and so much more. While we did not cover these elements in this article, we did implement all of these in the Pong game. To see how, take a closer look at the full code in the repository.
We now know how to design our games by using entities and their behaviors. We can implement these elements by using Flame Behaviors, which allows us to create a scalable and well-structured code base. To top it off, we can easily test every single behavior without having to worry about overlapping code functionality.
We hope this sheds some light on how easy it is to write games in Flutter while maintaining best practices in your codebase. We can't wait to see what kind of games everyone will be making with Flame Behaviors!
Felix Angelov contributed to this article.