Building a multiplayer quiz game with Flutter and Supabase

Building a multiplayer quiz game with Flutter and Supabase

·

10 min read

Introduction

During the Supabase Launch Week 6 Hackathon, I decided to take on the challenge of building a multiplayer quiz game using Flutter and Supabase.

It had been a while since I wrote something about Supabase, and there's been a lot of changes and new functionality I had been looking forward to trying out, especially Realtime. So a Supabase hackathon was the perfect chance to get back into it!

Working on it a couple of hours at a time over about a week, I managed to build a simple quiz game that you can play with friends at the same time. In this post, I will share the journey of building the quiz game and how I used Supabase to achieve the multiplayer functionality.

If you want to play, you can try out the live demo here; there's also a practice mode in which you can play alone.

Building Supaquiz

The idea was simple; a quiz game that you can play with friends at the same time. With Supabase's Realtime functionality, it seemed achievable to build a functional app with Flutter during the hackathon.

It would take too long to share how I built Supaquiz step-by-step, but I will go over some specific parts that I found interesting and/or challenging. The project is open-source, so you can have a look at the source code here.

User interface

As easy as it is to build user interfaces in Flutter, this is usually my least favorite part as it can take a while to get a screen/widget to look exactly as I want it to. But in this case, the screens were quite simple, so all I had to decide was the main color (I went with what I call "Supabase Green") and a font. A quick search led me to the Press Start 2P font, which fit well.

const supabaseGreen = Color.fromRGBO(101, 217, 165, 1.0);

ThemeData get theme {
  final theme = ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: supabaseGreen,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
  );
  return theme.copyWith(
    textTheme: GoogleFonts.pressStart2pTextTheme(theme.textTheme),
    primaryTextTheme: GoogleFonts.pressStart2pTextTheme(theme.primaryTextTheme),
  );
}a

With the style decided on, the most complex widget I had to build was the button!

class AppButton extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;

  const AppButton({
    Key? key,
    required this.label,
    required this.onPressed,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Material(
        elevation: 8.0,
        child: OutlinedButton(
          onPressed: onPressed,
          style: OutlinedButton.styleFrom(
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(4.0)),
            ),
            side: BorderSide(
              color: supabaseGreen,
              width: 2,
            ),
          ),
          child: Padding(
            padding:
                const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
            child: Text(
              label.toUpperCase(),
              style: Theme.of(context).primaryTextTheme.bodyLarge,
            ),
          ),
        ),
      ),
    );
  }
}

And with the buttons done, the rest was easy. Here is what the title screen looks like:

Screenshot of the Supaquiz title screen

For multiplayer mode, you can either host a game or join a game. Each game needs a host, though if you wanted you could host a game and start it before anyone else joins... There is also a practice mode if you want to play alone.

The Trivia API

It's a quiz game, so we need questions! The Trivia API was perfect for this. It's a completely free, simple API, and fetching questions in the app using it was simple.

class TriviaRepository {
  static const _authority = 'the-trivia-api.com';

  Future<List<TriviaQuestion>> getQuestions(int numOfQuestions) async {
    final url = Uri.https(
        _authority, 'api/questions', {'limit': numOfQuestions.toString()});
    final response = await http.get(url);
    final questions = jsonDecode(utf8.decode(response.bodyBytes))
        as List<Map<String, dynamic>>;
    return questions.map(TriviaQuestion.fromJson).toList();
  }
}

As you can see above, it's only a simple GET call to the API, with a limit query parameter to return a specific amount of questions. It also supports more customization, such as specific categories for the questions as well as difficulty.

Supaquiz fetches a set of questions before every game and displays the question and possible answers on the screen.

Screenshot showing the wrong answer in red and correct answer in green

I promise I got the answer wrong for the sake of the screenshot! If you get the answer wrong it's marked in red, while the correct answer is in a bright "Supabase Green".

Setting up Supabase

Setting up Supabase was a breeze using the Supabase CLI, a nice addition since the last time I used Supabase.

The CLI sets up your Supabase config with a simple command, supabase init, and starts up a local instance of Supabase for local development with supabase start.

I added some table creation SQL statements in the seed.sql file generated by the CLI, and was ready to go.

-- seed.sql
create type game_status as enum ('pending', 'started', 'complete');

create table games (
  id bigint generated by default as identity primary key,
  channel uuid default uuid_generate_v4() not null,
  status game_status default 'pending' not null,
  seconds_per_question int not null,
  host_id uuid references auth.users (id) default auth.uid() not null
);

Anonymous login

I was initially thinking of making Supaquiz work without needing users, as this was for a hackathon project and it had to be built fast. But still, I wanted to have users for future potential features such as statistics by tracking games played and number of correct answers.

Requiring sign-up with email for a demo project can be a barrier for some people, and I wanted to make it easy to start playing right away. Additionally, I wanted to test out the multiplayer functionality with friends during the development process. Unfortunately, anonymous sign-in is not currently supported by GoTrue, the authentication server used by Supabase.

There is a workaround, however, as you can disable email confirmations and assign users a custom email and password when first visiting the app. The AuthService, which has "anonymous sign-in", looks like this:

class AuthService {
  static const _emailKey = 'email';
  static const _defaultPassword = '82eb32f2a3ef';

  final GoTrueClient _auth;
  final SharedPreferences _preferences;

  AuthService(this._auth, this._preferences);

  Future<AuthResponse> signIn() async {
    if (_preferences.containsKey(_emailKey)) {
      return _auth.signInWithPassword(
        email: _preferences.getString(_emailKey)!,
        password: _defaultPassword,
      );
    } else {
      final email = _randomEmail;
      final response =
          await _auth.signUp(email: email, password: _defaultPassword);
      log('User signed up with random email $email');
      _preferences.setString(_emailKey, email);
      return response;
    }
  }

  static String get _randomEmail {
    return '${Uuid().v4().toString()}@dartling.dev';
  }
}

On sign-in, a user is signed up with a random @dartling.dev email and a default password. The generated email is stored in shared preferences to avoid creating a new user every time on the same device. The AuthService#signIn function is called after a user selects their nickname.

Nickname selection screen

With users/authentication/authorization, I could also enable Row Level Security to restrict access to games and answers. As an example, here are the RLS policies for the games table:

create policy "Users can create games" on public.games
for insert to authenticated with check (true);

create policy "Users can view games" on public.games
for select to authenticated using (true);

create policy "Games can only be updated by their hosts" on public.games
for update using (auth.uid() = host_id)
with check (auth.uid() = host_id);

Game codes

For the games to be multiplayer, someone would have to host a game and share it with others. Games can be shared with codes. For the game codes, I wanted to have strings that are short, easy to share between people, and unique. After some searching, I came across Hashids, which fit my use case perfectly. With Hashids, you can map integers (which would be the game IDs in the database) to a unique hash of the length and characters of your choice.

Here are some snippets from the game code encoding and decoding logic.

// Hashids config
_hashIds = HashIds(
  minHashLength: 4,
  alphabet: 'abcdefghijkmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789',
);

// game ID to game code
String _toGameCode(int gameId) {
  return _hashIds.encode(gameId);
}

// game code to game ID
int? _toGameId(String gameCode) {
  final decoded =
      _hashIds.decode(gameCode.replaceAll('O', '0').replaceAll('l', '1'));
  return decoded.isNotEmpty ? decoded.first : null;
}

I chose a minimum 4-character long hash and excluded the capital letter O and lowercase l from the alphabet, as I found them to look too similar to the numbers 0 and 1 respectively with the game's font. When users input a game code, O is mapped to 0 and l is mapped to 1, to make things easier for everyone.

Screenshot showing the "waiting screen" of a game with the game code

Multiplayer functionality

By this point, I had an app with a simple UI, questions fetched from the Trivia API, plus Supabase set up with authentication. I also had logic to generate shareable game codes so that others can join someone's game. The last piece is the most fun one, multiplayer.

My plan to achieve multiplayer mode was simple; you can choose to host a game, and other players can join it. At any time, the host can choose to start the game, and each player answers the questions on their device until the end of the game.

With Supabase Realtime, we can have devices listen to any changes to a specific game in the games table. When a host starts the game, the game's status is updated from pending to started. Players in the game will be notified of the game's status update, and start the game. To enable listening to real-time updates of the games table, I had to make this small change:

alter publication supabase_realtime add table public.games;

With that enabled, data can be exposed as streams through the Supabase client.

Stream<GameStatus> getGameStatus(int gameId) {
  return _supabaseClient
      .from('games')
      .stream(primaryKey: ['id'])
      .eq('id', gameId)
      .map((e) => GameStatus.fromString(e.first['status']));
}

Initially, I planned to have an additional round/question number field in the games table, and have users listen to updates to the round field of a game so the game can move on to the next question at the same time for all players. Instead, I went for a hackier approach with a time limit. The host can choose how many seconds players will have to answer the question, and after the time is up, the game moves on to the next question.

Screenshot showing a question with a time limit

Once all questions are answered, the final scores are calculated and displayed to all players. To calculate the scores, I created a Postgres function:

create or replace function calculate_scores(game_id_param int) returns table (
  user_id uuid, nickname text, score int
) as $$ begin return query
select
  players.user_id :: uuid as user_id,
  min(players.nickname):: text as nickname,
  (count(answers.id) * 100):: int as score
from
  players
  join questions on questions.game_id = players.game_id
  left join answers on answers.question_id = questions.id
  and answers.user_id = players.user_id and questions.correct_answer = answers.answer
where players.game_id = game_id_param
group by players.user_id
order by score desc;
end;
$$ language plpgsql;

The calculate_scores function is called from the Supabase client:

Future<List<PlayerScore>> getScores(int gameId) async {
  await Future.delayed(const Duration(seconds: 2));
  final scores = await _supabaseClient.rpc('calculate_scores',
      params: {'game_id_param': gameId}).select<List<Map<String, dynamic>>>();
  log('Player scores for game $gameId: ${scores.join(', ')}');
  return scores.map(PlayerScore.fromJson).toList();
}

Screenshot of the results of a quiz game, with scores next to each player name

And with that, the game is over! You get 100 points for every correct answer.

Plans for the future

Given more time, there are many more features I would have liked to implement for Supaquiz. The hackathon is over, but if I find myself still playing the game, I might spend some time improving it at some point.

  • Player statistics (games played, games won, questions answered correctly...)

  • Quiz customization (categories, difficulty)

  • Other game modes (TV mode)

Wrapping up

In this post, I shared a few parts of my experience building Supaquiz, a real-time multiplayer quiz game with Flutter and Supabase. I went into some detail on some of the challenges faced, including the implementation of anonymous sign-in, game code generation and real-time functionality.

By the end of Launch Week 6, I finally had a working quiz game that I could play with friends. You can check out the live demo here and the source code here. I'm also thrilled to share that Supaquiz won the Best Flutter Project category at the hackathon!

Overall, participating in the Supabase Launch Week 6 hackathon was a fun and rewarding experience. I'm looking forward to participating in more hackathons in the future.

Did you find this article valuable?

Support Christos by becoming a sponsor. Any amount is appreciated!