Blog · · 9 min read

UI Challenge: Piano Tiles clone in Flutter

Hi guys! This post is pretty straight-forward. I wanted to create a basic clone of Piano Tiles 2 — a mobile game where you play on a simplified piano. Now I want to share with you how to do that in very few steps using Flutter. Let’s get started!

TLDR: Piano Tiles 2 clon app in Flutter, code available here.

Setup

Model

Let’s start with setting basic Note model we will be using:

note.dart
class Note {
final int orderNumber;
final int line;
NoteState state = NoteState.ready;
Note(this.orderNumber, this.line);
}
enum NoteState { ready, tapped, missed }

Every note is represented by its number in the song, the line (tone?) in which it should appear (from 0 to 3) and its state which can be modified from ready to tapped or missed.

The song can look like that:

song_provider.dart
List<Note> initNotes() {
return [
Note(0, 0),
Note(1, 1),
Note(2, 2),
Note(3, 1),
Note(4, 3),
Note(5, 0),
...
Note(39, 2),
Note(40, 3),
Note(41, -1), //Add 4 times -1 notes so that song will end after last normal note reaches end
Note(42, -1),
Note(43, -1),
Note(44, -1),
];
}

App structure

App structure is fairly simple, we will have a MaterialApp with one screen:

main.dart
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Piano Tiles 2 clone',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MainPage(),
);
}
}
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
@override
Widget build(BuildContext context) {
return Material(
child: Stack(
fit: StackFit.passthrough,
children: <Widget>[
Image.asset(
'assets/background.jpg',
fit: BoxFit.cover,
),
Row(
children: <Widget>[
Expanded(child: Container()),
LineDivider(),
Expanded(child: Container()),
LineDivider(),
Expanded(child: Container()),
LineDivider(),
Expanded(child: Container()),
],
)
],
),
);
}
}
class LineDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: double.infinity,
width: 1,
color: Colors.white,
);
}
}

We use Material so that we can access nice Texts and AlertDialogs, normally you would use Scaffold, which also provides a Material to draw on, but we don’t really need it, so we will use simple Material. We also use Stack – it gives us a way to put widgets on top of each other. Right now what we want to stack is a Row containing empty lines and LineDividers on top of the background image.

This is our starting point:

Illustration: App structure


Drawing tiles

Tile Widget

Let’s start with a Tile widget which will be a simple rectangle of a specific height. Depending on note’s state it can be black, transparent or red.

tile.dart
class Tile extends StatelessWidget {
final NoteState state;
final double height;
const Tile({Key key, this.height, this.state}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: height,
child: Container(color: color),
);
}
Color get color {
switch (state) {
case NoteState.ready: return Colors.black;
case NoteState.missed: return Colors.red;
case NoteState.tapped: return Colors.transparent;
default: return Colors.black;
}
}
}

Line Widget

Now let’s move to Widget representing one of 4 lines in the game. Line widget will accept 4 current notes at the time and it will draw Tiles representing each note from with matching line (tone) number:

line.dart
class Line extends StatelessWidget {
final int lineNumber;
final List<Note> currentNotes;
const Line({Key key, this.currentNotes, this.lineNumber}) : super(key: key);
@override
Widget build(BuildContext context) {
//get heights
double height = MediaQuery.of(context).size.height;
double tileHeight = height / 4;
//get only notes for that line
List<Note> thisLineNotes =
currentNotes.where((note) => note.line == lineNumber).toList();
//map notes to widgets
List<Widget> tiles = thisLineNotes.map((note) {
//specify note distance from top
int index = currentNotes.indexOf(note);
double offset = (3 - index) * tileHeight;
return Transform.translate(
offset: Offset(0, offset),
child: Tile(
height: tileHeight,
state: note.state,
),
);
}).toList();
return SizedBox.expand(
child: Stack(
children: tiles,
),
);
}
}

As you can see, for each note we can get what index that note has, therefore we can calculate how much it should be moved from top using Transform.translate.

Now all we just need to use Line widget in MainPage:

main.dart
class _MainPageState extends State<MainPage> {
List<Note> notes = initNotes();
@override
Widget build(BuildContext context) {
return ...
Row(
children: <Widget>[
_drawLine(0),
LineDivider(),
_drawLine(1),
LineDivider(),
_drawLine(2),
LineDivider(),
_drawLine(3),
],
}
_drawLine(int lineNumber) {
return Expanded(
child: Line(
lineNumber: lineNumber,
currentNotes: notes.sublist(0, 4),
),
);
}
}

And we can see the first 4 notes displayed:

Illustration: Line Widget


Moving the Tiles

So far, we are always using the first 4 notes: notes.sublist(0, 4). To change that, we need to introduce a variable that will store the current index to be displayed. We will also need to periodically increment it. To do that, we will use AnimationController and add a listener to it, so that on every completed animation, currentNote will increase:

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
List<Note> notes = initNotes();
int currentNoteIndex = 0;
AnimationController animationController;
@override
void initState() {
super.initState();
animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: 300)); //<--- Init the AnimationController
animationController.addStatusListener((status) {
if (status == AnimationStatus.completed) { //<--- Whenever animation finishes
if (currentNoteIndex == notes.length - 4) {
//song finished
} else { //<--- If the song didn't end
setState(() => ++currentNoteIndex); //<--- Increase currentNoteIndex
animationController.forward(from: 0); //<--- And start over the animation
}
}
});
animationController.forward(); //<--- start the animation for the first time
}
@override
void dispose() {
animationController.dispose(); //<--- remember to dispose the controller
super.dispose();
}
_drawLine(int lineNumber) {
return Expanded(
child: Line(
lineNumber: lineNumber,
currentNotes: notes.sublist(currentNoteIndex, currentNoteIndex + 4), //<--- use currentNoteIndex
),
);
}
...
}

Having that, we can see how tiles are moving:

Animated movement

As you can see, the Tiles are moving but they lack smoothness. Let’s deal with it now.

At first, we need to notice, that in order for a Line to display moving Tiles, it has to have access to 5 of them instead of 4, because once one tile starts to disappear in the bottom the other one has to show from the top.

What is also worth noting is that each Line should be rebuilt on every animation change. To easily achieve that, we can change Line’s superclass from StatelessWidget to AnimatedWidget, which ensures that on every animation tick, the widget will be drawn again. Also, having access to animation will let us easily adjust translate offsets we calculated earlier – let’s see the code:

line.dart
class Line extends AnimatedWidget { //<--- Use AnimatedWidget
final int lineNumber;
final List<Note> currentNotes;
const Line(
{Key key,
this.currentNotes,
this.lineNumber,
Animation<double> animation}) //<-- Add animation to constructor
: super(key: key, listenable: animation); //<-- And pass it to super constructor
@override
Widget build(BuildContext context) {
Animation<double> animation = super.listenable; //<--get the animation
...
//map notes to widgets
List<Widget> tiles = thisLineNotes.map((note) {
//specify note distance from top
int index = currentNotes.indexOf(note);
double offset = (3 - index + animation.value) * tileHeight; //<-- add animation.value to offset
...
}).toList();
return SizedBox.expand(
child: Stack(
children: tiles,
),
);
}
}

Now we just need to add small adjustments in HomePage:

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
...
@override
void initState() {
...
animationController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (currentNoteIndex == notes.length - 5) { //<--update end treshold
//song finished
}
...
}
_drawLine(int lineNumber) {
return Expanded(
child: Line(
lineNumber: lineNumber,
currentNotes: notes.sublist(currentNoteIndex, currentNoteIndex + 5), //<-- pass 5 notes
animation: animationController, //<-- pass animationController
),
);
}
}

We can see nice, animated, moving tiles!


Tap handling

Now it’s time to handle user taps on tiles. We are going to handle only taps on tiles — not outside of them. To do that we need to use GestureDetector. In the first version of this app, which I showed in my Twitter post, I used GestureDetector’s onTap method. This method gets called, when a child is tapped, meaning when a user quickly puts a finger on a child and the takes it from the child. Seems right, doesn’t it? Except, that if a user releases the touch outside the child, onTap will not get called. Having in mind that our Tiles are moving quickly, it was rather common to touch the Tile, but to pull the finger up when the Tile was no longer there, this way the tap method was not called and the user was frustrated because he knows he tapped it. There is a quick solution to that: instead of using onTap, we will use onTapDown which is called as soon as the user touches the child.

Let’s see the code:

tile.dart
class Tile extends StatelessWidget {
final VoidCallback onTap; //<--new parameter
const Tile({Key key, this.height, this.state, this.onTap}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: height,
child: GestureDetector(
onTapDown: (_) => onTap(), //<-- call onTap
child: Container(color: color),
),
);
}
...
}
line.dart
class Line extends AnimatedWidget {
final Function(Note) onTileTap; //<--new parameter
...
@override
Widget build(BuildContext context) {
...
List<Widget> tiles = thisLineNotes.map((note) {
...
child: Tile(
height: tileHeight,
state: note.state,
onTap: () => onTileTap(note), //<-- pass tap callback
)
...
}
}
main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
...
void _onTap(Note note) {
setState(() {
note.state = NoteState.tapped; //<--set note state to tapped
});
}
_drawLine(int lineNumber) {
return Expanded(
child: Line(
lineNumber: lineNumber,
currentNotes: notes.sublist(currentNoteIndex, currentNoteIndex + 5),
onTileTap: _onTap, //<--specify onTap callback
animation: animationController,
),
);
}
}

This way, when a user taps on a Tile, we change its state to tapped, which means it will be transparent, like that:

Now we can add few adjustments to handling taps.

Point score

That one is rather simple, let’s have a variable that stores points, display it on the screen and increase it every time the user taps a tile.

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
int points = 0; //<-- add points field
...
@override
Widget build(BuildContext context) {
return Material(
child: Stack(
fit: StackFit.passthrough,
children: <Widget>[
Image.asset(...),
Row(...),
_drawPoints(), //<-- add points widget on top of stack
],
),
);
}
void _onTap(Note note) {
setState(() {
note.state = NoteState.tapped;
++points; //<-- increase number of points
});
}
_drawPoints() { //<-- draw points
return Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Text(
"$points",
style: TextStyle(color: Colors.red, fontSize: 60),
),
),
);
}
}

Ensure tap order

main.dart
void _onTap(Note note) {
bool areAllPreviousTapped = notes
.sublist(0, note.orderNumber)
.every((n) => n.state == NoteState.tapped);
if (areAllPreviousTapped) {
setState(() {
note.state = NoteState.tapped;
++points;
});
}
}

This way, a tap is only handled if all previous tiles were tapped.

Starting game on tap

Right now the game starts when the screen gets initialized. We can add a flag, so that the first tap will cause animationController to start.

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
bool hasStarted = false;
...
@override
void initState() {
...
animationController.forward(); //<-- remove this line
}
void _onTap(Note note) {
bool areAllPreviousTapped = notes
.sublist(0, note.orderNumber)
.every((n) => n.state == NoteState.tapped);
if (areAllPreviousTapped) {
if (!hasStarted) { //<-- If game hasn't started
setState(() => hasStarted = true); //<-- Set flag to true
animationController.forward(); //<-- And start the animation
}
setState(() {
note.state = NoteState.tapped;
++points;
});
}
}
}

Play a note!

I am not a musician and I have absolutely no idea what sounds should I use, however, I found some samples I assumed I can use. We will use audioplayers package created by Luan Nico. At first, we need to add the dependency and sound files to the pubspec.yaml file. You can find the notes in my GitHub repository.

pubspec.yaml
name: piano_tiles
description: A new Flutter project.
version: 1.0.0+1
environment:
sdk: ">=2.0.0-dev.68.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
audioplayers: ^0.8.0 #<-- Add this dependency
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets:
- assets/background.jpg
- assets/a.wav #<-- Add audio files
- assets/c.wav
- assets/e.wav
- assets/f.wav

Now we can play a note on tap:

main.dart
import 'package:audioplayers/audio_cache.dart';
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
AudioCache player = new AudioCache(); //<-- use AudioCache
...
void _onTap(Note note) {
...
_playNote(note); //<-- play a note on tap
setState(() {
note.state = NoteState.tapped;
++points;
});
}
_playNote(Note note) {
switch (note.line) { //<-- choose a sound depending on the note's line
case 0:
player.play('a.wav');
return;
case 1:
player.play('c.wav');
return;
case 2:
player.play('e.wav');
return;
case 3:
player.play('f.wav');
return;
}
}
}

I will not include a video with sound, if you want, you can clone the repo and build it by yourself 🙂 Warning: It ain’t Mozart.

main.dart
@override
void initState() {
super.initState();
animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300));
animationController.addStatusListener((status) {
if (status == AnimationStatus.completed && isPlaying) { //<-- Add this check
if (notes[currentNoteIndex].state != NoteState.tapped) { //<--Add this check
//game over
setState(() {
isPlaying = false; //<--Stop the game
});
} else if (currentNoteIndex == notes.length - 5) {
//song finished
} else {
setState(() => ++currentNoteIndex);
animationController.forward(from: 0);
}
}
});
}

Finishing the game

The last part is to add logic for finishing the game. As said earlier, we won’t be handling taps on wrong lines, so the only way to lose is to miss the Tile. Implementing that is very easy, all we need to do is modify out animation’s status listener so that it also checks if current(last) tile was tapped, if it wasn’t it means it was missed, meaning game over. To stop the game, we will add isPlaying flag, we will check it during animation updates and we will set it to false when the game is over.

main.dart
@override
void initState() {
super.initState();
animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300));
animationController.addStatusListener((status) {
if (status == AnimationStatus.completed && isPlaying) { //<-- Add this check
if (notes[currentNoteIndex].state != NoteState.tapped) { //<--Add this check
//game over
setState(() {
isPlaying = false; //<--Stop the game
});
} else if (currentNoteIndex == notes.length - 5) {
//song finished
} else {
setState(() => ++currentNoteIndex);
animationController.forward(from: 0);
}
}
});
}

This code only stops the game, in Piano Tiles 2, there is also an animation which indicates what Tile you missed. Theoretically, it might be a problem, as the Tile we missed is already gone, however, if we reverse the animationController, then we should be able to see it again. What is more, we can set the Tile’s state to missed so that it will turn red.

main.dart
if (notes[currentNoteIndex].state != NoteState.tapped) {
//game over
setState(() {
isPlaying = false;
notes[currentNoteIndex].state = NoteState.missed; //<-- set note state to missed
});
animationController.reverse(); //<-- reverse last animation
}

Hmm… since we are on the run, why not create a simple popup with a number of achieved points?

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
...
@override
void initState() {
super.initState();
animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300));
animationController.addStatusListener((status) {
if (status == AnimationStatus.completed && isPlaying) {
if (notes[currentNoteIndex].state != NoteState.tapped) {
//game over
setState(() {
isPlaying = false;
notes[currentNoteIndex].state = NoteState.missed;
});
animationController.reverse().then((_) => _showFinishDialog()); //<--Show dialog when reverse animaiton finishes
} else if (currentNoteIndex == notes.length - 5) {
//song finished
_showFinishDialog(); //<--Show dialog when song is finished
} else {
setState(() => ++currentNoteIndex);
animationController.forward(from: 0);
}
}
});
}
void _showFinishDialog() {
showDialog( //<--Show a dialog
context: context,
builder: (context) {
return AlertDialog(
title: Text("Score: $points"), //<--With number of points
actions: <Widget>[
FlatButton( //<-- and restart button
onPressed: () => Navigator.of(context).pop(),
child: Text("RESTART"),
),
],
);
},
);
}
}

Start it over!

main.dart
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
...
void _restart() {
setState(() {
hasStarted = false;
isPlaying = true;
notes = initNotes();
points = 0;
currentNoteIndex = 0;
});
animationController.reset();
}
void _showFinishDialog() {
showDialog(
...
).then((_) => _restart()); //<-- restart game when dialog is closed
}
}

The last thing we want to do is add a simple restart function so that we can keep on playing!


And that’s it!

We created a simplified Piano Tiles app with smooth animations, sounds and score system. It may lack some features but I guess it is enough to say, that with Flutter sky is the limit. 🙂

I hope you enjoyed this post as much as I enjoyed writing it.

You can see the full code on GitHub >> here <<. If you liked the topic, you can leave a star on GitHub to let me know 🙂

Be sure to stay updated with my posts on Twitter (@marcin_szalek), Facebook (@mszalekblog) and Medium (@mszalek)

Cheers 🙂