-1

I have been building basic and complex mobile apps in Android and Flutter also have knowledge of NodeJS. I have already built a basic NodeJs multiplayer server for Texas Holdem Poker. Mulitple rooms and table logic are still remaining.

I want to develop a client mobile app in Flutter as I have deep knowledge of flutter.

I have been exploring the packages and convenient tools that are going to be used in Flutter but am still clueless.

This game development in Flutter is a new challenge for me so I would like to ask whether the technology stack for such a game is good or not?

Shall I consider switching from Flutter to something else?

Kuntec
  • 76
  • 2

1 Answers1

1

I'd say go for it. I created a card game myself while learning Flutter - just needed a fun project to see it end to end.

Similarly to you, I used node.js in the backend, enabling me to play the game with my friends.

I ended up going with GraphQL for my back end services (using AWS AppSync service). Since GraphQL will give you push notification over websockets, it was ideal to send data between different clients playing the game. Node.js deployed on AWS Lambda with Dynamo DB for persistence worked without a problem.

I also ended up doing an AI algorithm (using Monte Carlo Tree Search) that enabled me to play the game against the AI. And as the final bit - I ported my back end node.js interface to dart, so I could play the game against the AI fully off-line.

I think I spent the most time figuring out how to do the card animation, especially since I wanted for the card moves to happen sequentially. AI would sometimes play the moves too fast, so there were multiple cards flying around at the same time. Once this was done - the rest of it was easy.

If you need more details, let me know. I'm happy to share bits of the code with you.

Edit: here are some code details. I'll try to edit out parts of code that you won't need, so probably some of it will fail to compile at first... And keep in mind - some things may be overcomplicated in my code - as I got the things up and running I would move on, so there is a lot to improve here...

First the back-end. As I mentioned, I used AWS: Dynamo DB to store the game data; a single lambda function to do all the work; and AppSync API: simply because it offers message push to the clients - you don't need to keep polling the back-end to see if there are any changes. And finally - I used Cognito user pool to authenticate users. In my app you can create your account, validate it through email etc.

I used AWS Amplify to setup a back-end: this is AWS framework that enables you to easily deploy your back-end even if you don't know that much about AWS. It is actually meant for app developers that don't need or want to learn about AWS in detail. Even if you know your way around AWS - it is still very useful, since it will automate a lot of security wiring for you, and will help you automate the deployment.

Currently, there is official amplify-flutter package; at the time I did this I used 3rd party package https://pub.dev/packages/amazon_cognito_identity_dart_2.

Now I'm assuming that you got your back-end setup: you deployed your node.js code in Lambda, and now you need your AppSync schema. Here's mine:

type Match @aws_iam @aws_cognito_user_pools {
    matchId: Int
    matchData: AWSJSON!
}

type PlayEvent @aws_iam @aws_cognito_user_pools {
    matchId: Int!
    success: Boolean!
    playerId: Int!
    eventType: String!
    inputData: AWSJSON
    eventData: AWSJSON
    matchData: AWSJSON
}

input PlayEventInput {
    matchId: Int!
    eventType: String!
    eventData: AWSJSON
    playerId: Int
}



type Query {
  getMatch(matchId: Int): Match @function(name: "tresetaFv2-${env}") @aws_iam @aws_cognito_user_pools
}

type Mutation {
  playEvent(input: PlayEventInput!): PlayEvent @function(name: "tresetaFv2-${env}") @aws_iam @aws_cognito_user_pools
}

type Subscription {
  onPlayEvent(matchId: Int, success: Boolean): PlayEvent @aws_subscribe(mutations: ["playEvent"])
}

As you can see, I'm using AWSJSON data type a lot - instead of coding my entire game schema in GraphQL, I'm simply passing JSON back and forth.

Few things to understand:

  • MatchData type holds your game state: what card each player holds, what are the cards in the deck, on the table, who's turn is it to play etc.
  • Query getMatch will be fired by the client app after selecting the match from the active matches list. When you join the game, this is how you fetch the game state.
  • Mutation playEvent is how you play your move: you pass PlayEventInput type - specifying matchID and playerID, event Type (in your case you can play card, fold, call...). The playEvent Mutation will return PlayEvent - telling you if the move was successful (was it legal at the time? Was your turn to play?), and it will return MatchData - the new game state after the move was played.
  • And finally - Subscription onPlayEvent. After each client joins the match (by match ID), it will subscribe to this subscription. As you can see from the subscription definition - it will subscribe to playEvent mutation: each time a player plays a move, it will notify all the others about the move - and pass on the result of the Mutation, so everyone will get the full game state to refresh their UI. A nice trick here is - you subscribe to mutations that have success=true, so you don't push any message for failed moves.

You will also see some annotations - this is how you tell Amplify to wire things for you:

  • @function(name: "tresetaFv2-${env}"): you tell it to call Lambda funciton called "tresetaFv2-${env}" to actuall do the work
  • @aws_iam @aws_cognito_user_pools - this is how you tell it that this API is scured by Cognito user pools.

So now we have the AWS back-end setup. So how do you actually run the query and subscribe to the events coming from the back-end?

First, I use this UserService in my code: https://github.com/furaiev/amazon-cognito-identity-dart-2/blob/main/example/lib/user_service.dart

This is my AppSync Service - this is just a generic way of calling AppSync:

import 'dart:convert';

import 'package:amazon_cognito_identity_dart_2/cognito.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:treseta_app/auth/auth_services.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';



class AppSyncImpl {
  final String _endPoint;

  String get _wsEndPoint => _endPoint.replaceAll('https', 'wss').replaceAll('appsync-api', 'appsync-realtime-api');

  String get _host => _endPoint.replaceAll('https://', '').replaceAll('/graphql', '');

  UserService _userService;
  set userService(UserService userService) => this._userService = userService;

  AppSyncImpl(this._endPoint, this._userService);

  Future<http.Response> getHttpResponse(Map<String, String> query) async {
    CognitoUserSession session = await _userService.getSession();

    return http.post(
      _endPoint,
      headers: {
        'Authorization': session.getAccessToken().getJwtToken(),
        'Content-Type': 'application/json',
      },
      body: json.encode(query),
    );
  }

  Future<Map<String, dynamic>> glQueryMutation(Map<String, String> query) async {
    http.Response response;
    try {
      response = await getHttpResponse(query);
    } catch (e) {
      print(e);
    }

    return json.decode(response.body);
  }

  int wsTimeoutInterval;

  void disconnectWebSocketChannel(WebSocketChannel wsChannel, String uniqueKey) {
    wsChannel.sink.add(json.encode({'type': 'stop', 'id': uniqueKey}));
  }

  Future<WebSocketChannel> wsSubscription(
      {@required Map<String, String> query,
      @required String uniqueKey,
      Function(Map<String, dynamic>) listener}) async {
    var jwtToken = (await _userService.getSession()).getIdToken().getJwtToken();

    var header = {'host': _host, 'Authorization': jwtToken};


    var encodedHeader = Base64Codec().encode(Utf8Codec().encode(json.encode(header)));
    // Note 'e30=' is '{}' in base64
    var wssUrl = '$_wsEndPoint?header=$encodedHeader&payload=e30=';

    var channel = IOWebSocketChannel.connect(wssUrl, protocols: ['graphql-ws']);

    channel.sink.add(json.encode({"type": "connection_init"}));
    channel.stream.listen((event) {
      var e = json.decode(event);

      switch (e['type']) {
        case 'connection_ack':
          wsTimeoutInterval = e['payload']['connectionTimeoutMs'];

          var register = {
            'id': uniqueKey,
            'payload': {
              'data': json.encode(query),
              'extensions': {'authorization': header}
            },
            'type': 'start'
          };
          var payload = json.encode(register);
          channel.sink.add(payload);
          break;
        case 'data':
          listener(e);
          break;
        case 'ka':
          print('Reminder: keep alive is not yet implemented!!!');
          break;
        case 'start_ack':
          print('Ws Channel: Subscription started');
          break;
        default:
          print('Unknown event $event');
      }
    }, onError: (error, StackTrace stackTrace) {
      print('Ws Channel: $error');
    }, onDone: () {
      channel.sink.close();
      print('Ws Channel: Done!');
    });

    return channel;
  }
}

And this is the actual back-end service; notice how there is a function that corresponds to each query, mutation and subscription in GraphQL Schema (getMatchData, playEvent, subscribeWS):

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

import 'package:treseta_app/appsync/app_sync.dart';
import 'package:treseta_app/auth/auth_services.dart';
import 'package:treseta_app/models/api_models.dart' as api;

import 'package:meta/meta.dart';
import 'package:treseta_app/models/game_model.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class AwsBackendService extends ChangeNotifier {
  final subscriptionStreamCtrl = StreamController<api.PlayEvent>.broadcast();
  Stream get subscriptionStream => subscriptionStreamCtrl.stream;

  UserService userService;
  String endPoint;

  AppSyncImpl _appSyncImpl;
  BuildContext context;

  AwsBackendService({@required this.context, this.userService, this.endPoint})
      : _appSyncImpl = AppSyncImpl(endPoint, userService);

  @override
  Future<api.Match> getMatchData(int matchId) async {
    final query = {
      'operationName': 'GetMatch',
      'query': '''query GetMatch { getMatch(matchId:$matchId){matchData, playerId}}'''
    };

    User user = await userService.getCurrentUser();
    String username = user.name;

    var matchData = await _appSyncImpl.glQueryMutation(query);

    var match = api.Match.fromJson(matchData['data']['getMatch']);
    match.matchData.username = username;

    return match;
  }

  Future<api.PlayEvent> playEvent(int matchId, int playerId, String eventType, api.PlayEventInputData eventData) async {
    var encoded = json.encode(eventData.toJson()).replaceAll("\"", "\\\"");
    final query = {
      'operationName': 'PlayEvent',
      'query':
          '''mutation PlayEvent { playEvent(input:{matchId:$matchId, eventType:"$eventType", eventData:"$encoded"}){matchId, eventData, success, playerId, eventType, inputData, matchData}}''',
    };

    api.PlayEvent result =
        await _appSyncImpl.glQueryMutation(query).then((value) => api.PlayEvent.fromJson(value['data']['playEvent']));

    User user = await userService.getCurrentUser();
    String username = user.name;

    result.matchData.username = username;

    return result;
  }

  WebSocketChannel _wsClient;
  String _wsUniqeKey;
  Future<void> subscribeWs(int matchId, int playerId, MatchData model) async {
    final query = {
      'operationName': 'OnPlayEvent',
      'query':
          'subscription OnPlayEvent { onPlayEvent(matchId:$matchId, success:true) {matchId, success,playerId,eventType,inputData,eventData,matchData}}',
    };

    _wsUniqeKey = UniqueKey().toString();
    _wsClient = await _appSyncImpl.wsSubscription(
        query: query,
        uniqueKey: _wsUniqeKey,
        listener: (Map<String, dynamic> message) {
          var playEvent = api.PlayEvent.fromJson(message['payload']['data']['onPlayEvent']);
          subscriptionStreamCtrl.sink.add(playEvent);
        });
    return;
  }

  void disconnect() {
    try {
      if (_wsClient != null) {
        _appSyncImpl.disconnectWebSocketChannel(_wsClient, _wsUniqeKey);
      }
    } catch (e) {
      print(e);
    }
  }

  @override
  void dispose() {
    super.dispose();
    subscriptionStreamCtrl.close();
    disconnect();
  }
}

You will see that each event we receive from AppSync subscription is just pumped into a Stream object.

And finally, my main ChangeNotifier Provider that holds the match data and notifies the UI is something like this - you will see how the incoming subscription events are processed, and UI is notified that there's a new event to be animated - a card being thrown, or new hand dealt etc.

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:treseta_app/backend/backend_service.dart';
import 'package:treseta_app/models/api_models.dart';
import 'package:treseta_app/models/game_model.dart';

class Treseta extends ChangeNotifier {
  MatchData model;

  int playerId;

  AwsBackendService _backendService;
  StreamSubscription backendServiceSubscription;

  AwsBackendService get backendService => _backendService;

  set backendService(BackendService value) {
    if (backendServiceSubscription != null) backendServiceSubscription.cancel();
    _backendService = value;

    // this is where we process the incoming subscription messages:
    backendServiceSubscription = _backendService.subscriptionStream.listen((inEvent) {
      PlayEvent playEvent = inEvent as PlayEvent;
      playCard(
          inData: playEvent,
          playerId: playEvent.playerId,
          card: playEvent.inputData.card);
    });
  }

  int _matchId;
  int get matchId => _matchId;
  set matchId(int value) {
    _matchId = value;
    subscribeAndGetMatchData();
  }

  Future<void> disconnect() async => backendService.disconnect();

  Future<MatchData> getMatchData() async {
    var match = await backendService.getMatchData(matchId);
    playerId = match.playerId;

    model = match.matchData;

    return model;
  }

  Future<void> subscribeAndGetMatchData() async {
    if (matchId != null) {
      disconnect();
      await getMatchData();
      await subscribe();
    }
    return;
  }

  Future<void> subscribe() async {
    backendService.subscribeWs(matchId, playerId, this.model);
  }

  void playCard(
      {GameCard card,
      GlobalKey sourceKey,
      GlobalKey targetKey,
      int playerId = 0,
      PlayEvent inData}) async {
    // Animation notifier logic goes here...
  }

  @override
  void dispose() {
    disconnect();
    backendServiceSubscription.cancel();
    super.dispose();
  }
}

There you go - I hope this helps. There is another bit on how to animate the cards - the big challenge is to run your animation sequentially - even if the players are quick enough to play cards very quickly. If you want, I can post some details around it as well.

*** One more edit **** I created a repo with a simple animation example. If I find time, I'll write some comments in Readme file...

https://github.com/andrija78/cards_demo

Andrija
  • 1,534
  • 3
  • 10
  • Thanks for this feedback and I'll go for it. – Kuntec Nov 30 '21 at 09:22
  • Can you please provide some flutter + node js code for multiplayer demo. So that I can have the base. I'm bit lost after creating a node js server. For heads-up this would be very helpful. – Kuntec Dec 04 '21 at 23:10
  • Sure, I'll post something here today or tomorrow. I'll try to explain the basics, part by part. – Andrija Dec 05 '21 at 07:56
  • I updated my answer with the back-end communication part. I'll try to share few animation ideas next... – Andrija Dec 06 '21 at 11:47
  • This is great. I'm excited to do this and waiting for your animation ideas. Thanks a lot. – Kuntec Dec 07 '21 at 04:14
  • Added a public repo here: https://github.com/andrija78/cards_demo. You can clone it and run it - it will be a simple card game - you get to throw your cards, and other players will randomly played theirs. Winner for each trick is also determined randomly, and cards are collected. – Andrija Dec 08 '21 at 08:32
  • Thanks. I'll clone it and run. – Kuntec Dec 08 '21 at 13:24
  • I finally added some comments to the github readme... – Andrija Dec 14 '21 at 11:00