Building a Car Race Game with Flutter and Flame [Part 2]

Amitsingh
10 min readJun 4, 2023

--

Introduction:

In this first part of the tutorial, we covered the basics of creating a car race game using Flutter and Flame. We explored concepts such as game structure, game loop, backgrounds, and player movement. We also introduced the use of components, collision detection, and handling user input.

GameManager

Now we will define our GameManger which control our game logic,The GameManager is like the mighty boss of the game. It's the one in charge of keeping track of important things and making important decisions.

class GameManager extends Component with HasGameRef<CarRace> {
GameManager();

Character character = Character.tesla;
ValueNotifier<int> score = ValueNotifier(0);

GameState state = GameState.intro;

bool get isPlaying => state == GameState.playing;
bool get isGameOver => state == GameState.gameOver;
bool get isIntro => state == GameState.intro;
void reset() {
score.value = 0;
state = GameState.intro;
}

void increaseScore() {
score.value++;
}

void selectCharacter(Character selectedCharcter) {
character = selectedCharcter;
}
}

enum GameState { intro, playing, gameOver }

We define our GameManager class and , it knows which character you’re playing with. Are you driving a Tesla or something else? The character property holds this information.

Next, it keeps an eye on the score. It knows how well you’re doing and makes sure to update it whenever you score some points. The score property is like its little assistant that keeps track of the numbers.

Now, let’s talk about the game state. The state property tells us if the game is just starting (intro), if you're in the middle of playing (playing), or if it's game over (gameOver). The isPlaying, isGameOver, and isIntro methods help us quickly check the current state.

The reset method is like a magic spell that resets everything. It sets the score back to zero and puts the game in the intro state. It's handy when you want to start fresh.

Oh, and don’t forget the increaseScore method! It's like a cheering squad that adds 1 point to your score whenever you do something cool.

Lastly, the selectCharacter method lets you switch your character. Fancy a different ride? Just call this method and choose your new wheels.

So, the GameManager is like the big boss that manages characters, scores, game states, and more. It keeps things organized and ensures the game runs smoothly. Without it, the game would be chaotic, like a squirrel driving a race car!

ObjectManager

Now once we defined our GameManger then we will define our ObjectManager class which hold our enemy objects logic when and where they will be displayed.

the ObjectManager class is like the magician's assistant, responsible for creating and managing all the objects in the game. It's like a conductor, making sure everything is in its place and adding a touch of magic.

final Random _rand = Random();

class ObjectManager extends Component with HasGameRef<CarRace> {
ObjectManager();

@override
void onMount() {
super.onMount();

addEnemy(1);
_maybeAddEnemy();
}

@override
void update(double dt) {
if (gameRef.gameManager.state == GameState.playing) {
gameRef.gameManager.increaseScore();
}

addEnemy(1);

super.update(dt);
}

final Map<String, bool> specialPlatforms = {
'enemy': false,
};

void enableSpecialty(String specialty) {
specialPlatforms[specialty] = true;
}

void addEnemy(int level) {
switch (level) {
case 1:
enableSpecialty('enemy');
}
}

final List<EnemyPlatform> _enemies = [];
void _maybeAddEnemy() {
if (specialPlatforms['enemy'] != true) {
return;
}

var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

var currentY =
gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;
var enemy = EnemyPlatform(
position: Vector2(
currentX,
currentY,
),
);
add(enemy);
_enemies.add(enemy);
_cleanupEnemies();
}

void _cleanupEnemies() {
Future.delayed(
const Duration(seconds: 4),
() {
_enemies.clear();
Future.delayed(
const Duration(seconds: 1),
() {
_maybeAddEnemy();
},
);
},
);
}
}

When the ObjectManager is mounted, it gets to work right away. It adds an enemy to the game and then starts checking if it should add more enemies. It's like saying, "Hey, let's start the show with some excitement!"

During each game update, the ObjectManager keeps an eye on the game state. If it's in the playing state, it's like it's clapping and cheering for you because it increases the score. You're doing great!

Now, let’s talk about specialties. The specialPlatforms map keeps track of whether a certain type of platform, like an enemy platform, is enabled or not. By calling the enableSpecialty method with the specialty name, you can unlock special platforms. It's like unlocking secret powers!

The addEnemy method is where the magic happens. It adds enemies to the game based on the specified level. For now, it only adds enemies for level 1 by enabling the 'enemy' specialty. It's like saying, "Bring in the bad guys!"

The _enemies list holds all the enemies that have been added to the game. It's like a lineup of troublemakers.

But wait, there’s more! The _maybeAddEnemy method checks if the 'enemy' specialty is enabled. If it is, it randomly generates a position for the enemy and adds it to the game. It's like saying, "Surprise! Here comes an enemy!"

Now, the _cleanupEnemies method is like a cleanup crew. It clears out all the enemies after a certain delay, creating a sense of challenge and keeping the game exciting. And then, after another short delay, it calls _maybeAddEnemy again to bring in new enemies. It's like a cycle of action!

So, the ObjectManager is the magical assistant that adds enemies, manages specialties, cleans up, and keeps the game thrilling. It's an essential part of the game's enchantment and ensures you're always on your toes, ready for the next challenge. Abracadabra!

Now Let’s define our main GameClass Which will Extends FlameGame

Create a new care_race.dart file and and create CarRace class which extends FlameGame.

class CarRace extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
CarRace({
super.children,
});
}

The CarRace class would is like the director of our car racing game. It manages all the different parts of the game and controls how they work together.

We have different things in the game, like the background, the game manager, the objects in the game, the player’s character, and the audio. The CarRace class keeps track of all these things and makes sure they are set up correctly.

enum Character {
farari,
lambo,
tesla,
}

class CarRace extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
CarRace({
super.children,
});

final BackGround _backGround = BackGround();
final GameManager gameManager = GameManager();
ObjectManager objectManager = ObjectManager();
int screenBufferSpace = 300;

EnemyPlatform platFrom = EnemyPlatform();

late Player player;

late AudioPool pool;
@override
FutureOr<void> onLoad() async {
await add(_backGround);
await add(gameManager);
overlays.add('gameOverlay');
pool = await FlameAudio.createPool(
'audi_sound.mp3',
minPlayers: 3,
maxPlayers: 4,
);
}

void startBgmMusic() {
FlameAudio.bgm.initialize();
FlameAudio.bgm.play('audi_sound.mp3', volume: 1);
}

@override
void update(double dt) {
super.update(dt);
if (gameManager.isGameOver) {
return;
}
if (gameManager.isIntro) {
overlays.add('mainMenuOverlay');
return;
}
if (gameManager.isPlaying) {
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _backGround.size.y,
);
camera.worldBounds = worldBounds;
}
}

@override
Color backgroundColor() {
return const Color.fromARGB(255, 241, 247, 249);
}

void setCharacter() {
player = Player(
character: gameManager.character,
moveLeftRightSpeed: 600,
);
add(player);
}

void initializeGameStart() {
setCharacter();

gameManager.reset();

if (children.contains(objectManager)) objectManager.removeFromParent();

player.reset();
camera.worldBounds = Rect.fromLTRB(
0,
-_backGround
.size.y, // top of screen is 0, so negative is already off screen
camera.gameSize.x,
_backGround.size.y +
screenBufferSpace, // makes sure bottom bound of game is below bottom of screen
);
camera.followComponent(player);

player.resetPosition();

objectManager = ObjectManager();

add(objectManager);
startBgmMusic();
}

void onLose() {
gameManager.state = GameState.gameOver;
player.removeFromParent();
FlameAudio.bgm.stop();
overlays.add('gameOverOverlay');
}

void togglePauseState() {
if (paused) {
resumeEngine();
} else {
pauseEngine();
}
}

void resetGame() {
startGame();
overlays.remove('gameOverOverlay');
}

void startGame() {
initializeGameStart();
gameManager.state = GameState.playing;
overlays.remove('mainMenuOverlay');
}
}

When the game starts, the onLoad method is called. It sets up the background, the game manager, and the game overlays. It also initializes the audio for the game.

The update method is called continuously during the game. It checks the game manager's state to see if the game is over, in the intro phase, or in the playing phase. Depending on the state, it adjusts the camera's view to focus on the relevant parts of the game world.

There are also methods to set the player’s character, start the game, handle game over events, and manage the pause state of the game.

In simple terms, the CarRace class controls everything in our car racing game and makes sure it runs smoothly. It's like the boss that keeps everything organized and makes the game exciting for the players.

Now we almost done to our logical Part in next steps we will define our diffrent overlay widgets.

We have mainly three overlay widgets:

  1. MainMenuOverlay
  2. GameOverlay
  3. GameOverOverlay

MainMenuOverlay

It represents the main menu screen that is displayed to the player before the game starts.

class MainMenuOverlay extends StatefulWidget {
const MainMenuOverlay(this.game, {super.key});

final Game game;

@override
State<MainMenuOverlay> createState() => _MainMenuOverlayState();
}

class _MainMenuOverlayState extends State<MainMenuOverlay> {
Character character = Character.tesla;

@override
Widget build(BuildContext context) {
CarRace game = widget.game as CarRace;

return LayoutBuilder(builder: (context, constraints) {
final characterWidth = constraints.maxWidth / 5;

final TextStyle titleStyle = (constraints.maxWidth > 830)
? Theme.of(context).textTheme.displayLarge!
: Theme.of(context).textTheme.displaySmall!;

return Material(
color: Theme.of(context).colorScheme.background,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'F1 Car Race',
style: titleStyle.copyWith(
height: .8,
),
textAlign: TextAlign.center,
),
const WhiteSpace(),
Align(
alignment: Alignment.center,
child: Text('Select your Car Model:',
style: Theme.of(context).textTheme.headlineSmall!),
),
const WhiteSpace(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
CharacterButton(
character: Character.tesla,
selected: character == Character.tesla,
onSelectChar: () {
setState(() {
character = Character.tesla;
});
},
characterWidth: characterWidth,
),
CharacterButton(
character: Character.farari,
selected: character == Character.farari,
onSelectChar: () {
setState(() {
character = Character.farari;
});
},
characterWidth: characterWidth,
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
CharacterButton(
character: Character.lambo,
selected: character == Character.lambo,
onSelectChar: () {
setState(() {
character = Character.lambo;
});
},
characterWidth: characterWidth,
),
],
),
const WhiteSpace(height: 50),
Center(
child: ElevatedButton(
onPressed: () async {
game.gameManager.selectCharacter(character);
game.startGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(100, 50),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Start'),
),
),
],
),
),
),
),
);
});
}
}

When the MainMenuOverlay widget is built, it receives a reference to the CarRace game instance. This allows it to access and interact with the game's functionality.

We used CharacterButton Widget for user’s to select their car Model.

class CharacterButton extends StatelessWidget {
const CharacterButton(
{super.key,
required this.character,
this.selected = false,
required this.onSelectChar,
required this.characterWidth});

final Character character;
final bool selected;
final void Function() onSelectChar;
final double characterWidth;

@override
Widget build(BuildContext context) {
return OutlinedButton(
style: (selected)
? ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
const Color.fromARGB(31, 64, 195, 255)))
: null,
onPressed: onSelectChar,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
Image.asset(
'assets/images/game/${character.name}.png',
height: characterWidth,
width: characterWidth,
),
const WhiteSpace(height: 18),
Text(
character.name,
style: const TextStyle(fontSize: 20),
),
],
),
),
);
}
}

Overall, the MainMenuOverlay widget provides an engaging and intuitive menu interface for players to select their car model and start playing the game.

GameOverlay

The GameOverlay widget is responsible for displaying the game overlay on top of the game screen in the "F1 Car Race" application.

class GameOverlay extends StatefulWidget {
const GameOverlay(this.game, {super.key});

final Game game;

@override
State<GameOverlay> createState() => GameOverlayState();
}

class GameOverlayState extends State<GameOverlay> {
bool isPaused = false;
final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

final Game game = CarRace();
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned(
top: 30,
left: 30,
child: GameScoreDisplay(game: widget.game),
),
Positioned(
top: 30,
right: 30,
child: ElevatedButton(
child: isPaused
? const Icon(
Icons.play_arrow,
size: 48,
)
: const Icon(
Icons.pause,
size: 48,
),
onPressed: () {
(widget.game as CarRace).togglePauseState();
setState(
() {
isPaused = !isPaused;
},
);
},
),
),
if (isMobile)
Positioned(
bottom: 10,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as CarRace).player.moveLeft();
},
onTapUp: (details) {
(widget.game as CarRace).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor:
Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_left, size: 64),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as CarRace).player.moveRight();
},
onTapUp: (details) {
(widget.game as CarRace).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor:
Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_right, size: 64),
),
),
),
],
),
const WhiteSpace(
height: 20,
),
],
),
),
),
if (isPaused)
Positioned(
top: MediaQuery.of(context).size.height / 2 - 72.0,
right: MediaQuery.of(context).size.width / 2 - 72.0,
child: const Icon(
Icons.pause_circle,
size: 144.0,
color: Colors.black12,
),
),
],
),
);
}
}

we created gameScore Display: Positioned at the top-left corner, it shows the current score of the game using the GameScoreDisplay widget.

we created Pause Button Positioned at the top-right corner, it displays an “Icons.pause” or “Icons.play_arrow” icon based on the current pause state of the game. Tapping the button toggles the pause state using the togglePauseState() method in the game instance.

class GameScoreDisplay extends StatelessWidget {
const GameScoreDisplay({super.key, required this.game, this.isLight = false});

final Game game;
final bool isLight;

@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: (game as CarRace).gameManager.score,
builder: (context, value, child) {
return Text('Score: $value',
style: Theme.of(context).textTheme.displaySmall!);
},
);
}
}

GameOverOverlay

Now let’s define our final gameOverlay widget that will display when game is end.

class GameOverOverlay extends StatelessWidget {
const GameOverOverlay(this.game, {super.key});

final Game game;

@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Center(
child: Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Game Over',
style: Theme.of(context).textTheme.displayMedium!.copyWith(),
),
const WhiteSpace(height: 50),
GameScoreDisplay(
game: game,
isLight: true,
),
const WhiteSpace(
height: 50,
),
ElevatedButton(
onPressed: () {
(game as CarRace).resetGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(200, 75),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Play Again'),
),
],
),
),
),
);
}
}

The GameOverOverlay widget provides a visually and informative screen when the game ends, and we displayed the final score and offering the option to play again.

Now in our homepage we can create our GameWidget:

Our GameWidget is configured with the game property set to the game instance, which is an instance of the CarRace game.

Inside the scaffold, we will use a LayoutBuilder to create a designated space for our game. We don't want it to go wild and take up the whole screen, so we set some constraints on its width. We're all about balance and control here!

class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Car Race',
themeMode: ThemeMode.dark,
theme: ThemeData(
colorScheme: lightColorScheme,
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: darkColorScheme,
textTheme: GoogleFonts.audiowideTextTheme(ThemeData.dark().textTheme),
useMaterial3: true,
),
home: const CarRaceHomePage(),
);
}
}

final Game game = CarRace();

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

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: Center(
child: LayoutBuilder(builder: (context, constraints) {
return Container(
constraints: const BoxConstraints(
maxWidth: 800,
minWidth: 550,
),
child: GameWidget(
game: game,
overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
'gameOverlay': (context, game) => GameOverlay(game),
'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
'gameOverOverlay': (context, game) => GameOverOverlay(game),
},
),
);
}),
),
);
}
}

Within this constrained space, we place the GameWidget. It's like a magic box that brings our car race to life. We pass in our trusty game object, which holds all the logic and excitement of the car race. But wait, there's more! We can also add different overlays to the game using the overlayBuilderMap. It's like having different levels and menus that pop up when you least expect it, adding an extra layer of fun and challenge to the game.

Thank you for taking the time to read this article! I hope you found it insightful and inspiring. If you enjoyed the content and found it helpful, I kindly ask for to leave one Clap:

LinkeDin: https://www.linkedin.com/in/amit-singh-023055193/

StackOverflow: https://stackoverflow.com/users/13051247/amit-singh

Github: https://github.com/amitsingh6391

--

--