Documentation
👨‍💻 ZkNoid for developers
Games architecture
Randzu game

Randzu game

Modules used

Randzu is a PvP multiplayer board games. Game process involves the mechanism for opponents to find each other and the ability to create a lobby to invite friends to play. Gams require gasless transactions to not interrupt user with wallet prompt every time when making moves. The following modules are used:

  • LobbyManager
  • Matchmaking
  • Session keys

Achitecture overview

Ranzu is a multiplayer game and it involves matchmaking.
Matchmaking base contract can be found here – MatchMaker.ts
Randzu logic contract can be found here – RandzuLogic.ts

Matchmaking

Players pool is divided into rounds. Round occures every 5 blocks passed in network. Users who want to play need to re-register in rounds until opponent is not found.

const roundId = this.network.block.height.div(PENDING_BLOCKS_NUM);

Matchmaking occures when user calls register passing session key. Firstly it checks that user can participate in game and is not already registered in the round. It registes user session key so next game transactions can be executed without confirmation.

@runtimeMethod()
public register(sessionKey: PublicKey, timestamp: UInt64): void {
  // If player in game – revert
  assert(this.activeGameId.get(this.transaction.sender).orElse(UInt64.from(0)).equals(UInt64.from(0)), "Player already in game");
 
  // Registering player session key
  this.sessions.set(sessionKey, this.transaction.sender);
  const roundId = this.network.block.height.div(PENDING_BLOCKS_NUM);
 
  // User can't re-register in round queue if already registered
  assert(
    this.queueRegisteredRoundUsers.get(new RoundIdxUser({ roundId, userAddress: this.transaction.sender })).isSome.not(),
    "User already in queue"
  );

Then pool queue is checked. If there is an opponent, he will be removed from the queue and a game with current player is initialized. If there’re no opponents in the queue, user is registered in the round pool. Then if an opponent joined, he will initialize the game

  const queueLength = this.queueLength.get(roundId).orElse(UInt64.from(0));
 
  const opponentReady = queueLength.greaterThan(UInt64.from(0));
  const opponent = this.queueRoundUsersList.get(
    new RoundIdxIndex({ roundId, index: queueLength.sub(Provable.if(opponentReady, UInt64.from(1), UInt64.from(0))) })
  );
 
  const gameId = this.gamesNum.get().orElse(UInt64.from(0)).add(UInt64.from(1));
  // Setting active game if opponent found
  this.games.set(
    Provable.if(
      opponentReady,
      gameId,
      UInt64.from(0)
    ),
    new GameInfo({
      player1: this.transaction.sender,
      player2: opponent.value.userAddress,
      currentMoveUser: this.transaction.sender,
      field: RandzuField.from(Array(RANDZU_FIELD_SIZE).fill(Array(RANDZU_FIELD_SIZE).fill(0))),
      winner: PublicKey.empty()
    })
  );

Case if opponent is not found

  // If opponent not found – adding current user to the list
  this.queueRoundUsersList.set(
    new RoundIdxIndex({ roundId, index: queueLength }),
    new QueueListItem({ userAddress: Provable.if(opponentReady, PublicKey.empty(), this.transaction.sender), registrationTimestamp: timestamp })
  );
 
  // If opponent not found – registeting current user in the list
  this.queueRegisteredRoundUsers.set(
    new RoundIdxUser(
      { roundId, userAddress: Provable.if(opponentReady, PublicKey.empty(), this.transaction.sender) }
    ),
    Bool(true)
  );
 
  // If opponent not found – incrementing queue length. If found – removing opponent by length decreasing 
  this.queueLength.set(roundId, (Provable.if(opponentReady, queueLength.sub(Provable.if(opponentReady, UInt64.from(1), UInt64.from(0))), queueLength.add(1))));

Then it’s registered that player and opponent are in an active game. They won’t be able to participate new games until they finish the active one

  // Assigning new game to player if opponent found
  this.activeGameId.set(this.transaction.sender, Provable.if(opponentReady, gameId, UInt64.from(0)));
 
  // Setting that opponent is in game if opponent found
  this.activeGameId.set(Provable.if(opponentReady, opponent.value.userAddress, PublicKey.empty()), gameId);

Here is how matchmaking looks like on front-end

Making moves

Once game is initialized players can call makeMove function

This function accepts new field state and a win witness if user proposed to be the winner

@runtimeMethod()
public makeMove(gameId: UInt64, newField: RandzuField, winWitness: WinWitness): void {
  const sessionSender = this.sessions.get(this.transaction.sender);
  const sender = Provable.if(sessionSender.isSome, sessionSender.value, this.transaction.sender);
 
  const game = this.games.get(gameId);
  assert(game.isSome, "Invalid game id");
  assert(
    game.value.currentMoveUser.equals(sender),
    `Not your move: ${sender.toBase58()}`
  );
  assert(
    game.value.winner.equals(PublicKey.empty()),
    `Game finished`
  );
  winWitness.assertCorrect();

If win is proposed, it’s checked that user really have marks of his color in a row either horizontally, vertically or diagonally. If non-zero win witness is provided and this check is passed, it’s considered that current user is the winner

  let addedCellsNum = UInt64.from(0);
  for (let i = 0; i < RANDZU_FIELD_SIZE; i++) {
    for (let j = 0; j < RANDZU_FIELD_SIZE; j++) {
      let currentFieldCell = game.value.field.value[i][j];
      let nextFieldCell = newField.value[i][j];
 
      assert(Bool.or(currentFieldCell.equals(UInt32.from(0)), currentFieldCell.equals(nextFieldCell)),
        `Modified filled cell at ${i}, ${j}`
      );
 
      addedCellsNum.add(Provable.if(currentFieldCell.equals(nextFieldCell), UInt64.from(0), UInt64.from(1)));
 
      assert(addedCellsNum.lessThanOrEqual(UInt64.from(1)), `Not only one cell added. Error at ${i}, ${j}`);
      assert(
        Provable.if(currentFieldCell.equals(nextFieldCell), Bool(true), nextFieldCell.equals(currentUserId)),
        'Added opponent`s color'
      );
 
      for (let wi = 0; wi < CELLS_LINE_TO_WIN; wi++) {
        const winPosX = winWitness.directionX.mul(UInt32.from(wi)).add(winWitness.x);
        const winPosY = winWitness.directionY.mul(UInt32.from(wi)).add(winWitness.y);
        assert(Bool.or(
          winProposed.not(),
          Provable.if(
            Bool.and(winPosX.equals(UInt32.from(i)), winPosY.equals(UInt32.from(j))),
            nextFieldCell.equals(currentUserId),
            Bool(true)
          )
        ), 'Win not proved');
      }
    }
  }
  
  game.value.winner = Provable.if(winProposed, game.value.currentMoveUser, PublicKey.empty());

If game is ended, players active games are resetted

  // Removing active game for players if game ended
  this.activeGameId.set(Provable.if(winProposed, game.value.player2, PublicKey.empty()), UInt64.from(0));
  this.activeGameId.set(Provable.if(winProposed, game.value.player1, PublicKey.empty()), UInt64.from(0));

Then field is updated and turn is passed to the other player

  game.value.field = newField;
  game.value.currentMoveUser = Provable.if(
    game.value.currentMoveUser.equals(game.value.player1),
    game.value.player2,
    game.value.player1
  );
  this.games.set(gameId, game.value);

Here is how moving looks like on front-end