2. Scaffolding

In this section we will use broad strokes to outline the main elements of the game. This includes the main game class, and the general layout.

KlondikeGame

In Flame universe, the FlameGame class is the cornerstone of most games. This class runs the game loop, dispatches events, owns all the components that comprise the game (the component tree), and usually also serves as the central repository for the game’s state.

So, create a new file called klondike_game.dart inside the lib/ folder, and declare the KlondikeGame class inside:

import 'package:flame/game.dart';
import 'package:flame/flame.dart';

class KlondikeGame extends FlameGame {
  @override
  Future<void> onLoad() async {
    await Flame.images.load('klondike-sprites.png');
  }
}

For now we only declared the onLoad method, which is a special handler that is called when the game instance is attached to the Flutter widget tree for the first time. You can think of it as a delayed asynchronous constructor. Currently, the only thing that onLoad does is that it loads the sprites image into the game; but we will be adding more soon. Any image or other resource that you want to use in the game needs to be loaded first, which is a relatively slow I/O operation, hence the need for await keyword.

I am loading the image into the global Flame.images cache here. An alternative approach is to load it into the Game.images cache instead, but then it would have been more difficult to access that image from other classes.

Also note that I am awaiting the image to finish loading before initializing anything else in the game. This is for convenience: it means that by the time all other components are initialized, they can assume the sprite sheet is already loaded. We can even add a helper function to extract a sprite from the common sprite sheet:

Sprite klondikeSprite(double x, double y, double width, double height) {
  return Sprite(
    Flame.images.fromCache('klondike-sprites.png'),
    srcPosition: Vector2(x, y),
    srcSize: Vector2(width, height),
  );
}

This helper function won’t be needed in this chapter, but will be used extensively in the next.

Let’s incorporate this class into the project so that it isn’t orphaned. Open the main.dart find the line which says final game = FlameGame(); and replace the FlameGame with KlondikeGame. You will need to import the class too. After all is done, the file should look like this:

import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';

void main() {
  final game = KlondikeGame();
  runApp(GameWidget(game: game));
}

Other classes

So far we have the main KlondikeGame class, and now we need to create objects that we will add to the game. In Flame these objects are called components, and when added to the game they form a “game component tree”. All entities that exist in the game must be components.

As we already mentioned in the previous chapter, our game mainly consists of Card components. However, since drawing the cards will take some effort, we will defer implementation of that class to the next chapter.

For now, let’s create the container classes, as shown on the sketch. These are: Stock, Waste, Pile and Foundation. Inside the lib/ folder create a sub-directory components, and then the file lib/components/stock.dart. In that file write

import 'package:flame/components.dart';

class Stock extends PositionComponent {
  @override
  bool get debugMode => true;
}

Here we declare the Stock class as a PositionComponent (which is a component that has a position and size). We also turn on the debug mode for this class so that we can see it on the screen even though we don’t have any rendering logic yet.

Likewise, create three more classes Foundation, Pile and Waste, each in its corresponding file. For now all four classes will have exactly the same logic inside, we’ll be adding more functionality into those classes in subsequent chapters.

At this moment the directory structure of your game should look like this:

klondike/
 ├─assets/
 │  └─images/
 │     └─klondike-sprites.png
 ├─lib/
 │  ├─components/
 │  │  ├─foundation.dart
 │  │  ├─pile.dart
 │  │  ├─stock.dart
 │  │  └─waste.dart
 │  ├─klondike_game.dart
 │  └─main.dart
 ├─analysis_options.yaml
 └─pubspec.yaml

Game structure

Once we have some basic components, they need to be added to the game. It is time to make a decision about the high-level structure of the game.

There exist multiple approaches here, which differ in their complexity, extendability, and overall philosophy. The approach that we will be taking in this tutorial is based on using the World component, together with a Camera.

The idea behind this approach is the following: imagine that your game world exists independently from the device, that it exists already in our heads, and on the sketch, even though we haven’t done any coding yet. This world will have a certain size, and each element in the world will have certain coordinates. It is up to us to decide what will be the size of the world, and what is the unit of measurement for that size. The important part is that the world exists independently from the device, and its dimensions likewise do not depend on the pixel resolution of the screen.

All elements that are part of the world will be added to the World component, and the World component will be then added to the game.

The second part of the overall structure is a camera (CameraComponent). The purpose of the camera is to be able to look at the world, to make sure that it renders at the right size on the screen of the user’s device.

Thus, the overall structure of the component tree will look approximately like this:

KlondikeGame
 ├─ World
 │   ├─ Stock
 │   ├─ Waste
 │   ├─ Foundation (×4)
 │   └─ Pile (×7)
 └─ CameraComponent

For this game I’ve been drawing my image assets having in mind the dimension of a single card at 1000×1400 pixels. So, this will serve as the reference size for determining the overall layout. Another important measurement that affects the layout is the inter-card distance. It seems like it should be somewhere between 150 to 200 units (relative to the card width), so we will declare it as a variable cardGap that can be adjusted later if needed. For simplicity, both the vertical and horizontal inter-card distance will be the same, and the minimum padding between the cards and the edges of the screen will also be equal to cardGap.

Alright, let’s put all this together and implement our KlondikeGame class.

First, we declare several global constants which describe the dimensions of a card and the distance between cards. We declare them as constants because we are not planning to change these values during the game:

  static const double cardWidth = 1000.0;
  static const double cardHeight = 1400.0;
  static const double cardGap = 175.0;
  static const double cardRadius = 100.0;
  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);

Next, we will create a Stock component, the Waste, four Foundations and seven Piles, setting their sizes and positions in the world. The positions are calculated using simple arithmetics. This should all happen inside the onLoad method, after loading the sprite sheet:

    final stock = Stock()
      ..size = cardSize
      ..position = Vector2(cardGap, cardGap);
    final waste = Waste()
      ..size = cardSize
      ..position = Vector2(cardWidth + 2 * cardGap, cardGap);
    final foundations = List.generate(
      4,
      (i) => Foundation()
        ..size = cardSize
        ..position =
            Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
    );
    final piles = List.generate(
      7,
      (i) => Pile()
        ..size = cardSize
        ..position = Vector2(
          cardGap + i * (cardWidth + cardGap),
          cardHeight + 2 * cardGap,
        ),
    );

Since Flame version 1.9.0, FlameGame sets up default world and camera objects. KlondikeGame is an extension of FlameGame, so we can add to that world all the components that we just created.

    world.add(stock);
    world.add(waste);
    world.addAll(foundations);
    world.addAll(piles);

Note

You may be wondering when you need to await the result of add(), and when you don’t. The short answer is: usually you don’t need to wait, but if you want to, then it won’t hurt either.

If you check the documentation for .add() method, you’ll see that the returned future only waits until the component is finished loading, not until it is actually mounted to the game. As such, you only have to wait for the future from .add() if your logic requires that the component is fully loaded before it can proceed. This is not very common.

If you don’t await the future from .add(), then the component will be added to the game anyways, and in the same amount of time.

Lastly, we use FlameGame’s camera object to look at the world. Internally, the camera consists of two parts: a viewport and a viewfinder. The default viewport is MaxViewport, which takes up the entire available screen size – this is exactly what we need for our game, so no need to change anything. The viewfinder, on the other hand, needs to be set up to take the dimensions of the underlying world into account.

We want the entire card layout to be visible on the screen without the need to scroll. In order to accomplish this, we specify that we want the entire world size (which is 7*cardWidth + 8*cardGap by 4*cardHeight + 3*cardGap) to be able to fit into the screen. The .visibleGameSize setting ensures that no matter the size of the device, the zoom level will be adjusted such that the specified chunk of the game world will be visible.

The game size calculation is obtained like this: there are 7 cards in the tableau and 6 gaps between them, add 2 more “gaps” to account for padding, and you get the width of 7*cardWidth + 8*cardGap. Vertically, there are two rows of cards, but in the bottom row we need some extra space to be able to display a tall pile – by my rough estimate, thrice the height of a card is sufficient for this – which gives the total height of the game world as 4*cardHeight + 3*cardGap.

Next, we specify which part of the world will be in the “center” of the viewport. In this case I specify that the “center” of the viewport should be at the top center of the screen, and the corresponding point within the game world is at coordinates [(7*cardWidth + 8*cardGap)/2, 0].

The reason for such choice for the viewfinder’s position and anchor is because of how we want it to respond if the game size becomes too wide or too tall: in case of too wide we want it to be centered on the screen, but if the screen is too tall, we want the content to be aligned at the top.

    camera.viewfinder.visibleGameSize =
           Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap);
    camera.viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0);
    camera.viewfinder.anchor = Anchor.topCenter;

If you run the game now, you should see the placeholders for where the various components will be. If you are running the game in the browser, try resizing the window and see how the game responds to this.

components/foundation.dart
1import 'package:flame/components.dart';
2
3class Foundation extends PositionComponent {
4  @override
5  bool get debugMode => true;
6}
components/pile.dart
1import 'package:flame/components.dart';
2
3class Pile extends PositionComponent {
4  @override
5  bool get debugMode => true;
6}
components/stock.dart
1import 'package:flame/components.dart';
2
3class Stock extends PositionComponent {
4  @override
5  bool get debugMode => true;
6}
components/waste.dart
1import 'package:flame/components.dart';
2
3class Waste extends PositionComponent {
4  @override
5  bool get debugMode => true;
6}
klondike_game.dart
 1import 'package:flame/components.dart';
 2import 'package:flame/flame.dart';
 3import 'package:flame/game.dart';
 4
 5import 'components/foundation.dart';
 6import 'components/pile.dart';
 7import 'components/stock.dart';
 8import 'components/waste.dart';
 9
10class KlondikeGame extends FlameGame {
11  static const double cardGap = 175.0;
12  static const double cardWidth = 1000.0;
13  static const double cardHeight = 1400.0;
14  static const double cardRadius = 100.0;
15  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
16
17  @override
18  Future<void> onLoad() async {
19    await Flame.images.load('klondike-sprites.png');
20
21    final stock = Stock()
22      ..size = cardSize
23      ..position = Vector2(cardGap, cardGap);
24    final waste = Waste()
25      ..size = cardSize
26      ..position = Vector2(cardWidth + 2 * cardGap, cardGap);
27    final foundations = List.generate(
28      4,
29      (i) => Foundation()
30        ..size = cardSize
31        ..position =
32            Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
33    );
34    final piles = List.generate(
35      7,
36      (i) => Pile()
37        ..size = cardSize
38        ..position = Vector2(
39          cardGap + i * (cardWidth + cardGap),
40          cardHeight + 2 * cardGap,
41        ),
42    );
43
44    world.add(stock);
45    world.add(waste);
46    world.addAll(foundations);
47    world.addAll(piles);
48
49    camera.viewfinder.visibleGameSize =
50        Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap);
51    camera.viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0);
52    camera.viewfinder.anchor = Anchor.topCenter;
53  }
54}
55
56Sprite klondikeSprite(double x, double y, double width, double height) {
57  return Sprite(
58    Flame.images.fromCache('klondike-sprites.png'),
59    srcPosition: Vector2(x, y),
60    srcSize: Vector2(width, height),
61  );
62}
main.dart
1import 'package:flame/game.dart';
2import 'package:flutter/widgets.dart';
3
4import 'klondike_game.dart';
5
6void main() {
7  final game = KlondikeGame();
8  runApp(GameWidget(game: game));
9}

And this is it with this step – we’ve created the basic game structure upon which everything else will be built. In the next step, we’ll learn how to render the card objects, which are the most important visual objects in this game.