How to build a Wallet-less Blockchain game with GraphQL
GraphQL is a kind of Web Socket library. You have to set GraphQL server in the server-side, but this is very useful to easily notify game players. The GraphQL itself is used heavily inside Twitter app and major applications.
I said you have to set GraphQL server, but you can also use some web tools.
GraphQL and onflow/fcl are both modern library and there are many similarities. Both are using original languages, gql(GraphQL language) and Cadence, when you want to call those functions, you will pass these original language functions into gql` ` in GraphQL and `` in Cadence, and set into dedicated methods and also pass variables witch are used in these functions.
When you imagine Blockchain game, what kind of thing do you imagine?
You may think that what is the benefit of using blockchain.
In the blockchain apps, you will do the transactions. In the meaning of Transaction, it is about transferring the moneys. So you can send money and receive money while playing the blockchain games. But blockchain transaction itself is not only used for transferring moneys, and it can save object datas inside the blockchain. So you can use transactions to save game datas. But main purpose are sending money and NFTs.
Why it is matter is, when you want to play high-networking game, the game is actually running on usual PCs inside GameArcade in Japan, though cabinets are different from usual PCs, but it is running on Windows OS. So, if you can insert coins from your house, you can play Arcade Games from your house. The main purpose of Blockchain game transactions are sending digital moneys to play high-networking games. You don’t need to buy game consoles any more because if the game itself is downloaded from the internet.
But you may wonder that if game itself is reverse-engineered and used as piracy software, the game providers would suffer losses. So that game itself should be protected by game console or so.. But this is not probably happen in Blockchain games. Not only the game player pay and transfer moneys to game providers, but game providers also can send and transfer digital moneys to game players. Since wallet addresses are distinct, whenever Game providers want to send digital moneys, they can. And Flow’s transaction fee are almost 0, so they are nothing to loss while transactions.
So that when you can get the prizes or standing out NFTs from game providers when you won the game, you will never play piracy software games.
What is Wallet-less Blockchain game ??
I said earlier blockchain transactions are not only used to transferring digital currency, it can be used to save game datas. But transactions will take a few seconds (about 5s), and game players don’t want to wait such time if the transactions are used for just saving game datas. Think of it, if every time wallet popup shows up when playing the game, you will lose a rhythm. So wallet popup are ideally only used for get money, pay digital currency, and connect and importing game resource datas.
So that wallet-less blockchain game matters. And if you used GraphQL, you can also use (I mean implement) social networking functions as GraphQL are good at WebSocket networking.
Is it difficult to implement?
No, not at all. Like I said there are many similarities between GraphQL and onflow/fcl, you can implement wallet-less functions really quickly, so that I write how to do that in this blog.
Before you implement ..
If you don’t know Query, Mutation, Input types, before you read the rest of this webpage, I recommend you to read official page ↓ so that you can understand both fcl and the GraphQL.
Now that you understand both GraphQL and fcl, let’s get started! Since GraphQL language are rather hard to learn, there are many auto-creation tools. After you typed inside GraphQL schema below,
type BCGGameServerProcess @model {
id: ID!
type: String!
message: String!
playerId: String!
}
type Subscription {
onCreateByPlayerid(playerId: String!): BCGGameServerProcess
@aws_subscribe(mutations: ["createBCGGameServerProcess"])
}
the major GraphQL tools have functions of auto-creating Query, Mutation and Subscription functions.
In this case, I am using AppSync.
GraphQL has officially have a few directives like @include or @skip. (More details are in below link.)
But AppSync has readied for you the @model directive. And after you typed amplify push
command on terminal, you will get all query, mutation, subscription methods and also GraphQL server functions, if you put @model directive on your type.
If you want to know how to do this, please buy a below book, in this book the instruction is explained with more than 30 pictures.
Now that you get all method requirements.
It includes
Query get / list functions in /src/graphql/queries.js.
export const getBCGGameServerProcess = /* GraphQL */ `
query GetBCGGameServerProcess($id: ID!) {
getBCGGameServerProcess(id: $id) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
export const listBCGGameServerProcesses = /* GraphQL */ `
query ListBCGGameServerProcesses(
$filter: ModelBCGGameServerProcessFilterInput
$limit: Int
$nextToken: String
) {
listBCGGameServerProcesses(
filter: $filter
limit: $limit
nextToken: $nextToken
) {
items {
id
type
message
playerId
createdAt
updatedAt
}
nextToken
}
}
`;
Mutation create, update, delete functions in /src/graphql/mutations.js.
export const createBCGGameServerProcess = /* GraphQL */ `
mutation CreateBCGGameServerProcess(
$input: CreateBCGGameServerProcessInput!
$condition: ModelBCGGameServerProcessConditionInput
) {
createBCGGameServerProcess(input: $input, condition: $condition) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
export const updateBCGGameServerProcess = /* GraphQL */ `
mutation UpdateBCGGameServerProcess(
$input: UpdateBCGGameServerProcessInput!
$condition: ModelBCGGameServerProcessConditionInput
) {
updateBCGGameServerProcess(input: $input, condition: $condition) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
export const deleteBCGGameServerProcess = /* GraphQL */ `
mutation DeleteBCGGameServerProcess(
$input: DeleteBCGGameServerProcessInput!
$condition: ModelBCGGameServerProcessConditionInput
) {
deleteBCGGameServerProcess(input: $input, condition: $condition) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
Subscription create, update, delete functions in /src/graphql/subscriptions.js.
export const onCreateBCGGameServerProcess = /* GraphQL */ `
subscription OnCreateBCGGameServerProcess(
$filter: ModelSubscriptionBCGGameServerProcessFilterInput
) {
onCreateBCGGameServerProcess(filter: $filter) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
export const onUpdateBCGGameServerProcess = /* GraphQL */ `
subscription OnUpdateBCGGameServerProcess(
$filter: ModelSubscriptionBCGGameServerProcessFilterInput
) {
onUpdateBCGGameServerProcess(filter: $filter) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
export const onDeleteBCGGameServerProcess = /* GraphQL */ `
subscription OnDeleteBCGGameServerProcess(
$filter: ModelSubscriptionBCGGameServerProcessFilterInput
) {
onDeleteBCGGameServerProcess(filter: $filter) {
id
type
message
playerId
createdAt
updatedAt
}
}
`;
After that, what you only need to do is importing these functions inside your front-end javascript file and call it, then GraphQL server’s resolver will be called.
You can check what resolver is look like from official page ⇩.
And I want to note that why I put this(↓) below the type is because Subscription is often customized to send each game players, so that usually subscription method implementations are required.
type Subscription {
onCreateByPlayerid(playerId: String!): BCGGameServerProcess
@aws_subscribe(mutations: ["createBCGGameServerProcess"])
}
Now, your wallet-less implementation of front-end is completed, and it is look like this:
async putCardOnTheField(field_position, card_id, used_intercept_card, enemy_skill_target) {
this.customLoading = true
setTimeout(() => (this.customLoading = false), 5000)
console.log("DEBUG The Card Put on the Field:", this.your_trigger_cards, enemy_skill_target, used_intercept_card)
// this.loadingDialog = true
console.log("LOG: Call a GraphQL mutation method to run Direct Lambda Resolver function (which located in serverside).")
const message = {
arg1: [{key: field_position, value: card_id }],
arg2: enemy_skill_target || 0,
arg3: [
{key: 1, value: this.your_trigger_cards[1] || 0},
{key: 2, value: this.your_trigger_cards[2] || 0},
{key: 3, value: this.your_trigger_cards[4] || 0},
{key: 4, value: this.your_trigger_cards[4] || 0},
],
arg4: used_intercept_card
}
const callProcess = {
type: 'put_card_on_the_field',
message: JSON.stringify(message),
playerId: this.player_id
}
await API.graphql({
query: createBCGGameServerProcess,
variables: { input: callProcess },
}).then((res) => {
console.log('LOG: GraphQL', res)
}).catch((err) => {
console.log('Error:', err)
})
// const transactionId = await this.$fcl.mutate({
// cadence: FlowTransactions.putCardOnField,
// args: (arg, t) => [
// arg([{key: field_position, value: card_id }], t.Dictionary({ key: t.UInt8, value: t.UInt16 })), // unit_card
// arg(enemy_skill_target || 0, t.UInt8), // enemy_skill_target
// arg([
// {key: 1, value: this.your_trigger_cards[1] || 0},
// {key: 2, value: this.your_trigger_cards[2] || 0},
// {key: 3, value: this.your_trigger_cards[4] || 0},
// {key: 4, value: this.your_trigger_cards[4] || 0},
// ], t.Dictionary({ key: t.UInt8, value: t.UInt16 })), // trigger_cards
// arg(used_intercept_card, t.Array(t.UInt8)) // used_intercept_card_positions
// ],
// proposer: this.$fcl.authz,
// payer: this.$fcl.authz,
// authorizations: [this.$fcl.authz],
// limit: 999
// })
// console.log(`TransactionId: ${transactionId}`)
this.show_game_dialog = false
this.turnChangeActionDone = true
this.checkTransactionComplete('putCardOnTheField')
},
GraphQL type has message, playerId, and type field, so that instead of calling onflow/fcl method, just replace it to GraphQL createBCGGameServerProcess query with these variables.
Next, Server-Side Implementation
In the server-side, GraphQL server will call the resolver function and it is described below in official webpage.
Query: {
human(obj, args, context, info) {
return context.db.loadHumanByID(args.id).then(
userData => new Human(userData)
)
}
}
You may think it’s kind of complicated, though, AppSync has the feature of Direct Lambda Resolver.
You can set just calling a Lambda function to response to GraphQL function calls, and this is actually very effective and simple.
1. Create a Lambda function
Create a Lambda function from scratch.
2. Write a Sample Code
Write return values which are same as type fields. It also needs id, createdAt and updatedAt if GraphQL call is the create mutation function. And press the Deploy button.
3. Open AppSync console and choose current API
Current API has been created by amplify push
command.
4. Press Data Sources from left pane and push Create data source button.
5. Copy the Lambda function name and paste to the top, and select AWS Lambda Function as Data source type.
Select Region same as Lambda function is deployed.
Select Lambda Function ARN. Role is same as it is, and press Create button.
Next, press Scheme tab from the left pane. And type “Mutation” inside the search bar.
6. Attach the data source to the GraphQL resolver
First, it is pipeline resolver mode, and this is not suited for the Lambda resource, so, push Actions button and select Update runtime.
Select Unit Resolver and press Update button
Choose the data source.
And finally press Save resolver.
Now, your resolver is look like this.
This is the whole process you have to do to ready the GraphQL server.
Do you think this is difficult? I believe you will not.
OK, now the last thing is just writing the transactions inside this lambda resolver. But before write transaction codes into the Lambda function it requires some dependency like @onflow/fcl, so download the Lambda code into the your computer, and access to the same folder with terminal, and command `npm install` to create node_modules folder. And after your code is ready, type below to zip the whole folder. Last thing is uploading to Lambda console.
zip -r ../src.zip *
And in my case, the resolver function is look like this:
const fs = require('fs');
const fcl = require("@onflow/fcl");
const t = require("@onflow/types");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { SHA3 } = require("sha3");
const FlowTransactions = {
matchingStart: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.matching_start(player_id: player_id)
}
execute {
log("success")
}
}
`,
gameStart: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32, drawed_cards: [UInt16]) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.game_start(player_id: player_id, drawed_cards: drawed_cards)
}
execute {
log("success")
}
}
`,
putCardOnField: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32, unit_card: {UInt8: UInt16}, enemy_skill_target: UInt8?, trigger_cards: {UInt8: UInt16}, used_intercept_positions: [UInt8]) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.put_card_on_the_field(player_id: player_id, unit_card: unit_card, enemy_skill_target: enemy_skill_target, trigger_cards: trigger_cards, used_intercept_positions: used_intercept_positions)
}
execute {
log("success")
}
}
`,
turnChange: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32, attacking_cards: [UInt8], enemy_skill_target: {UInt8: UInt8}, trigger_cards: {UInt8: UInt16}, used_intercept_position: {UInt8: [UInt8]}) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.turn_change(player_id: player_id, attacking_cards: attacking_cards, enemy_skill_target: enemy_skill_target, trigger_cards: trigger_cards, used_intercept_position: used_intercept_position)
}
execute {
log("success")
}
}
`,
startYourTurn: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32, blocked_unit: {UInt8: UInt8}, used_intercept_position: {UInt8: UInt8}) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.start_your_turn_and_draw_two_cards(player_id: player_id, blocked_unit: blocked_unit, used_intercept_position: used_intercept_position)
}
execute {
log("success")
}
}
`,
surrendar: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.surrendar(player_id: player_id)
}
execute {
log("success")
}
}
`,
claimWin: `
import CodeOfFlowAlpha6 from 0x9e447fb949c3f1b6
transaction(player_id: UInt32) {
prepare(signer: AuthAccount) {
let admin = signer.borrow<&CodeOfFlowAlpha6.Admin>(from: CodeOfFlowAlpha6.AdminStoragePath)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.claimWin(player_id: player_id)
}
execute {
log("success")
}
}
`,
}
exports.handler = async function (event) {
console.log("Event", JSON.stringify(event, 3))
const input = event.arguments?.input || {};
let player_id = input.playerId ? parseInt(input.playerId) : 0
let message = input.message ? JSON.parse(input.message) : {}
var KEY_ID_IT = 1
if (fs.existsSync('/tmp/sequence.txt')) {
KEY_ID_IT = parseInt(fs.readFileSync('/tmp/sequence.txt', {encoding: 'utf8'}));
}
try {
const client = new SecretsManagerClient({region: "ap-northeast-1"});
const response = await client.send(new GetSecretValueCommand({
SecretId: "SmartContractPK",
VersionStage: "AWSCURRENT",
}));
const EC = require('elliptic').ec;
const ec = new EC('p256');
fcl.config()
.put("accessNode.api", "https://rest-testnet.onflow.org")
// CHANGE THESE THINGS FOR YOU
const PRIVATE_KEY = JSON.parse(response.SecretString)?.SmartContractPK;
const ADDRESS = "0x9e447fb949c3f1b6";
const KEY_ID = 0;
const CONTRACT_NAME = "CodeOfFlowAlpha6";
const sign = (message) => {
const key = ec.keyFromPrivate(Buffer.from(PRIVATE_KEY, "hex"))
const sig = key.sign(hash(message)) // hashMsgHex -> hash
const n = 32
const r = sig.r.toArrayLike(Buffer, "be", n)
const s = sig.s.toArrayLike(Buffer, "be", n)
return Buffer.concat([r, s]).toString("hex")
}
const hash = (message) => {
const sha = new SHA3(256);
sha.update(Buffer.from(message, "hex"));
return sha.digest();
}
async function authorizationFunction(account) {
return {
...account,
tempId: `${ADDRESS}-${KEY_ID}`,
addr: fcl.sansPrefix(ADDRESS),
keyId: Number(KEY_ID),
signingFunction: async (signable) => {
return {
addr: fcl.withPrefix(ADDRESS),
keyId: Number(KEY_ID),
signature: sign(signable.message)
}
}
}
}
async function authorizationFunctionProposer(account) {
KEY_ID_IT = !KEY_ID_IT || KEY_ID_IT > 5 ? 1 : KEY_ID_IT + 1
fs.writeFileSync('/tmp/sequence.txt', KEY_ID_IT.toString());
return {
...account,
tempId: `${ADDRESS}-${KEY_ID_IT}`,
addr: fcl.sansPrefix(ADDRESS),
keyId: Number(KEY_ID_IT),
signingFunction: async (signable) => {
return {
addr: fcl.withPrefix(ADDRESS),
keyId: Number(KEY_ID_IT),
signature: sign(signable.message)
}
}
}
}
if (input.type === "player_matching") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.matchingStart,
args: (arg, t) => [
arg(player_id, t.UInt32)
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "game_start") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.gameStart,
args: (arg, t) => [
arg(player_id, t.UInt32),
arg(message, t.Array(t.UInt16))
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "put_card_on_the_field") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.putCardOnField,
args: (arg, t) => [
arg(player_id, t.UInt32),
arg(message.arg1, t.Dictionary({ key: t.UInt8, value: t.UInt16 })), // unit_card
arg(message.arg2, t.UInt8), // enemy_skill_target
arg(message.arg3, t.Dictionary({ key: t.UInt8, value: t.UInt16 })), // trigger_cards
arg(message.arg4, t.Array(t.UInt8)) // used_intercept_positions
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "turn_change") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.turnChange,
args: (arg, t) => [
arg(player_id, t.UInt32),
arg(message.arg1, t.Array(t.UInt8)), // attacking_cards
arg(message.arg2, t.Dictionary({ key: t.UInt8, value: t.UInt8 })), // enemy_skill_target
arg(message.arg3, t.Dictionary({ key: t.UInt8, value: t.UInt16 })), // trigger_cards
arg(message.arg4, t.Dictionary({ key: t.UInt8, value: t.Array(t.UInt8) })) // used_intercept_position
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "start_your_turn") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.startYourTurn,
args: (arg, t) => [
arg(player_id, t.UInt32),
arg(message.arg1, t.Dictionary({ key: t.UInt8, value: t.UInt8 })), // blocked_unit
arg(message.arg2, t.Dictionary({ key: t.UInt8, value: t.UInt8 })), // used_intercept_position
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "surrendar") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.surrendar,
args: (arg, t) => [
arg(player_id, t.UInt32),
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
} else if (input.type === "claim_win") {
const transactionId = await fcl.mutate({
cadence: FlowTransactions.claimWin,
args: (arg, t) => [
arg(player_id, t.UInt32),
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999
})
console.log(`TransactionId: ${transactionId}`)
message = `Transaction is On Going. TransactionId: ${transactionId}`
fcl.tx(transactionId).subscribe(res => {
console.log(res);
})
}
return {
id: new Date().getTime(),
type: input.type || "",
message: KEY_ID_IT + " : " + message,
playerId: player_id,
createdAt: new Date(),
updatedAt: new Date()
};
} catch (error) {
return {
id: new Date().getTime(),
type: input.type || "",
message: error.toString(),
playerId: player_id,
createdAt: new Date(),
updatedAt: new Date()
};
}
};
And your Server side coding for the GraphQL is completed.
Let’s test
Now, you don’t need to press Approve button on wallet popup.
And even though you don’t know how subscription work inside AppSync, but it (=GraphQL Subscription) actually works. So, you can create whatever network communication functions are. By telling opponent’s player that transaction is currently ongoing or so, even though you have not configured this to the resolver.
API.graphql({
query: onCreateBCGGameServerProcess,
}).subscribe({
next: (serverData) => {
console.log('BCGGameServerProcess SubscriptionData:', serverData)
},
})
And this is the end of article. Thank you very much for reading this❤️.
— -
Ads.
I have been publishing a book. It’s only 25.6 USD.
The essential reference book for blockchain game development!
It covers all object methods, including variable types, structs, arrays, and resources. It includes dApp development using GraphQL to communicate with the blockchain.
Here is a blockchain game developed using this reference.
This game, in fact, all the logic is running on the blockchain.
It’s a card game played against each other over the Internet, but it’s all handled through a smart contract.
The total length of the smart contract is 2,450 lines long.
Source code of the game itself is here -> https://github.com/temt-ceo/CODE-of-flow
This summarizes how to call the blockchain wallet from within the Flutter app, how to issue transactions from within the iPhone/Android app, and how to issue transactions from the server side.
With Jacob’s permission, a reference book containing the essence of Jacob’s videos is available for only 25.6 USD.