Development of a multiplayer game on Dart+Flutter

About the game

Hello everyone, we will develop a game that is quite simple – it is an analogue of tic-tac-toe, only slightly modified. On this gif you can see the game itself:

inspired by this GIF

inspired by this GIF

Actually, after watching the gif, I wanted to do something similar. The game will be multiplayer, so I will logically divide the story into two parts – the server and the client.

If you are not interested in the article, but only need the code, then it is available here

I chose dart on the server and flutter+flame on the client. Why is dart on the server? I really like the flutter technology, and since it is written in dart, I decided to use the same language on the server in order to be able to share parts of the code.

I want to be able to play everywhere – desktop, mobile, web. Fortunately, flutter provides all this. The only limitation is that I have web in the list of supported platforms. Because of this, the server and client will communicate with each other via websocket.

We have decided on the tools, let’s move on to the project itself.

Preparation

Let’s create a flutter project:

flutter create tttgame

immediately write the dependencies in pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter

  logging: ^1.0.2
  flame: ^1.8.1
  uuid: ^3.0.7
  base2e15: ^1.0.0
  web_socket_channel: ^2.4.0

In order:

  • logging – for logging

  • flame – game engine for flutter. You can read about it here

  • uuid – generation of unique identifiers for the client

  • base2e15 – with its help we will reduce the size of messages between the server and the client.

  • web_socket_channel – is used on the client. The dart framework has several approaches to working with websockets. dart:io – for work in desktop and dart:html – for web . In order not to drag into the verification code on which platform the client is currently running and to duplicate the code (websocket in dart:io different from websocket in dart:html), we will take a universal package that works on any of the platforms.

Added dependencies, wrote in the console dart pub get to pull all the packages into the project and we are ready for implementation servers communication protocol between server and client.

Server-Client communication

Let’s think about how our server will communicate with the client? As mentioned above, the protocol will be websocket, messages will be encrypted using base2e15

I see how our game works like this: the client connects to the server, the server gives the client a unique identifier and adds it to the list of clients that are currently connected. The server periodically sends the current game status to all clients. The client can send a message to the server about its progress. And the server sends the result to the client – this move is possible or not. In total, we have three types of messages that will be sent from the server to the client and back:

  • welcome – When a client connects to a server and the server generates a unique identifier for the client, the server should send a message with that identifier to the client.

  • gameStatus – the server periodically sends the current game status to all clients.

  • move – a message that the client sends to the server with information about its move, and the server sends back information about the admissibility of this move.

Well, let’s go to the folder lib create a folder entityinside this folder file message.dart and write:

enum MessageType {
  welcome,
  gameStatus,
  move,
}

In the same folder, let’s create a few more files with auxiliary enum:

player_role.dart
enum PlayerRole {
  topPlayer,
  bottomPlayer,
  observer,
}

The player can be at the top of the table, at the bottom of the table and an observer

winner.dart
enum Winner {
  none,
  top,
  bottom,
}

There may not be a winner, it may also be a player from above or a player from below.

figure_type.dart
enum FigureType {
  small,
  medium,
  large,
  none,
}

We have three types of figures: small, large or medium. Also, the figure may not be in the cell.

Now we need to stop and think a little. How to understand that now the server received not a welcome message, but a gameStatus? Obviously, the first field should be the type of message I want to send.

Because in the file message.dart let’s add the following:

message.dart
class Message {
  final MessageType type;
  final WelcomeMessage? welcomeMessage;
  final Move? move;
  final GameStatus? gameStatus;

  Message(this.type, {this.welcomeMessage, this.move, this.gameStatus});

  factory Message.fromJson(Map<String, dynamic> json) {
    return Message(
      MessageType.values.firstWhere((e) => e.toString() == json['type']),
      gameStatus: json.containsKey('gameStatus') ? GameStatus.fromJson(json['gameStatus']) : null,
      welcomeMessage:
          json.containsKey('welcomeMessage') ? WelcomeMessage.fromJson(json['welcomeMessage']) : null,
      move: 
          json.containsKey('move') ? Move.fromJson(json['move']) : null,
    );
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = {'type': type.toString()};
    if (gameStatus != null) {
      data['gameStatus'] = gameStatus!.toJson();
    }
    if (welcomeMessage != null) {
      data['welcomeMessage'] = welcomeMessage!.toJson();
    }
    if (move != null) {
      data['move'] = move!.toJson();
    }
    return data;
  }

  String toJsonString() {
    final jsonString = jsonEncode(toJson());
    final base2e15String = Base2e15.encode(jsonString.codeUnits);
    return base2e15String;
  }

  factory Message.fromJsonString(String jsonString) {
    final base2e15Bytes = Base2e15.decode(jsonString);
    final base2e15String = String.fromCharCodes(base2e15Bytes);
    final jsonData = jsonDecode(base2e15String);
    return Message.fromJson(jsonData);
  }
}

In general, this class could be made abstract with a handle method and an implementation of this method in each separate class. But even in this case, we would have to pass the type of the message that we forwarded. If there is a guru dart who will explain why my approach is not correct, I will listen with pleasure.

Let’s take a closer look at this class:

  • final MessageType type; – the type of message that just arrived.

  • final WelcomeMessage? welcomeMessage;
    final Move? move;
    final GameStatus? gameStatus;

    These are optional fields of the class. They may be null. this is what we specify in the constructor: Message(this.type, {this.welcomeMessage, this.move, this.gameStatus}); – we say that these fields can be specified during initialization, or they can be empty.

  • factory Message.fromJson – everything is simple here. If the message contained any of MessageType, then we parse it from the json structure. Works exactly the same toJson() – if our fields are not empty, then we turn the class data into json.

  • Functions String toJsonString()And factory Message.fromJsonString(String jsonString) are used to encode/decode the final message in base2e15 or a string.

The classes themselves WelcomeMessage, Move, GameStatus :

welcome_message.dart
import 'player_role.dart';

class WelcomeMessage {
  WelcomeMessage(this.clientId, this.canPlay, this.playerRole);
  final String clientId;
  final bool canPlay;
  final PlayerRole playerRole;

  factory WelcomeMessage.fromJson(Map<String, dynamic> json) {
    return WelcomeMessage(
      json['clientId'],
      json['canPlay'],
      PlayerRole.values.byName(json['playerRole']),
    );
  }

  Map<String, dynamic> toJson() => {
        'clientId': clientId,
        'canPlay': canPlay,
        'playerRole': playerRole.name,
      };
}
move.dart
import 'figure_type.dart';

class Move {
  Move(this.clientId, this.sourceCellId, this.targetCellId, this.figureType,
      {this.canPut});
  String clientId;
  int sourceCellId;
  int targetCellId;
  FigureType figureType;
  bool? canPut;

  factory Move.fromJson(Map<String, dynamic> json) => Move(
        json['clientId'],
        json['sourceCellId'],
        json['targetCellId'],
        FigureType.values.byName(json['figureType']),
        canPut: json.containsKey('canPut') ? json['canPut'] : null,
      );

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = {
      'clientId': clientId,
      'sourceCellId': sourceCellId,
      'targetCellId': targetCellId,
      'figureType': figureType.name,
    };
    if (canPut != null) {
      data['canPut'] = canPut;
    }
    return data;
  }
}
game_status.dart
import 'board.dart';

class GameStatus {
  GameStatus(this.board, {this.winnerId});
  Board board;
  String? winnerId;

  factory GameStatus.fromJson(Map<String, dynamic> json) {
    return GameStatus(
      Board.fromJson(json['board']),
      winnerId: json.containsKey('winnerId') ? json['winnerId'] : null,
    );
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = {
      'board': board.toJson(),
    };
    if (winnerId != null) {
      data['winnerId'] = winnerId;
    }
    return data;
  }
}

As you can see, in the class GameStatus we use the Board helper class. We will get to it soon, but first, let’s take a look at what we have already written. All classes are pretty similar. In functions fromJson And toJson class fields are parsed from \ to json format for data transfer over the network.

Let’s go through the class fields:

  • class WelcomeMessage:

    • final String clientId – we tell the client its unique identifier

    • final bool canPlay – only the server knows how many clients are currently connected. And if less than two, then the player obviously cannot play.

    • final PlayerRole playerRole; – there are only two places at the table – the top player and the bottom one. Also, there is the role of an observer who cannot move the pieces, but simply watch the game.

  • class Move:

    • String clientId – this is how the client tells the server that it was he who went

    • int sourceCellId – from which cell the figure was taken

    • int targetCellId – in which cell the player wants to put this figure

    • FigureType figureType– body type (small, medium large)

    • bool? canPut– an optional field that the client does not send. It is passed by the server with the value true if this move is possible and false if it is impossible.

  • class GameStatus:

    • String? winnerId – optional field and equal null until there is no winner in the game. If the winner is determined, then there will be clientId the winning player

    • Board board– here lives the state of the field that is stored on the server.

We also have a shape class – it is very simple and similar to the previous ones:

figure.dart
import 'figure_type.dart';

class Figure {
  const Figure(this.figureType, this.color, this.cellId);

  final FigureType figureType;
  final int color;
  final int cellId;

  factory Figure.fromJson(Map<String, dynamic> json) {
    return Figure(
      FigureType.values.byName(json['figureType']),
      json['color'],
      json['cellId'],
    );
  }

  Map<String, dynamic> toJson() => {
        'figureType': figureType.name,
        'color': color,
        'cellId': cellId,
      };
}

In the next class, we will need constants, so in the root folder lib create a folder common and inside her file constants.dart :

constants.dart
import '../entity/figure.dart';
import '../entity/figure_type.dart';

abstract class Constants {
  static const int player1Color = 1;
  static const int player2Color = 2;
  static const int fakeCellId = 404;
  static const int noColor = 0;
  
  static const Figure noneFigure = Figure(FigureType.none, noColor, fakeCellId);
  static const List<List<int>> winnigCombinations = <List<int>>[
    <int>[3, 4, 5],
    <int>[6, 7, 8],
    <int>[9, 10, 11],
    <int>[3, 7, 11],
    <int>[5, 7, 9],
    <int>[3, 6, 9],
    <int>[4, 7, 10],
    <int>[5, 8, 11],
  ];
  static const int winningCount = 3;
  static const int port = 8080;
  static const int maxAttempts = 5;
  static const String host="localhost";
  static const Duration reconnectDelay = Duration(seconds: 5);
  static const broadcastInterval = Duration(milliseconds: 100);
}

Since we have a server that works in the console and on the server side, the colors from materials.dart are not available, we designate them with numbers. Also, we need noneFigure And winningCombinations to determine the winner.

So we come to our main class, which will contain all the logic of the game from the server side. Let’s go to the folder entity create a file board.dart and take a closer look:

board.dart
import '../common/constants.dart';
import 'figure.dart';
import 'figure_type.dart';
import 'winner.dart';

class Board {
  List<Figure> figures;

  Board(this.figures);

  factory Board.fromJson(Map<String, dynamic> json) {
    final List<dynamic> figuresJson = json['figures'];
    final figures =
        figuresJson.map((figureJson) => Figure.fromJson(figureJson)).toList();
    return Board(figures);
  }

  Map<String, dynamic> toJson() => {
        'figures': figures.map((figure) => figure.toJson()).toList(),
      };

  void startnewGame() {
    figures.clear();
    figures.add(const Figure(FigureType.small, Constants.player1Color, 0));
    figures.add(const Figure(FigureType.small, Constants.player1Color, 0));

    figures.add(const Figure(FigureType.medium, Constants.player1Color, 1));
    figures.add(const Figure(FigureType.medium, Constants.player1Color, 1));

    figures.add(const Figure(FigureType.large, Constants.player1Color, 2));
    figures.add(const Figure(FigureType.large, Constants.player1Color, 2));

    figures.add(const Figure(FigureType.small, Constants.player2Color, 12));
    figures.add(const Figure(FigureType.small, Constants.player2Color, 12));

    figures.add(const Figure(FigureType.medium, Constants.player2Color, 13));
    figures.add(const Figure(FigureType.medium, Constants.player2Color, 13));

    figures.add(const Figure(FigureType.large, Constants.player2Color, 14));
    figures.add(const Figure(FigureType.large, Constants.player2Color, 14));
  }

  void removeFigureFromCell(int cellId) {
    final int index = figures
        .indexWhere((Figure figureServer) => figureServer.cellId == cellId);
    if (index != -1) {
      figures.removeAt(index);
    }
  }

  bool canPutFigure(int cellId, FigureType otherFigureType) {
    final Figure figureServer = figures.lastWhere(
        (Figure figureServer) => figureServer.cellId == cellId,
        orElse: () => Constants.noneFigure);
    if (figureServer.figureType == FigureType.none) {
      return true;
    } else if (figureServer.figureType == FigureType.large) {
      return false;
    } else {
      switch (otherFigureType) {
        case FigureType.small:
          return false;
        case FigureType.medium:
          if (figureServer.figureType == FigureType.small) {
            return true;
          } else {
            return false;
          }
        case FigureType.large:
          if (figureServer.figureType == FigureType.small ||
              figureServer.figureType == FigureType.medium) {
            return true;
          } else {
            return false;
          }
        case FigureType.none:
          return true;
      }
    }
  }

  void putFigure(Figure figure) =>
      figures.add(Figure(figure.figureType, figure.color, figure.cellId));

  Winner checkWinner() {
    if (playerWin(Constants.player1Color)) {
      return Winner.top;
    } else if (playerWin(Constants.player2Color)) {
      return Winner.bottom;
    }
    return Winner.none;
  }

  bool playerWin(int playerColor) {
    final List<int> playerCells = <int>[];
    for (final Figure pFigure in figures) {
      final Figure lastFigure = figures
          .where((Figure element) => element.cellId == pFigure.cellId)
          .last;
      if (lastFigure.color == playerColor) {
        playerCells.add(pFigure.cellId);
      }
    }
    for (final List<int> wins in Constants.winnigCombinations) {
      int matchCount = 0;
      for (final int w in wins) {
        if (playerCells.contains(w)) {
          matchCount++;
        }
      }
      if (matchCount == Constants.winningCount) {
        return true;
      }
    }
    return false;
  }
}

The only field that the class has is List<Figure> figures – a list of figures on the playing field.

A little more about the playing field. As you can see in the gif at the beginning, the entire field is 15 cells. In our game it will look like this:

playing field

playing field

Cells 0,1,2,12,13,14 will initially contain player figures (two pieces in each field), the rest of the cells are empty. Based on this representation, we can write functions removeFigureFromCellAnd startnewGame,putFigure. The functions are pretty simple and I won’t go into explaining their logic.

To function input canPutFigure the cell in which the player wants to put a piece and the type of piece he wants to put is transmitted. We look at whether there are any figures in the specified cell at all, and if so, what size they are. If the figure that we want to put in this cell is larger, then the server considers this action valid.

Functions checkWinner And playerWin check whether we have a winner after the next move. We take winnigCombinations from the constants, which lists all combinations of cells to win and compare the current position of the players’ pieces. If the combinations matched, then the player won.

Server

Everything is ready to write the server. It’s pretty simple. We will use the library dart:io because the server will only run in the console.

I go to the folder libcreate a folder server and inside it is a file server.dart .

Full server code:

server.dart
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import '../common/constants.dart';
import '../entity/message.dart';
import '../entity/player_role.dart';
import '../entity/welcome_message.dart';
import '../server/game_state.dart';

class ServerManager {
  final Logger _log = Logger('Server');
  final Map<String, WebSocket> connectedClients = {};
  bool gameStated = false;
  GameState gameState = GameState();

  void init() async {
    final server =
        await HttpServer.bind(InternetAddress.loopbackIPv4, Constants.port);
    _log.info(
        'WebSocket started ws://${server.address.address}:${server.port}/');

    await for (HttpRequest request in server) {
      if (WebSocketTransformer.isUpgradeRequest(request)) {
        WebSocketTransformer.upgrade(request).then((webSocket) {
          handleNewConnection(webSocket);
        });

        broadcast();
      } else {
        request.response.write('Pizza delivering');
        await request.response.close();
      }
    }
  }

  void handleNewConnection(WebSocket webSocket) {
    final clientId = const Uuid().v4();
    _log.info('A new client connected: $clientId.');

    connectedClients[clientId] = webSocket;
    bool canPlay = false;
    PlayerRole playerRole = PlayerRole.observer;
    if (connectedClients.length <= 2) {
      canPlay = true;
      if (connectedClients.length == 1) {
        gameState.topPlayerId = clientId;
        playerRole = PlayerRole.topPlayer;
      } else if (connectedClients.length == 2) {
        gameState.bottomPlayerId = clientId;
        playerRole = PlayerRole.bottomPlayer;
      }
    }

    final welcomeMessage = Message(MessageType.welcome,
        welcomeMessage: WelcomeMessage(clientId, canPlay, playerRole));
    final messageString = welcomeMessage.toJsonString();
    webSocket.add(messageString);
    if (connectedClients.length > 1) {
      if (!gameStated) {
        _log.info(
            'A new game starting for clients: ${connectedClients.keys.toString()}');
        gameState.gameStatus.board.startnewGame();
        gameStated = true;
      }
    }
    handleMessages(webSocket, clientId);
  }

  void handleMessages(WebSocket webSocket, String clientId) {
    webSocket.listen((data) {
      final message = Message.fromJsonString(data);
      if (message.type == MessageType.move) {
        final move = message.move;
        gameState.moveFigure(move!);
      }
    }, onError: (error) {
      _log.shout('Error connection for client $clientId: $error');
      connectedClients.remove(clientId);
    }, onDone: () {
      _log.warning('Connection for client $clientId closed');
      connectedClients.remove(clientId);
    });
  }

  void broadcast() {
    Timer.periodic(Constants.broadcastInterval, (timer) {
      final message =
          Message(MessageType.gameStatus, gameStatus: gameState.gameStatus);
      final messageString = message.toJsonString();
      connectedClients.forEach((clientId, webSocket) {
        webSocket.add(messageString);
      });
    });
  }
}

void main() async {
  Logger.root.onRecord.listen((record) {
    // ignore: avoid_print
    print('${record.level.name}: ${record.time}: '
        '${record.loggerName}: '
        '${record.message}');
  });
  ServerManager server = ServerManager();
  server.init();
}

Let’s take it in order:

  • final Logger _log = Logger('Server'); – we initiate a logger to write messages to the console.

  • final Map<String, WebSocket> connectedClients = {}; – a list of connected clients will be stored here.

  • bool gameStated = false; – since we need 2 people for the game, and initially we have 0, the game has not started by default.

  • GameState gameState = GameState(); – here we will store the current status of the game in the internal representation of the server.

  • In function init() we are initiating a websocket server. If the request came via the http protocol, we simply return a text response. If the request was to websocket, we start working with a new client in the function handleNewConnection

  • handleNewConnection – we generate a unique identifier for each new player and, depending on how many clients are connected, we give him a role either at the gaming table, or the role of an observer. Collecting a message like WelcomeMessage and send it to the client. At the end we call the function handleMessages to listen to the communication channel with the client.

  • handleMessages – from the client we expect only messages of type move. All others are ignored. We call the method gameState.moveFigure(move!) – we will analyze this class a bit later.

  • broadcast – we use this function to periodically send the current state of the game to clients. Every 100 milliseconds, all clients will receive messages like MessageType.gameStatus

Well, to complete the server, it remains to write the last class. Let’s go to the folder server create a file game_state.dart and write the following:

game_state.dart
import 'package:logging/logging.dart';

import '../entity/board.dart';
import '../entity/figure.dart';
import '../entity/figure_type.dart';
import '../entity/game_status.dart';
import '../entity/move.dart';
import '../entity/winner.dart';
import '../common/constants.dart';

class GameState {
  final Logger _log = Logger('Server');
  late String? topPlayerId;
  late String? bottomPlayerId;
  Winner winner = Winner.none;
  bool bottomPlayerTurn = false;
  GameStatus gameStatus = GameStatus(Board(<Figure>[]));

  Move moveFigure(Move move) {
    final FigureType figureType =
        FigureType.values.firstWhere((FigureType e) => e == move.figureType);
    if (move.clientId == topPlayerId && !bottomPlayerTurn ||
        move.clientId == bottomPlayerId && bottomPlayerTurn) {
      bool canPut =
          gameStatus.board.canPutFigure(move.targetCellId, figureType);
      if (canPut) {
        int color = Constants.player1Color;
        bottomPlayerTurn = true;
        if (move.clientId == bottomPlayerId) {
          color = Constants.player2Color;
          bottomPlayerTurn = false;
        }
        gameStatus.board
            .putFigure(Figure(figureType, color, move.targetCellId));
        gameStatus.board.removeFigureFromCell(move.sourceCellId);
        winner = gameStatus.board.checkWinner();
        if (winner == Winner.top) {
          _log.info('Player $topPlayerId win');
          gameStatus.winnerId = topPlayerId;
        } else if (winner == Winner.bottom) {
          _log.info('Player $bottomPlayerId win');
          gameStatus.winnerId = bottomPlayerId;
        }
        move.canPut = true;
      } else {
        move.canPut = false;
      }
    }
    return move;
  }
}

This class implements the internal representation of the game on the server. Here we have both players (topPlayerId, bottomPlayerId) and the winner (winner) and the state of the current move (bottomPlayerTurn) and current game status (gameStatus). There is only one function in the class – it makes or does not make a move. The logic of the work is as follows: first we check whose move and whether the player can make a move. Next, we look at whether it is possible to put a piece on the cell that the player wants. If yes, then we lay down the figure, remove the figure from the cell where this figure was taken from and check whether any player has won after the next move. As a result, the function forms a class Move with an answer for the client – is the move that the player wants to make possible.

Now you can start the server. Go to the root folder of the project and write in the console:

dart .\lib\server\server.dart

We will see that the server is running:

INFO: 2023-08-08 23:34:56.358943: Server: WebSocket started ws://127.0.0.1:8080/

You can with curl or telnet to check if the server is responding.

Everything this time. Thank you for your attention. Code of the first part is available Here. In the next part, we will write a client to communicate with our server.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *