Making a 2.5D game using Flutter — Tutorial

Flavius Holerga
9 min readMar 10, 2024

--

Recently I showcased my first attempt at making a game using Flutter and many people were asking me how did I manage to make a 3D looking game with essentially a 2D game engine. In this blog I want to detail a bit my process and provide some insights into how you can use Flame to build impressive looking games.

By the end of this tutorial, you will be able to create a very simple 2.5D game using flutter! If you want to make more, I’ve shared the full repository for Socket Tower as well.

Screenshot of my 3D flutter game submission (full version)

Context

During the Global Gamers Challenge, I was tasked with creating a cross-platform game that promotes environmental sustainability. After a bit of brainstorming I came up with the idea of a Tower Bloxx like game where you would stack up house appliances instead of buildings.

However, no matter how many tries I took, I just couldn’t nail down something that felt good design-wise. And since by trait I am no artist, the initial style I chose would have relied too much on the quality of the assets if made 2D. As a result, the thought of building something low-poly with a 3D design quickly took precedence and Socket Tower was born!

Setting up a Flame project

If this is your first Flame project, fear not as we will go through all the required steps to get a simple demo up and running.

First things first, assuming you have just made a new Flutter project, you’ll first need to add flame. Add the following to your pubspec.yaml file.

dependencies:
flutter:
sdk: flutter
flame: ^1.14.0

In main.dart file, we will only initialize a simple FlameGame instance for now. We will also set ensure that the app is in portrait mode and that we are in full screen. Furthermore, I will also set a gradient background to create a more stylish demo.

We will also explicitly instantiate our Game object when in debug mode so we can make use of the hot refresh in flutter. This is needed to circumvent an issue with the hot reload in flame.

import 'package:block_stack_3d/game_loop.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();
Flame.device.fullScreen();
Flame.device.setPortraitUpOnly();

GameLoop game = GameLoop();
runApp(GameWidget(
game: kDebugMode ? GameLoop() : game,
backgroundBuilder: (context) {
return Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0xFF058bde), Color(0xFFe0f0fa)],
),
),
),
],
);
},
));
}

Now that we have our main function, let’s also define our GameLoop class. Our goal for this demo is simple: when the user taps on the screen, we want to spawn a 3D object at that position and drop it. For that we need a collision system and tap callbacks. Let’s make sure our FlameGame instance inherits those properties.

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

class GameLoop extends FlameGame
with TapCallbacks, HasCollisionDetection {}

Next, we need a way to detect when the users taps on the screen and also a way to run some code once the scene initializes. Thankfully flame provides some basic events which we can override like so:

class GameLoop extends FlameGame

@override
Future<void> onLoad() async {print("Hello world");}

@override
void onTapDown(TapDownEvent event) {print("Tap tap");}
}

Great! We now have a very basic flame game which currently does nothing. We will continue by adding some basic components.

The illusion of a 3D object

3D objects are great! They give you the illusion of depth, they can rotate and look very different depending on the angle you are observing them. But in lots of cases, especially in simple mobile games, you do not actually need all these properties.

In Socket Tower, all objects move on a specific set of axis and their movements are predictable. Furthermore, there are no rotations. So there is no need to actually render the object in 3D as the observer angle is fixed.

This is good news because that means that we can actually trick the observer by showing a 3D perspective, while handling everything in the back as 2D objects. That also means less computations and overall better performance.

Object perception in Socket Tower

At its core, our game will be just a series of polygons interacting with each other. The secret is in the viewing angle. I’ve decided to use an isometric view (30 degrees angle relative to the horizontal axis), which means that the angles inside the polygons are deterministic (120 degrees and 60 respectively).

I provided the toaster asset inside the demo github, as well as in the Socket Tower github. We will use this as the droppable object. Include it in your pubspec.yaml file.

flutter:
uses-material-design: true
assets:
- assets/images/player.png
- assets/images/ground.png

Next, let’s create a SpriteComponent for the bottom decoration and add it to our FlameGame.

In bottom_decoration.dart we will define our intance like so:

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class BottomDecoration extends SpriteComponent with CollisionCallbacks {
BottomDecoration({required this.startingPosition})
: super(scale: Vector2(.5, .5));

final Vector2 startingPosition;
late ShapeHitbox hitbox;

@override
Future<void> onLoad() async {
sprite = await Sprite.load('ground.png');
position = startingPosition;
anchor = Anchor.bottomCenter;

final defaultPaint = Paint()
..color = const Color.fromARGB(135, 255, 86, 86)
..style = PaintingStyle.fill;

Vector2 startingPos = Vector2(100, 60);
Vector2 startingSize = sprite!.srcSize;

hitbox = PolygonHitbox([
startingPos,
startingPos + Vector2(startingSize.x / 2, startingSize.y / 4),
startingPos +
Vector2(startingSize.x / 2, startingSize.y + startingSize.y / 2),
startingPos + Vector2(0, startingSize.y),
])
..paint = defaultPaint
..collisionType = CollisionType.passive
..renderShape = true;

add(hitbox);
}
}

As you can see, we have hardcoded some values to create a polygonal collision box within the boundaries of our sprite. Let’s now add it to the game loop. Inside the GameLoop onLoad method, add the following:

@override
Future<void> onLoad() async {
BottomDecoration bottomDecoration =
BottomDecoration(startingPosition: Vector2(size.x / 2, size.y));
add(bottomDecoration);
}

Running your application now should result in the following view:

Cool! You now have your first collision box. Let’s continue with the falling toasters.

Adding falling objects

Now that we have our ground, let’s move to our “players”. We will proceed very similarly by creating a Sprite component base class where we will handle all the physics.

class FallingBox extends SpriteComponent with CollisionCallbacks {
FallingBox(
{required this.imgPath,
required this.startingPosition,
required this.positionCollisionBox,
this.collisionBox})
: super(scale: Vector2(.5, .5));

final String imgPath;
final Vector2? collisionBox;
final Vector2 startingPosition;
Vector2 positionCollisionBox;

late ShapeHitbox hitbox;

static const double SPEED = 100;
double acceleration = 1;
bool isFalling = true;

@override
Future<void> onLoad() async {
sprite = await Sprite.load(imgPath);
position = startingPosition;
anchor = Anchor.center;

final defaultPaint = Paint()
..color = const Color.fromARGB(135, 255, 86, 86)
..style = PaintingStyle.fill;

// make parallelogram hitbox
Vector2 startingPos = positionCollisionBox;
Vector2 startingSize = collisionBox ?? sprite!.srcSize;

hitbox = PolygonHitbox([
startingPos,
startingPos + Vector2(startingSize.x / 2, startingSize.y / 4),
startingPos +
Vector2(startingSize.x / 2, startingSize.y - startingSize.y / 4),
startingPos + Vector2(0, startingSize.y - startingSize.y / 2),
])
..paint = defaultPaint
..renderShape = true;

add(hitbox);
}
}

Next, we will have to update the position of the object each frame in order to simulate gravity. There are a couple of ways you can achieve this. For Socket Tower and this demo I chose to compute the movement myself and accelerate the falling at a linear pace.

@override
void update(double dt) {
super.update(dt);
if (!isFalling) return;

position += Vector2(0, 2) * dt * SPEED * acceleration;
acceleration += 0.02 * dt * SPEED + 0.0002 * dt * SPEED * SPEED;
}

Lastly, we need a way to stop the objects from falling once they collide with the ground. To do that, we can simply detect collisions with the onCollision event, and if the object we collided with is passive, we can stop our box from falling as well.

It is important to note that in Flame, there are 3 types of hitboxes: passive, active and inactive. Passive hitboxes do not collide with other passive hitboxes, which is why it is recommended that once our object stops moving, to make the hitbox passive as well so we do not get spammed with collision events.

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
setToPassive();
super.onCollision(intersectionPoints, other);
}

void setToPassive() {
hitbox.collisionType = CollisionType.passive;
isFalling = false;
}

And finally, we need to spawn these objects when the user taps on screen inside our previous game_loop.dart file:

@override
void onTapDown(TapDownEvent event) {
add(FallingBox(
imgPath: 'player.png',
startingPosition: event.localPosition,
positionCollisionBox: Vector2(0, 60),
));
}

Congratulations! You should now have an abundance of falling toasters on your demand. Below is a screenshot of how your project should look like at this point.

However, you might notice that the 3D illusion does not always work if the Sprites should logically be behind. Remember, what the game sees is different than what you see. And the game only sees a series of polygons, with no real way of sorting them on the Z-axis, as there is no Z-axis.

Breaking the illusion due to lack of 3rd axis

Real-time rendering

We have made good progress. But the biggest challenge is yet to come. How do we determine which block goes first only given a list of polygons?

Well, the secret is once again the angle. Because the observer is fixed, we can make up some rules through which we can decide which polygon should be rendered first.

To do that, we will write another wrapper called box_stack.dart which will contain a list of falling objects. Then, instead of adding the FallingObject directly to the GameLoop as we did previously, we will now add it to the BoxStack and let it handle the rendering process of each component within, while also following our custom rendering rules. Inside game_loop:

class GameLoop extends FlameGame with TapCallbacks, HasCollisionDetection {
late BoxStack boxStack;
@override
Future<void> onLoad() async {
BottomDecoration bottomDecoration =
BottomDecoration(startingPosition: Vector2(size.x / 2, size.y));
add(bottomDecoration);

boxStack = BoxStack(Vector2(size.x, size.y));
}

@override
void onTapDown(TapDownEvent event) {
FallingBox box = FallingBox(
imgPath: 'player.png',
startingPosition: event.localPosition,
positionCollisionBox: Vector2(0, 60),
);
boxStack.add(box);
boxStack.players.add(box);
}
}

And lastly, the box stack definition. I will not go into detail regarding each check, but in a nutshell, I am verifying each possible variation of polygon placement and differentiating between core cases based on if the polygons would overlap if there were no collisions. The rules of rendering differ if that is the case.

import 'package:block_stack_3d/falling_box.dart';
import 'package:flame/components.dart';
import 'package:flutter/rendering.dart';

class BoxStack extends PositionComponent {
BoxStack(Vector2 size)
: super(
size: size,
position: Vector2(0, 0),
);

List<FallingBox> players = [];

@override
void render(Canvas canvas) {
sortPlayers();
for (var element in players) {
element.parent = this;
}
super.render(canvas);
}

bool isOverlap(FallingBox A, FallingBox B) {
if (B.hitbox.absolutePosition.x + B.hitbox.absoluteScaledSize.x <
A.hitbox.absolutePosition.x ||
A.hitbox.absolutePosition.x + A.hitbox.absoluteScaledSize.x <
B.hitbox.absolutePosition.x) {
return false;
}
return true;
}

int shouldLower(FallingBox A, FallingBox B) {
final x1 = A.hitbox.absolutePosition.x;
final y1 = A.hitbox.absolutePosition.y;
final x2 = B.hitbox.absolutePosition.x;
final y2 = B.hitbox.absolutePosition.y;
if (isOverlap(A, B)) {
if (y1 < y2 && x1 < x2) {
return 1;
} else if (y1 == y2 && x1 < x2) {
return 1;
} else if (y1 < y2 && x1 == x2) {
return 1;
} else if (y1 == y2 && x1 == x2) {
return 1;
} else if (y1 < y2 && x1 > x2) {
return 1;
}
return -1;
} else {
if (y1 < y2 && x1 < x2) {
return -1;
} else if (y1 == y2 && x1 < x2) {
return -1;
} else if (y1 > y2 && x1 < x2) {
return -1;
}
return 1;
}
}

void sortPlayers() {
players.sort((a, b) => shouldLower(a, b));
}
}

Conclusion

Hooray, you now have a fully functional 3D application in Flutter which will fool the untrained eye.

Screenshot of final demo application with correct rendering rules

However there is much more to do if you plan to make an actual game. Feel free to take a dive inside the Socket Tower source code to find out more about effects, particles, UI overlays, animations and much more!

Tutorial repository: https://github.com/MrHup/3d-blocks-tutorial

Socket Tower: https://github.com/MrHup/socket-tower

Global Gamers Challenge: https://globalgamers.devpost.com/

--

--