A 1v1 NFT strategy battler, built using Chainlink Functions, VRF, and Automation. It features random, secret hands drawn by Chainlink Functions, which are revealed only to the player, and no one else, using only the chain, the oracle, and the DON-hosted secrets gateway. A local Fuji node is used to connect to the blockchain.

Inspiration

Imagine a game where there is a secret set of coordinates, and the player receives a hint about the correct location every time they make a guess. Or a card game, where each player draws a random, secret hand, which must be kept secret from other players.

In both of these cases, randomness is generated in a single event, the randomness is temporarily kept secret, and is referred to later whenever the player makes decisions.

Chainlink VRF is excellent for producing cryptographically secure random values. And for many applications it already performs just fine out of the box.

But there is one problem I was hoping to solve: Chainlink VRF values are public as soon as they hit the chain. So while they are random, that randomness is visible to everyone. Which is part of the goal - they are verifiably random. Is there a way this property could be preserved, while still maintaining secrecy?

Chainlink Functions provides a solution. By using the new DON-hosted secrets gateway and a secret AES CSPRNG key known only to the oracle, the Functions DON can come to consensus on a pseudorandom value, while preventing on-chain observers from predicting what that pseudorandomness will be.

What it does

Time Crystal is a complete PvP strategy game. A player registers with the contract by minting a dNFT and obtaining seeds from VRF. They'll expend a seed to request a random, secret hand from Chainlink Functions. The Functions callback asks Chainlink Automation to push the player into the matchmaking queue. Once the game is over, Chainlink Automation will evaluate the gameplay and update both players' dNFTs.

The process starts with registration. Locally, the player generates their own AES key, and encrypts this with a DON-controlled RSA public key. The player's encrypted key is uploaded on-chain with transferAndCall, which in the same transaction will mint a Time Crystal dNFT, call VRF to generate some seed values, and transfer some LINK up-front to cover the cost of a few matches.

After receiving the seed values, the player is ready to join the matchmaking queue. They will encrypt a secret passphrase with their AES key, and pass this to the DON, along with a VRF-generated seed and new iv.

The DON will use its private RSA key to decrypt the player's AES key, and then decrypt the secret passphrase. It will pass the seed and the iv to its own secret AES CSPRNG key, generating randomness several times, which it will use to draw 5 random cards from a deck. These cards are concatenated with the secret passphrase, and finally hashed.

The oracle commits the SHA256 hash to the chain. No observer can possibly know which cards are contained in that hash, except for one person: the player, who provided the secret password.

To extract the cards, the player locally runs a "birthday attack" against the hash. Because the player knows the password, and knows that the hash contains 5 random cards, their game client will generate all possible combinations and hash them until it collides with the oracle-produced hash.

Meanwhile, Chainlink Automation will have pushed the player into the matchmaking queue. As soon as another player is also ready to play, the match will begin.

The 5 random cards are the abilities of an "entity" that has entered the player's Time Crystal dNFT. The object is to use these abilities to defeat the opposing player's entity, by reducing its HP to zero.

Gameplay proceeds with a commit-reveal turn scheme. Both players must commit the hash of their move, and then reveal the hash. In this way, both moves execute simultaneously. Since the hands produced by Chainlink Functions are secret, neither player knows what the other player has, which leads into a strategic triangle:

Players might try to play cautiously, but their opponents might punish that with greedy gameplay, building resources to play powerful cards later on. Or, a greedy resource player could get punished by an aggressive player, who favors cheap attacking cards. But an overly aggressive player would be shut down by a patient defensive player.

Rather than validating every move as it happens, the contract only checks that 1) the supplied card matches the committed hash, and 2) the card has a valid mapping in the contract. After this, it just concatenates the card onto an "action string."

Only at the end of the game do the two "action strings" go to Chainlink Automation, which evaluates them with some on-chain logic, performing many sequences of operations to check that the winner's cards were actually in their hand, and that they did enough damage to reduce their opponent's HP to zero. This efficiency is entirely dependent on the Log Trigger and secure forwarder.

Finally, after Automation determines the outcome of the game, it updates both players' Time Crystal dNFTs. Both are awarded experience, and the winning player drains half the energy of their opponent's Crystal.

These parameters are part of the Crystal's metadata, as are its banked VRF seeds. The Crystal has to be staked to use the seeds. However, once unstaked, the Crystals are fully transferrable, seeds included.

How we built it

The game was built in Godot, using Godot Rust and ethers-rs to format transaction calldata and perform encryption. A local Fuji node is used as the RPC. I built the contract in Remix, and used Hardhat and the Functions starter kit to deploy it on-chain. I made ample use of the Chainlink Services dApp for testing. Blender, Krita, and Stable Diffusion were used to create the art assets.

Challenges we ran into

There were quite a few! The biggest challenge was working with the various limitations of Functions (32 byte return limit, single type return, deno standard library) and keeping everything interoperable between GDScript <> Rust <> Solidity <> JavaScript, while still demonstrating the ideas of oracle key exchange and secret randomness.

The other big challenge was keeping the game in a reasonable scope. About halfway through development, I realized that my design simply was too gas inefficient to be feasible, which required revamping how the game worked.

I wanted to make as complete a game as possible, and while I think it could use some improvements, I'm pleased that I was able to produce a full game.

A detailed writeup can be found in my Devblog. I don't expect anyone to go through the whole thing, but it describes my process, challenges, and decisions from start to finish. It includes pictures and videos. If you have an hour or so after the hackathon is over, you might enjoy it.

Accomplishments that we're proud of

I'm very happy with the "birthday attack" mechanism for extracting secret values from the oracle-committed hash, which allows the player to prove the validity of their hand against the hash.

I'm also proud to have successfully run the game using a local RPC. The speed and fluidity of transactions is a testament to what is possible for future applications with integrated blockchain clients.

What we learned

I learned a lot about the different cryptographic primitives, and their strengths and weaknesses. I improved my Javascript, tried some new ideas with my Godot UI, and learned a bit more about Blender modeling.

What's next for Time Crystal

There are a few problems that need to be solved.

First and foremost is the security of the AES and RSA keys used for passing game secrets. While the current design protects those secrets from almost everyone, there's still a single point of failure.

Since I am the uploader of these keys, I know what they are, and I could use them to decrypt any player's secrets. That isn't secure.

As far as the AES CSPRNG key is concerned, I believe this could be solved by designing an "oracle key ceremony", where several oracles each upload pieces of the key to the gateway, such that no single oracle has access to the whole key during the creation process.

Because the DON gateway operates using threshold encryption, as long as the key arrives there in a decentralized way, no one will have knowledge of the full key, and it should be secure for the purpose of generating secret randomness.

The current implementation is forced to use the SHA-1 hash for its RSA encryption, which is not secure, and needs to be replaced with a more modern hashing scheme.

I'd like to try experimenting with a subnet and HyperSDK, to see how precompiles might improve performance, and explore whether it's possible to create a "light client" just for my subnet.

Finally, there could be improvements to both the strategic and economic aspects of the game.

Credits

Godot

Godot Rust

Ethers-rs

AllSky Skybox by rpgwhitelock

Star Nest Shader by Lyagva, ported from Kali's work on Shadertoy

Rainbow Shader by Exuin

Ice Shader by NekotoArts

FPS Controller by Garbaj

Link-chan and Avax-chan

  -Design: Anonymous artists

  -Art: Stable Diffusion

  Model: Kohaku-XL by kblueleaf

  LoRA: Pixel Art XL by NeriJS

Built With

Share this project:

Updates