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