Реализация простой пиксельной игры в блокчейне Ethereum
Всем привет! Вдохновившись r/place и желая реализовать наконец-то свой первый смарт-контракт на блокчейне, мы решили сделать всем доступное и веселое приложение в сети Ethereum, которое позволяет рисовать на холсте размером в 1000×1000 px, сохраняя каждый выбранный и раскрашенный пользователем пиксель в блокчейн. Вы можете рисовать также в реальном времени со своими друзьями и наблюдать, как в реальном времени меняется цвет выбранного пикселя по мере того, как в сети подтверждаются транзакции смарт-контракта.
И все это лишь потребует немного Ethereum-a на покрытие расходов газа транзакции!
В данной статье я бы хотел рассказать, как у нас получилась текущая первая версия приложения и с какими техническими сложностями нам пришлось столкнуться.
Компоненты
Планируя в большей степени сфокусироваться на написании смарт-контракта, мы не хотели тратить очень много времени на серверную архитектуру и сделали все максимально простым.
Основным условием для работы с приложением является наличие Ethereum-совместимого браузера и плагина к браузеру (Metamask etc) с доступом к кошельку. В этом случае клиент самостоятельно отправляет транзакции в блокчейн и сервер лишь отдает обновленный стейт контракта через Nginx. Таким образом, вся целостность и безопасность данных гарантируется смарт-контрактом в блокчейне.
Таким образом у нас получились:
- Ethereum ÐApp — смарт-контракт, написанный на Solidity и задеплоенный в основную сеть.
- Front-end — написанный на Vanilla JS и использующий web3.js библиотеку и требующий Metamask плагин для работы с контрактом.
- Server-side — клиент, написанный на Scala с использованием Web3J для работы с blockchain нодой.
- Parity — нода, задеплоенная на DigitalOcean для синхронизации с сетью.
Смарт-контракт
В первую очередь мы взялись за реализацию смарт-контракта и учитывая наш начальный опыт в работе с Solidity, и полагаясь на best practices доступные в сети, мы решили пойти путем MVC и сделать модель для хранения стейта контракта и контроллер, который сможет обновлять эту модель.
Самый большой плюс такого подхода в том, что в будущем, при наличии изменений и по мере появления новой функциональности, нам нет необходимости мигрировать стейт приложения и понадобится обновить лишь контроллер.
Единственный минус такого подхода — это большая стоимость смарт-контракта при инициализации в сети Ethereum (на тот момент порядка $1,100).
Наивная реализация
Первоначально мы не думали об эффективности нашей имплементации и преждевременной оптимизации, поэтому сделали первый контракт максимально топорным. Учитывая размер нашего холста для рисования в 1000 px x 1000 px, у нас получился массив из 1 миллиона uint-ов, в котором мы хранили все пиксели.
Пример реализации контракта ниже:
contract PixelsField is Controllable {
uint[1000000] public colors;
event PixelChanged(uint coordinates, uint clr);
function setPixel(uint coordinates, uint clr) public onlyController {
require(clr < 16);
require(coordinates < 1000000);
colors[coordinates] = clr;
PixelChanged(coordinates, clr);
}
function allPixels() public view returns (uint[1000000]) {
return colors;
}
}
Трудности
На первый взгляд все работало просто отлично, мы смогли сохранить первые пиксели в блокчейн но, как только попытались прочитать стейт из сети, нас ждало огромное разочарование.
- Передать на клиент массив с размером 1 миллион пикселей, хранящийся в контракте с типом данных uint256, было невозможно за приемлемое время и к тому же из-за огромного размера этот массив было практически невозможно распарсить на клиенте.
- В итоге, вариант записи целого пикселя в один тип данных как uint256 был очень дорог и неэффективен и нам нужно было придумать вариант компрессии.
Улучшенная версия
Будучи расстроенными неудачей в первой версии имплементации, мы стали думать о возможном алгоритме сжатия и чтения пикселей, как нам сделать более компактную версию массива да и еще сохранить туда один из возможных 16-ти раскрашенного пикселя.
Изучив внимательно типы данных, доступные в Solidity, мы решили остаться с uint256, но начали записывать в него как позицию, так и цвет пикселя, тем самым надеясь поместить каждый пиксель в 4 bit-а из 256 доступных.
Для этого нам понадобилось немного побитовой магии, в которой мы кодируем координаты по X и Y каждого пикселя в индекс элемента массива и применяем битовый сдвиг и маску.
Пример реализации ниже:
function setPixel(uint coordinate, uint color) public onlyController {
require(color < 16);
require(coordinate < 1000000);
uint idx = coordinate / ratio;
uint bias = coordinate % ratio;
uint old = colors[idx];
uint zeroMask = ~(bitMask << (n * bias));
colors[idx] = (old & zeroMask) | (color << (n * bias));
PixelChanged(coordinate, color);
}
Быстрый подсчет нашей реализованной оптимизации показал, что для хранения 1 миллиона пикселей нам нужно будет лишь 1 000 0000 / 64 бита = 15 625 элементов типа uint256.
Тем самым мы уменьшили изначальный массив из нашей наивной реализации в 64 раза и смогли прочитать весь массив за приемлемое время на клиенте.
Полный пример стейта контракта ниже:
contract PixelsField is Controllable {
event PixelChanged(uint coordinates, uint clr);
uint[15625] public colors;
uint bitMask;
uint n = 4;
uint ratio = 64;
function PixelsField() public {
bitMask = (uint8(1) << n) - 1;
}
function setPixel(uint coordinate, uint color) public onlyController {
require(color < 16);
require(coordinate < 1000000);
uint idx = coordinate / ratio;
uint bias = coordinate % ratio;
uint old = colors[idx];
uint zeroMask = ~(bitMask << (n * bias));
colors[idx] = (old & zeroMask) | (color << (n * bias));
PixelChanged(coordinate, color);
}
function getPixel(uint coordinate) public view returns (uint) {
var idx = coordinate / ratio;
var bias = coordinate % ratio;
return (colors[idx] >> (n * bias)) & bitMask;
}
function allPixels() public view returns (uint256[15625]) {
return colors;
}
}
Взаимодействие с контрактом
Для взаимодействия с контрактом из UI мы добавили следующие функции, которые имеют доступ к состоянию контракта:
function getPixel(uint coordinate) public view returns (uint)
function allPixels() public view returns (uint256[15625])
Пользовательский интерфейс
Наша цель была сделать максимально простой и легкий UI, где пользователь фокусируется на холсте для рисования и из доступных инструментов есть лишь выбор цвета и возможность зума.
Отрисовка всего холста — достаточно быстрая и недорогая операция прежде всего из-за компактного размера массива пикселей в блокчейне и использования Canvas-а в браузере.
Более того, мы учли, что среди посетителей могут быть также те, у кого не установлен Ethereum-совместимый браузер или плагин к браузеру (Metamask etc), поэтому мы позволили нашему серверу генерировать текущий стейт всех пикселей на холсте из блокчейна и отдавать уже клиенту статичную картинку через Nginx.
Чтобы перекрасить пиксель, мы используем библиотеку web3.js. Вызов функции из контракта приведен ниже:
const colorSelected = (color) => () => {
hidePicker();
web3.eth.getAccounts((_error, accounts) => {
if (accounts.length === 0) {
alert("Please login in you wallet. Account not found ¯\_(ツ)_/¯.");
return;
};
const config = {
from: accounts[0],
gasPrice: 2500000000,
gasLimit: 50000,
value: 0
};
try {
controllerContract.methods.setPixel(settings.selectedcoordinate, color).send(config, (error, addr) => {
if (error) {
console.log(error);
return;
}
userPixels.push({
coord: settings.selectedcoordinate,
color: color
});
const {x, y} = numberToCoord(settings.selectedcoordinate);
setPixel(ctx, x, y, settings.colors[color]);
});
} catch (error) {
console.log(error);
}
});
}
Сервер
Реализация API не была нашим приоритетом, поскольку мы надеялись полностью положиться на библиотеку web3.js на клиенте.
Но поскольку есть ряд пользователей браузеров без Ethereum совместимых плагинов и мобильных устройств, мы решились на поднятие своей Parity ноды в DigitalOcean окружении и синхронизации ее с сетью.
Для того, чтоб взаимодействовать с Parity мы написали легковесный API, который poll-ит Parity ноду, забирает текущий стейт контракта и отрисовывает этот самый последний стейт, сохраняя картинку в png формате на сервере. Дальше это уже забота Nginx отдать картинку клиенту.
Поскольку стейт контракта это массив из uint256 данных, примерный payload того, что мы получаем из контракта выглядит вот так:
0×000000000000000000000000000000000000000000000000000000000000000b….
И нам приходится делать трансформацию учитывая наши доступные 16 цветов на клиенте и требуемый результат в виде png картинки:
Пример ниже:
mport java.awt.{Color => AwtColor}
import java.io.{File, FileOutputStream}
import java.time.Instant
import com.sksamuel.scrimage.nio.PngWriter
import com.sksamuel.scrimage.{Image, Pixel}
import com.typesafe.scalalogging.StrictLogging
import org.web3j.utils.{Numeric => NumericTools}
import scala.util.Try
object Composer extends StrictLogging {
private lazy val colorMapping: Map[Char, String] = Map(
'0' -> "#FFFFFF",
'1' -> "#9D9D9D",
'2' -> "#000000",
'3' -> "#BE2633",
'4' -> "#E06F8B",
'5' -> "#493C2B",
'6' -> "#A46422",
'7' -> "#EB8931",
'8' -> "#F7E26B",
'9' -> "#2F484E",
'a' -> "#44891A",
'b' -> "#A3CE27",
'c' -> "#1B2632",
'd' -> "#005784",
'e' -> "#31A2F2",
'f' -> "#B2DCEF")
private lazy val pixelsMapping: Map[Char, Pixel] = hex2Pixels(colorMapping)
private val canvasHeight = 1000
private val canvasWidth = 1000
private val segmentLength = 64
def hex2Pixels(map: Map[Char, String]): Map[Char, Pixel] = {
def pixel(hex: String) = {
for {
color <- Try(AwtColor.decode(hex)).toOption
pixel = Pixel(color.getRed, color.getGreen, color.getBlue, 255)
} yield pixel
}
for {
(color, hex) <- map
pixel <- pixel(hex)
} yield color -> pixel
}
def apply(encoded: String): Unit = {
val startedAt = Instant.now
val pixels = translateToPixels(encoded)
write(pixels, fileName)
logger.info(s"Successfully wrote $fileName, took ${ Instant.now.toEpochMilli - startedAt.toEpochMilli } ms")
}
def translateToPixels(encoded: String): List[Pixel] = {
def decode(color: Char) = for (pixel <- pixelsMapping.get(color)) yield pixel
val extracted = NumericTools.cleanHexPrefix(encoded)
extracted.grouped(segmentLength)
.toList
.par
.flatMap(_.reverse.toSeq)
.flatMap(decode)
.toList
}
private def write(pixels: List[Pixel], fileName: String): Unit = {
val file = new File(fileName)
val out = new FileOutputStream(file, false) // don't append existing file
val image = Image(canvasWidth, canvasHeight, pixels.toArray)
val pngWriter = PngWriter()
pngWriter.write(image, out)
out.flush()
out.close()
}
}
Результат
Чему научились:
Подводя итоги, нам было очень интересно и познавательно сделать первое приложение на блокчейне и выложить его в mainnet и мы надеемся, что пользователям будет также увлекательно попробовать нарисовать что-то на блокчейне и оставить это в истории :)
Дальнейшие планы
Мы собираемся развивать ethplace.io и с удовольствием поделимся в скором времени новостями о новых интересных фичах, над которыми работаем!