Only 2 in 5 startups are profitable, according to a recent survey. An MVP (minimum viable product) significantly increases the chances of a startup's profitability as it allows such businesses to collect early user feedback without spending the entire budget on an app with complete functionality.
With MVP, you can build an app with basic functionality in short terms and on a limited budget, collect user feedback, and continue expanding the solution with your development team according to this feedback.
MVPs are becoming increasingly popular in the gaming industry. Today, we will explore the ins and outs of rapid game MVP development with Flutter and Flame, a stellar combination for building cross-platform minimum-viable products.
Flutter, a feature-packed and secure platform for cross-platform development, has taken the mobile app world by storm, and its reach extends far beyond UI. With the help of Flame, a robust and open-source game engine built on top of Flutter, you can craft stunning 2D games that run smoothly on Android, iOS, Web, and Desktop devices.
Flutter has also become a popular solution for building game MVPs due to its integral features that facilitate the fast development of solutions that present the basic functionality across different devices. In particular, various Flutter benefits and integral functions allow:
Flutter doesn't consume many computing resources and facilitates the simple setup of cross-platform applications.
The app MVP based on the Flutter and Flame combination is a reliable yet relatively simple to develop solution. It compiles directly to native code, ensuring smooth gameplay and responsiveness. You can develop your game MVP once and deploy it across different platforms, saving time and resources. Flutter and Flame handle the platform differences under the hood.
In addition, both technologies boast vibrant communities with extensive documentation, tutorials, and code examples. This means that you'll never be stuck for an answer or inspiration.
Flame provides a whole toolset for creating MVP game features in short terms and without overspending resources. This cross-platform modeling framework offers tools for a wide array of different use cases:
Most of the above-mentioned features are essential for many games and should not be overlooked even at the MVP development stage. What is really important is that Flame significantly boosts the speed of developing the functionality mentioned above, allowing you to release such features even in the earliest product versions.
Now, instead of talking about Flame, let's create an MVP containing basic features of our own game with this framework. Before we start, you must have installed Flutter 3.13 or higher, your favorite IDE and device for testing.
This game is inspired by Chrome Dino. Ah, the famous Dino Run! It's more than just a game from Chrome. It's a beloved Easter egg hidden within the browser's offline mode.
Our project will have the following gameplay:
And it will be called “Forest Run!”
Create an empty Flutter project like you do every time you start a new app. To start, we need to set dependencies in pubspec.yaml for our project. When writing this post, the latest version of Flame is 1.14.0. Also, let's define all assets paths now, so there will be no need to return to this file later. And put images into directory assets/images/. We need to put it here because Flame will scan exactly this path:
environment:
sdk: '>=3.2.3 <4.0.0'
flutter: '>=3.13.0'
dependencies:
flutter:
sdk: flutter
flame: ^1.14.0
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/images/character/
- assets/images/background/
- assets/images/forest/
- assets/images/font/
Remember to put all images under assets/images/ because Flame will not parse other directories.
You'll need a lot of images for any game. But what if you are not good at design? Thankfully, there are a lot of open-source assets that you can use for your projects. Assets for this game were taken from itch.io. We will use these resources for our project:
You can visit those links, or just download prepared assets (LINK TO ASSETS ARCHIVE) for this project and copy all content to your project.
Flame has a similar philosophy to Flutter. In Flutter, everything is a Widget; in Flame, everything is a Component, even the whole Game. Every Component can override 2 methods: onLoad() and update(). onLoad() is called only once when Component is mounted into the ComponentTree and update() is fired on each frame. Very similar to initState() and build() from StatefulWidget in Flutter.
Now, let's write some code. Create a class that extends FlameGame and loads all our assets into the cache.
class ForestRunGame extends FlameGame {
@override
Future<void> onLoad() async {
await super.onLoad();
await images.loadAllImages();
}
}
Next, use ForestRunGame in main.dart. Also, you can use methods from Flame.device to configure device orientation. And there is GameWidget, which serves as a bridge between widgets and components.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Flame.device.fullScreen();
await Flame.device.setLandscape();
runApp(GameWidget(game: ForestRunGame()));
}
At this point, we can already start the game, but there will be only a black screen. So, we need to add our components.
We will divide the forest into two components: background and foreground. Firstly, we'll handle the background. Have you ever scrolled through a page that felt dynamic? As if you were scrolling through more than one view at once? That's a parallax effect, and it happens when the different elements of a page move at different speeds, creating a 3D depth effect.
As you can think, we'll use a parallax for our background. Extend ParallaxComponent and set up a stack of images using ParallaxImageData. Also, there is baseVelocity for the initial layers' speed and velocityMultiplierDelta, which stands for the relative difference in speed between layers. And the last thing, configure the priority field (z-index) to move it behind other components.
class ForestBackground extends ParallaxComponent<ForestRunGame> {
@override
Future<void> onLoad() async {
priority = -10;
parallax = await game.loadParallax(
[
ParallaxImageData('background/plx-1.png'),
ParallaxImageData('background/plx-2.png'),
ParallaxImageData('background/plx-3.png'),
ParallaxImageData('background/plx-4.png'),
ParallaxImageData('background/plx-5.png'),
],
baseVelocity: Vector2.zero(),
velocityMultiplierDelta: Vector2(1.4, 1.0),
);
}
}
The background is done; now, it's time to add the foreground. Extend the PositionComponent so we can align the ground to the bottom of the screen. We also need the HasGameReference mixin to access the game cache.
To create ground, you just need to put the ground block image in line multiple times. In Flame, image components are called sprites. A Sprite is a region of an image that can be rendered in the Canvas. It might represent the entire image or be one of the pieces a sprite sheet comprises.
Also, remember that the X-axis is right-oriented and the Y-axis is bottom-oriented. The center of the axes is positioned in the left top corner of the screen.
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> {
static final blockSize = Vector2(480, 96);
late final Sprite groundBlock;
late final Queue<SpriteComponent> ground;
@override
void onLoad() {
super.onLoad();
groundBlock = Sprite(game.images.fromCache('forest/ground.png'));
ground = Queue();
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
final newBlocks = _generateBlocks();
ground.addAll(newBlocks);
addAll(newBlocks);
y = size.y - blockSize.y;
}
List<SpriteComponent> _generateBlocks() {
final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length;
final lastBlock = ground.lastOrNull;
final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width;
return List.generate(
max(number, 0),
(i) => SpriteComponent(
sprite: groundBlock,
size: blockSize,
position: Vector2(lastX + blockSize.x * i, y),
priority: -5,
),
growable: false,
);
}
}
And the last thing, add these components to our ForestRunGame.
class ForestRunGame extends FlameGame {
late final foreground = ForestForeground();
late final background = ForestBackground();
@override
Future<void> onLoad() async {
await super.onLoad();
await images.loadAllImages();
add(foreground);
add(background);
}
}
Now, try to launch the game. At this point we already have our forest.
Forest looks good, but it's only a picture at this moment. So, we are going to create Jack, who will run through this forest under the player's guidance. Unlike trees and ground, the player needs animations to feel alive. We used Sprite for ground blocks, but we're going to use SpriteAnimation for Jack. How does this work? Well, all is easy, you just need to loop a sequence of sprites. For example, our run animation has 8 sprites, which replace each other with a small time gap.
Jack can run, jump, and be idle. To represent his states, we can add a PlayerState enum. Then create a Player that extends SpriteAnimationGroupComponent and pass PlayerState as a generic argument. This component has an animations field where animations for every PlayerState are stored and a current field, which represents the current state of the player, that needs to be animated.
enum PlayerState { jumping, running, idle }
class Player extends SpriteAnimationGroupComponent<PlayerState> {
@override
void onLoad() {
super.onLoad();
animations = {
PlayerState.running: SpriteAnimation.fromFrameData(
game.images.fromCache('character/run.png'),
SpriteAnimationData.sequenced(
amount: 8,
amountPerRow: 5,
stepTime: 0.1,
textureSize: Vector2(23, 34),
),
),
PlayerState.idle: SpriteAnimation.fromFrameData(
game.images.fromCache('character/idle.png'),
SpriteAnimationData.sequenced(
amount: 12,
amountPerRow: 5,
stepTime: 0.1,
textureSize: Vector2(21, 35),
),
),
PlayerState.jumping: SpriteAnimation.spriteList(
[
Sprite(game.images.fromCache('character/jump.png')),
Sprite(game.images.fromCache('character/land.png')),
],
stepTime: 0.4,
loop: false,
),
};
current = PlayerState.idle;
}
}
The player states are ready. Now, we need to give the player a size and position on the screen. I'm going to set his size to 69x102 pixels, but feel free to change it as you like. For position, we must know the coordinates of the ground. By adding the HasGameReference mixin, we can access the foreground field and get its coordinates. Now, let's override the onGameResize method, which is called every time the size of the application is changed, and set the position of Jack there.
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameReference<ForestRunGame> {
static const startXPosition = 80.0;
Player() : super(size: Vector2(69, 102));
double get groundYPosition => game.foreground.y - height + 20;
// onLoad() {...} with animation setup
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = startXPosition;
y = groundYPosition;
}
}
As was done before, add the player to our Game.
class ForestRunGame extends FlameGame {
// Earlier written code here...
late final player = Player();
@override
Future<void> onLoad() async {
// Earlier written code here...
add(player);
}
}
If you start the game, you see Jack is already in the forest!
Our game has three states: intro, play, and game over. So, we'll add the enum GameState that represents those ones. To make Jack run, we need speed and acceleration variables. Also, we need to calculate the distance traveled (will be used later).
As was mentioned earlier, the Component has two main methods: onLoad() and update(). We already used the onLoad method a few times. Now, let's talk about update(). This method has one parameter called dt. It represents the time that has passed from the last time the update() was called.
To calculate current speed and traveled distance, we'll use the update() method and some basic kinematics formulas:
enum GameState { intro, playing, gameOver }
class ForestRunGame extends FlameGame {
static const acceleration = 10.0;
static const maxSpeed = 2000.0;
static const startSpeed = 400.0;
GameState state = GameState.intro;
double currentSpeed = 0;
double traveledDistance = 0;
// Earlier written code here...
@override
void update(double dt) {
super.update(dt);
if (state == GameState.playing) {
traveledDistance += currentSpeed * dt;
if (currentSpeed < maxSpeed) {
currentSpeed += acceleration * dt;
}
}
}
}
Actually, we will use a trick to make development simpler: Jack will be steady, but the forest will move towards Jack. So, we need our forest to apply game speed.
For parallax background, we just need to pass game speed. And it will automatically handle the rest.
class ForestBackground extends ParallaxComponent<ForestRunGame> {
// Earlier written code here...
@override
void update(double dt) {
super.update(dt);
parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0);
}
}
For the foreground, we need to shift every ground block. Also, we need to check if the first block in the queue has left the screen. If so, then remove it and put it at the end of the queue;
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> {
// Earlier written code here...
@override
void update(double dt) {
super.update(dt);
final shift = game.currentSpeed * dt;
for (final block in ground) {
block.x -= shift;
}
final firstBlock = ground.first;
if (firstBlock.x <= -firstBlock.width) {
firstBlock.x = ground.last.x + ground.last.width;
ground.remove(firstBlock);
ground.add(firstBlock);
}
}
}
Everything is ready, but a trigger. We want to start running on click. Our targets are both mobile and desktop, so we want to handle screen taps and keyboard events.
Luckily, Flame has a way to do it. Just add a mixin for your input type. For the keyboard, it is KeyboardEvents and TapCallbacks for screen tapping. Those mixins give you the possibility to override related methods and provide your logic.
The game must start if the user presses the spacebar or taps the screen.
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks {
// Earlier written code here...
@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
if (keysPressed.contains(LogicalKeyboardKey.space)) {
start();
}
return KeyEventResult.handled;
}
@override
void onTapDown(TapDownEvent event) {
start();
}
void start() {
state = GameState.playing;
player.current = PlayerState.running;
currentSpeed = startSpeed;
traveledDistance = 0;
}
}
As a result, Jack can run now after clicking.
Now, we want to have obstacles on the road. In our case, they will be represented as poisonous bushes. Bush is not animated, so we can use SpriteComponent. Also, we need a game reference to access its speed. And one more thing; we don't want to spawn bushes one by one, because this approach can cause a situation when Jack simply can't pass a line of bushes with a jump. It is a random number from the range, which depends on the current game speed.
class Bush extends SpriteComponent with HasGameReference<ForestRunGame> {
late double gap;
Bush() : super(size: Vector2(200, 84));
bool get isVisible => x + width > 0;
@override
Future<void> onLoad() async {
x = game.size.x + width;
y = -height + 20;
gap = _computeRandomGap();
sprite = Sprite(game.images.fromCache('forest/bush.png'));
}
double _computeRandomGap() {
final minGap = width * game.currentSpeed * 100;
final maxGap = minGap * 5;
return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap;
}
@override
void update(double dt) {
super.update(dt);
x -= game.currentSpeed * dt;
if (!isVisible) {
removeFromParent();
}
}
}
Who is planting bushes? Nature, of course. Let's create Nature which will manage our bush generation.
class Nature extends Component with HasGameReference<ForestRunGame> {
@override
void update(double dt) {
super.update(dt);
if (game.currentSpeed > 0) {
final plant = children.query<Bush>().lastOrNull;
if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) {
add(Bush());
}
}
}
}
Now, let's add Nature to our ForestForeground.
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> {
// Earlier written code here...
late final Nature nature;
@override
void onLoad() {
// Earlier written code here...
nature = Nature();
add(nature);
}
Now, our forest has bushes. But wait, Jack is just running through them. Why is this happening? It's because we haven't implemented hitting yet.
Here, Hitbox will help us. Hitbox is another component in Flame's zoo of components. It encapsulates collision detection and gives you the possibility to handle it with custom logic.
Add one for Jack. Remember that the component's position will place its left-right corner, not center. And with size, you handle the rest.
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameReference<ForestRunGame> {
// Earlier written code here...
@override
void onLoad() {
// Earlier written code here...
add(
RectangleHitbox(
position: Vector2(2, 2),
size: Vector2(60, 100),
),
);
}
}
And one for the bush. Here, we will set the collision type to passive for some optimization. By default, the type is active, which means that Flame will check if this hitbox is collided with every other hitbox. We have only a player and bushes. Since the player already has an active collision type and bushes can't collide with each other, we can set the type to passive.
class Bush extends SpriteComponent with HasGameReference<ForestRunGame> {
// Earlier written code here...
@override
void onLoad() {
// Earlier written code here...
add(
RectangleHitbox(
position: Vector2(30, 30),
size: Vector2(150, 54),
collisionType: CollisionType.passive,
),
);
}
}
It's cool, but I can't see if the position of the hitbox was adjusted right. How can I test it?
Well, you can set the debugMode field of Player and Bush to true. It will allow you to see how your hitboxes are positioned. Purple defines the size of the component and yellow indicates the hitbox.
Now, we want to detect when there is a collision between the player and the bush. For this, you need to add HasCollisionDetection mixin to Game and then CollisionCallbacks for components, which need to handle collision.
class ForestRunGame extends FlameGame
with KeyboardEvents, TapCallbacks, HasCollisionDetection {
// Earlier written code here...
}
For now, just pause the game when the collision is detected.
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameReference<ForestRunGame>, CollisionCallbacks {
// Earlier written code here...
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
game.paused = true;
}
}
If Jack wants to avoid those bushes, he needs to jump. Let's teach him. For this feature, we need the gravity constant and the initial vertical speed of Jack's jump. Those values were chosen by eye, so feel free to adjust them.
So, how does gravity work? Basically, it's the same acceleration but oriented to the ground. So, we can use the same formulas for vertical position and speed. So, our jump will have 3 steps:
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameReference<ForestRunGame>, CollisionCallbacks {
static const gravity = 1400.0;
static const initialJumpVelocity = -700.0;
double jumpSpeed = 0;
// Earlier written code here...
void jump() {
if (current != PlayerState.jumping) {
current = PlayerState.jumping;
jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500);
}
}
void reset() {
y = groundYPos;
jumpSpeed = 0;
current = PlayerState.running;
}
@override
void update(double dt) {
super.update(dt);
if (current == PlayerState.jumping) {
y += jumpSpeed * dt;
jumpSpeed += gravity * dt;
if (y > groundYPos) {
reset();
}
} else {
y = groundYPos;
}
}
}
And now let's trigger jumping by click from ForestRunGame
class ForestRunGame extends FlameGame
with KeyboardEvents, TapCallbacks, HasCollisionDetection {
// Earlier written code here...
@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
if (keysPressed.contains(LogicalKeyboardKey.space)) {
onAction();
}
return KeyEventResult.handled;
}
@override
void onTapDown(TapDownEvent event) {
onAction();
}
void onAction() {
switch (state) {
case GameState.intro:
case GameState.gameOver:
start();
break;
case GameState.playing:
player.jump();
break;
}
}
}
Now, Jack can handle bushes.
When the game is over, we want to show text on the screen. Text in Flame works differently from Flutter. You have to create a font first. Under the hood, it's just a map, where char is a key and sprite is a value. Almost always, the game's font is one image where all needed symbols are gathered.
For this game, we need only digits and caps letters. So, let's create our font. To do so, you have to pass source image and glyphs. What is a glyph? Glyph is a union of information about char, its size, and position in the source image.
class StoneText extends TextBoxComponent {
static const digits = '123456789';
static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
StoneText({
required Image source,
required super.position,
super.text = '',
}) : super(
textRenderer: SpriteFontRenderer.fromFont(
SpriteFont(
source: source,
size: 32,
ascent: 32,
glyphs: [
_buildGlyph(char: '0', left: 480, top: 0),
for (var i = 0; i < digits.length; i++)
_buildGlyph(char: digits[i], left: 32.0 * i, top: 32),
for (var i = 0; i < letters.length; i++)
_buildGlyph(
char: letters[i],
left: 32.0 * (i % 16),
top: 64.0 + 32 * (i ~/ 16),
),
],
),
letterSpacing: 2,
),
);
static Glyph _buildGlyph({
required String char,
required double left,
required double top,
}) =>
Glyph(char, left: left, top: top, height: 32, width: 32);
}
Now, we can create the game over panel and use it in the game.
class GameOverPanel extends PositionComponent
with HasGameReference<ForestRunGame> {
bool visible = false;
@override
Future<void> onLoad() async {
final source = game.images.fromCache('font/keypound.png');
add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16)));
add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16)));
}
@override
void renderTree(Canvas canvas) {
if (visible) {
super.renderTree(canvas);
}
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = size.x / 2;
y = size.y / 2;
}
}
Now, we can show our panel, when Jack hits the bush. Also let's modify the start() method, so we can restart the game on click. Also, we need to clear all the bushes from the forest.
class ForestRunGame extends FlameGame
with KeyboardEvents, TapCallbacks, HasCollisionDetection {
// Earlier written code here...
late final gameOverPanel = GameOverPanel();
@override
Future<void> onLoad() async {
// Earlier written code here...
add(gameOverPanel);
}
void gameOver() {
paused = true;
gameOverPanel.visible = true;
state = GameState.gameOver;
currentSpeed = 0;
}
void start() {
paused = false;
state = GameState.playing;
currentSpeed = startSpeed;
traveledDistance = 0;
player.reset();
foreground.nature.removeAll(foreground.nature.children);
gameOverPanel.visible = false;
}
}
And now, we need to update the collision callback in the player.
class Player extends SpriteAnimationGroupComponent<PlayerState>
with HasGameReference<ForestRunGame>, CollisionCallbacks {
// Earlier written code here...
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
game.gameOver();
}
}
Now, you can see Game Over when Jack hits a bush. And restart the game just by clicking again.
And the final touch-score calculation.
class ForestRunGame extends FlameGame
with KeyboardEvents, TapCallbacks, HasCollisionDetection {
late final StoneText scoreText;
late final StoneText highText;
late final StoneText highScoreText;
int _score = 0;
int _highScore = 0;
// Earlier written code here...
@override
Future<void> onLoad() async {
// Earlier written code here...
final font = images.fromCache('font/keypound.png');
scoreText = StoneText(source: font, position: Vector2(20, 20));
highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20));
highScoreText = StoneText(
text: '00000',
source: font,
position: Vector2(332, 20),
);
add(scoreText);
add(highScoreText);
add(highText);
setScore(0);
}
void start() {
// Earlier written code here...
if (_score > _highScore) {
_highScore = _score;
highScoreText.text = _highScore.toString().padLeft(5, '0');
}
_score = 0;
}
@override
void update(double dt) {
super.update(dt);
if (state == GameState.playing) {
traveledDistance += currentSpeed * dt;
setScore(traveledDistance ~/ 50);
if (currentSpeed < maxSpeed) {
currentSpeed += acceleration * dt;
}
}
}
void setScore(int score) {
_score = score;
scoreText.text = _score.toString().padLeft(5, '0');
}
}
That's all folks!
Now, try it out, and try to beat my high score. It's 2537 points!
It was a lot, but we did it. We have created a minimum viable product for a mobile game with physics, animations, score calculation, and much more. There is always room for improvement, and, just like any other MVP, our product can be expected with new features, mechanics, and game modes in the future.
Also, there is a flame_audio package, which you can use to add some background music, sounds of jumping or hitting, etc.
As for now, our main objective was creating the basic product functionality in short terms and with limited resource allocation. The combination of Flutter and Flame proved to be a perfect fit for building a game MVP that can be used to collect user feedback and keep upgrading the app in the future.
You can check the results of our effort here.
With its powerful features, ease of use, and thriving community, Flutter and Flame are a compelling choice for aspiring game developers. Whether you're a seasoned pro or just starting out, this combination offers the tools and potential to bring your game ideas to life. So, grab your creativity, dive into the world of Flutter and Flame, and start building the next mobile gaming sensation!
We hope you found this article enjoyable and informative. If you want more insights into software development or wish to discuss your own MVP project, don't hesitate to explore Leobit or reach out to our technical team!
Written by Maxym Marina
Maxym is a skilled Flutter developer at Leobit who specializes in crafting high-performance mobile applications. His expertise lies in Dart enabling him to develop efficient, clean and maintainable code. This proficiency translates into building user-centric apps from a single codebase and ensuring a consistent user experience across all platforms.