Build an Encrypted Wordle Game Onchain using FHE and Zama's fhEVM

February 29, 2024
The Zama Team

One challenge of the Zama Bounty Program Season 4 was to create an onchain game that keeps private states hidden using Zama's fhEVM programmable privacy. Github user kroist successfully completed this bounty, and this blog post is based on his contribution. Curious about the Zama Bounty Program? Learn more on Github.

The programmable privacy of the fhEVM is particularly beneficial for applications like games, which are typically multi-party interaction systems where privacy is crucial. Thus, we tasked the community to create a web3 game leveraging encrypted onchain states thanks to Zama’s fhEVM and Fully Homomorphic Encryption (FHE). We were thrilled to receive a multitude of entries, all of which were evaluated based on three key aspects:

  • Integration of Zama's fhEVM.
  • The quality of the code.
  • And the entertainment value, as we mustn't forget the essence of gaming.

Choosing a winner proved to be a challenging task given the high quality of the entries submitted, but here are the three winning projects alongside their implementation:

This blog post will shine a spotlight on "FHEordle" an innovative take on the popular Wordle game leveraging FHE.

Wordle is a word puzzle game where players guess a five-letter word within a limited number of attempts, receiving hints after each guess that indicate the accuracy and placement of their chosen letters. The game concludes when the word is correctly guessed or the maximum attempts are exhausted.

In this version, players engage in the familiar task of guessing a five-letter word but within a blockchain-based environment. It's important to note that in a blockchain setting, all data is public. Therefore, storing the word for players to guess directly onchain would compromise the game's integrity, as it would be publicly visible. While encrypting the word is a possibility, without Fully Homomorphic Encryption (FHE), verifying the correctness of guesses without revealing the encryption remains a challenge.

Put simply, hosting the Wordle game onchain without FHE would not be viable.

Zama’s fhEVM introduces a new realm of possibilities for onchain applications. This season's winner tackled this challenge with a novel design, not just utilizing FHE's capabilities, but also elevated the experience by providing hints based on encrypted integers and employing a 26-bit mask for feedback, skillfully avoiding the use of traditional loops. 

Let's delve into how 'FHEordle' is redefining encrypted web3 gaming standards.

A clever design avoiding unnecessary loops

The word players attempt to guess is encrypted and represented by five unsigned integers, each corresponding to a letter. A notable challenge in this game is providing feedback on letter guesses without resorting to traditional loops for checking each letter. The solution? The developer came up with a clever method: using a 26-bit mask to indicate which letters are present in the word. Take, for instance, the word [.c-inline-code]quick[.c-inline-code].

This technique generates a mask like [.c-inline-code]00000100010000010100000100[.c-inline-code], where each [.c-inline-code]1[.c-inline-code] represents the presence of a specific letter in the word according to its position in the alphabetical order. This allows for an efficient check of any letter's existence by simply looking at the corresponding bit in the mask.To generate this mask, for every letter we generate a corresponding mask. Then, we merge these single letter masks with bit OR operators to get the complete mask.

euint8[5] private word1Letters; // All 5 encrypted letters
    euint32 private word1LettersMask; // The generated mask
    uint32 public word1; // will be set when the word is revealed
    bool public wordSubmitted;
    bool public gameStarted;

    function submitWord1(euint8 l0, euint8 l1, euint8 l2, euint8 l3, euint8 l4) public onlyRelayer {
        require(!wordSubmitted, "word submitted");
        word1Letters[0] = l0;
        word1Letters[1] = l1;
        word1Letters[2] = l2;
        word1Letters[3] = l3;
        word1Letters[4] = l4;
        word1LettersMask = 
            TFHE.or(
                TFHE.shl(TFHE.asEuint32(1), word1Letters[0]),
                TFHE.or(
                    TFHE.shl(TFHE.asEuint32(1), word1Letters[1]),
                    TFHE.or(
                        TFHE.shl(TFHE.asEuint32(1), word1Letters[2]),
                        TFHE.or(
                            TFHE.shl(TFHE.asEuint32(1), word1Letters[3]), 
                            TFHE.shl(TFHE.asEuint32(1), word1Letters[4])
                        )
                    )
                )
            );
        wordSubmitted = true;
        gameStarted = true;
    }

Encoding word

Players submit their guesses as a single 32-bit integer. This integer is crafted by identifying the alphabetical position of each letter and then multiplying it by 26, raised to the power of the letter's position in the word (starting from 0 for the first letter, up to 4 for the fifth letter). Essentially, this forms a base-26, or hexavigesimal, representation. For instance, for the word [.c-inline-code]quick[.c-inline-code]:

The word [.c-inline-code]quick[.c-inline-code], when submitted by the player, is represented by the 32-bit integer [.c-inline-code]4,610,856[.c-inline-code]. This number is the sum of the encoded values of all the letters in the word.

Validating your word

When a player submits a guess, the system not only processes the encoded word but also verifies it against a proof. This proof uses a Merkle tree to confirm that the guessed word is from an approved list of valid english words, allowing only the root hash to be stored on the blockchain rather than the entire list.

uint8 public nGuesses = 0;
  uint32[5] public guessHist;

    bytes32 constant public rootAllowed = 0xd3e7a12d252dcf5de57a406f0bd646217ec1f340bad869182e5b2bfadd086993;    

  function guessWord1(
        uint32 word,
        bytes32[] calldata proof
    ) public onlyPlayer {
        require(gameStarted, "game not started");
        require(nGuesses < 5, "cannot exceed five guesses!")

        uint8 zeroIndex = 0;
        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(zeroIndex, word))));
        require(MerkleProof.verify(proof, rootAllowed, leaf), "Invalid word");
        guessHist[nGuesses] = word;
        nGuesses += 1;
    }
   

Feedback on your submitted guess

Following this transaction, a view method [.c-inline-code]getEqMask[.c-inline-code] checks each letter in the guess against the target word in sequence. It then creates a mask similar to the one used for the target word in [.c-inline-code]getLetterMaskGuess[.c-inline-code]: by conducting a bitwise AND operation between the player's mask and the target word's mask, the system can accurately inform players which letters are correct, without revealing their specific positions. This efficient method ensures the integrity of the game while keeping the gameplay engaging and fair.

function getEqMask(
        uint8 guessN 
    ) internal view returns (euint8) {
        uint32 word = guessHist[guessN]; // Get guess by index
        uint8 l0 = uint8((word) % 26);
        uint8 l1 = uint8((word/26) % 26);
        uint8 l2 = uint8((word/26/26) % 26);
        uint8 l3 = uint8((word/26/26/26) % 26);
        uint8 l4 = uint8((word/26/26/26/26) % 26);
        euint8 g0 = TFHE.asEuint8(TFHE.eq(word1Letters[0], l0));
        euint8 g1 = TFHE.asEuint8(TFHE.eq(word1Letters[1], l1));
        euint8 g2 = TFHE.asEuint8(TFHE.eq(word1Letters[2], l2));
        euint8 g3 = TFHE.asEuint8(TFHE.eq(word1Letters[3], l3));
        euint8 g4 = TFHE.asEuint8(TFHE.eq(word1Letters[4], l4));
        euint8 eqMask = TFHE.or(
            TFHE.shl(g0, 0),
            TFHE.or(TFHE.shl(g1, 1), TFHE.or(TFHE.shl(g2, 2), TFHE.or(TFHE.shl(g3, 3), TFHE.shl(g4, 4))))
        );
        return eqMask;
    }

    function getLetterMaskGuess(
        uint8 guessN
    ) internal view returns (euint32) {
        uint32 word = guessHist[guessN];
        uint32 l0 = (word) % 26;
        uint32 l1 = (word/26) % 26;
        uint32 l2 = (word/26/26) % 26;
        uint32 l3 = (word/26/26/26) % 26;
        uint32 l4 = (word/26/26/26/26) % 26;
        uint32 base = 1;
        uint32 letterMask = (base<<l0)|(base<<l1)|(base<<l2)|(base<<l3)|(base<<l4);
        return TFHE.and(word1LettersMask, TFHE.asEuint32(letterMask));
    }

    function getGuess(
        uint8 guessN
    ) public view onlyPlayer returns (uint8, uint32) {
        require(guessN < nGuesses, "cannot exceed nGuesses");
        euint8 eqMask = getEqMask(guessN);
        euint32 letterMaskGuess = getLetterMaskGuess(guessN);
        return (TFHE.decrypt(eqMask), TFHE.decrypt(letterMaskGuess));
    }

Claim the win

If you correctly guess the answer, you can claim your win by submitting the number of the guess that was correct.

function claimWin(uint8 guessN) public onlyPlayer {
        euint8 fullMask = TFHE.asEuint8(31);
        bool compare = TFHE.decrypt(TFHE.eq(fullMask, getEqMask(guessN)));
        if (compare) {
            playerWon = true;
        }
    }

Conclusion

This submission stands out for its ingenious application of a 26-bit mask, enabling secure verification of letter presence without relying on loops. This method is not only innovative but also practical, earning it the top spot in our bounty competition. It exemplifies how cryptographic techniques can be creatively used in game design, highlighting FHE's potential to boost privacy and security in gaming.

Check out the full implementation of this season’s winning bounty here: FHEordle by Kroist.

Additional links

Read more related posts