Xây dựng trò chơi với Near, Aurora và BOS

NEAR Vietnam DAO
Aurora Platform
Published in
9 min readMay 8, 2023

Michael Birch. Ngày 5 tháng 5 năm 2023.

Giới thiệu

Trong blog này, chúng ta sẽ khám phá việc xây dựng một trò chơi Tic Tac Toe đơn giản bằng cách sử dụng stack công nghệ của hệ sinh thái Near. Điều này bao gồm việc sử dụng Aurora để có trải nghiệm tích hợp liền mạch (giao dịch miễn phí), Near cho logic hợp đồng thông minh phức tạp và BOS cho giao diện người dùng. Kết quả cuối cùng là một ứng dụng hoàn toàn phi tập trung, miễn phí sử dụng mà bất kỳ ai cũng có thể chọn và chơi.
Tic Tac Toe được chọn làm ví dụ vì nó dễ hiểu và đủ nhỏ để mã được sử dụng trong một bài đăng trên blog. Nhưng kiến trúc và ngăn xếp công nghệ tương tự này cũng có thể được áp dụng cho các dự án lớn! Ví dụ: hợp đồng thông minh có thể chạy một công cụ cờ vua thay vì một công cụ Tic Tac Toe. Hoặc nó có thể không liên quan gì đến trò chơi và hợp đồng thông minh chạy trình xác minh bằng chứng không có kiến ​​thức cho một số ứng dụng. Các khả năng là vô tận!
Bài đăng này hiển thị một số đoạn mã là các phần độc lập; tuy nhiên, không phải tất cả các mã được hiển thị. Mã hoàn chỉnh cho các hợp đồng thông minh được sử dụng trong ví dụ này có sẵn trên GitHub. Mã front-end hoàn chỉnh có trên BOS.

Mô hình

Dự án này bao gồm ba thành phần:

  1. Một hợp đồng thông minh phi trạng thái được viết bằng Rust và được triển khai cho Near, có trạng thái Tic Tac Toe và đầu vào và trả về trạng thái cập nhật làm đầu ra.
  2. Hợp đồng Solidity được triển khai cho Aurora, người dùng tương tác với nó để bắt đầu trò chơi Tic Tac Toe và thực hiện các bước tiếp theo. Hợp đồng này sử dụng Near one để tạo đối thủ máy tính và nó lưu trữ các trò chơi của người dùng trong bộ nhớ.
  3. Giao diện người dùng được viết bằng JavaScript được cung cấp bởi BOS. Đây là giao diện mà người dùng tương tác trực tiếp và gửi các giao dịch đến hợp đồng thông minh Solidity trên Aurora.

Tất cả các thành phần này chạy trên nền tảng blockchain; không cần lấy bất kỳ tài nguyên phần cứng nào để triển khai dApp này và bất kỳ ai cũng có thể tương tác với nó.
Một cách để nghĩ về kiến trúc này là tương tự như một ứng dụng Web2 sử dụng cả JavaScript (JS) và WebAssembly (Wasm). Mã JS xử lý trạng thái (cookie, DOM, v.v.), trong khi Wasm xử lý tính toán nặng hơn sẽ không hiệu quả khi thực hiện trực tiếp trong JS. Trong trường hợp của chúng tôi, mã Solidity xử lý trạng thái trong khi mã Rust trên Near xử lý tính toán nặng hơn (và cuối cùng mã này cũng chạy dưới dạng Wasm, làm cho sự tương tự thậm chí còn mạnh hơn).
Trong các phần tiếp theo, chúng ta sẽ thảo luận chi tiết về từng thành phần này.

Near contract

Như đã mô tả ở trên, hợp đồng Near không có trạng thái và xử lý logic phức tạp hơn của ứng dụng của chúng tôi, trong trường hợp này là trình phát máy tính Tic Tac Toe. Nó rất rõ ràng và dễ dàng để viết mã như vậy trong Rust. Chúng tôi có một mô-đun trong đó một số loại cơ bản được xác định:

#[repr(i8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellState {
Empty = 0,
X = 1,
O = -1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GameState {
/// Row-major representation of the board
pub board: [CellState; BOARD_SIZE],
}

Và một mô-đun khác sử dụng các loại đó để phân tích vị trí Tic Tac Toe, sau đó thực hiện:

pub enum MoveResult {
Move { updated_state: GameState },
GameOver { winner: CellState },
}
pub fn get_move(state: GameState) -> MoveResult {
// ... elided for brevity
}
enum Evaluation {
Sums {
sums: [i8; ROW_SIZE + ROW_SIZE + 2],
total: i8,
},
GameOver {
winner: CellState,
},
}
fn evaluate_position(state: GameState) -> Evaluation {
// ... elided for brevity
}

Cuối cùng, có điểm đầu vào hợp đồng được viết bằng Near SDK:

#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, Default)]
pub struct TicTacToe;
#[near_bindgen]
impl TicTacToe {
pub fn get_move(&self, state: String) -> GetMoveResponse {
let parsed_state: types::GameState = state
.parse()
.unwrap_or_else(|_| env::panic_str("Invalid state string"));
match logic::get_move(parsed_state) {
logic::MoveResult::Move { updated_state } => {
let serialized_state = updated_state.to_string();
let winner = match logic::get_move(updated_state) {
logic::MoveResult::GameOver { winner } => Some(format!("{winner:?}")),
logic::MoveResult::Move { .. } => None,
};
GetMoveResponse {
updated_state: serialized_state,
winner,
}
}
logic::MoveResult::GameOver { winner } => GetMoveResponse {
updated_state: state,
winner: Some(format!("{winner:?}")),
},
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct GetMoveResponse {
updated_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
winner: Option<String>,
}

Điều thú vị về việc đây là một hợp đồng phi trạng thái là bạn có thể tương tác hoàn toàn với nó bằng cách sử dụng lệnh gọi xem (về cơ bản là sử dụng Near làm nền tảng tính toán không máy chủ). Tôi đã viết một front-end powered by BOS để tương tác trực tiếp với hợp đồng NEAR này để minh họa điểm này. Vì không có giao dịch nào thực sự được gửi đến chuỗi, nên nó phản hồi nhanh hơn nhiều so với sản phẩm cuối cùng mà chúng tôi đang xây dựng trong bài đăng này. Nhưng điện toán phi trạng thái có các ứng dụng hạn chế, do đó, việc thực hiện các giao dịch trên chuỗi để truy cập trạng thái vẫn rất quan trọng trong các trường hợp sử dụng trong thế giới thực. Đối với điều này, chúng tôi đang sử dụng Aurora.

Hợp đồng Aurora

Hợp đồng Solidity được triển khai trên Aurora xử lý việc quản lý trạng thái và là hợp đồng mà người dùng thực hiện giao dịch. Hợp đồng này sử dụng tính năng cross-contract calls (XCC) của Aurora để gọi trực tiếp hợp đồng Near khi nó cần biết nước đi tiếp theo của đối thủ máy tính. Về cơ bản, đây là mã trông như thế nào (một số chi tiết được bỏ qua cho ngắn gọn):

contract TicTacToe is AccessControl {
using AuroraSdk for NEAR;
using AuroraSdk for PromiseCreateArgs;
using AuroraSdk for PromiseWithCallback;
using AuroraSdk for PromiseResult;
using Codec for bytes;
    constructor(string memory _ticTacToeAccountId, IERC20 _wNEAR) {
ticTacToeAccountId = _ticTacToeAccountId;
near = AuroraSdk.initNear(_wNEAR);
wNEAR = _wNEAR;
_grantRole(OWNER_ROLE, msg.sender);
_grantRole(CALLBACK_ROLE, AuroraSdk.nearRepresentitiveImplicitAddress(address(this)));
}
// Start a new game where `player_preference = 0` means player goes second (plays O) and
// `player_preference > 0` means the plater goes first (plays X).
function newGame(uint256 player_preference) public {
address player = msg.sender;
games[player] = 0;
if (player_preference == 0) {
takeComputerTurn(player, 0);
}
}
function takePlayerTurn(uint256 move) public {
address player = msg.sender;
uint256 currentState = games[player];
require(currentState < 0x1000000000000000000, "Game Over");
require(legalMoves[move] > 0, "Invalid move");
require(move & currentState == 0, "Move at filled cell");
currentState ^= move;
games[player] = currentState;
takeComputerTurn(player, currentState);
}
function getGameState(address player) public view returns (uint256) {
return games[player];
}
// Call the tic tac toe contract on NEAR to make a move.
function takeComputerTurn(address player, uint256 initialState) private {
bytes memory data = abi.encodePacked("{\"state\":\"", encodeStateForNear(initialState), "\"}");
PromiseCreateArgs memory callGetMove = near.call(ticTacToeAccountId, "get_move", data, 0, GET_MOVE_NEAR_GAS);
PromiseCreateArgs memory callback = near.auroraCall(
address(this),
abi.encodeWithSelector(this.computerTurnCallback.selector, player),
0,
COMPUTER_TURN_CALLBACK_NEAR_GAS
);
callGetMove.then(callback).transact();
}
// Get the result of calling the NEAR contract. Update the internal state of this contract.
function computerTurnCallback(address player) public onlyRole(CALLBACK_ROLE) {
PromiseResult memory result = AuroraSdk.promiseResult(0);
if (result.status != PromiseResultStatus.Successful) {
revert("Tic tac toe Near call failed");
}
// output is of the form `{"updated_state":"<NINE_STATE_BYTES>","winner":"CellState::<X|O|Empty>"}`
// where the `winner` field is optional.
uint256 updatedState = decodeNearState(result.output);
if (result.output.length > 37) {
// Indicate the game is over by setting some higher bytes
updatedState ^= 0x1100000000000000000000;
}
games[player] = updatedState; emit Turn(player, string(result.output));
}
}

Điều thú vị khi sử dụng Aurora cho các giao dịch trực tuyến là chúng tôi có thể dễ dàng tiếp nhận người dùng với 50 giao dịch miễn phí mà Aurora cung cấp cho bất kỳ người dùng nào (việc tiếp nhận đơn giản hơn vì họ không cần mua tiền điện tử để trang trải phí gas; họ chỉ cần bắt đầu chơi trò chơi của chúng tôi ngay lập tức).
Phần cuối cùng của câu đố là có một giao diện người dùng tương tác với người dùng và thay mặt họ thực hiện các giao dịch đối với hợp đồng này.

Giao diện người dùng BOS

Hệ điều hành chuỗi khối (BOS) cho phép tạo các giao diện người dùng phi tập trung nơi mã được lưu trữ trên chuỗi khối Near. Các cổng BOS (mà ai cũng có thể chạy) sau đó cung cấp mã cho người dùng cuối. Điều này thuận tiện cho tôi với tư cách là nhà phát triển vì tôi không cần lưu trữ bất kỳ máy chủ nào cho giao diện người dùng của mình; Tôi biết rằng các cổng BOS sẽ lo việc đó cho tôi.
Nếu bạn đã quen với việc sử dụng khung React JavaScript, bạn sẽ không gặp vấn đề gì khi viết giao diện người dùng trong BOS. Bản thân tôi không phải là nhà phát triển JS nhiều và thậm chí tôi còn thấy việc sử dụng BOS để tạo một giao diện người dùng đơn giản khá dễ dàng (hãy ghi nhớ điều này khi bạn nhìn vào giao diện người dùng; tôi không phải là một chuyên gia giao diện người dùng nhà phát triển). Mã nguồn hoàn chỉnh có thể được xem trên chính BOS, nhưng đây là một số điểm nổi bật của mã:

const sender = Ethers.send("eth_requestAccounts", [])[0];
if (!sender) return <Web3Connect connectLabel="Connect with Web3" />;const contractAbi = fetch(
"https://gist.githubusercontent.com/birchmd/3db801d6115ceaaafb3d7e8fd94e0dc2/raw/5aa660a746d8f137df2c77142bfba36057dab6ef/TicTacToe.abi.json"
);
const iface = new ethers.utils.Interface(contractAbi.body);const contract = new ethers.Contract(
contract_address,
contractAbi.body,
Ethers.provider().getSigner()
);
initState({
board: {
isGameOver: false,
board: [".", ".", ".", ".", ".", ".", ".", ".", "."],
},
pendingPlayer: "X",
player: "X",
playerNumber: 1,
expectNewState: true,
firstQuery: true,
startingNewGame: false,
});
const newGame = () => {
// Don't allow sending new transactions while waiting
// for the state to update.
if (state.expectNewState) {
return;
}
let player_prefernece; if (state.pendingPlayer == "X") {
State.update({ player: "X", playerNumber: 1 });
player_prefernece = 1;
} else {
State.update({ player: "O", playerNumber: 17 });
player_prefernece = 0;
}
contract.newGame(player_prefernece).then((tx) => {
State.update({ expectNewState: true, startingNewGame: true });
tx.wait().then((rx) => {
console.log(rx);
getGameState();
});
});
};
const playerMove = (index) => {
if (
!state.expectNewState &&
!state.board.isGameOver &&
state.board.board[index] == "."
) {
const move =
"0x" +
(
new BN(state.playerNumber) * new BN(256).pow(new BN(8 - index))
).toString(16);
contract.takePlayerTurn(move).then((tx) => {
State.update({ expectNewState: true, startingNewGame: false });
tx.wait().then((rx) => {
console.log(rx);
getGameState();
});
});
}
};
const getGameState = () => {
// shot curcuit to avoid constantly hitting the RPC
if (!state.expectNewState) {
return;
}
const encodedData = iface.encodeFunctionData("getGameState", [sender]); Ethers.provider()
.call({
to: contract_address,
data: encodedData,
})
.then((boardHex) => {
const result = parseBoardHex(boardHex);
const expectNewState =
state.expectNewState &&
!state.firstQuery &&
result.isGameOver == state.board.isGameOver &&
JSON.stringify(result.board) === JSON.stringify(state.board.board);
State.update({
board: result,
player,
playerNumber,
winner,
expectNewState,
firstQuery: false,
});
});
};
return (
<>
{getGameState()}
<table>
<tr>
<TopLeftCell onClick={() => playerMove(0)}>
{state.board.board[0]}
</TopLeftCell>
<TopCenterCell onClick={() => playerMove(1)}>
{state.board.board[1]}
</TopCenterCell>
<TopRightCell onClick={() => playerMove(2)}>
{state.board.board[2]}
</TopRightCell>
</tr>
<tr>
<MiddleLeftCell onClick={() => playerMove(3)}>
{state.board.board[3]}
</MiddleLeftCell>
<MiddleCenterCell onClick={() => playerMove(4)}>
{state.board.board[4]}
</MiddleCenterCell>
<MiddleRightCell onClick={() => playerMove(5)}>
{state.board.board[5]}
</MiddleRightCell>
</tr>
<tr>
<BottomLeftCell onClick={() => playerMove(6)}>
{state.board.board[6]}
</BottomLeftCell>
<BottomCenterCell onClick={() => playerMove(7)}>
{state.board.board[7]}
</BottomCenterCell>
<BottomRightCell onClick={() => playerMove(8)}>
{state.board.board[8]}
</BottomRightCell>
</tr>
</table>
<br></br>
{state.board.isGameOver && <div>{state.winner}</div>}
{state.expectNewState ? (
<div>
<p>Waiting for new data from RPC...</p>
</div>
) : (
<div />
)}
<br></br>
<label for="selectPlayer">Play as:</label>
<select
id="selectPlayer"
onChange={(e) => State.update({ pendingPlayer: e.target.value })}
>
<option value="X">X</option>
<option value="O">O</option>
</select>
<div class="mb-3">
<button onClick={newGame}>New Game</button>
</div>
</>
);

Demo và kết luận

Ứng dụng này hiện đang có trên BOS! Bạn có thể dùng thử tại đây hoặc xem bản trình diễn được ghi trước ở đây. Để sử dụng ứng dụng demo, hãy đảm bảo rằng MetaMask của bạn được kết nối với Aurora Testnet (giao diện BOS có thể cho biết mạng không được nhận dạng, nhưng nó vẫn hoạt động để gửi giao dịch).
Bài đăng này đã khám phá ngăn xếp công nghệ NEAR để xây dựng các ứng dụng phi tập trung hoàn toàn. Toàn bộ ứng dụng này được lưu trữ trên chuỗi từ đầu đến cuối. Blockchain Near cung cấp lớp tính toán cơ sở với thời gian chạy do WebAssembly hỗ trợ, Aurora cung cấp lớp bền vững trong khi vẫn duy trì khả năng tích hợp dễ dàng trong các giao dịch miễn phí và BOS cung cấp giao diện người dùng không cần máy chủ được xây dựng trên chuỗi khối Near.
Tôi hy vọng bạn thích blog này và cảm thấy được truyền cảm hứng để tự mình xây dựng bằng cách sử dụng Aurora, Near và BOS!

--

--