Building ZK Battleship with Noir and Stylus

This example implements Battleship, a game where players must trust that opponents follow the rules without revealing their board configuration.

⚠️ Disclaimer: This is an experimental project intended for research and educational purposes. It has not been audited, and should not be used in production or with real funds. Use at your own risk.

What we're building

The ZK Battleship game demonstrates a complete zero-knowledge, turn-based battleship system with these key features:

  • Privacy: Ship layouts remain secret using commitment
  • Integrity: Each shot includes a zero-knowledge proof that the reported result matches the committed board
  • Anti-cheat: A single board commitment (e.g., Merkle root or Poseidon hash) binds the entire game; proofs prevent ship movement or falsified outcomes
  • Transparency: Turns, coordinates, and outcomes are public, while ship positions and salts stay private
  • Decentralization: Runs on Arbitrum with on-chain verification; proofs are generated client-side

Demo videos can be found here. Code for the whole project can be found here

Architecture Overview

The ZK Battleship implementation uses zero-knowledge proofs to ensure game integrity while maintaining privacy. The system consists of:

Noir Circuits:

  • Board validation circuit - proves valid ship placement
  • Shot verification circuit - proves hit/miss results

Stylus Contracts:

  • Game state management
  • Proof verification via generated verifier contracts
  • Turn-based gameplay logic

Noir Stylus Verifier:

  • Automatically generates Stylus verifier contracts from Noir circuits
  • Provides efficient on-chain proof verification

ZK Circuit Design

The implementation requires two distinct circuits to handle different aspects of the game:

1. Board Validation Circuit

  • Private inputs: Ship positions, nonce (salt)
  • Public inputs: Board hash
  • Purpose: Proves that a board configuration is valid (no overlapping ships, within bounds)

2. Shot Verification Circuit

  • Private inputs: Ship positions, nonce
  • Public inputs: Board hash, opponent shot coordinates (x,y), hit result
  • Purpose: Proves that a claimed hit/miss result is accurate for the given shot

Implementation Stack

Noir Circuits

  • Noir - Domain-specific language for ZK circuits
  • Rust-like syntax for easier development and maintenance
  • Built-in cryptographic primitives (Poseidon hashing)

Stylus Smart Contracts

  • Arbitrum Stylus - Rust-based smart contracts
  • WASM execution for gas efficiency
  • Native Rust development experience

Noir Stylus Verifier

  • Generates Stylus verifier contracts from Noir circuits
  • Command-line tool: nsv generate, nsv deploy
  • Seamless integration between Noir and Stylus

Project Structure

examples/battleship/
├── circuits/
│   ├── common/          # Shared constants and utilities
│   ├── board/           # Board validation circuit
│   └── shoot/           # Shot validation circuit
├── contracts/           # Stylus smart contracts
├── apps/
│   ├── cli/            # Command-line interface
│   └── www/            # Web application
└── packages/
    └── core/           # Shared TypeScript library

Noir Circuit Implementation

Shared Constants and Utilities

The circuits/common/src/lib.nr module defines game constants and the board hashing function:

use poseidon::poseidon2::Poseidon2::hash;

pub global BOARD_SIZE: u32 = 10;

pub global NUMBER_OF_SHIPS: u32 = 5;

pub global EMPTY_BOARD: [[Field; BOARD_SIZE]; BOARD_SIZE] = [
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];

pub global SHIP_LENGTHS: [u8; NUMBER_OF_SHIPS] = [
    5, // Carrier
    4, // Battleship
    3, // Cruiser
    3, // Submarine
    2, // Destroyer
];

pub global X_INDEX: u32 = 0;
pub global Y_INDEX: u32 = 1;
pub global DIRECTION_INDEX: u32 = 2;
pub global SHIP_DIRECTION_DOWN: u8 = 0;
pub global SHIP_DIRECTION_RIGHT: u8 = 1;


pub fn hash_board(nonce: Field, ships: [[u8; 3]; NUMBER_OF_SHIPS]) -> Field {
    // Hash the board with the nonce.
    // Poseidon takes in a series of numbers, so we want to serialize each ship position as a number.
    // We know a Battleship position is (0...9), so we encode (x,y,p) array as a 3-digit number
    // ie, [3,2,1] would become "123"
    let mut hash_input: [Field; NUMBER_OF_SHIPS + 1] = [0; NUMBER_OF_SHIPS + 1];
    hash_input[0] = nonce;
    for i in 0..NUMBER_OF_SHIPS {
        let ship = ships[i];
        let x = ship[X_INDEX];
        let y = ship[Y_INDEX];
        let p = ship[DIRECTION_INDEX];
        hash_input[i + 1] = (x as Field) * 100 + (y as Field) * 10 + (p as Field);
    }

    let computed_board_hash = hash(hash_input, NUMBER_OF_SHIPS + 1);

    computed_board_hash
}

Board Validation Circuit

The board validation circuit (circuits/board/src/main.nr) ensures valid ship placement:

use common::{
    X_INDEX, Y_INDEX, BOARD_SIZE, DIRECTION_INDEX, NUMBER_OF_SHIPS, SHIP_DIRECTION_DOWN,
    SHIP_DIRECTION_RIGHT, SHIP_LENGTHS, hash_board, 
};

// This circuit checks if a shot is a hit or a miss
// @param nonce: Field
// @param ships: [x,y,direction:0=down,1=right]. These are ordered Carrier, Battleship, Cruiser, Submarine, Destroyer.
// @param board_hash: pub Field
// @param x: pub u8
// @param y: pub u8
// @param hit: pub bool
fn main(nonce: Field, ships: [[u8; 3]; NUMBER_OF_SHIPS], board_hash: pub Field, x: pub u8, y: pub u8, hit: pub bool) {
    // validate the guess is actually valid
    assert(x >= 0 & x < BOARD_SIZE as u8);
    assert(y >= 0 & y < BOARD_SIZE as u8);

    // validate the inputted ships matches the public hash
    assert(board_hash == hash_board(nonce, ships));

    // check if it's a hit to any of the ships
    let hit0 = is_hit(x, y, ships[0], SHIP_LENGTHS[0]);
    let hit1 = is_hit(x, y, ships[1], SHIP_LENGTHS[1]);
    let hit2 = is_hit(x, y, ships[2], SHIP_LENGTHS[2]);
    let hit3 = is_hit(x, y, ships[3], SHIP_LENGTHS[3]);
    let hit4 = is_hit(x, y, ships[4], SHIP_LENGTHS[4]);
    assert(hit0 | hit1 | hit2 | hit3 | hit4 == hit);
}

fn is_hit(guess_x: u8, guess_y: u8, ship: [u8; 3], len: u8) -> bool {
    let mut is_hit = false;
    if (ship[DIRECTION_INDEX] == SHIP_DIRECTION_DOWN) {
        let x_match = guess_x == ship[X_INDEX];
        let y_in_range = (guess_y >= ship[Y_INDEX]) & (guess_y < ship[Y_INDEX] + len);
        is_hit = x_match & y_in_range;
    } else if (ship[DIRECTION_INDEX] == SHIP_DIRECTION_RIGHT) {
        let y_match = guess_y == ship[1];
        let x_in_range = (guess_x >= ship[X_INDEX]) & (guess_x < ship[X_INDEX] + len);
        is_hit = y_match & x_in_range;
    } else {
        assert(false, "Invalid direction");
    }

    is_hit
}

Key validations:

  • Ship placement within board boundaries
  • No overlapping ships
  • Hash verification against ship configuration
  • Nonce-based protection against rainbow table attacks

Shot Verification Circuit

The shot verification circuit (circuits/shoot/src/main.nr) validates hit/miss claims:

use common::{
    X_INDEX, Y_INDEX, BOARD_SIZE, DIRECTION_INDEX, NUMBER_OF_SHIPS,
    SHIP_DIRECTION_DOWN, SHIP_DIRECTION_RIGHT, SHIP_LENGTHS, hash_board,
};

// This circuit checks if a shot is a hit or a miss
fn main(
    nonce: Field,
    ships: [[u8; 3]; NUMBER_OF_SHIPS],
    board_hash: pub Field,
    x: pub u8,
    y: pub u8,
    hit: pub bool
) {
    // Validate the guess coordinates are within bounds
    assert(x >= 0 & x < BOARD_SIZE as u8);
    assert(y >= 0 & y < BOARD_SIZE as u8);

    // Validate the inputted ships match the public hash
    assert(board_hash == hash_board(nonce, ships));

    // Check if the shot hits any of the ships
    let hit0 = is_hit(x, y, ships[0], SHIP_LENGTHS[0]);
    let hit1 = is_hit(x, y, ships[1], SHIP_LENGTHS[1]);
    let hit2 = is_hit(x, y, ships[2], SHIP_LENGTHS[2]);
    let hit3 = is_hit(x, y, ships[3], SHIP_LENGTHS[3]);
    let hit4 = is_hit(x, y, ships[4], SHIP_LENGTHS[4]);
    
    assert(hit0 | hit1 | hit2 | hit3 | hit4 == hit);
}

fn is_hit(guess_x: u8, guess_y: u8, ship: [u8; 3], len: u8) -> bool {
    let mut is_hit = false;
    
    if (ship[DIRECTION_INDEX] == SHIP_DIRECTION_DOWN) {
        let x_match = guess_x == ship[X_INDEX];
        let y_in_range = (guess_y >= ship[Y_INDEX]) & (guess_y < ship[Y_INDEX] + len);
        is_hit = x_match & y_in_range;
    } else if (ship[DIRECTION_INDEX] == SHIP_DIRECTION_RIGHT) {
        let y_match = guess_y == ship[Y_INDEX];
        let x_in_range = (guess_x >= ship[X_INDEX]) & (guess_x < ship[X_INDEX] + len);
        is_hit = y_match & x_in_range;
    }
    
    is_hit
}

Key validations:

  • Shot coordinates within board bounds
  • Board integrity (ships unchanged since creation)
  • Accurate hit/miss calculation
  • Cryptographic proof of correctness

Stylus Smart Contract

The game logic is implemented in Rust using the Stylus SDK. Key components include:

#[storage]
struct StorageGame {
    player1: StorageAddress,
    player2: StorageAddress,
    player1_board_hash: StorageU256,
    player2_board_hash: StorageU256,
    player1_points: StorageU256,
    player2_points: StorageU256,
    moves_count: StorageU256,
    moves: StorageMap<U256, StorageMove>,
}

#[public]
impl Battleship {
    /// Create a new game
    pub fn create_game(
        &mut self,
        game_id: U256,
        board_hash: U256,
        proof: Bytes,
    ) -> Result<(), BattleshipErrors> {
        // Verify the board is valid using the board verifier contract
        if !verify_board_proof(self.vm(), self.board_verifier.get(), proof, board_hash) {
            return Err(BattleshipErrors::InvalidProof(InvalidProof {}));
        }

        // Check game_id is not already taken
        if self.games.get(game_id).player1.get() != Address::ZERO {
            return Err(BattleshipErrors::GameAlreadyCreated(GameAlreadyCreated {}));
        }

        // Create the game
        let player1 = self.vm().msg_sender();
        let mut game = self.games.setter(game_id);
        game.player1.set(player1);
        game.player1_board_hash.set(board_hash);
        // ... initialize other fields

        Ok(())
    }

    /// Make a shot in the game
    pub fn shoot(
        &mut self,
        game_id: U256,
        previous_move_hit_proof: Bytes,
        previous_move_hit: bool,
        previous_move_x: U256,
        previous_move_y: U256,
        x: U256,
        y: U256,
    ) -> Result<(), BattleshipErrors> {
        // Validate shot coordinates
        if x >= U256::from(BOARD_SIZE) || y >= U256::from(BOARD_SIZE) {
            return Err(BattleshipErrors::InvalidShot(InvalidShot {}));
        }

        // For non-first moves, verify the previous move result with a proof
        if moves_count > U256::ZERO {
            if !verify_shoot_proof(
                vm,
                shoot_verifier_addr,
                previous_move_hit_proof,
                current_player_board_hash,
                previous_move_hit,
                previous_move_x,
                previous_move_y,
            ) {
                return Err(BattleshipErrors::InvalidProof(InvalidProof {}));
            }
        }

        // Record the new move and update game state
        // ...

        Ok(())
    }
}

Core functionality:

  • Game state management (players, moves, scores)
  • ZK proof verification via generated verifier contracts
  • Turn-based game flow enforcement
  • Win condition detection

User Interfaces

The example includes both CLI and web interfaces:

# Create a game
./src/main.ts create --private-key $PRIVATE_KEY --join-code 123456

# Join a game  
./src/main.ts join --private-key $PRIVATE_KEY_2 --join-code 123456

# Play the game
./src/main.ts play --private-key $PRIVATE_KEY $GAME_ID

Live Deployment

The complete system is deployed on Arbitrum Sepolia:

ContractAddress
BoardVerifier0xecb6faf4ade0e0a6df7b41ee9ba07c9cf5fdf205
ShootVerifier0x62965b4f17523b61a295788d7fa6f269c940c5a3
Battleship0xb3448a6f3958ac075182196dd717d5f574f81663

Deployment Workflow

# 1. Generate and deploy verifier contracts
cd $root/circuits/board
nsv generate && nsv deploy --rpc-url $RPC_URL --private-key $PRIVATE_KEY

cd $root/circuits/shoot  
nsv generate && nsv deploy --rpc-url $RPC_URL --private-key $PRIVATE_KEY

# 2. Deploy main game contract with verifier addresses
cd $root/contracts
cargo stylus deploy --endpoint $RPC_URL --private-key $PRIVATE_KEY \
  --constructor-args <BOARD_VERIFIER_ADDR> <SHOOT_VERIFIER_ADDR>

Key Benefits

Zero-Knowledge Gaming

  • Trustless gameplay with hidden information
  • Cryptographic guarantees instead of trusted third parties
  • Privacy-preserving competitive gaming

Noir Circuit Development

  • Rust-like syntax for familiar development experience
  • Built-in cryptographic primitives
  • Comprehensive testing framework

Stylus Smart Contracts

  • Native Rust development for blockchain
  • WASM execution efficiency
  • Reduced gas costs compared to Solidity

Noir Stylus Verifier Integration

  • Seamless workflow from circuit to verifier contract
  • Single command deployment
  • Type-safe proof verification

Next Steps

This tutorial provides a foundation for building ZK applications with Noir and Stylus. Consider extending the implementation with:

  • Enhanced game features: Place ships manually, game sessions, moves timeouts, etc
  • Incentives: Make users compete for a prize, ensure they get penalized for abandoning the game without reporting opponents hit/miss, etc
  • State persistence: The demo does not currently persist, boards nor nonces, so if users leave of miss connection they are out of the game.