Finite Field
ZKP Secret Card Game Secured by Chainlink VRF
Created on 30th May 2025
•
Finite Field
ZKP Secret Card Game Secured by Chainlink VRF
The problem Finite Field solves
Chainlink VRF and zero knowledge proofs combine to solve a difficult problem in blockchain gaming: trustlessly generating values (such as cards) that are both secret and verifiably random.
Oracles can produce on-chain randomness, but naturally these values are public, and in blockchain gaming, these random values must be used as seeds in pseudorandom functions that are publicly known (to prevent cheating). Since the seed and function are both public, it is possible for anyone to simply calculate what the output will be, and secrecy is lost.
But there is a way to introduce sufficient uncertainty that we preserve the property of secrecy. If a Chainlink VRF value is hashed with a value picked from a fixed set of numbers, and the resulting hash used as a seed, an adversary will only be able to predict a range of possible hands, and they cannot know for sure which hand the player has.
And with a zero knowledge proof, we can enforce that the player:
- Used the Chainlink VRF value,
- combined with a local seed from the allowed set,
- to draw a set of cards from the allowed deck,
- without revealing which local seed was picked.
Put another way, we can achieve randomness that cannot be fully controlled by the player, and cannot be fully predicted by an adversary.
It's true that the player themselves can pick amongst all of the possible hands and choose the "optimal" hand before the game even starts. Which is why we introduce a second piece of the puzzle: using Chainlink VRF to generate the game objective (and therefore the scoring criteria) at the moment the game starts, after all players have committed the hash of their secret hands.
While the player can certainly influence which cards they pick, they cannot choose exactly what they want, and they also have no idea what the value of their hand will be until after the game begins.
And once the game starts, players can raise their bid, fold, or potentially introduce even more uncertainty to the game: by swapping out 2 of their cards.
Swapping initiates another Chainlink VRF call, which this time is not combined with a local seed. Rather, the result of this draw is completely random and completely public, and all players can see what is drawn.
Not only are these drawn cards public, there is a small chance of drawing a special card: the inverse card, which inverts the score of the player's entire hand. This introduces both risk for the swapping player, and uncertainty for the opponents: did that inverse card just turn a bad hand into a stellar one?
Completing the swap requires another zero knowledge proof (to update the on-chain hand hash), and at the end of the game, all remaining players must reveal their cards with another zero knowledge proof revealing their link to that on-chain hash.
These three Chainlink VRF calls, three zero knowledge proofs, and the rules of the smart contract create a competitive card game that is verifiably random, secret, and fair.
Challenges we ran into
This was my first project using circom and snarkjs, and the idiosyncrasies of circom took some getting used to (limited usage of "if", having to apply the modulus p to VRF values on-chain because they can sometimes exceed the size of the finite field, using intermediate signals to avoid non-quadratic constraints, using Poseidon hashes) but it was very satisfying to get all three ZKPs working together in concert with Chainlink VRF.
The circom compiler gives pretty good debugging information, and ChatGPT was very helpful in explaining why certain approaches just don't work.
Other builders have also solved some common problems (like how to properly pick something out of a set using modulo) and iden3's prebuilt templates (such as the comparators and poseidon templates) were all really helpful.
Solidity's custom errors were also indispensable for figuring out which part of the on-chain validation was failing.
Architecture
The game interface was built with Godot Engine and exported for the web. The interface uses the wallet's chosen RPC to read information from the chain, determine the player's state, and update itself accordingly.
Proofs are produced by snarkjs. All necessary javascript and zk files (snarkjs, the witness calculators, the zkeys and compiled circuit wasm files generated by circom) are embedded in the game's files and loaded into the browser window at runtime.
There are two primary contracts: the GameToken and the main game contract. Players first wrap ETH to buy GameTokens that are automatically deposited into the game contract. Tokens can be burned to withdraw the equivalent amount of ETH from the token contract.
Once they've deposited some tokens, players are eligible to participate in games.
The "player state" is mapped to the player's address and the token address, and is overwritten each time the player completes a game using that token. The "game state" is mapped to the game id number and updated over the course of the game in response to player actions and VRF callbacks.
Chainlink VRF requests are made using the direct funding method, paid in ETH. The fee is overestimated and excess is returned to the caller in the same transaction.
The three ZK verification contracts (generated by circom) were deployed separately and plugged into the main game contract.
The lifecycle of a typical game:
-
Players each purchase a Chainlink VRF seed, combine this with a secret seed, and commit their hand by passing a ZKP validating a hand hash. When purchasing a VRF seed, they all commit to the same GameToken address and the same ante.
-
Someone initiates the game. Note that the startGame function is a public function callable by anyone, and could be called by a matchmaking bot or server. This function initiates a VRF call to determine the game's scoring criteria.
-
Games have a "maximum spend" that locks a certain quantity of tokens for the duration of the game, ensuring all bids are fully funded.
-
The VRF callback starts the game, setting the initial timestamp. Players may submit actions (folding, raising, or swapping) up to the 4 minute time limit. Swapping initiates another VRF call, and the returned value must be used in another ZKP to prove the swap and update the on-chain hand hash.
-
After 4 minutes, any players who have not folded will have their bids automatically raised to match the high bid. Players then have 6 minutes to prove their hand by submitting another ZKP. Hands are scored according to the game session's randomly chosen criteria.
-
After the full 10 minute limit has elapsed, anyone (including a bot) can call the concludeGame function, which distributes the pot to all winners and clears the player state for anyone who has not yet folded or proved their hand.
Future Work
For future development, the matchmaking and conclusion of games could be automated by a bot. There is also room to experiment with commits of a "local seed hash" when requesting VRF for a hand or a swap. This hash (a randomly picked secret number combined with some obscuring noise) would allow us to enforce the usage of that local seed with a ZKP.
This would create an entirely new variant of the game, where players would have no ability to influence the composition of their hand (as they must use the secret local seed they have committed when calling VRF) and would also eliminate the ability to predict the opponents' range of hands, due to the vast number of possible seeds.
Alternative scoring systems and decks could also be used in this framework to create additional variants and entirely new games.
Tracks Applied (1)
Onchain Finance
Technologies used
