Create a leaderboard with Riverpod 2 and Isar

As shared with you last week, I want to create a simple game with Flutter. If you haven't seen the first article of this series of articles, you can find it here:

Using flutter_animate for a Flutter Game animation
When I first started creating mobile applications, I started with a silly game named Boun&Twee. At the time, Flutter didn’t exist, so it was only compatible with Android. There was no animation, and updating the layout was painful with a lot of XML tweaking 😅 In this article, I’ll try

But even if I loved how the game was playable, I couldn't challenge my friends to beat me. They were no leaderboard, and my memory isn't what it used to be 👴

Before taking the game online, I wanted to have a local leaderboard. After carefully reviewing all the solutions, I decided to go with Isar, mainly for its cross-platform support.

isar | Dart Package
Extremely fast, easy to use, and fully async NoSQL database for Flutter.

To provide isar to my game, I'm going to use Riverpod.

riverpod | Dart Package
A simple way to access state from anywhere in your application while robust and testable.

Creating the model

As the leaderboard is going to be pretty simple, I'm going to define the schema for my Scores:

part 'score.g.dart';

@collection
class Score {
  Id? id;

  late String name;

  @Index()
  late int score;

  @override
  String toString() {
    return "Score(name: $name, score: $score)";
  }
}

Once the build_runner has run, you should be able to provide the schema to your Isar instance.

Isar.open([ScoreSchema])

Since we're going to access the Isar instance from different providers, we're going to create our first Riverpod provider:

part 'provider.g.dart';

@Riverpod(keepAlive: true)
Future<Isar> isarInstance(FutureProviderRef ref) {
  return Isar.open([ScoreSchema]);
}

We're using the new syntax for Riverpod 2, with generated code here. The main benefit is having hot-reload thanks to the generated code.

Creating a score manager

Now we will need to handle the different interactions with the Isar Score collection.

We'll need something to add a score and retrieve the previous scores (pretty simple).

class ScoreManager {
  final Isar isar;

  ScoreManager(this.isar);

  Future<void> addScore(String name, int score) async {
    final newScore = Score()
      ..name = name
      ..score = score;

    await isar.writeTxn(() async {
      await isar.scores.put(newScore);
    });
  }

  Future<List<Score>> getScores() async {
    return isar.scores.where().sortByScoreDesc().findAll();
  }

  Future<List<Score>> getHighScores() async {
    return isar.scores.where().sortByScoreDesc().limit(8).findAll();
  }
}

As you can see, to create this ScoreManager I need the Isar instance. So I'll use Riverpod once again to provide ScoreManager.

@riverpod
Future<ScoreManager> scoreManager(ScoreManagerRef ref) async {
  final isar = await ref.watch(isarInstanceProvider.future);
  return ScoreManager(isar);
}

Then we'll create two providers that will be responsible of giving all the scores and the high scores:

@riverpod
Future<List<Score>> scores(ScoresRef ref) async {
  final scoreManager = await ref.watch(scoreManagerProvider.future);
  return scoreManager.getScores();
}

@riverpod
Future<List<Score>> highScores(ScoresRef ref) async {
  final scoreManager = await ref.watch(scoreManagerProvider.future);
  return scoreManager.getHighScores();
}

Till now, we haven't plugged anything into the UI, it's only business logic detached from the UI, thanks to Riverpod!

Displaying the high scores

Now that we have all the providers, we can finally display the scores:

import 'package:boun_twee/models/score.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class HighScorePage extends ConsumerWidget {
  const HighScorePage({super.key});

  @override
  Widget build(BuildContext context, ref) {
    final scores = ref.watch(highScoresProvider);
    return Scaffold(
      body: Center(
        child: scores.when(
          data: ((scores) {
            return Column(
              mainAxisSize: MainAxisSize.min,
              children: scores.map(
                ((score) {
                  return Text(
                    "${score.name} - ${score.score}",
                    style: const TextStyle(
                      fontSize: 24,
                      color: Colors.black,
                    ),
                  );
                }),
              ).toList(),
            );
          }),
          loading: (() => const CircularProgressIndicator()),
          error: ((error, stack) => Text(error.toString())),
        ),
      ),
    );
  }
}

As you can see, we don't need any StatefulWidget since riverpod is handling all the error management for us.

Conclusion

In conclusion, we've seen that you can have a locally persisted leaderboard accessible anywhere in your app with very few lines of code.

To not miss the next article of this series, be sure to subscribe to my newsletter. No spam, just my new articles! 🚀

Below you'll find a small code example exclusive to free members.

Adding a new high score