Build a realtime Tic-tac-toe game with Flutter and Supabase

Build a realtime Tic-tac-toe game with Flutter and Supabase

·

10 min read

Introduction

Another Supabase Launch Week has passed, with some exciting new updates. During a previous launch week, I had participated in a hackathon by building a multiplayer quiz game with Flutter and Supabase, and I wrote about my experience in a blog post.

In this post, we will build a two-player, realtime Tic-tac-toe game. While parts of this are similar to Supaquiz, the multiplayer quiz game I built as part of the hackathon, this post is more of a how-to guide. It will also make use of Supabase's new anonymous login feature, which was implemented as a "hack" in the previous multiplayer game.

The full source code is on GitHub, so I will focus on snippets highlighting the main logic, rather than sharing snippets to build this step-by-step.

Set up Flutter and Supabase

Lets create an empty Flutter app, and then initialize Supabase in the project.

flutter create supatictactoe --empty
cd supatictactoe
supabase init

Then we can run supabase start to start Supabase locally. When running, we will have the Supabase URL and anon key which we will need for our secrets.dart file, which is .gitignore'd.

// secrets.dart
const supabaseUrl = 'http://127.0.0.1:54321';
const supabaseAnonKey = 'eyJhbGc...';

These secrets are used to initialise the Supabase client when setting up our dependencies:

final supabase = await Supabase.initialize(
    url: supabaseUrl,
    anonKey: supabaseAnonKey,
);

Anonymous login

This is a very simple but very useful feature of Supabase Auth, and I'm really happy it finally landed. When building Supaquiz, the anonymous login feature was quite hacky! It would create a random email and random password and log in the user. Because email verification was turned off, a user was logged in when opening the app. This meant we could not log in with the same user, but in the context of a trivia game played with friends, it wasn't that necessary.

The funny part about my previous anonymous hack is that the random emails I used had the dartling.dev domain, and ALL emails from that domain are forwarded to me. This meant that anyone who forked Supaquiz and tried to play, without noticing they have to disable email verification first, would cause random Supabase email verification emails to be sent to me, if they were running on Supabase dev. I wasn't expecting it, but I got quite a few of these emails!

Supabase now supports this out of the box. Here's how simple this is:

class AuthService {
  final GoTrueClient _auth;

  AuthService(this._auth);

  Future<AuthResponse> signIn() async {
    return _auth.signInAnonymously();
  }
}

No random email/password hacks, no random email confirmation emails in my inbox... it just works!

Game codes

Tic-tac-toe is a game for 2 players. In order to share the game with someone else, we implement "game codes" which can be shared. A user can "host" a game and share their game code. As soon as another user joins a game using that code, the game starts.

I go about using HashIds to convert game IDs to easy to share game codes in the Supaquiz article.

Database schema

For the database schema, we're going with a very simple approach. The board is stored as a 9-character string on a games table, defaulting to all zeroes. We can replace the zeroes with 1 or 2 depending on which player moves.

We also have a status, and we will listen to these status updates to start the game by moving to the game screen in the Flutter app, or showing the winner when completed.

The last_played is either 1 or 2 depending on which player moved last, so that we know which player is allowed to make a move. We default to 1 so that player 2 always goes first. The winner is a nullable integer which will be set to 1 or 2 if one of the player wins.

create type game_status as enum ('pending', 'started', 'complete');

create table games (
  id bigint generated by default as identity primary key,
  status game_status default 'pending' not null,
  board text not null default '000000000',
  last_played int not null default 1,
  winner int
);

-- To listen to status and board updates
alter publication supabase_realtime add table public.games;

The last line is used to add real-time support to the games table, as we want to react to status updates and board changes in the Flutter app.

The game service

The Supabase logic to create and update game is abstracted in a GameService. In the game service, we can create a new game, join a game (for the second player), and make a move in the board. We also have a method to stream the game "state", by listening to realtime updates.

New game

Future<Game> newGame() async {
  final game =
      await _supabaseClient.from('games').insert({}).select().single();
  final gameId = game['id'];
  final gameCode = _codeManager.toCode(gameId);
  log('Created game with code $gameCode (ID $gameId)');
  return Game(gameId, gameCode, 1);
}

New game creates a new game. Note that we don't need to pass any information to create it, all the values are set to the default ones: pending status and player 1 as last_played, and an empty board. We use the CodeManager class to convert the game ID to a game code, using HashIds.

Join game

Future<Game> joinGame(String gameCode) async {
  final gameId = _codeManager.toId(gameCode);
  log('Searching for game with code $gameCode (ID $gameId)');

  if (gameId == null) {
    throw InvalidGameCodeException('Invalid code');
  }

  final game = await _supabaseClient
      .from('games')
      .select()
      .eq('id', gameId)
      .maybeSingle();
  if (game == null) {
    throw InvalidGameCodeException('Invalid code');
  }

  final status = game['status'];
  log('Found game with status $status');
  if (status != 'pending') {
    throw InvalidGameCodeException('Game has already started');
  }

  await _supabaseClient
      .from('games')
      .update({'status': 'started'}).eq('id', gameId);

  return Game(gameId, gameCode, 2);
}

Join game accepts a game code, converts it to an ID, and looks up for the game by ID in the database. If a game is found, and the game is still pending (which means no one else joined), we update the game status to started. This will trigger the game to start for both players.

Play move

Future<void> playMove(Game game, GameState state, int index) async {
  if (state.lastPlayed == game.player || state.board[index] != 0) {
    return;
  }
  state.board[index] = game.player;
  await _supabaseClient.from('games').update({
    'board': state.board.join(),
    'last_played': game.player,
  }).eq('id', game.id);
}

A player can "move" to a square in the grid, only if it's their turn, and only if the square is empty. To make this change, we replace the board string at the selected index to either 1 or 2 depending on the player, and set the last_played field to the player.

Stream game state

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

GameState toGameState(Map<String, dynamic> game) {
  final status = GameStatus.fromString(game['status']);
  print(game['board']);
  final board = (game['board'] as String)
      .split('')
      .map((e) => int.tryParse(e) ?? 0)
      .toList();
  return GameState(status, board, game['last_played'], game['winner']);
}

Last but not least, we stream all updates to the game by its ID, in order to show the changes as they happen.

The game view

In Flutter, the main game screen is a StreamBuilder widget, which streams the game state from the GameService and shows the board in a 3x3 grid.

class GameView extends StatelessWidget {
  final Game game;

  const GameView({super.key, required this.game});

  @override
  Widget build(BuildContext context) {
    final textStyle = TextStyle(fontSize: 40);
    final colorScheme = Theme.of(context).colorScheme;

    return StreamBuilder<GameState>(
        stream: Services.of(context).gameService.streamState(game.id),
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.active ||
              snapshot.data == null) {
            return const SizedBox();
          }
          final state = snapshot.data!;
          log('Game status ${state.status}');
          if (state.status == GameStatus.complete) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                    state.winner != null
                        ? 'Player ${state.winner!} wins!'
                        : 'It\'s a tie!',
                    textAlign: TextAlign.center,
                    style: textStyle.copyWith(color: colorScheme.primary)),
                Text(
                    state.winner == game.player
                        ? 'Congratulations!'
                        : 'Better luck next time!',
                    textAlign: TextAlign.center,
                    style: textStyle),
              ],
            );
          }
          log('Game state updated with board ${state.board}');
          return Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              if (state.lastPlayed != game.player) Text('Your turn'),
              if (state.lastPlayed == game.player)
                Text('Waiting for Player ${game.secondPlayer}...'),
              GridView.builder(
                padding: const EdgeInsets.all(8.0),
                itemCount: 9,
                shrinkWrap: true,
                gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 3,
                ),
                itemBuilder: (BuildContext context, int index) {
                  return GestureDetector(
                    onTap: () => _onTap(context, state, index),
                    child: Container(
                      decoration: BoxDecoration(
                        border: Border.all(color: colorScheme.secondary),
                      ),
                      child: Center(
                        child: Text(
                          state.getSquareLabel(index),
                          style: textStyle.copyWith(
                            color: state.board[index] == game.player
                                ? colorScheme.primary
                                : null,
                          ),
                        ),
                      ),
                    ),
                  );
                },
              ),
            ],
          );
        });
  }

  void _onTap(BuildContext context, GameState state, int index) {
    Services.of(context).gameService.playMove(game, state, index);
  }
}

We use GridView.builder to build the grid, if the game status is started . If the game status is complete, we skip the grid and show a message with the winner, if it's not a tie.

Winning

Notice that the game service does not have a method to determine the winner. We could add something there for this. For example, on every move, we could write some Dart code to check the board to see if there's a winner. If there is, we can update the game status to complete and set the winner.

While doing this on the client-side is a nice, simple way to do it, there is a different, although a bit more complicated way, to do this from the database with a function and a trigger.

First, we write a function check_winner() that checks the board for a winner. If there is one, it sets the game status to complete and updates the winner. If there's no winner, it checks if there are any empty squares left. If there aren't, it ends the game by also setting the status to complete, as there can be no winner.

-- Function to check if there is a winner and end the game
create function check_winner()
returns trigger as $$
begin
    -- check rows, columns and diagonals for a win
    if substring(new.board, 1, 1) = '1' and substring(new.board, 2, 1) = '1' and substring(new.board, 3, 1) = '1' or
       substring(new.board, 4, 1) = '1' and substring(new.board, 5, 1) = '1' and substring(new.board, 6, 1) = '1' or
       substring(new.board, 7, 1) = '1' and substring(new.board, 8, 1) = '1' and substring(new.board, 9, 1) = '1' or
       substring(new.board, 1, 1) = '1' and substring(new.board, 4, 1) = '1' and substring(new.board, 7, 1) = '1' or
       substring(new.board, 2, 1) = '1' and substring(new.board, 5, 1) = '1' and substring(new.board, 8, 1) = '1' or
       substring(new.board, 3, 1) = '1' and substring(new.board, 6, 1) = '1' and substring(new.board, 9, 1) = '1' or
       substring(new.board, 1, 1) = '1' and substring(new.board, 5, 1) = '1' and substring(new.board, 9, 1) = '1' or
       substring(new.board, 3, 1) = '1' and substring(new.board, 5, 1) = '1' and substring(new.board, 7, 1) = '1' then
        update games set status = 'complete', winner = 1 where id = new.id;
        return new;
    end if;

    if substring(new.board, 1, 1) = '2' and substring(new.board, 2, 1) = '2' and substring(new.board, 3, 1) = '2' or
       substring(new.board, 4, 1) = '2' and substring(new.board, 5, 1) = '2' and substring(new.board, 6, 1) = '2' or
       substring(new.board, 7, 1) = '2' and substring(new.board, 8, 1) = '2' and substring(new.board, 9, 1) = '2' or
       substring(new.board, 1, 1) = '2' and substring(new.board, 4, 1) = '2' and substring(new.board, 7, 1) = '2' or
       substring(new.board, 2, 1) = '2' and substring(new.board, 5, 1) = '2' and substring(new.board, 8, 1) = '2' or
       substring(new.board, 3, 1) = '2' and substring(new.board, 6, 1) = '2' and substring(new.board, 9, 1) = '2' or
       substring(new.board, 1, 1) = '2' and substring(new.board, 5, 1) = '2' and substring(new.board, 9, 1) = '2' or
       substring(new.board, 3, 1) = '2' and substring(new.board, 5, 1) = '2' and substring(new.board, 7, 1) = '2' then
        update games set status = 'complete', winner = 2 where id = new.id;
        return new;
    end if;

    -- check if the board has no zeroes indicating a full board
    if position('0' in new.board) = 0 then
        update games set status = 'complete' where id = new.id;
    end if;

    return new;
end;
$$ language plpgsql;

We define a trigger to call this function after every update of the board field of each game.

-- Trigger check winner function after each board update
create trigger trigger_check_winner
after update of board on games
for each row
execute function check_winner();

You could easily argue that this is way too much complexity, and doing it on the client is the easiest way. I agree. But having the backend (in this case the database) rather than the client determine the winner feels more "right".

Wrapping up

In this post, we built a realtime Tic-tac-toe game with Flutter and Supabase. We made use of Supabase Auth's new anonymous login feature, which was released last week as part of the Supabase Launch (GA) Week.

You can find the full source code here.

Did you find this article valuable?

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