From Zero to a Multiplatform Flutter Game in a week
Most mobile developers don’t look at games because it looks like a crazy amount of work! Thinking about memory management, sprites manipulation, complex UI/UX, etc and, if you come from an Android background like me… you know how limited it is to build games using native code. But there’s where Flutter can give you a hand!
I started using Flutter for a bit more than a year already and I noticed the huge potential to build apps but, also, to build games. The first game I saw running on pure Dart/Flutter was “Space Blast” and blew my mind
then, I saw the amazing guys from 2dimensions
with their platform “Flare” to build/animate characters, environments, etc overall sprites manipulation which not a familiar task for most mobile developers.
I met Guido and Luigi Rosso at the Google I/O ’19 and I told them the idea to build a fun tap game using their platform and they saw it as totally feasible.
To put everyone into context, the game is “Tap Titans”
Alright! Enough intro… let’s just jump into it!
Where to start… I got the “idea” (inspired one), now I need a simple example. Background, a number and from every tap, decreasing that value:
GestureDetector(
onTapDown: (TapDownDetails details) => damage(details),
)var _bossDamage = 980;
void damage(TapDownDetails details) {
setState(() {
_bossDamage = _bossDamage - 30 <= 0 ? 980 : _bossDamage - 30;
});
}
Then, adding the visual elements and that’s it:
Next step was to add the actual UI/Elements to make it look like a “game”. We need a character, an actual boss to deal damage to it, maybe some power-ups and coins to buy those.
My “hero” uses 2 images, one for the “idle” state and one for the “attack” state. When you tap on the screen, the image switches to create the visual impact:
String hero() {
return tap ? "assets/character/attack.png" : "assets/character/idle.png";
}
For the boss, I needed more than one (to make it a bit more enjoyable):
static List<Bosses> getBosses() {
var list = List<Bosses>();
list.add(Bosses("Lunabi", 450, "assets/boss/boss_one.png"));
list.add(Bosses("ivygrass", 880, "assets/boss/boss_two.png"));
list.add(Bosses("Tombster", 1120, "assets/boss/boss_three.png"));
list.add(Bosses("Glidestone", 2260, "assets/boss/boss_four.png"));
list.add(Bosses("Smocka", 2900, "assets/boss/boss_five.png"));
list.add(Bosses("Clowntorch", 4100, "assets/boss/boss_six.png"));
list.add(Bosses("Marsattack", 5380, "assets/boss/boss_seven.png"));
list.add(Bosses("Unknown", 7000, "assets/boss/boss_eight.png"));
list.add(Bosses("ExArthur", 10000, "assets/boss/boss_nine.png"));
return list;
}
Don’t tell me how I came out with those names… coding at 2am makes you do that. Every boss will have a name, a life bar and an actual image.
For the Power-Ups I followed a similar logic like the bosses:
static List<PowerUps> getPowerUps() {
var list = List<PowerUps>();
list.add(PowerUps("Master Sword", 2.15, false, 50));
list.add(PowerUps("Lengendary Sword", 2.45, false, 180));
list.add(PowerUps("Keyblade", 3.75, false, 300));
list.add(PowerUps("Lightsaber", 4.95, false, 520));
list.add(PowerUps("Buster Sword", 6.15, false, 1700));
list.add(PowerUps("Soul Edge", 8.65, false, 2400));
return list;
}
You have the Sword/Weapon name, the multiplier (from base deal damage), if you bought them or not and the total amount of coins necessary to get it.
Then I thought about adding a visual “hitbox” for every time you tap on the screen:
Widget hitBox() {
if (tap) {
return Positioned(
top: yAxis,
left: xAxis,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: 20.0),
child: Material(
color: Colors.transparent,
child: StrokeText(
"-${damageUser.toInt().toString()}",
fontSize: 14.0,
fontFamily: "Gameplay",
color: Colors.red,
strokeColor: Colors.black,
strokeWidth: 1.0,
),
),
),
Image.asset(
"assets/elements/hit.png",
fit: BoxFit.fill,
height: 80.0,
width: 80.0,
),
],
),
);
} else {
return Container();
}
}
This will add the visual element, the position of your finger and a small “hint” with the total amount of damage dealt for every single tap with a Y offset.
Remember the “damage” method? I used the TapDownDetails details parameters to get the actual X and Y position of the finger.
xAxis = details.globalPosition.dx - 40.0;
yAxis = details.globalPosition.dy - 80.0;
I also added a “hide” logic to remove that element when you lift your finger from the screen
GestureDetector(
onTapDown: (TapDownDetails details) => damage(details),
onTapUp: (TapUpDetails details) => hide(null),
onTapCancel: () => hide(null),
),
The list of “Power-Ups” is pretty much straight forward
ListView.builder(
padding: EdgeInsets.only(bottom: 20.0, left: 10.0, right: 10.0),
itemCount: list.length,
itemBuilder: (context, position) {
PowerUps powerUp = list[position];
int bgColor = !powerUp.bought && coins >= powerUp.coins
? 0xFF808080
: !powerUp.bought ? 0xFF505050 : 0xFF202020;
return swordElement(bgColor, powerUp, position);
},
)
I’m adding some visual enhancements for the buttons and the backgrounds and every Sword/Weapon
Widget swordElement(int bgColor, PowerUps powerUp, int position) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 5.0,
),
child: Container(
height: 70,
child: Card(
color: Color(bgColor),
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
powerUp.name,
style: Utils.textStyle(11.0),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0),
child: FancyButton(
size: 20,
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 10.0, bottom: 2, top: 2),
child: Text(
!powerUp.bought ? "BUY" : "BOUGHT",
style:
Utils.textStyle(13.0, color: !powerUp.bought ? Colors.white : Colors.grey),
),
),
Padding(
padding: EdgeInsets.only(left: 8.0, right: !powerUp.bought ? 2.0 : 0.0),
child: Text(
!powerUp.bought ? powerUp.coins.toString() : "",
style: Utils.textStyle(13.0),
),
),
coinVisibility(powerUp.bought),
],
),
color: !powerUp.bought && coins >= powerUp.coins
? Colors.deepPurpleAccent
: Colors.deepPurple,
onPressed: !powerUp.bought && coins >= powerUp.coins ? () => buyPowerUp(position) : null,
),
)
],
),
),
),
);
}
After all this work! I have a truly visual improvement over my first GIF
Adding sounds is always a plus for every single game:
AudioPlayer hitPlayer;
AudioCache hitCache;hitPlayer = AudioPlayer();
hitCache = AudioCache(fixedPlayer: hitPlayer);// If the audio was playing, we stop it to init again
hitPlayer.pause();
hitCache.play('audio/sword.mp3');
This logic was used across the game, every time you kill an enemy, you’ll earn coins, a sound for that, every time you buy something, a sound for that as well. BTW, what is a game without background music? You can loop it this way:
AudioCache musicCache;
AudioPlayer instance;void playMusic() async {
musicCache = AudioCache(prefix: "audio/");
instance = await musicCache.loop("bgmusic.mp3");
}
That will go inside your InitState and will be stopped at your Dispose method. Also, If you noticed, I’m using a custom font to make it more “game” look alike.
GOING MULTI-PLATFORM!!! 🎮😎
I think the biggest benefits from using Flutter is the fact that you can actually use the same code to build apps for Android, iOS, Desktop (Mac, Linux, Windows) and…. 🥁🥁🥁 WEB!
One step at the time! Let’s go for iOS:
You’ll need a Mac and XCode to build the code in order to run the app. You can use your physical device, but an emulator is more than enough.
If you’re using Android Studio like I do, hit the “RUN” button while selecting the iOS device and that’s it. “Open iOS Simulator” should be an option inside your device list.
If you’re old school, you can run “flutter devices” to check the device ID and then hit:
flutter run -d {device id}
// without the curly braces
Desktop:
This one is a bit more tricky because you need extra steps to make it run. I’m using a Mac but it should be a pretty similar process for every other platform (Linux and Windows)
All I did was to follow this exact (and great) Medium post and it was running with a small change inside my code:
I used the terminal to hit this command:
export ENABLE_FLUTTER_DESKTOP=true
If you follow this step, you can clone the official desktop repo:
git clone https://github.com/google/flutter-desktop-embedding.gitcd example
and grab the “Mac”, “Windows” and “Linux” folders, copy those into your project and that’s it.
Now let’s get back into the code… we need to make a small change
void _setTargetPlatformForDesktop() {
TargetPlatform targetPlatform;
if (Platform.isMacOS) {
targetPlatform = TargetPlatform.iOS;
} else if (Platform.isLinux || Platform.isWindows) {
targetPlatform = TargetPlatform.android;
}
if (targetPlatform != null) {
debugDefaultTargetPlatformOverride = targetPlatform;
}
}
That method will go inside your main() one:
void main() {
_setTargetPlatformForDesktop();
runApp(TapHero());
}
make sure to import these 2 classes
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show debugDefaultTargetPlatformOverride;
PRO TIP! If you want to lock the phone’s rotation, use this line before your return inside your build method:
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);// use this import
import 'package:flutter/services.dart';
after all this work, “macOS” should be visible inside your list of devices. Once you hit run for that device, you’ll get something like this:
Something is missing… oh, yeah! THE WEB 🌎 This one is a bit more tricky, but Ayush did also a Medium post about it:
The web version follows the logic of the Desktop one. You need to clone the Web repo if you want (it has a “hello_world” example):
git clone git@github.com:flutter/flutter_web.git
cd examples/hello_world
make sure you’re using the latest version of Flutter with:
flutter upgrade
and you also need to have webdev active
flutter packages pub global activate webdevflutter packages upgrade
this one will allow you to run and build Web apps. You’ll also need to add the paths for Dart and Webdev to make the magic work!
After all this “hard” work, you’ll need to change your flutter import libraries for “flutter_web”. For example:
import 'package:flutter_web/material.dart';
then, move your assets inside /web/assets folder and last, but not least, you need to hit this line over your terminal:
webdev serve
and that’s it! http://localhost:8080/ should be your new best friend! Let’s take a look:
Yay!!! It works!!! 🌎 + 💻 + 📱🤖 + 📱🍎
Harder, Better, Faster, Stronger
Welcome Screen
No game can be complete without a “Welcome Screen”, this will give your users a small hint of what’s coming.
All I did was reusing the game’s assets: Boss, hero, background and I did a logo for it.
To add extra gaming experience (?) a Blur was applied and some ashes/fire particles in the background as well. If you want the Blur snippet, I’m using this one:
BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 4.0,
sigmaY: 4.0,
),
child: Align(
alignment: Alignment.bottomCenter,
child: Align(
alignment: Alignment.topCenter,
heightFactor: heroYAxis,
child: Image.asset(
heroAsset(),
width: size / 1.5,
height: size / 1.5,
fit: BoxFit.cover,
),
),
),
)
If you want the particles one, I’m using a flutter web version logic for that one which you can give it a try here:
https://flutter.github.io/samples/particle_background
and the source code is right here:
https://github.com/flutter/samples/tree/master/web/particle_background
“Final” result:
Adding more gaming logic
I’m my quest to make the game a bit difficult, funnier, interesting… etc I decided to add a time limit (which increases every time you kill a boss) and a Share Score button to let everyone know how far you got into the game:
I’m using a regular AnimationController for the clock from the desired limit in milliseconds until it reaches 0. If you want to get the string for the time, use this snippet:
String get timerString {
Duration duration = controller.duration * controller.value;
return '${(duration.inMinutes).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}';
}
You “Game Over” will happen when the animation controller reaches the dismissed state (that’s because I’m using the reverse(); function):
controller.addStatusListener((status) {
if (status == AnimationStatus.dismissed) {
setState(() {
gameOver = true;
});
}
});
Getting an actual web URL
Do you kids know about Firebase? Pretty much does everything we need for this task!
I thought it was going to be hard, but it was super simple. If you’re familiar with the “hosting” service, you should be good to go in 10min.
Make sure to follow every step from the official documentation:
https://firebase.google.com/docs/hosting/quickstart
and after you do that, you’ll need to build your Flutter Web App:
webdev build
You’ll see a new folder inside your project called “build” and, from your Firebase init process, you should also have a “public” folder.
Copy the entire build folder content inside your public one and that’s it! hit:
firebase deploy
Your Flutter Web App should be running using the new .web.app domain!
If you want to go ahead and give it a try:
https://gametaphero.web.app 🌎 🎮 🎉
Pro Tip 2! Your public/index.html follows the same logic as any other website. You can add favicons, search/status bar color (for Chrome mobile), app name, etc.
Gamepad support?! What?! 🎮
Experimental!!! Don’t try this at home… or maybe you will 🤔
So far I manage to make it work using Android + Switch Pro Controller (I’ll do my best to get the PS4 and Xbox Controller + maybe a generic one).
You’ll need to use channels for this task. Starting with the Android Kotlin code:
var channel = MethodChannel(flutterView, "gamepad")channel.setMethodCallHandler { call, result ->
when {
call.method == "isGamepadConnected" -> {
val ids = InputDevice.getDeviceIds()
for (id in ids) {
val device = InputDevice.getDevice(id)
val sources = device.sources
if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD) {
result.success(true)
}
}
result.success(false)
}
call.method == "getGamePadName" -> {
val gamepadIds = InputDevice.getDeviceIds()
for (id in gamepadIds) {
val device = InputDevice.getDevice(id)
val sources = device.sources
if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD) {
result.success(device.name)
}
}
}
else -> result.notImplemented()
}
}
}
this could be inside your MainActivity class. I saw other implementations but that’s the one I’m currently working with. Take in mind that this will increase the size of your app, but just a little bit.
You can also implement the InputManager.InputDeviceListener interface to detect the connection/disconnection of gamepads:
Android Kotlin code:
var inputManager = getSystemService(Context.INPUT_SERVICE) as InputManageroverride fun onResume() {
super.onResume()
inputManager.registerInputDeviceListener(this, null)
}
override fun onPause() {
super.onPause()
inputManager.unregisterInputDeviceListener(this)
}
override fun onInputDeviceRemoved(deviceId: Int) {
channel.invokeMethod("gamepadRemoved", true)
}
override fun onInputDeviceAdded(deviceId: Int) {
channel.invokeMethod("gamepadName", InputDevice.getDevice(deviceId).name)
}
override fun onInputDeviceChanged(deviceId: Int) {
channel.invokeMethod("gamepadName", InputDevice.getDevice(deviceId).name)
}
every time an event happens, Android will send a channel notification to our Dart/Flutter code:
static const MethodChannel _channel = const MethodChannel('gamepad');
static Future<bool> get isGamePadConnected async {
final bool isConnected = await _channel.invokeMethod('isGamepadConnected');
return isConnected;
}
static Future<String> get gamepadName async {
final String name = await _channel.invokeMethod("getGamePadName");
return name;
}
Those will handle the request to get the Device Name and Device Connection. If you want to listen for the Android events, inside your initState method do the following:
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case "gamepadName":
setState(() {
_gamepadName = call.arguments;
});
break;
case "gamepadRemoved":
setState(() {
_gamepadName = "Undefined";
});
break;
}
});
Your arguments could be any of the followings:
Final result?! 🤯🤯🤯
Moving forward — at June 4th
This game is far away from over! I’m Open Sourcing the entire code for everyone to see, use, implement, improve, etc. The next steps will be using Flare to animate the characters, background and any other UI elements.
I build this during my free time in a week! Flutter is incredible! I can’t wait to see what’s coming 🥳🥳🥳
I’ll be using Codemagic for the CI/CD to get the apps running over Play Store and App Store.
Please, keep coming back! I’ll be posting updates with the Open Source link, Flare implementations, etc
UPDATE Open Source
This project is now Open Source, take a look at the repos:
Check the Mobile + Desktop repo: https://github.com/mkiisoft/taphero
Check the Web repo: https://github.com/mkiisoft/taphero_web
AMAZING LINKS
Follow me on Twitter: https://twitter.com/geekmz
Github: https://github.com/mkiisoft
HighSide: https://highside.io/
2dimensions: https://www.2dimensions.com/
Codemagic CI/CD: https://codemagic.io/start/
Flutter Web: https://flutter.dev/web
Flutter in Spanish: https://twitter.com/EsFlutter