Сейчас регулярно выходят анонсы про NFT-metaverse-блокчейн-игры, которые привлекали инвестиции в миллионы долларов по оценке в миллиарды, но при изучении проектов там оказываются либо плашки Coming Soon, либо продажа JPG-картинок на аукционах NFT-токенов, либо централизованные проекты с гомеопатическими дозами блокчейна. Перед тем, как окрестить это всё пузырем хайпа, я решил разобраться в технологическом стеке самостоятельно и сделать свою блокчейн-игру с NFT, потратив минимум ресурсов. Читайте под катом как у меня это получилось всего за 2 дня, а также покупайте мои NFT (нет).

Главные критерии создаваемый игры для меня были такие:

  • Победа определяется умением, а не рандомом

  • Возможность играть против живых людей на реальные деньги

Проблемы, которые надо было решить:

  • Доверие. Перед тем как поставить деньги на кон, игрок должен быть уверен, что он точно получит банк в случае победы, а правила игры не будут изменены.

  • Простота. Если сложно ввести/вывести деньги или разобраться в игре, это сужает круг игроков.

  • Нехватка личных ресурсов. Мне надо это сделать с минимальными временными затратами и, желательно, без юридических последствий.

Делать свой прием платежей не пришлось, ведь в блокчейне каждый аккаунт автоматически является кошельком. Если игру целиком засунуть в смарт-контракт, то она получится бездоверительной, так как не требует сервера/бекэнда и следовательно не подразумевает наличия центра доверия. Нужно лишь доверять коду. Игроки отправляют все свои действия в смарт-контракт, тот их обрабатывает и в конце принимает решение, кто выиграл, а потом автоматически выплачивает приз.

Смарт-контракт (или децентрализованное приложение, dApp) - это некая автономная неизменяемая сущность (микросервис), которая работает в распределенной сети (блокчейне) и запускается в контейнерах на серверах валидаторов. Валидаторы финансово заинтересованы вести себя правильно и оставаться доступными. Таким образом пользователи игры могут довериться, что код сработает предсказуемым образом, а его автор не сможет сбежать с деньгами, выключив сервера.

На блокчейнах "первой волны" выполнять транзакции было довольно дорого, но в последние годы появилось немало решений с крайне дешевыми транзакциями, что-то вроде $0.001 за “ход” с временем подтверждения в 1 секунду. RTS или шутеры тут конечно, не построишь, но как минимум настольные и логические игры уже выглядят пригодными. Также использование новыми блокчейнами Wasm в качестве виртуальной машины позволяет нам не изобретать велосипед свою собственную игровую механику, а использовать что-то написанное раньше и выложенное в Open Source.

Я решил начать с обычной игры в шашки, по максимуму используя чужой готовый код. Открыл git, запустил поиск и взял первые ссылки из выдачи: готовый код для логики игры (rusty-checkers) и JS UI для фронтенда (checkers).

Делаем смарт-контракт

Само децентрализованное приложение я сделал на блокчейне NEAR, развернув проект через create-near-app, в папку для контракта я скопировал весь код из rusty-checkers, добавил в главную библиотеку lib.rs импорт файлов игры, заменил функции вывода (долой println! ), убрал методы для stdin и stdout и по минимуму обновил код согласно велениям времени, например, принудительно дописал dyn для всех Trait объектов. В общем, смиренно подчинился всем требованиям великого и ужасного компилятора Rust и меньше чем через полчаса мой код уже компилировался. Пришло время обновить логику.

Как было

Старая функция main() работала примерно так: 

  • Рисуется игровое поле

checkers::print_board(game.board()).unwrap();
  • Запускается цикл, игрока просят сделать ход, читая его с клавиатуры и проверяя на валидность.

stdin().read_line(&mut line);let parse_result = checkers::parse_move(&line);
  • Ход обрабатывается, если всё ок, то меняется состояние игры

let move_result = apply_positions_as_move(&mut game, positions);
  • Производится проверка, если есть проигравший, то цикл прерывается

Ok(game_state) => match game_state {
	GameState::InProgress => { },	
	GameState::GameOver{winner_id} => { }
}

Как стало

Этот код я сократил до функции make_move, которой в качестве входного параметра передается game_id и line (строка с ходом, ведь клавиатуры в блокчейне у нас нет). Далее мы:

  • Считываем игру из состояния смарт-контракта

let mut game: Game = self.games.get(game_id).expect("Game not found");
  • Проверяем, что у аккаунта, вызывающего данный метод, есть право хода 

assert_eq!(game.current_player_account_id(), env::predecessor_account_id(), "ERR_NO_ACCESS");
  • Дальнейший код оставляем без изменений. Проверяем на валидность сделанный ход 

let parse_result = input::parse_move(&line);
  • Совершаем ход

let move_result = util::apply_positions_as_move(&mut game, positions);
  • Проверяем победителя 

Ok(game_state) => match game_state {
	GameState::InProgress => { },
	GameState::GameOver{winner_id} => { }
}
  • Сохраняем игру в состояние смарт-контракта (объект games)

self.games.insert(&game_id, &game_to_save);

Функция отличается вот так (было -> стало)

Получается, что перед тем как сделать ход, контракт "читает" состояние игры, запускает написанную ранее в rusty-checkers механику проведения хода, а потом, если были изменения, записывает состояние доски назад в хранилище. Чтобы не хранить в блокчейне вычисляемые значения, создаем объект GameToSave, в котором находятся:

#[derive(BorshDeserialize, BorshSerialize)]
pub struct GameToSave {
	pub(crate) player_1: PlayerInfo,
	pub(crate) player_2: PlayerInfo,
	pub(crate) reward: TokenBalance,
	pub(crate) winner_index: Option<usize>,
	pub(crate) turns: u64,
	pub(crate) last_turn_timestamp: Timestamp,
	pub(crate) total_time_spent: Vec<Timestamp>,
	pub(crate) board: BoardToSave,
	pub(crate) current_player_index: usize
}

Player_1, player_2 - имена аккаунтов игроков, reward - размер награды за игру и указание адреса контракта токена, в котором выплачивается награда, winner_index - индекс победителя (0/1), сам объект тут имеет тип Option, то есть может не иметь значения. Turns - количество сделанных в партии ходов, выводится на UI. Last_turn_timestamp - время сделанного последнего хода и total_time_spent - массив потраченного каждым игроком времени, для того, чтобы можно было принудительно остановить партию, если один из игроков потратил слишком много времени. Board - объект с игровой доской, current_player_index - индекс текущего игрока (0/1) оставлены из оригинального кодаBorshDeserialize, BorshSerialize - сериализации Borsh для Rust.

Что мы должны сохранять в состоянии контракта:

#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)]
pub struct Checkers {    
	games: LookupMap<GameId, GameToSave>,    
	available_players: UnorderedMap<AccountId, VGameConfig>,    
	stats: UnorderedMap<AccountId, VStats>,    
	available_games: UnorderedMap<GameId, (AccountId, AccountId)>,    
	whitelisted_tokens: LookupSet<AccountId>,    
	next_game_id: GameId,    
	service_fee: Balance
}
  • games - хешмап, где каждому GameId соответствует объект игры (GameToSave), рассмотренный выше. 

  • available_players -  хешмап игроков в листе ожидания, нужен для того, чтобы найти пару на игру. Для каждого аккаунта тут хранится объект VGameConfig.

    pub struct GameConfig {    
    	pub(crate) deposit: Option<Balance>,    
    	pub(crate) first_move: FirstMoveOptions,    
    	pub(crate) opponent_id: Option<AccountId>
    }

    Тут хранится deposit (сумма, которую игрок поставил на кон), first_move - настройки первого хода (выбранный заранее порядок или рандом) и opponent_id при необходимости сыграть лишь с конкретным оппонентом.

  • stats - хешмап со статистикой игроков, а также рефералла, пригласившего его.

  • available_games - массив с id игр, проходящих в данный момент

  • whitelisted_tokens - массив с адресами контрактов токенов, которые принимаются в качестве депозита,

  • next_game_id - id для следующей создаваемой игры

  • service_fee - процент, который сервис взимает в качестве комиссии с выигрыша.

Можно заметить, что в коде используется два разных хешмапа, один LookupMap и другой UnorderedMap, их отличие тут в том, что UnorderedMap поддерживает итерации и позволяет вывести, например, список всех активных игроков. Для LookupMap такой возможности нет, но у нас и нет необходимости "пробегать" в цикле все сыгранные игры, так как оппоненты будут запрашивать данные о своей игре по game_id, который они уже знают, а фронтэнды смогут считывать данные о текущих играх из небольшого объекта available_games. За счет отсутствия сериализации ключей, работа с объектом LookupMap обходится дешевле по потребляемому газу.

Также пришлось написать функцию для распределения награды, капитуляции, сохранения статистики, реферальную систему и другие вспомогательные методы. Но это уже больше "рюшечки" на будущее.

Делаем фронтэнд

С фронтендом получилось “разобраться” еще проще. Код на JS из взятой имплементации принимает игровое поле как объект 8 * 8, где 0 - пустая клетка, 1 и 2 - шашки игроков.

var gameBoard = [     
  [0, 1, 0, 1, 0, 1, 0, 1],     
  [1, 0, 1, 0, 1, 0, 1, 0],     
  [0, 1, 0, 1, 0, 1, 0, 1],     
  [0, 0, 0, 0, 0, 0, 0, 0],     
  [0, 0, 0, 0, 0, 0, 0, 0],     
  [2, 0, 2, 0, 2, 0, 2, 0],     
  [0, 2, 0, 2, 0, 2, 0, 2],     
  [2, 0, 2, 0, 2, 0, 2, 0]   
];

Чтобы сделать преемственность данных, я дописал свою функцию вывода игрового поля, которая переводит абстрактные классы шашек в такие же числа, а дамки (King) я закодировал отрицательными числами. 

Пример кода для вывода поля
for row in 0..board.number_rows {              
	for column in 0..board.number_columns {
  	let tile = &board.tiles[board.indices_to_index(row, column)];
    match tile.get_piece() {
    	None => 0,
      Some(piece) => match piece.get_type() {
      	PieceType::Man => piece.get_player_id() as i8,
        PieceType::King => piece.get_player_id() as i8 * -1
      }
    }
  }
}

Далее потребовалось научиться считывать ходы, сделанные на моем форке UI в понятном для Rust-кода виде, разворачивать на 180 градусов доску для второго игрока, блокировать поле, пока к нужному игроку не перешел ход. Для интерактивности я обновляю игру по таймеру, благо вызовы чтения из блокчейна бесплатные. Это всё было сделано на максимально убогом JS-коде, ссылаться на него мне стыдно, хотя он и работает.

В качестве "клея" между смарт-контрактом и JS кодом фронтенда я использовал near-api-js, там можно инициализировать контракт, указать доступные методы и потом вызывать их с необходимыми параметрами в виде простых js-вызовов: осуществляющих чтение (viewMethods) и запись (changeMethods).

window.contract = await new window.nearApi.Contract(
	window.walletConnection.account(), 
	nearConfig.contractName, {              
  	viewMethods: ['get_available_players', 'get_available_games', 'get_game'],
  	changeMethods: ['make_available', 'start_game', 'make_move', 'give_up', 'make_unavailable', 'stop_game'],
	})

Потом запустить игру можно, например, вот так:

await window.contract.start_game({opponent_id: player}, GAS_START_GAME, deposit)

Где GAS_START_GAME - константа для прикладываемого к транзакции газа, а deposit - сумма ставки в токенах.

Итого процесс выглядит примерно так, 

  • мы заходим на сайт с UI

  • логинимся c помощью NEAR-аккаунта, автоматически регистрируем ключ, который способен взаимодействовать лишь с контрактом игры и не может переводить токены без подтверждения пользователя 

  • Смотрим на игроков в листе ожидания и либо начинаем игру с одним из них, либо добавляемся в этот лист и ожидаем, пока выберут нас

  • Играем в шашки, делая по очереди ходы, UI отправляет наши действия в контракт через команду make_move, состояние фронтэнда синхронизируется с состоянием текущей игры, хранящимся в смарт-контракте. Таким образом получается, что любые "читы" на UI не имеют смысла.

  • Как только игра завершается, победитель получает все токены, поставленные на кон.

Добавляем NFT

Приправляем игру NFT-косметикой: если игрок купил NFT-токены со специального контракта то он и его соперники будут видеть графику из NFT на шашках этого игрока.

Имплементация NFT оказалась самой простой, тут я тоже задействовал чужой код, но на этот раз из core_contracts для блокчейна NEAR. Создал новый контракт и импортировал библиотеки:

near_contract_standards::impl_non_fungible_token_core!(NfTCheckers, tokens);
near_contract_standards::impl_non_fungible_token_approval!(NfTCheckers, tokens); 
near_contract_standards::impl_non_fungible_token_enumeration!(NfTCheckers, tokens);

Все базовые функции NFT сразу стали доступны в контракте, поэтому функция nft_mint для создания NFT всего лишь проверяет доступ текущего пользователя и вызывает стандартный метод, передавая туда данные для токена:

#[payable]     
pub fn nft_mint(         
	&mut self,         
	token_id: TokenId,         
	receiver_id: AccountId,         
	token_metadata: TokenMetadata) 
	-> Token {
 		assert_eq!(self.owner_id, env::predecessor_account_id(), "ERR_NO_ACCESS");
 		self.tokens.internal_mint(token_id, receiver_id, Some(token_metadata))
}
    

Чтобы уменьшить количество кода, я задействовал библиотеку web4 и добавил функцию генерирования css-файла для каждого отдельного токена, где задается название токена и аккаунт владельца токена.

pub fn web4_get(&self, request: Web4Request) -> Web4Response {
	let path = request.path.expect("Path expected");         
	let token_id = get_token_id(&path).unwrap_or_default();  
	if !token_id.is_empty() {            
		if path.starts_with(NFT_CSS_SOURCE) {    
			if let Some(token) = self.tokens.nft_token(token_id) { 
				return Web4Response::css_response(          
					format!("div#board .piece.{}.{} {{ 
						background-image: url('{}'); 
						background-size: cover; 
						background-repeat: unset; }}",
						token.owner_id.to_string(), 
						token.token_id.to_string(), 
						token.metadata.expect("ERR_MISSING_DATA").media.unwrap_or_default())
				);
			}
		}
	}
}

Этот код выдает из NFT-контракта примерно такой css-стиль:

div#board .piece.zavodil_near.chip {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAAEJElEQVR4nO3bO2wcRRjA8R+JEQUCxyAo6AgFhJcBCxBxFGwgkikoKSKg4lVAgWQrIKoUIAVwJAShAdEAERGio6DgYRcJD0GQEI4UJKCIEAUCxRFQhPhBMXu5NdnH7D32HN/9pdWOv5tv5rvvdr6Zb3bMgAF1MIxjWEjKVWQbgp1YTa6dFWVdZVMdnfyvn00VZV2lLgesWwYO6LUBHeJKvIdTWMS7uCJGcaiLRtXFJfgKV6dkD+Mu3IK/i5RjnoDdeDa5r0f2aH75D5MLrsF0mXLME/A4JjGP9ysaN4Q7MZaSjSXy0QjZGXyNpYI+7kjuC3gwVb4h6bttPhfm5bkWdA9ozuutXgdK+jiU1DuJbbheiAOrQlxoibtTBkxmyCYi25nTvgPKHH8vVjL0VmLsrCsIfo+Zijqz1g6JPD7Dc3hR8/ucwfPCsC0kzwEn8FJS3i6Ms6GU7ESEYWlO4tMWdGJ5GR/hfuHX/xjHK/aXSzsxoDEE6taNZqMshHrOQSxrP+DFXstJn21zQScaEQyq+2laweZ2G+mUA1aT+xEc7lCbeezAeFLulP1t03g099bQ195Uf23T90Gw7x1QZSU4hFdxbZdsaYVPMmQ/4hnFCdRZqjjgdjxVoX6Di/CokLdX4S+8jdMFde7LkR3ElzGdpB0wjNvkTy03p8pHrV2qZhnS4Am8FmNMBpvxesHn6eX1iGbavR0X5+gs4zth92iNA44IOXQMM9YmGkUR+fLINlvR3ZUqT2gum2dL9BZwE/VvicXO2x2Z4mLaTztgXPEQGFXu2fXCjJCCZ7EsDGGsdcApxZlXVFRdJxwVsRfAWgdswa2Kn4DzhcYeYxa5QfCw+CC43okOgv26EhwEwcYfgyCYKg+CoD4MgnWvBLu9woul7SA4K37f/s+q1qX4o+TzdDo8kip3JQj+kyqP5dY6lzeFHOCqCjrwG94qqZOXhX6hhXS4jG/whuwNkaJ0+LTW0+Eyst42HRdsjaKKA5bwdM5nvRrbu8qrFNOvK8Gz9L0DOj0NjgvHabrJeHmVePr+1VinjD4kGFQXK6qfV+oJdZ4PmFD9CE/lGHCPcGpsizDXviLs35+3VHHAHuzTjBtTeEg4kPh7h+2qwpPCOcEl4awQYWE2hZ+VrCZjHbANLwhffhG/4kZsxX48UqI/oni1mKcTw27hBNu85om2Oc33BB1xwBQuTMo7hH9q+EA4mPhAhP6o7Pd4Pafb6fAxFQJSQRtFTKTujSX5pObT0BGuw79JB4vChkIj4r5ToDckLFymU/WnheEQIxsX/yO1NAtUYca5JzJ/EncsPcu4WFksW4UgvS8pR1FlCMziWzyGy4RDzPuVHEevkV80Z4FoqsaAeZG7recLfZ8NDhzQawN6TV0OWMkox8o2BMP4IbkurSgbMKCL/AdBqz8yz5YYiQAAAABJRU5ErkJggg==');
background-size: cover;
background-repeat: unset;
}

Осталось только добавить в интерфейс функцию, которая читает NFT-токены, хранящиеся на аккаунтах игроков и подгружает соответствующие им css-файлы.

Всё готово! Мы сделали игру, логика которой целиком хранится в смарт-контракте на блокчейне и где есть реальное использование NFT! Один ход в игре стоит ~$0.006, что еще можно оптимизовать при желании.

Fork me on Github

Если вы захотите встроить в созданный мой контракт другие игры, то для этого надо подключить файлы с новой логикой от новой игры, заменить функцию make_move и сохранение/вывод игрового поля, и вуаля - вот вам готовые крестики-нолики, шахматы, го или более сложные настольные игры. Кто будет делать, пишите мне в телеграм, вместе поиграем!

Ссылка на репозитории: контракт, UI

Комментарии (15)


  1. Revertis
    24.12.2021 14:13

    Правильно ли я понимаю, что каждый ход игры это некая транзакция в блокчейне, которая потом исполняется валидаторами об смартконтракт?


    1. SuperNatural Автор
      24.12.2021 16:24

      1. Revertis
        24.12.2021 16:53
        +2

        Ага, значит поэтому блокчейн весит уже 4,3Тб.


        1. SuperNatural Автор
          24.12.2021 18:13
          +1

          Вы так пишете, как будто это что-то плохое. :) Дело же не в размере, а в том, если ли защита от спама данными. В Near для этого есть интересное решение Storage Staking.

          В рамках моей имплементации, в контракте хранится только текущее состояние игры, а не каждый ход. Также никто не мешает удалять старые игры совсем. Пока они оставлены лишь для того, чтобы просигнализировать игрокам о результате игры.


        1. JekaMas
          25.12.2021 08:44

          Там очень плохо данные представлены, поэтому та же полная архивная нода Эфира давно за 10Тб перевалила.

          Если переработать представление данных то получается в 10 раз меньше.

          См. проект Erigon


  1. Vitter
    24.12.2021 15:08
    +2

    Что мешает подключить к акку шашечного-бота, чтоб он доил доверчивых людей, пожелавших сыграть в ваши шашки?


    1. SuperNatural Автор
      24.12.2021 16:31
      +2

      Да, такой сценарий возможен. Отмечу лишь, что в децентрализованной среде таким ботам будет чуть сложнее выживать, так как можно заранее посмотреть достоверную статистику соперника и историю транзакций по его аккаунту, чтобы распознать потенциального бота. Либо можно изначально создать предложение сыграть конкретному блокчейн-аккаунту (этого нет на UI, но я сразу добавил в код).


  1. garikbesson
    24.12.2021 16:32

    Спасибо за статью, полезно. Закинул в закладки, буду разбирать контракт по кусочкам, много есть моментов, которые пригодятся.
    Очень понравилась фишка с генерируемым css на основе NFT :-)


    1. SuperNatural Автор
      24.12.2021 16:34

      Супер! Идея с NFT как источником CSS появилась после того, как я научил NFT-токены генерировать QR-коды для самих себя (сами картинки QR-кодов не храняются в блокчейне, а генерируются для отображения "на лету").


  1. gorin_da
    24.12.2021 19:02

    Игра сама ведь проходит не в блокчейне NEAR? Сама игра происходит в браузере. А вот **ядро** игры (данные, ходы) хранятся уже в блокчейне?

    Точно также как все другие *игры на блокчейне*, в любых други блокчейнах, верно?


    1. SuperNatural Автор
      24.12.2021 22:58
      +1

      В моем примере вся игра происходит на блокчейне, "браузер" (игрок) лишь посылает ход, а "ядро" (смарт-контракт) решает, валидный ли это ход, и что случается с игрой после данного хода.

      Но, увы, далеко не все игры на блокчейне работают именно так.


    1. vtools
      26.12.2021 20:10

      Не все, есть блокчейны, где код интерфейсной части хранится тоже на блокчейне (HTML,JS, CSS), а отображение в кошельках (десктопе, мобильном, веб). Но таких блокчейнов мало и они не известны.Например вот такая есть игра: https://terawallet.org/dapp/156#idMap1


  1. Ukaru
    25.12.2021 21:42

    Огромная благодарность. И за статью и за проект.

    Буду разбираться


  1. Agrav1vtyg0d
    26.12.2021 04:10

    Почему выбрали именно near?


    1. SuperNatural Автор
      26.12.2021 04:11
      +1

      Проще всего было поиграться с примерами кода, near.dev - так и втянулся, потом на хакатон их сходил