Very Good Ranch: A very good game made with Flutter

How to use make a fun game with “boring” code

October 6, 2022
By 
October 6, 2022
updated on
April 19, 2024
By 
Guest Contributor

The Flutter community has been experimenting with game development for a while, resulting in many incredible packages, experiments, and games. We saw interest in game development reach a new level when Google announced the Casual Games Toolkit at Google I/O 2022. At the same time, Google released I/O Pinball, a showcase game that pushed the limits of Flutter and demonstrated that the framework can in fact be used to build a robust 2D game. I/O Pinball was also our first experience building a game with Flutter as a team at VGV.

With interest in game development growing in the Flutter community, we wanted to push our skills further and figure out how we at VGV might standardize and approach game development in a way that mirrors our “boring” approach to app development. Today we release our demo: Very Good Ranch, a game of our own, built with Flutter and Flame.

About Very Good Ranch

How to play it

Make friends by keeping your unicorns happy — watch them grow with the right food and enough love. How many unicorns can you befriend?

Gameplay goes like this:

  • From time to time, a baby unicorn will arrive at your ranch.
  • Each unicorn has two bars: the top indicates their happiness and the bottom bar indicates their fullness level.
  • If you keep your unicorns’ levels high, they will evolve. If you don’t, they will fly away.
  • At times, different types of food will spawn at your ranch.
  • As time passes, unicorns get hungry and bored.
  • Clicking a unicorn pets them and increases their enjoyment (the top bar increases).
  • Dragging and dropping food on the unicorn increases their fullness (the bottom bar increases).
  • If the food fed to the unicorn is its favorite, their enjoyment will also increase (the top bar increases).
  • If you keep a unicorn happy, it will evolve into the next unicorn stage.

Concepts and ideas

Game ideation is a big challenge on its own. When creating a game, you have to account for game design, playability, game mechanics, and rules, and on top of all that, making the game fun. We had quite the challenge ahead of us!

While we are not a game studio, we knew that we could create something cool and learn a lot in the process. So we agreed on only two requirements: the game should be simple and it should feature unicorns 🦄

We brainstormed for some time and landed on a Tamagotchi-like game, where you have to take care of multiple unicorns that arrive at your ranch. Like all games, we started focusing on its core mechanics, as outlined in this file (note that a few principles have been updated since the original gameplay design, for example, there are only four types of food, not five). Here are some early explorations into designs and game mechanics:

Once the basics of the gameplay were in place, we started polishing the game, adding sprite sheets, animations, and lastly, making the game look good, thanks to the magical assets provided by HOPR!

Then, came time to build the game — a huge shoutout to our teammate Renan Araújo, who led the development of this project. Let’s take a closer look at the code!

Bringing our scalable standards to game development

When we started planning the development of the game, we wanted to take what we learned on I/O Pinball and expand it. It was important for us to be able to apply our standards for app development to make this a scalable project that we could iterate upon, while maintaining a high-quality game. Read on for a closer look at some of the elements that embody our approach to scalable app development.

Separation of concerns

At Very Good Ventures, we stick to a layered architecture when building apps. This allows us to decouple our business logic from the presentation of the application so that we can easily test, iterate, and reuse components. We applied a similar approach to this game, with the help of components and entities.

Four stages of unicorn evolution

Components: How an object looks in a game

In the context of Very Good Ranch, we used flame_behaviors to help us decouple the components from the entities and their behaviors. For example, we have a unicorn component which is focused on rendering the unicorn assets. Each unicorn component uses a sprite sheet because the assets are always animated, whether they’re walking around the ranch or eating food.

Baby unicorn eating sprite sheet
class BabyUnicornComponent extends UnicornComponent {
  BabyUnicornComponent({ UnicornState? initialState})
    : super(
        spritePadding: const EdgeInsets.only(
          top: 136,
          left: 45,
          right: 55,
          bottom: 33,
        ),
        spriteComponent: UnicornSpriteComponent(
          initialState: initialState,
          eatAnimationData: UnicornAnimationData(
            columnsAmount: 10,
            frameAmount: 90,
            filePath: Assets.animations.babyEat.keyName,
          ),
          idleAnimationData: UnicornAnimationData(
            columnsAmount: 10,
            frameAmount: 50,
            filePath: Assets.animations.babyIdle.keyName,
          ),
          pettedAnimationData: UnicornAnimationData(
            columnsAmount: 10,
            frameAmount: 90,
            filePath: Assets.animations.babyPetted.keyName,
          ),
          walkAnimationData: UnicornAnimationData(
            columnsAmount: 7,
            frameAmount: 34,
            filePath: Assets.animations.babyWalkCycle.keyName,
          ),
        ),
    );
  }
)

The above component is just a representation of how the unicorn looks. It does not have any logic or behaviors for interacting with anything else in the ranch. Components live in the ranch_components subpackage and come with their own sandbox so that you can play around and tweak them in isolation before adding them to the game.

Entities: A composition of one or more components and their behaviors

An entity consists of one or more components and one or more behaviors — think of it as how an object looks and behaves in a game. For example, a car entity in a game could consist of multiple components (such as wheels, axle, and a body, which may all be separate components) and multiple behaviors (such as moving and turning). Entities are the basic building blocks of your game.

In Very Good Ranch, we have a unicorn entity that contains a unicorn component (the visual representation), along with state properties such as the current evolution phase. Various behaviors are applied to the unicorn entity, such as the evolving behavior, moving behavior, petting behavior, and more, to determine how the unicorn interacts with its surroundings and other entities (food) within the game.

class Unicorn extends Entity with Steerable, HasGameRef<SeedGame> {
  factory Unicorn({
    required Vector2 position,
    UnicornComponent? unicornComponent,
    required UnicornGaugeCallback onMountGauge,
    required UnicornGaugeCallback onUnmountGauge,
  }) {
    final _unicornComponent = unicornComponent ?? BabyUnicornComponent();
    final size = _unicornComponent.size;
    return Unicorn._(
      position: position,
      size: size,
      behaviors: [
        DraggingBehavior(),
        EvolvingBehavior(),
        PropagatingCollisionBehavior(RectangleHitbox()),
        MovingBehavior(),
        FoodCollidingBehavior(),
        FullnessDecreasingBehavior(),
        EnjoymentDecreasingBehavior(),
        LeavingBehavior(),
        PettingBehavior(),
        PositionalPriorityBehavior(anchor: Anchor.bottomCenter),
      ],
      enjoyment: UnicornPercentage(1),
      fullness: UnicornPercentage(1),
      unicornComponent: _unicornComponent,
      onMountGauge: onMountGauge,
      onUnmountGauge: onUnmountGauge,
    );
  }
  ...

Behaviors: How an entity acts

As we mentioned above, our unicorn entity contains many behaviors such as the EvolvingBehavior to decouple the logic of how a unicorn acts from the state of a unicorn. Behaviors have access to their respective entity and can manipulate state, position, and other properties on the entity. In the case of the EvolvingBehavior, we inspect the evolution stage along with fullness, happiness, and number of times fed in order to determine whether a unicorn should evolve. If it’s time for the unicorn to evolve, we update the evolution stage on the entity accordingly. This triggers the evolution animation and the unicorn’s appearance changes.

class EvolvingBehavior extends Behavior<Unicorn>
    with FlameBlocReader<BlessingBloc, BlessingState> {
  @override
  void update(double dt) {
    if (!shouldEvolve || parent.waitingCurrentAnimationToEvolve) {
      return;
    }

    final nextEvolutionStage = getNextEvolutionStage();
    parent.setEvolutionStage(nextEvolutionStage).then((value) {
      bloc.add(UnicornEvolved(to: nextEvolutionStage));
    });
  }

  bool get shouldEvolve {
    if (parent.evolutionStage == UnicornEvolutionStage.adult) {
      return false;
    }
    return parent.timesFed >= Config.timesThatMustBeFedToEvolve &&
        parent.happiness >= Config.happinessThresholdToEvolve;
  }

  UnicornEvolutionStage getNextEvolutionStage() {
    final currentEvolutionStage = parent.evolutionStage;
    if (currentEvolutionStage == UnicornEvolutionStage.baby) {
      return UnicornEvolutionStage.child;
    }
    if (currentEvolutionStage == UnicornEvolutionStage.child) {
      return UnicornEvolutionStage.teen;
    }
    if (currentEvolutionStage == UnicornEvolutionStage.teen) {
      return UnicornEvolutionStage.adult;
    }
    return currentEvolutionStage;
  }
}

Managing game state with Flame Bloc

In addition to flame_behaviors, Very Good Ranch also uses flame_bloc in order to manage state that is shared by Flame components as well as vanilla Flutter widgets (learn more about flame_bloc in this article).

Building on the evolution example, when a unicorn evolves, the EvolvingBehavior notifies a BlessingBloc (did you know a herd of unicorns is called a blessing?) which keeps track of the number of unicorns at your ranch in each stage. The EvolvingBehavior does this using the FlameBlocReader mixin which allows the behavior to have access to the BlessingBloc instance:

class EvolvingBehavior extends Behavior<Unicorn>
    with FlameBlocReader<BlessingBloc, BlessingState> {
  @override
  void update(double dt) {
    ...
    bloc.add(UnicornEvolved(to: nextEvolutionStage));
    ...
  }
}

The BlessingBloc state looks like:

class BlessingState extends Equatable {
  BlessingState.initial()
    : this._(
      babyUnicorns: 0,
      childUnicorns: 0,
      teenUnicorns: 0,
      adultUnicorns: 0,
    );
  ...
}

When the BlessingBloc receives a UnicornEvolved event, it emits a new state with the updated unicorn count. In addition, the BlessingBloc reacts to other events such as UnicornSpawned, and UnicornDespawned.

/// Blessing is a collective noun for unicorns.
/// This [Bloc] keeps count of how many unicorns are in the ranch.
class BlessingBloc extends Bloc<BlessingEvent, BlessingState> {
  BlessingBloc() : super(BlessingState.initial()) {
    on<UnicornSpawned>(_onUnicornSpawned);
    on<UnicornDespawned>(_onUnicornDespawned);
    on<UnicornEvolved>(_onUnicornEvolved);
  }
  ...
}

The _UnicornCounter widget reacts to changes in the BlessingBloc state using a BlocBuilder and renders the updated count accordingly.

class _UnicornCounter extends StatelessWidget {
  const _UnicornCounter({
    required this.type,
  });

  final UnicornType type;

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<BlessingBloc, BlessingState>(
      builder: (context, state) {
        final count = state.getUnicornCountForType(type);
        return UnicornCounter(
          isActive: count > 0,
          type: type,
          child: Text(count.toString()),
        );
      },
    );
  }
}

This approach allows us to share state across Flame and Flutter in a reactive way while keeping things easy to test and using familiar mechanisms such as BlocProvider, FlameBlocProvider, and BlocBuilder to interface with a bloc.

Testing

Component Tests

One of the side effects of the layered, decoupled architecture is we can easily test things in isolation. For example, when testing a component we can leverage golden tests to ensure it looks as it should at any point in its animation cycle. The following snippet illustrates what a golden test for the baby unicorn’s idle animation looks like.

flameTester.testGameWidget(
  'idle animation',
  setUp: (game, tester) async {
    final unicorn = BabyUnicornComponent();
    await game.ensureAdd(
      PositionComponent(
        position: Vector2(150, 150),
        children: [unicorn],
      ),
    );
  },
  verify: (game, tester) async {
    for (var i = 0.0, index = 0;
        i <= UnicornSpriteComponent.idleAnimationDuration;
        i += 0.3, index++) {
      game.update(0.3);
      await tester.pump();

      await expectLater(
        find.byGame<TestGame>(),
        matchesGoldenFile(
          'golden/baby_unicorn_component/idle/frame_$index.png',
        ),
      );
    }
  },
);
snapshots from golden tests

Entity Tests

When testing an entity, we can ensure the entity contains the correct behaviors in response to changes in its state. For example, when a unicorn has the correct level of fullness, enjoyment, and number of times fed, we can verify it evolves to the next stage.

flameTester.test(
  'evolves to next stage',
  (game) async {
    final unicorn = Unicorn(
      position: Vector2.all(1),
      onMountGauge: (gauge) {},
      onUnmountGauge: (gauge) {},
    );

    await game.background.ensureAdd(unicorn);

    expect(unicorn.evolutionStage, UnicornEvolutionStage.baby);
    unicorn.timesFed = Config.timesThatMustBeFedToEvolve;

    // First ensure that the evolution effect is added.
    game.update(0);
    await game.ready();

    // Then wait for the evolution effect to finish.
    game.update(4.2);
    await game.ready();

    expect(unicorn.evolutionStage, UnicornEvolutionStage.child);
    expect(unicorn.size, ChildUnicornComponent().size);
  },
);

Behavior Tests

When it comes to testing behaviors, we can verify that when the behavior is applied to an entity with a predefined state, it should apply the correct changes to the entity. For example, we can verify that when the EvolvingBehavior is added to a baby unicorn that is ready to evolve, it evolves to the next stage and notifies the BlessingBloc.

flameTester.test('from baby to kid', (game) async {
  final enjoymentDecreasingBehavior = _MockEnjoymentDecreasingBehavior();
  final fullnessDecreasingBehavior = _MockFullnessDecreasingBehavior();

  final evolvingBehavior = EvolvingBehavior();

  final unicorn = Unicorn.test(
    position: Vector2.zero(),
    behaviors: [
      enjoymentDecreasingBehavior,
      fullnessDecreasingBehavior,
      evolvingBehavior,
    ],
  );
  await game.ready();
  await game.background.ensureAdd(unicorn);

  expect(unicorn.evolutionStage, UnicornEvolutionStage.baby);
  unicorn.timesFed = Config.timesThatMustBeFedToEvolve;

  unicorn.enjoyment.value = 1;
  unicorn.fullness.value = 1;

  game.update(0);
  await game.ready();

  game.update(4.2);
  await game.ready();

  expect(unicorn.evolutionStage, UnicornEvolutionStage.child);
  expect(unicorn.timesFed, 0);

  await Future.microtask(() {
    verify(
      () => blessingBloc.add(
        const UnicornEvolved(to: UnicornEvolutionStage.child),
      ),
    ).called(1);
  });
});

Testing Automation

When building scalable applications, it’s really important to be able to enforce best practices via automation. We use Very Good Workflows to run various checks in our CI, such as ensuring all tests are passing and that the affected packages are fully tested (in terms of line coverage).

name: ranch_components

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on:
  push:
    paths:
      - "packages/ranch_components/**"
      - ".github/workflows/ranch_components.yaml"

  pull_request:
    paths:
      - "packages/ranch_components/**"
      - ".github/workflows/ranch_components.yaml"

jobs:
  build:
    uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
    with:
      flutter_channel: stable
      flutter_version: 3.3.3
      working_directory: packages/ranch_components
      coverage_excludes: "lib/gen/*.dart"

For more about testing, check out our Flutter testing resource blog.

Preloading Assets

Games can include a variety of different assets including images, sprite sheets, custom fonts, audio files, and more. Because games are constantly updating, any asset loading in the middle of the game can impact performance. In Very Good Ranch, we wanted to be able to preload the assets so that they are available immediately when the user starts a game.

To do this, we created a LoadingPage which handles rendering a loading progress bar while assets are being loaded. Once all assets are loaded, the user is redirected to the TitlePage. The LoadingPage integrates with a PreloadCubit that manages the current preload progress and schedules the preloading of each asset group (FoodComponent, UnicornComponent, etc.). The PreloadCubit is provided at the root of the application via BlocProvider and initiates the preloading phase immediately:

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        ...
        BlocProvider(
          create: (_) => PreloadCubit(
            UnprefixedImages(),
            RanchSoundPlayer(),
          )..loadSequentially(),
        )
      ],
      child: const AppView(),
    );
  }
}

Internally, the LoadingPage listens for state changes in the PreloadCubit via BlocBuilder and updates the progress bar using an AnimatedProgressBar.

return BlocBuilder<PreloadCubit, PreloadState>(
  builder: (context, state) {
    final loadingLabel = l10n.loadingPhaseLabel(state.currentLabel);
    final loadingMessage = l10n.loading(loadingLabel);
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 24),
          child: Assets.images.loading.image(),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 8),
          child: AnimatedProgressBar(progress: state.progress),
        ),
        Text(
          loadingMessage,
          style: primaryTextTheme.bodySmall!.copyWith(
            color: const Color(0xFF0C5A4D),
            fontWeight: FontWeight.w900,
          ),
        ),
      ],
    );
  },
);

The LoadingPage also uses a BlocListener to redirect to the TitlePage when the assets have all been loaded successfully:

return BlocListener<PreloadCubit, PreloadState>(
  listenWhen: _isPreloadComplete,
  listener: (context, state) => onPreloadComplete(context),
  child: ...
);

The end result is a loading screen that reports the progress to the user and ensures all assets are available by the time the user enters the game. This process of loading the assets to the time you can start playing the game should only take a few seconds (depending on the speed of your internet connection, of course).

Game Juiciness

There is a saying in software development: "The first 90 percent of the code accounts for the first 90 percent of the development time. The remaining 10 percent of the code accounts for the other 90 percent of the development time." We’ve found that this can be true of game development when it came to Very Good Ranch. The last ten percent of development certainly seemed to account for 90 percent of the time spent due to final tweaks, polish, bug fixes, playtesting, and generally making the game juicy — or, as we like to think of it, taking our game from good to very good.

One of the most difficult challenges that we faced was synchronizing the unicorn animations. Animations can become stacked easily. For example, when a unicorn is in the process of evolving, the user can feed or even pet the unicorn, which can cause interruptions in the evolution animation.

While there is no super secret tip here on how to solve that, plenty of playtests were used to identify any issues and unit tests were added to ensure that any bugs we fixed would not resurface. Separation of concerns played a big role, as it made writing these tests easier and less fragile.

What’s next

Very Good Ranch was a fun opportunity to continue to explore how Flutter (and Flame) can be used for game development. While VGV is primarily a Flutter app development consultancy, it’s pretty cool that we can easily switch gears and use the same tech stack we use to build apps to make robust games. Building this Very Good Ranch demo allowed us to stretch our development capabilities, test our existing tools, and provide some additional resources and tooling for the Flutter Community.

While we don’t plan to actively iterate on Very Good Ranch, we will continue to maintain it and hope that it serves as a good resource for Flutter developers looking to learn more about building complex games in Flutter with a focus on architecture, testing, and automation. We also hope you enjoy taking care of your unicorns and watching them grow — how many unicorns can you keep in your ranch?

Play Very Good Ranch here →

Take a closer look at Very Good Ranch on GitHub →

Renan Araújo contributed to this article.

More Stories