Skip to main content

Plinko

Plinko is an example implementation of the popular casino game. The Plinko game on Sui incorporates advanced cryptographic techniques to ensure fairness and transparency. Players drop Plinko balls onto a pegged board, where they randomly fall into slots representing different multipliers. This document details the game's mechanics, cryptographic features, and the methodology for calculating trace paths and verifying signatures.

Building an on-chain Plinko game shares a lot of similarities with the Coin Flip game and Blackjack game. For that reason, this example covers only the smart contracts (Move modules) and frontend logic.

info

You can find the source files for this example in the Plinko repo on GitHub.

A version of the game is also deployed at Mysten Plinko.

Gameplay

The Plinko game, implemented through smart contracts on the Sui blockchain, incorporates cryptographic techniques to ensure fairness and transparency. Utilizing a blend of BLS signatures, hash functions, and verifiable random function (VRF) inputs, the game calculates the trace path for each ball, determining the game's outcome based on the number of Plink balls a player chooses to drop.

The game mechanics involve a player starting a game by specifying the number of balls and staking a certain amount. The backend generates a BLS signature for the game's randomness source, which is verified on-chain to ensure it's untampered. The game uses a Counter NFT for each round to generate a unique VRF input, ensuring each game's randomness is distinct and cannot be predicted or repeated. The number of Plinko balls, chosen by the player, directly influences the game's complexity and potential payout, as each ball's final position is determined by traversing a cryptographic trace path generated from the hashed BLS signature extended to accommodate the total number of balls.

Sequence diagram

Move modules

Follow the comments in each module's code to understand the logic each creates.

plinko::plinko

The plinko::plinko module combines various Sui blockchain features, such as coin handling, event emissions, and dynamic object fields, to create on-chain Plinko games.

plinko.move
module plinko::plinko {
// === Imports ===
use std::vector;
use sui::coin::{Self, Coin};
use sui::balance::{Self, Balance};
use sui::sui::SUI;
use sui::bls12381::bls12381_min_pk_verify;
use sui::object::{Self, UID, ID};
use sui::tx_context::{Self, TxContext};
use sui::transfer;
use sui::event::emit;
use sui::hash::{blake2b256};
use sui::dynamic_object_field::{Self as dof};

// Import Counter NFT module
use plinko::counter_nft::{Self, Counter};

// Import HouseData module
use plinko::house_data::{Self as hd, HouseData};

// === Errors ===
const EStakeTooLow: u64 = 0;
const EStakeTooHigh: u64 = 1;
const EInvalidBlsSig: u64 = 2;
const EInsufficientHouseBalance: u64 = 5;
const EGameDoesNotExist: u64 = 6;

// === Structs ===

/// Represents a game and holds the accrued stake.
struct Game has key, store {
id: UID,
game_start_epoch: u64,
stake: Balance<SUI>,
player: address,
// The VRF input used to generate the extended beacon
vrf_input: vector<u8>,
fee_bp: u16
}

// === Events ===

/// Emitted when a new game has started.
struct NewGame has copy, drop {
game_id: ID,
player: address,
vrf_input: vector<u8>,
user_stake: u64,
fee_bp: u16
}

/// Emitted when a game has finished.
struct Outcome has copy, drop {
game_id: ID,
result: u64,
player: address,
// The trace path of the extended beacon
trace: vector<u8>
}

// === Public Functions ===

/// Function used to create a new game. The player must provide a Counter NFT and the number of balls.
public fun start_game(counter: &mut Counter, num_balls: u64, coin: Coin<SUI>, house_data: &mut HouseData, ctx: &mut TxContext): ID {
let fee_bp = hd::base_fee_in_bp(house_data);
let (id, new_game) = internal_start_game(counter, num_balls, coin, house_data, fee_bp, ctx);
dof::add(hd::borrow_mut(house_data), id, new_game);
id
}

/// Completes the game by calculating the outcome and transferring the funds to the player.
/// The player must provide a BLS signature of the VRF input and the number of balls to calculate the outcome.
/// It emits an Outcome event with the game result and the trace path of the extended beacon.
public fun finish_game(game_id: ID, bls_sig: vector<u8>, house_data: &mut HouseData, num_balls: u64, ctx: &mut TxContext): (u64, address, vector<u8>) {
// Ensure that the game exists.
assert!(game_exists(house_data, game_id), EGameDoesNotExist);

// Retrieves and removes the game from HouseData, preparing for outcome calculation.
let Game {
id,
game_start_epoch: _,
stake,
player,
vrf_input,
fee_bp: _
} = dof::remove<ID, Game>(hd::borrow_mut(house_data), game_id);

object::delete(id);

// Validates the BLS signature against the VRF input.
let is_sig_valid = bls12381_min_pk_verify(&bls_sig, &hd::public_key(house_data), &vrf_input);
assert!(is_sig_valid, EInvalidBlsSig);

// Initialize the extended beacon vector and a counter for hashing.
let extended_beacon = vector::empty<u8>();
let counter: u8 = 0;

// Extends the beacon until it has enough data for all ball outcomes.
while (vector::length(&extended_beacon) < (num_balls * 12)) {
// Create a new vector combining the original BLS signature with the current counter value.
let hash_input = vector::empty<u8>();
vector::append(&mut hash_input, bls_sig);
vector::append(&mut hash_input, vector::singleton(counter));
// Generate a new hash block from the unique hash input.
let block = blake2b256(&hash_input);
// Append the generated hash block to the extended beacon.
vector::append(&mut extended_beacon, block);
// Increment the counter for the next iteration to ensure a new unique hash input.
counter = counter + 1;
};

// Initializes variables for calculating game outcome.
let trace = vector::empty<u8>();
// Calculate the stake amount per ball
let stake_per_ball = balance::value<SUI>(&stake) / num_balls;
let total_funds_amount: u64 = 0;

// Calculates outcome for each ball based on the extended beacon.
let ball_index = 0;
while (ball_index < num_balls) {
let state: u64 = 0;
let i = 0;
while (i < 12) {
// Calculate the byte index for the current ball and iteration.
let byte_index = (ball_index * 12) + i;
// Retrieve the byte from the extended beacon.
let byte = *vector::borrow(&extended_beacon, byte_index);
// Add the byte to the trace vector
vector::push_back<u8>(&mut trace, byte);
// Count the number of even bytes
// If even, add 1 to the state
// Odd byte -> 0, Even byte -> 1
// The state is used to calculate the multiplier index
state = if (byte % 2 == 0) { state + 1 } else { state };
i = i + 1;
};

// Calculate multiplier index based on state
let multiplier_index = state % vector::length(&hd::multiplier(house_data));
// Retrieve the multiplier from the house data
let result = *vector::borrow(&hd::multiplier(house_data), multiplier_index);

// Calculate funds amount for this particular ball
// Divide by 100 to adjust for multiplier scale and SUI units
let funds_amount_per_ball = (result * stake_per_ball)/100;
// Add the funds amount to the total funds amount
total_funds_amount = total_funds_amount + funds_amount_per_ball;
ball_index = ball_index + 1;
};

// Processes the payout to the player and returns the game outcome.
let payout_balance_mut = hd::borrow_balance_mut(house_data);
let payout_coin = coin::take(payout_balance_mut, total_funds_amount, ctx);

balance::join(payout_balance_mut, stake);

// transfer the payout coins to the player
transfer::public_transfer(payout_coin, player);
// Emit the Outcome event
emit(Outcome {
game_id,
result: total_funds_amount,
player,
trace
});

// return the total amount to be sent to the player, (and the player address)
(total_funds_amount, player, trace)
}

// === Public-View Functions ===

/// Returns the epoch in which the game started.
public fun game_start_epoch(game: &Game): u64 {
game.game_start_epoch
}

/// Returns the total stake.
public fun stake(game: &Game): u64 {
balance::value(&game.stake)
}

/// Returns the player's address.
public fun player(game: &Game): address {
game.player
}

/// Returns the player's vrf_input bytes.
public fun vrf_input(game: &Game): vector<u8> {
game.vrf_input
}

/// Returns the fee of the game.
public fun fee_in_bp(game: &Game): u16 {
game.fee_bp
}

// === Admin Functions ===

/// Helper function to check if a game exists.
public fun game_exists(house_data: &HouseData, game_id: ID): bool {
dof::exists_(hd::borrow(house_data), game_id)
}

/// Helper function to check that a game exists and return a reference to the game Object.
/// Can be used in combination with any accessor to retrieve the desired game field.
public fun borrow_game(game_id: ID, house_data: &HouseData): &Game {
assert!(game_exists(house_data, game_id), EGameDoesNotExist);
dof::borrow(hd::borrow(house_data), game_id)
}

// === Private Functions ===

/// Internal helper function used to create a new game.
/// The player must provide a guess and a Counter NFT.
/// Stake is taken from the player's coin and added to the game's stake.
fun internal_start_game(counter: &mut Counter, num_balls: u64, coin: Coin<SUI>, house_data: &HouseData, fee_bp: u16, ctx: &mut TxContext): (ID, Game) {
let user_stake = coin::value(&coin);
// Ensure that the stake is not higher than the max stake.
assert!(user_stake <= hd::max_stake(house_data), EStakeTooHigh);
// Ensure that the stake is not lower than the min stake.
assert!(user_stake >= hd::min_stake(house_data), EStakeTooLow);
// Ensure that the house has enough balance to play for this game.
assert!(hd::balance(house_data) >= (user_stake*(*vector::borrow(&hd::multiplier(house_data), 0)))/100, EInsufficientHouseBalance);

// Get the VRF input and increment the counter
let vrf_input = counter_nft::get_vrf_input_and_increment(counter, num_balls);

let id = object::new(ctx);
let game_id = object::uid_to_inner(&id);

// Create a new game object and emit a NewGame event.
let new_game = Game {
id,
game_start_epoch: tx_context::epoch(ctx),
stake: coin::into_balance<SUI>(coin),
player: tx_context::sender(ctx),
vrf_input,
fee_bp
};
// Emit a NewGame event
emit(NewGame {
game_id,
player: tx_context::sender(ctx),
vrf_input,
user_stake,
fee_bp
});

(game_id, new_game)
}
}

Error codes

Error handling is integral to the module, with specific codes indicating various failure states or invalid operations:

  • EStakeTooLow: Indicates that the stake provided is below the minimum threshold.
  • EStakeTooHigh: Indicates that the stake exceeds the maximum allowed limit.
  • EInvalidBlsSig: Denotes an invalid BLS signature.
  • EInsufficientHouseBalance: Indicates the house does not have enough balance to cover the game's outcome.
  • EGameDoesNotExist: Used when a referenced game cannot be found.

Events

  • NewGame: Emitted when a new game starts, capturing essential details like game ID, player address, VRF input, stake, and fee basis points.
  • Outcome: Emitted upon the conclusion of a game, detailing the outcome, including the game ID, result, player address, and a trace of the game's execution.

Structures

  • Game: Represents an individual game session, holding information such as the game ID, epoch of game start, stake amount, player address, VRF input, and the fee basis points.

Entry functions

  • start_game: Initiates a new Plinko game session, accepting parameters like a counter NFT, the number of balls selected by the player, stake, house data, and transaction context.
  • finish_game: Calculates and finalizes the game outcome, traces the path the balls travel, and distributes the total funds to the player based on outcomes.

Accessors

Provide read-only access to the game's properties, such as:

  • game_start_epoch
  • stake
  • player
  • vrf_input
  • fee_in_bp

Public helper functions

Include utilities like:

  • fee_amount: Calculates the fee amount based on the stake and fee basis points.
  • game_exists: Checks if a game exists within the house data.
  • borrow_game: Retrieves a reference to a game object for further processing.

Internal helper functions

  • internal_start_game: A core utility that facilitates the creation of a new game, ensuring compliance with stake limits, house balance sufficiency, and the generation of a unique game ID.

plinko::house_data

The plinko::house_data module in the Plinko game is designed to manage the game's treasury and configurations. It's responsible for storing the house funds, setting the game parameters (like maximum and minimum stakes), and handling game fees. It also stores the house public key for verifying game outcomes. The module provides functions to adjust game settings, manage the house funds, and ensure the integrity and fairness of the game through cryptographic verification.

house_data.move
module plinko::house_data {
// === Imports ===
use std::vector;
use sui::object::{Self, UID};
use sui::balance::{Self, Balance};
use sui::sui::SUI;
use sui::coin::{Self, Coin};
use sui::package::{Self};
use sui::tx_context::{Self, TxContext};
use sui::transfer::{Self};

// === Friends ===
friend plinko::plinko;

// === Errors ===
const ECallerNotHouse: u64 = 0;
const EInsufficientBalance: u64 = 1;


// === Structs ===

/// Configuration and Treasury shared object, managed by the house.
struct HouseData has key {
id: UID,
// House's balance which also contains the accrued winnings of the house.
balance: Balance<SUI>,
// Address of the house or the game operator.
house: address,
// Public key used to verify the beacon produced by the back-end.
public_key: vector<u8>,
// Maximum stake amount a player can bet in a single game.
max_stake: u64,
// Minimum stake amount required to play the game.
min_stake: u64,
// The accrued fees from games played.
fees: Balance<SUI>,
// The default fee in basis points. 1 basis point = 0.01%.
base_fee_in_bp: u16,
// Multipliers used to calculate winnings based on the game outcome.
multiplier: vector<u64>
}

/// A one-time use capability to initialize the house data;
/// created and sent to sender in the initializer.
struct HouseCap has key {
id: UID
}

/// Used as a one time witness to generate the publisher.
struct HOUSE_DATA has drop {}

fun init(otw: HOUSE_DATA, ctx: &mut TxContext) {
// Creating and sending the Publisher object to the sender.
package::claim_and_keep(otw, ctx);

// Creating and sending the HouseCap object to the sender.
let house_cap = HouseCap {
id: object::new(ctx)
};

transfer::transfer(house_cap, tx_context::sender(ctx));
}

/// Initializer function that should only be called once and by the creator of the contract.
/// Initializes the house data object with the house's public key and an initial balance.
/// It also sets the max and min stake values, that can later on be updated.
/// Stores the house address and the base fee in basis points.
/// This object is involved in all games created by the same instance of this package.
public fun initialize_house_data(house_cap: HouseCap, coin: Coin<SUI>, public_key: vector<u8>, multiplier: vector<u64>, ctx: &mut TxContext) {
assert!(coin::value(&coin) > 0, EInsufficientBalance);

let house_data = HouseData {
id: object::new(ctx),
balance: coin::into_balance(coin),
house: tx_context::sender(ctx),
public_key,
max_stake: 10_000_000_000, // 10 SUI = 10^9.
min_stake: 1_000_000_000, // 1 SUI.
fees: balance::zero(),
base_fee_in_bp: 100, // 1% in basis points.
multiplier: vector::empty<u64>()
};

set_multiplier_vector(&mut house_data, multiplier);

let HouseCap { id } = house_cap;
object::delete(id);

transfer::share_object(house_data);
}

// === Public-Mutative Functions ===

fun set_multiplier_vector(house_data: &mut HouseData, v: vector<u64>) {
vector::append<u64>(&mut house_data.multiplier, v);
}

public fun update_multiplier_vector(house_data: &mut HouseData, v: vector<u64>, ctx: &mut TxContext) {
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);
house_data.multiplier = vector::empty<u64>();
set_multiplier_vector(house_data, v);
}

/// Function used to top up the house balance. Can be called by anyone.
/// House can have multiple accounts so giving the treasury balance is not limited.
public fun top_up(house_data: &mut HouseData, coin: Coin<SUI>, _: &mut TxContext) {
coin::put(&mut house_data.balance, coin)
}

/// A function to withdraw the entire balance of the house object.
/// It can be called only by the house
public fun withdraw(house_data: &mut HouseData, ctx: &mut TxContext) {
// Only the house address can withdraw funds.
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);

let total_balance = balance(house_data);
let coin = coin::take(&mut house_data.balance, total_balance, ctx);
transfer::public_transfer(coin, house(house_data));
}

/// House can withdraw the accumulated fees of the house object.
public fun claim_fees(house_data: &mut HouseData, ctx: &mut TxContext) {
// Only the house address can withdraw fee funds.
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);

let total_fees = fees(house_data);
let coin = coin::take(&mut house_data.fees, total_fees, ctx);
transfer::public_transfer(coin, house(house_data));
}

/// House can update the max stake. This allows larger stake to be placed.
public fun update_max_stake(house_data: &mut HouseData, max_stake: u64, ctx: &mut TxContext) {
// Only the house address can update the base fee.
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);

house_data.max_stake = max_stake;
}

/// House can update the min stake. This allows smaller stake to be placed.
public fun update_min_stake(house_data: &mut HouseData, min_stake: u64, ctx: &mut TxContext) {
// Only the house address can update the min stake.
assert!(tx_context::sender(ctx) == house(house_data), ECallerNotHouse);

house_data.min_stake = min_stake;
}

/// Returns a mutable reference to the balance of the house.
public(friend) fun borrow_balance_mut(house_data: &mut HouseData): &mut Balance<SUI> {
&mut house_data.balance
}

/// Returns a mutable reference to the fees of the house.
public(friend) fun borrow_fees_mut(house_data: &