Battleship Game
Battleship is a strategic board game in which two players compete to sink an opponent's fleet hidden from their view on the playing field. Players place their ships on a grid and then take turns shooting at squares on the opponent's field to locate and sink their ships. The goal of the game is to be the first to destroy all of your opponent's ships.
The game of Battleship requires logical thinking, strategy, and luck. Players must position their ships to remain well hidden from the enemy, with each ship having a different length, adding variety to the tactics of the game.
Battleship is a popular game that operates entirely on-chain. The primary game version does not have a backend or centralized components. However, there is an option to include vouchers that allow users to play with a zero tokens on their balance (gasless transactions). This requires activation of the backend to issue vouchers (instructions for voucher integration will be detailed in the article).
Another feature of this example is an option that allos anyone to interact with the dApp without needing to sign in (signless transactions). The dApp transaction operates as usual with one difference — a voucher is issued not directly to a user, but to a temporarily created account (sub-account) to which the user grants temporary rights to sign transactions on their behalf in this application.
These features allow users to interact with the game for a certain period without the need to individually sign each transaction. By eliminating the repetitive signing process, this approach streamlines interactions and significantly improves the overall UX efficiency. For more details about how to use Gasless and Signless features in your dApps, visit EZ-Transactions package article.
The source code is available on GitHub. This article describes the program interface, data structure, basic functions and explains their purpose. It can be used as is or modified to suit your own scenarios.
Important notice: The implementation is based on the interaction of two programs: the main
game
program and thebot
program that users will interact with during the game. To successfully load the game into the Vara Network Testnet, it is imperative to upload the bot program first. Subsequently, during the initialization phase of the main game program, specifying the bot program's address is a crucial step.
Also, everyone can play the game via this link - Play Battleship (VARA tokens are required for gas fees).
How to run
- Build a program
Additional details regarding this matter can be located within the README directory of the program.
- Upload the program to the Vara Network Testnet
Initiate the process by uploading the bot program, followed by the subsequent upload of the main program. Further details regarding the process of program uploading can be located within the Getting Started section.
- Build and run user interface
More information about this can be found in the README directory of the frontend.
- Optional. Build and run the backend to release vouchers
Comprehensive instructions on the voucher execution process are provided within the README.
Implementation details
Battleship program description
The Battleship program contains the following information:
struct Battleship {
pub games: HashMap<ActorId, Game>,
pub msg_id_to_game_id: BTreeMap<MessageId, ActorId>,
pub bot_address: ActorId,
pub admin: ActorId,
pub sessions: HashMap<ActorId, Session>,
pub config: Config,
}
games
- this field contains the addresses of the players and information about their gamesmsg_id_to_game_id
- this field is responsible for tracking the bot's reply messagesbot_address
- bot addressadmin
- admin address
Where the structure of the "Game" is defined as follows
pub struct Game {
pub player_board: Vec<Entity>,
pub bot_board: Vec<Entity>,
pub player_ships: Ships,
pub bot_ships: Ships,
pub turn: Option<BattleshipParticipants>,
pub start_time: u64,
pub end_time: u64,
pub total_shots: u64,
pub game_over: bool,
pub game_result: Option<BattleshipParticipants>,
}
player_board
- is a vector with the status of cells of the player's boardbot_board
- is a vector with the status of cells of the bot's boardplayer_ships
- the location of the player shipsbot_ships
- the location of the bot shipsturn
- is a field indicating the turn queue of a movestart_time
- game starting timeend_time
- end game timetotal_shots
- number of shotsgame_over
- is a field indicating the end of the gamegame_result
- is a field indicating who won the game
Field cells can take on different values:
pub enum Entity {
Empty,
Unknown,
Occupied,
Ship,
Boom,
BoomShip,
DeadShip,
}
Initialization
To initialize the game program, it only needs to be passed the bot's program address (this program will be discussed a little later)
pub struct BattleshipInit {
pub bot_address: ActorId,
pub config: Config,
}
Action
pub enum BattleshipAction {
// To start the game
StartGame {
ships: Ships,
session_for_account: Option<ActorId>,
},
// In order to make a move
Turn {
step: u8,
session_for_account: Option<ActorId>,
},
// Change the bot program (available only for admin)
ChangeBot {
bot: ActorId,
},
// Clean the program state (available only for admin);
// leave_active_games specifies how to clean it
ClearState {
leave_active_games: bool,
},
// Deletion of a player's game
DeleteGame {
player_address: ActorId,
},
CreateSession {
key: ActorId,
duration: u64,
allowed_actions: Vec<ActionsForSession>,
},
DeleteSessionFromProgram {
account: ActorId,
},
DeleteSessionFromAccount,
UpdateConfig {
gas_for_start: Option<u64>,
gas_for_move: Option<u64>,
gas_to_delete_session: Option<u64>,
block_duration_ms: Option<u64>,
},
}
Reply
pub enum BattleshipReply {
// Every time a message is sent to the bot's program.
MessageSentToBot,
// When the game is over
EndGame(BattleshipParticipants),
// When the bot's program address changes
BotChanged(ActorId),
SessionCreated,
SessionDeleted,
ConfigUpdated,
}
Logic
Change to “After initialization of the program, the function to start the game will be available BattleshipAction::StartGame { ships: Ships }
, where the location of the ships are transmitted. The program verifies the proper placement of ships and ensures the absence of any ongoing games for the user. Upon successful completion of these checks, the program initiates the game creation process and dispatches a message, msg::send_with_gas
, to the bot program, prompting it to create the game.
fn start_game(&mut self, mut ships: Ships, session_for_account: Option<ActorId>) {
// ...
let msg_id = msg::send_with_gas(
self.bot_address,
BotBattleshipAction::Start,
self.config.gas_for_start,
0,
)
.expect("Error in sending a message");
self.msg_id_to_game_id.insert(msg_id, player);
msg::reply(BattleshipReply::MessageSentToBot, 0).expect("Error in sending a reply");
}
The msg_id
is stored in the msg_id_to_game_id
variable to use it in handle_reply
(One of the message processing functions that Gear provides)
#[no_mangle]
extern fn handle_reply() {
let reply_to = msg::reply_to().expect("Failed to query reply_to data");
let battleship = unsafe { BATTLESHIP.as_mut().expect("The game is not initialized") };
let game_id = battleship
.msg_id_to_game_id
.remove(&reply_to)
.expect("Unexpected reply");
let game = battleship
.games
.get_mut(&game_id)
.expect("Unexpected: Game does not exist");
Using handle_reply
, the bot retrieves response messages, while msg_id_to_game_id
facilitates the retrieval of a particular game for state modification.
After the game starts, the BattleshipAction::Turn { step: u8 }
function becomes available for executing moves. This function requires the passage of the cell number at which the player intends to shoot. The program conducts thorough checks on the entered data and the game state to ensure a valid move can be made. If the checks pass successfully, the program updates the field states, shifts the turn to the bot and dispatches msg::send_with_gas
to the bot program for it to make its move.
fn player_move(&mut self, step: u8, session_for_account: Option<ActorId>) {
// ...
game.turn = Some(BattleshipParticipants::Bot);
let board = game.get_hidden_field();
let msg_id = msg::send_with_gas(
self.bot_address,
BotBattleshipAction::Turn(board),
self.config.gas_for_move,
0,
)
.expect("Error in sending a message");
self.msg_id_to_game_id.insert(msg_id, player);
msg::reply(BattleshipReply::MessageSentToBot, 0).expect("Error in sending a reply");
Just as when starting a game, a reply message from the bot regarding its move is received in handle_reply
.
In summary, the interaction between the two programs is reduced to the ability to receive response messages in a separate function, denoted as handle_reply()
. The whole implementation of the function looks as follows:
#[no_mangle]
extern fn handle_reply() {
let reply_to = msg::reply_to().expect("Failed to query reply_to data");
let battleship = unsafe { BATTLESHIP.as_mut().expect("The game is not initialized") };
let game_id = battleship
.msg_id_to_game_id
.remove(&reply_to)
.expect("Unexpected reply");
let game = battleship
.games
.get_mut(&game_id)
.expect("Unexpected: Game does not exist");
let action: BattleshipAction =
msg::load().expect("Failed to decode `BattleshipAction` message.");
match action {
BattleshipAction::StartGame {
ships,
session_for_account: _,
} => game.start_bot(ships),
BattleshipAction::Turn {
step,
session_for_account: _,
} => {
game.turn(step);
game.turn = Some(BattleshipParticipants::Player);
if game.player_ships.check_end_game() {
game.game_over = true;
game.game_result = Some(BattleshipParticipants::Bot);
game.end_time = exec::block_timestamp();
msg::send(
game_id,
BattleshipReply::EndGame(BattleshipParticipants::Bot),
0,
)
.expect("Unable to send the message about game over");
}
}
_ => (),
}
}
Bot program
As for the bot program, it has two functions:
pub enum BotBattleshipAction {
Start,
Turn(Vec<Entity>),
}
After receiving these messages in handle()
, the program splits them into the appropriate Action and processes them accordingly. In the case of BotBattleshipAction::Start
, the program takes on the task of generating a random field and then responds by providing the coordinates of the ship. In the other case of BotBattleshipAction::Turn
, the program is responsible for obtaining the current state of the field, and after careful analysis, it formulates a response by providing the coordinate of the cell where the shot is fired.
#[no_mangle]
extern fn handle() {
let action: BotBattleshipAction = msg::load().expect("Unable to load the message");
match action {
BotBattleshipAction::Start => {
let ships = generate_field();
msg::reply(
BattleshipAction::StartGame {
ships,
session_for_account: None,
},
0,
)
.expect("Error in sending a reply");
}
BotBattleshipAction::Turn(board) => {
let step = move_analysis(board);
msg::reply(
BattleshipAction::Turn {
step,
session_for_account: None,
},
0,
)
.expect("Error in sending a reply");
}
}
}
Program metadata and state
Metadata interface description:
pub struct BattleshipMetadata;
impl Metadata for BattleshipMetadata {
type Init = In<BattleshipInit>;
type Handle = InOut<BattleshipAction, BattleshipReply>;
type Others = ();
type Reply = ();
type Signal = ();
type State = InOut<StateQuery, StateReply>;
}
One of Gear's features is reading partial states.
pub enum StateQuery {
All,
Game(ActorId),
BotContractId,
SessionForTheAccount(ActorId),
}
pub enum StateReply {
All(BattleshipState),
Game(Option<GameState>),
BotContractId(ActorId),
SessionForTheAccount(Option<Session>),
}
To display the program state information, the state()
function is used:
#[no_mangle]
extern fn state() {
let battleship = unsafe { BATTLESHIP.take().expect("Unexpected error in taking state") };
let query: StateQuery = msg::load().expect("Unable to load the state query");
match query {
StateQuery::All => {
msg::reply(StateReply::All(battleship.into()), 0).expect("Unable to share the state");
}
StateQuery::Game(player_id) => {
let game_state = battleship.games.get(&player_id).map(|game| GameState {
player_board: game.player_board.clone(),
bot_board: game.bot_board.clone(),
player_ships: game.player_ships.count_alive_ships(),
bot_ships: game.bot_ships.count_alive_ships(),
turn: game.turn.clone(),
start_time: game.start_time,
total_shots: game.total_shots,
end_time: game.end_time,
game_over: game.game_over,
game_result: game.game_result.clone(),
});
msg::reply(StateReply::Game(game_state), 0).expect("Unable to share the state");
}
StateQuery::BotContractId => {
msg::reply(StateReply::BotContractId(battleship.bot_address), 0)
.expect("Unable to share the state");
}
StateQuery::SessionForTheAccount(account) => {
msg::reply(
StateReply::SessionForTheAccount(battleship.sessions.get(&account).cloned()),
0,
)
.expect("Unable to share the state");
}
}
}
Gaming sessions (signless transactions)
To further enhance the gaming experience and make it more user-friendly, the introduction of gaming sessions offers a significant improvement. Players now have the option to create a trusted game session, during which they are not required to sign every transaction individually. This trusted session creates a new temporary private key, valid for a specific time window, which empowers the temporary account to sign and send specific transactions on behalf of the player.
These sessions allow users to establish predefined rules for interacting with a Dapp, offering the flexibility for unrestricted usage within these guidelines, eliminating the need to authorize each transaction separately. This approach not only facilitates a seamless Dapp experience but also ensures the security of assets, as users can specify the permissible actions for the Dapp.
At the session's conclusion, whether it's the end of the indicated window or when the user decides to terminate it, the temporary key becomes obsolete and is discarded.
To initiate a session, a player sends a message to the program (assuming the program supports this message type):
CreateSession {
key: ActorId,
duration: u64,
allowed_actions: Vec<ActionsForSession>,
},
where:
key
is the temporary account playing on the user's behalf;duration
is the time frame for the session's validity;allowed_actions
are the messages the temporary account can send.
A session is structured as follows, allowing a player to set it up before starting the game:
pub struct Session {
// the address of the player who will play on behalf of the user
pub key: ActorId,
// until what time the session is valid
pub expires: u64,
// what messages are allowed to be sent by the account (key)
pub allowed_actions: Vec<ActionsForSession>,
}
After creating a session, the player must also fund the temporary account, which can be done via a voucher.
In this example, the messages:
StartGame { ships: Ships },
Turn { step: u8 },
are now expanded to:
StartGame { ships: Ships, session_for_account: Option<ActorId> },
Turn { step: u8, session_for_account: Option<ActorId> },
introducing the option to play either personally or using a pre-established game session.
If session_for_account
is None
, the player plays on their own. If the game proceeds through a session, the address of the player who will be represented in the game should be specified in session_for_account
.
The program then verifies:
- whether the specified player has a session;
- whether the message sender matches the key of this session;
- whether the session is still valid.
Players have the option to delete their session before the game ends, further enhancing control and flexibility in the gaming experience.
Source code
The source code of this example of Battleship Game program and the example of an implementation of its testing is available on gear-foundation/dapp/contracts/battleship.
See also an example of the program testing implementation based on gtest
: gear-foundation/dapps/battleship/tests.
For more details about testing programs written on Vara, refer to the Program Testing article.