This repo originated as an proof of concept onchain Rock Paper Scissors game as an exploration in blockchain gaming powered by Cadence on Flow. Through that process, the UX hurdles faced by all onchain games - wallet onboarding, in-game transaction signing, etc. - revealed there was no way to create a meaningfully onchain game that was also user-friendly and permissionlessly composable. Consequently, this repo served as a sandbox to prototype Hybrid Custody and make account linking a reality.
The contents in this repo are the onchain components powering the Walletless Arcade.
In addition to onchain gaming, you'll find a number of novel Cadence implemenations including:
As gaming makes its way into Web 3.0, bringing with it the next swath of mainstream users, we created this repo as a playground to develop proof of concept implementations that showcase the power of onchain games built with the Cadence resource-oriented programming language. Through this exploration, we discovered the importance of improving onboarding and reducing in-app UX friction, and so iterated our way to an initial hybrid custody model. It's our hope that the work and exploration here uncovers unique design patterns that are useful towards composable game designs and, more broadly, novel custody models, helping to pave the way for a thriving community of developers building the best apps in the world on Flow.
For our first proof of concept game, we've created the RockPaperScissorsGame
and supporting contract GamingMetadataViews
.
As a learning ground for upcoming Cadence Attachments, we wanted to demonstrate how NFT metadata could update with gameplay in an entirely separate contract than the NFT was defined. So we created DynamicNFT
which contains interfaces for attachments & receivers for those attachments.
The attachment receiver portion of that model is implemented in GamePieceNFT
(modeled heavily after MonsterMaker
) and the attachments are implemented in RockPaperScissorsGame
.
Taken together with any NFT implementing DynamicNFT
, these contracts define an entirely onchain game with a dynamic NFT that accesses an ongoing record of its win/loss data.
:information_source: While Hybrid Custody contracts & transactions are included in this repo, they're not critical to the core gameplay logic. It's recommended that readers focus on understanding the game-related contracts and components and layer on the process of account linking in the context of the application user flow.
Players engage in single or two-player single round matches of RPS. After configuring their GamePlayer
resource, users can start a match by escrowing a GamePieceNFT
. The match is playable once both players have escrowed their NFTs (or after the first player to escrow if in single player mode). The escrowed NFT gets an attachment enabling retrieval of its win/loss record and another that maintains the playable moves for the game - rock, paper, and scissors, as expected.
:information_source: NFTs are escrowed into matches to ensure the NFT provided is a) actually owned by the player and b) players can't bypass updates to their NFT's win/loss record.
Once playable, the match proceeds in stages - commit and resolve (to be replaced by a commit-reveal pattern to obfuscate onchain moves). Players must first commit their moves. After both players have submitted moves, the match can be resolved. On resolution, a winner is determined and the associated NFT's win/loss record is amended with the match results.
Of course, once the match is over (or if a timeout is reached without resolution) the escrowed NFTs can then be returned to their respective escrowing players.
Things get much more interesting when the onchain game is coupled with account linking - seen in this demo - allowing for embedded wallets to abstract transaction execution in-app while unlocking those in-app assets to be accessible by a user's primary wallet. To facilitate a fuller game experience in said app, TicketToken
was introduced as a player reward for winning matches, just like you'd win in an real arcade!
The accompanying TicketToken
and ArcadePrize
contracts aren't special in and of themselves - simple FT and NFT contracts. However, once a user links their wallet with the app account used to play the game - the account issued TicketToken
when the player wins a match - the authenticated account is issued access to the app account. This link between accounts exists onchain and establishes what we'll call a "parent-child" hierarchy between user accounts where the user's wallet mediated account is the "parent" to the partitioned "child" account.
:information_source: This setup introduces account models similar to Web2's app authorization into our decentralized Web3 context. Linked child accounts can be thought of as sub-accounts to the parent, allowing both the user and the custodial party (game app in this case) to maintain access on the app-managed account.
After linking, the user can authenticate in other unrelated apps with their wallet, and any app recognizing Hybrid Custody accounts can identify all associated child accounts, their contents, and facilitate transactions interacting with child-account custodied assets with a transaction signed by the parent account alone.
To demonstrate this, ArcadePrize
accepts TicketToken
redemption for minting NFTs. Redeeming FTs for NFTs isn't new, but the ability to sign a transaction with one account and, using delegated account access, acquire funds from another to mint an NFT to the signing account is new, and it solves an enormous problem in the Web3 app experience.
GamingMetadataViews - Defining the metadata structs relevant to an NFT's win/loss data and assigned moves as well as interfaces designed to be implemented as attachments for NFTs. These interfaces enable the implementing contracts to alter values associated with data on an NFT's attachments via limited access control, a useful feature for smart contract game development, among other use cases.
RockPaperScissorsGame - As you might imagine, this contract contains the game's moves, logic as well as resources and interfaces defining the rules of engagement in the course of a match. Additionally, receivers for Capabilities to matches are defined in GamePlayer
resource and interfaces that allow players to create matches, be added and add others to matches, and engage with the matches they're in. The Match
resource is defined as a single round of Rock, Paper, Scissors that can be played in either single or two player modes, with single-player modes randomizing the second player's move on a contract function call.
DynamicNFT
, featuring png's seen elsewhere in Flow demos, and used to demonstrate NFT escrow in RockPaperScissorsGame
gameplay.ArcadePrize
NFTsTicketToken
redemption. An interesting note, you can redeemContracts enabling account linking are not critical to the gameplay, but layer nicely to create a smooth in-app experience. While understanding the Hybrid Custody contracts is useful, it's recommended you focus on understanding the game-related contracts and components before layering on these mental constructs if you're solely intersted in understanding the mechanics of the onchain gaming.
:information_source: For more info on the parts making up
HybridCustody
, check out these docs
Taking a look at RockPaperScissorsGame
, you'll see that it stands on its own - a user with any DynamicNFT
can engage with the game to play single and multiplayer matches. The same goes for TicketToken
and GamePieceNFT
contracts in that they are independent components not necessarily designed to be used together.
Each set of contracts were created as composable building blocks and used together to create a unique app experience.
HybridCustody
was layered on to solve both the app UX problem created by the current dichotomous app- v. self-custody paradigms and to give users access to in-app assets outside the context of the custodial app.
Optimizing for openness and interoperability is not without its tradeoffs.
The game contract's use of any NFT maximizes composability, but it means that NFTs with the same ID cause collisions in the win/loss record mapping indexed on escrowed NFT IDs. This shouldn't be an issue for NFTs that assign ids on UUIDs, but users could experience a case where they effectively share a win/loss record with another NFT of the same ID. This could be handled by indexing on the hash of an NFT's ID along with its Type which should yield a unique value or alternatively, the NFTs UUID. The latter would be a harder ask as it's unlikely a requestor would have the NFT's UUID on hand if it's not already the equivalent to its ID. In the end, the contract indexes on UUID.
A bit of a note on best practices...it's evident that defining onchain game logic must involve adversarial thinking. For example, we could have (and did at one point) return NFTs from escrow on match resolution to remove the need for an additional function call. However, we discovered that a malicious player could technically prevent Match
resolution (a custom Receiver
could panic
on deposit()
). This along with the revelation that a malicious player could also assure game outcomes with the afforementioned post-condition on match results led us to the commit-resolve pattern you see in the contracts & transactions.
With the context and components explained, we can more closely examine how they interact in an end-to-end user experience. For simplicity, we'll assume everything goes as it's designed and walk the happy path.
With linked accounts, there are two ways a user can onboard.
đ "Wallet-less" onboarding - First, a app can onboard a user with Web2 credentials, creating a Flow account for the user and abstracting away key management.
đ "Blockchain-native" onboarding flow - Second, a user native to the Flow ecosystem can connect their wallet and start the app experience with controll over the app account. In our version, the app will still abstract key management, but will additionally delegate control over the app account to the user's authenticated account via AuthAccount Capabilities.
After a user authenticates via some traditional Web2 authentication mechanism, the app initiates walletless onboarding
A new public/private key pair is generated
Providing the generated public key, initial funding amount, and MonsterComponent
NFT attributes, the walletless onboarding transaction starts by creating a new account from the signer's AccountCreator
resource.
:warning: Note that any old account creation mechanism can be used in your walletless onboarding flow, but this resource allowed us to query created addresses from custodied keys.
A GamePieceNFT
Collection is configured in the new account
The signer mints a GamePieceNFT
to the new account's Collection
A GamePlayer
resource is configured in the new account so it can play RockPaperScissorsGame.Match
es
A TicketToken.Vault
is saved & linked in the new account
After a user's wallet has been connected, run the blockchain-native multisig onboarding transaction signed by both a developer account & the user. Note that this would require a backend account pre-configured with an AccountCreator
& funded with FLOW to pay for new account creation, though account creation can be handled by many other mechanisms.
This onboarding transaction does the following.
GamePieceNFT
CollectionGamePlayer
resourceTicketToken
Vault in the new accountGamePieceNFT.Collection
in the user's connected accountTicketToken.Vault
in the user's connected accountHybridCustody.OwnedAccount
resource in the new accountHybridCustody.Manager
in the user's accountGamePieceNFT
to the new account's Collection
HybridCustody.Manager
:information_source: The output of this transaction is a new account, custodied by the key provider, fully configured for gameplay, and linked to the signing user's account as a child account. Both the user and the application have access on the new account - the user via
HybridCustody
and the app via key custody.
Since a match timeout is specified upon Match
creation and retrieval of NFT
s is contingent on either the timeout being reached or the Match
no longer being in play, a player can easily retrieve their NFT
after timeout by calling returnPlayerNFTs()
on their MatchPlayerActions
Capability.
Since this Capability is linked on the game contract account which (in an actual stakes environment) shouldn't have active keys, the user can be assured that the Capability will not be unlinked. Additionally, since the method deposits the NFT
to the Receiver
provided upon escrow, they can be assured that it will not be accessible to anyone else calling returnPlayerNFTs()
.
Receiver
Capability before the NFT could be returnedIn this edge case, the Receiver
Capability provided upon escrowing would no longer be linked to the depositing playerâs Collection
. In this case, as long as the escrowing player still has their GamePlayer
, they could call retrieveUnclaimedNFT()
, providing a reference to their GamePlayerID
and the Receiver
they want their NFT returned to.
This wouldn't be encountered by the Match
until returnPlayerNFTs()
is called after match resolution. Depending on the order of the Receiver
Capabilities in the nftReceivers
mapping, this could prevent the other player from retrieving their NFT via that function. At that point, however, the winner & loser have been decided and the game is over (inPlay == false
). The other player could then call retrieveUnclaimedNFT()
to retrieve the NFT that the trolling Receiver was preventing from being returned.
In the event a player changes their mind after creating a match, they'd currently have to wait the length of timeout to call returnPlayerNFTs()
. Changing this behavior is scoped as a future improvement to enable abandoning a match before initiating gameplay, likely only to be updated in singleplayer mode matches.
To demo the functionality of this repo, clone it and follow the steps below by entering each command using Flow CLI from the package root:
Start the emulator & deploy the contracts
In one terminal window, run:
flow emulator
In another terminal window, run the setup script creating our accounts & deploying contracts:
sh setup.sh
Lastly, a HybridCustody pre-requisite includes setting up a Capability Filter
and Capability Factory Manager
. These ensure that parent account access is scoped to just the Capabilities they need to interact with the assets we as the developer want them to access. For more info on CapabilityFilter
& CapabilityFactory
, read these docs.
flow transactions send ./transactions/hybrid_custody/dev_setup/setup_filter_and_factory_manager.cdc \
045a1763c93006ca GamePieceNFT 045a1763c93006ca TicketToken --signer emulator-game
Generate public/private key pair
flow keys generate
Initialize walletless onboarding
onboarding/walletless_onboarding
pubKey: String,
fundingAmt: UFix64,
monsterBackground: Int,
monsterHead: Int,
monsterTorso: Int,
monsterLeg: Int
flow transactions send transactions/onboarding/walletless_onboarding.cdc <PUBLIC_KEY> <FUNDING_AMT> <BACKGROUND> <HEAD> <TORSO> <LEG> --signer emulator-game
Query for new account address from public key
child_account/get_child_address_from_public_key_on_creator: Address
creatorAddress: Address
pubKey: String
flow scripts execute scripts/account_creator/get_address_from_pub_key.cdc 045a1763c93006ca <PUBLIC_KEY>
Add the child account to your flow.json (assuming following along on flow-cli)
"accounts": {
"emulator-account": {
"address": "f8d6e0586b0a20c7",
"key": "<EMULATOR_ACCOUNT_PRIVATE_KEY>"
},
"child": {
"address": "01cf0e2f2f715450",
"key": "<CHILD_PRIVATE_KEY>"
}
}
Query for NFT.id
game_piece_nft/get_collection_ids: [UInt64]
address: Address
flow scripts execute scripts/game_piece_nft/get_collection_ids.cdc <CHILD_ADDRESS>
Query for GamePlayer.id
rock_paper_scissors_game/get_game_player_id: UInt64
playerAddress: Address
flow scripts execute scripts/rock_paper_scissors_game/get_game_player_id.cdc <CHILD_ADDRESS>
Setup a new singleplayer Match
rock_paper_scissors_game/game_player/setup_new_singleplayer_match
submittingNFTID: UInt64
matchTimeLimitInMinutes: UInt
flow transactions send transactions/rock_paper_scissors_game/game_player/setup_new_singleplayer_match.cdc <NFT_ID> <TIME_LIMIT> --signer child
Query Match.id
NewMatchCreated
event filtered on creatorID == GamePlayer.id
rock_paper_scissors_game/get_matches_in_play: [UInt64]
address: Address
flow scripts execute scripts/rock_paper_scissors_game/get_matches_in_play.cdc <CHILD_ADDRESS>
Submit moves for the Match
rock_paper_scissors_game/game_player/submit_both_singleplayer_moves
matchID: UInt64
move: UInt8
flow transactions send transactions/rock_paper_scissors_game/game_player/submit_both_singleplayer_moves.cdc <MATCH_ID> <MOVE> --signer child
Resolve Match
& return escrowed NFTs
rock_paper_scissors_game/game_player/resolve_match_and_return_nfts
matchID: UInt64
flow transactions send transactions/rock_paper_scissors_game/game_player/resolve_match_and_return_nfts.cdc <MATCH_ID> --signer child
Query move history for both players one of a number of ways:
Listen for MatchOver
event filtered on matchID == Match.id
and map userâs GamePlayer.id
to player1ID
or player2ID
in the event values, displaying the player1MoveRawValue
and player2MoveRawValue
as appropriate
rock_paper_scissors_game/get_match_move_history: {UInt64: RockPaperScissorsGame.SubmittedMove}?
matchID: UInt64
flow scripts execute scripts/rock_paper_scissors_game/get_match_move_history.cdc <MATCH_ID>
rock_paper_scissors_game/get_match_move_history_as_raw_values: {UInt64: UInt8}?
matchID: UInt64
flow scripts execute scripts/rock_paper_scissors_game/get_match_move_history_as_raw_values.cdc <MATCH_ID>
Query playerâs NFT win/loss record
game_piece_nft/get_rps_win_loss: GamingMetadataViews.BasicWinLoss?
address: Address
id: UInt64
flow scripts execute scripts/game_piece_nft/get_rps_win_loss.cdc <CHILD_ADDRESS> <NFT_ID>
There are two ways to go about this process. One involves a multi-signature transaction where both the existing app account (soon to be âchildâ account) and the userâs main account (soon to be âparentâ account) sign a transaction in which all changes are made. Another approach is to have the app account sign a transaction publishing its AuthAccount capability to then be claimed by the userâs account in a subsequent transaction.
For both the following transaction, you'll want to create an account if following along in flow-cli
flow accounts create # account name: parent | network: emulator
:information_source: Depending on your use case and custodial architecture, you may find it easier to configure publish & claim; however, both linking modalities are included for illustration.
At the end of either process, the two accounts are linked by resource representation onchain and both are configured such that the app has all it needs to play the game on behalf of the player. The userâs main account (AKA parent account) maintains a Capability on the app account (AKA child account) via HybridCustody
components, allowing the player to access in-app assets while the app maintains signing authority on behalf of the user when playing in-game.
Generate public/private key pair
flow keys generate
Initialize blockchain-native onboarding, signing as both the authenticated user and the application's backend account.
onboarding/blockchain_native_onboarding
pubKey: String
fundingAmt: UFix64
factoryAddress: Address
filterAddress: Address
minterAddress: Address
flow transactions execute transactions/onboarding/blockchain_native_onboarding.cdc \
<PUBLIC_KEY> 0.5 0x045a1763c93006ca 0x045a1763c93006ca 0x045a1763c93006ca \
--proposer emulator-game --payer emulator-game --authorizer parent --authorizer emulator-game \
--filter payload --save blockchain_native_onboarding.rlp
flow transactions sign blockchain_native_onboarding.rlp --signer parent --signer emulator-game --filter payload --save blockchain_native_onboarding.rlp
flow transactions send-signed blockchain_native_onboarding.rlp
Query for new account address from public key
account_creator/get_address_from_pub_key.cdc
creatorAddress: Address
pubKey: String
flow scripts execute scripts/account_creator/get_address_from_pub_key.cdc 045a1763c93006ca <PUBLIC_KEY>
Based on Match results (queried above in game_piece_nft/get_rps_win_loss
) and checked against the user's GamePlayer.id
(queried in rock_paper_scissors_game/get_game_player_id
), weâll want to mint tokens to the child accountâs TicketToken.Vault
. These tokens can be redeemed for an ArcadePrize.NFT
later in the demo.
Mint tokens to the playerâs app account
ticket_token/mint_tokens
recipient: Address
amount: UFix64
flow transactions send transactions/ticket_token/mint_tokens.cdc <CHILD_ADDRESS> <AMOUNT>
Query the balance of tokens in the account
ticket_token/get_balance: UFix64
- panics if Vault is not configured
of: Address
flow scripts execute scripts/ticket_token/get_balance.cdc <CHILD_ADDRESS>
In this section, weâll use the TicketToken.Vault in the child account to pay for an NFT to the signing accountâs Collection. This serves as an example for how a app can present and utilize the assets in a connected accountâs child account(s), creating a seamless experience compared to the fragmented UX previously inherent to isolated app accounts.
Query for the TicketToken.Vault.balance in each of the userâs child accounts
ticket_token/get_all_account_balances_from_storage: {Address: UFix64}
parentAddress: Address
flow scripts execute scripts/ticket_token/get_balance_of_all_child_accounts.cdc <PARENT_ADDRESS>
hybrid_custody/get_all_vault_bal_from_storage: {Address: {Type: UFix64}}
address: Address
flow scripts execute scripts/hybrid_custody/get_all_vault_bal_from_storage.cdc <PARENT_ADDRESS>
// Where VaultInfo has the following interface
pub struct VaultInfo {
pub let name: String?
pub let symbol: String?
pub var balance: UFix64
pub let description: String?
pub let externalURL: String?
pub let logos: MetadataViews.Medias?
pub let storagePathIdentifier: String
pub let receiverPathIdentifier: String?
pub let providerPathIdentifier: String?
pub fun addBalance(_ addition: UFix64)
}
Query for all publicly accessible NFTs in the connected account & its child accounts
hybrid_custody/get_all_nft_display_views_from_storage: [NFTData]
address: Address
flow scripts execute scripts/hybrid_custody/get_all_nft_display_views_from_storage.cdc <PARENT_ADDRESS>
// Where NFTData has the following interface
pub struct NFTData {
pub let name: String
pub let description: String
pub let thumbnail: String
pub let resourceID: UInt64
pub let ownerAddress: Address?
pub let collectionName: String
pub let collectionDescription: String
pub let collectionURL: String
pub let collectionStoragePathIdentifier: String?
pub let collectionPublicPathIdentifier: String
}
Mint a rainbow duck for 10.0 TicketTokens, redeeming the TicketTokens in the userâs child account & minting to the signerâs Collection
arcade_prize/mint_rainbow_duck_paying_with_child_vault
fundingChildAddress: Address
minterAddress: Address
flow transactions send transactions/arcade_prize/mint_rainbow_duck_paying_with_child_vault.cdc <CHILD_ADDRESS> 045a1763c93006ca --signer parent
Again query for all publicly accessible NFTs in the connected account & its child accounts to see the NFT that was minted among all of the userâs owned NFTs
hybrid_custody/get_all_nft_display_views_from_storage: [NFTData]
address: Address
flow scripts execute scripts/hybrid_custody/get_all_nft_display_views_from_storage.cdc <PARENT_ADDRESS>
The contracts in this repos have been deployed to
If you want to play this game on testnet in a fully fledged Hybrid Custody app, check out our demo implementation here.
As for good old fashioned self-custody, while you won't be able to perform TicketToken minting, you can play RockPaperScissors Matches using your own wallet and NFTs. You could however use your own NFTs to engage with the contracts via Flow CLI, FlowRunner or Raft. Here's how:
Onboard your account with GamePieceNFT Collection, NFT, & GamePlayer resource
onboarding/self_custody_onboarding.cdc
minterAddress: Address
flow transactions send transactions/onboarding/self_custody_onboarding.cdc 917b2b1dafdcfa58 --signer <YOUR_ACCOUNT_NAME> --network testnet
After you have a Collection, NFT, & GamePlayer configured, you're ready to play the game!
rock_paper_scissors_game/game_player/setup_new_singleplayer_match.cdc
submittingNFTID: UInt64
matchTimeLimitInMinutes: UInt
flow transactions send transactions/rock_paper_scissors_game/game_player/setup_new_singleplayer_match.cdc <NFT_ID> <MATCH_TIMEOUT> --signer <YOUR_ACCOUNT_NAME> --network testnet
Submit your move & the randomized second player's move
rock_paper_scissors_game/game_player/submit_both_singleplayer_moves.cdc
matchID: UInt64
move: UInt8
- 0: rock, 1: paper, 2: scissors
flow transactions send transactions/rock_paper_scissors_game/game_player/submit_both_singleplayer_moves.cdc <MATCH_ID> <MOVE> --signer <YOUR_ACCOUNT_NAME> --network testnet
Resolve the Match & return your NFT. Note that resolution needs to occur at least one block from when the last move was submitted.
rock_paper_scissors_game/game_player/resolve_match_and_return_nfts.cdc
matchID: UInt64
flow transactions send transactions/rock_paper_scissors_game/game_player/resolve_match_and_return_nfts.cdc <MATCH_ID> --signer <YOUR_ACCOUNT_NAME> --network testnet
Query the moves played for the Match
rock_paper_scissors_game/get_match_move_history: {UInt64: RockPaperScissorsGame.SubmittedMove}?
matchID: UInt64
flow scripts execute scripts/rock_paper_scissors_game/get_match_move_history.cdc <MATCH_ID> --network testnet
You can additionally query your NFT's win/loss record
game_piece_nft/get_rps_win_loss: GamingMetadataViews.BasicWinLoss?
address: Address
id: UInt64
flow scripts execute scripts/game_piece_nft/get_rps_win_loss.cdc <YOUR_ADDRESS> <NFT_ID> --network testnet