Для обеспечения прозрачности и доказуемой честности мы сгенерировали цепочку из 10 миллионов хэшей SHA256 для каждой мультиплеерной игры, начиная с серверного секрета, результат которого 10 миллионов раз повторно подавался обратно в SHA256.
SHA256 конечного хэша для Platform: eeb4d340bfaed702601b9a08faba2f8088c4810c3e3a883293accaf25053b883
SHA256 конечного хэша для Crash: c46f0705f6ba4df891ce44accda8f89f5a6fa8b987eb7c7b445280cf6cabfbc6
SHA256 конечного хэша для Battle: 63cb1eb64ec4eb4de02f6bad85e42365d08f37497a1b7e907bd0b9183f27e1b3
Публикуя это здесь, мы исключаем возможность выбора альтернативной цепочки SHA256. Теперь игровой сервер проходит через данную цепочку хэшей в обратном порядке, используя данные значения для расчета результатов игры доказуемо честным образом.
1. Секретный начальный хэш: Каждый тип мультиплеерной игры имеет свой собственный приватный начальный хэш (известный только серверу), формирующий корень уникальной хэш-цепочки.
2. Генерация хэш-цепочки: Из начального секретного хэша мы генерируем цепочку из 10 000 000 хэшей путем многократного применения SHA256.
func HashSeed(seed string) string {
hash := sha256.Sum256([]byte(seed))
return hex.EncodeToString(hash[:])
}
func CalculateHashChain(firstHash string) []string {
hashes := make([]string, 10_000_000)
for i := int64(0); i <= 10_000_000; i++ {
firstHash = HashSeed(firstHash)
hashes[i] = firstHash
}
return hashes
}
3. Обратное потребление: Мы раскрываем публике конечный хэш в цепочке. Каждый игровой раунд использует хэши в обратном порядке: Раунд 1 использует хэш[10_000_000] Раунд 2 использует хэш[9_999_999] и так далее…
4. Данная конструкция делает невозможным подделку будущих хэшей, позволяя при этом любому проверить все предыдущие хэши.
Непредсказуемость: Будущие игровые результаты не могут быть известны или подвержены влиянию, даже со стороны сервера.
Проверяемость: Игроки могут проверить целостность любого предыдущего раунда, используя только публично раскрытый конечный хэш.
Изоляция игр: Каждый тип игры имеет свою собственную цепочку, предотвращая манипуляции между играми.
Для обеспечения доказуемой честности в одиночных играх наша платформа использует систему, основанную на трех основных компонентах для генерации результирующего сида:
1. Клиентский сид: Это сид, контролируемый пользователем. Он автоматически генерируется при регистрации пользователя, но пользователь может изменить его в любое время. Изменение клиентского сида инициирует генерацию нового серверного сида.
2. Серверный сид: Он генерируется сервером с использованием внутренних параметров, которые не раскрываются пользователю. Он уникально привязан к текущему клиентскому сиду. Для каждого клиентского сида существует соответствующий серверный сид. Когда пользователь меняет клиентский сид, предыдущий серверный сид раскрывается для целей верификации.
3. Nonce: Уникальное число, основанное на временной метке ставки (в миллисекундах). Оно обеспечивает то, что даже повторяющиеся действия дают разные результаты.
Данные три значения объединяются и хэшируются с использованием HMAC-SHA256 для генерации конечного игрового сида, который затем используется для определения результата игры.
func GenerateUserGameSeed(userSeed string, userServerSeed string, nonce int64) (string, error) {
data := fmt.Sprintf("%s:%s:%d", userServerSeed, userSeed, nonce)
mac := hmac.New(sha256.New, []byte(data))
gameSeed := hex.EncodeToString(mac.Sum(nil))
return gameSeed, nil
}
Сервер предоставляет хэшированную версию текущего серверного сида до совершения какой-либо ставки.
После того как пользователь изменяет свой клиентский сид, ранее использовавшийся серверный сид раскрывается.
Это позволяет пользователям проверить, что все результаты, сгенерированные с предыдущим серверным сидом, были последовательными и честными.
В игре Dice пользователь выбирает от 1 до 5 чисел (из 6 возможных сторон). С помощью сгенерированного игрового сида симулируется один бросок кости. Если выпавшее число совпадает с одним из выбранных пользователем чисел, пользователь выигрывает.
func rollDice(seed string, selectedNumbers []int64) (int64, bool, error) {
if len(selectedNumbers) < 1 || len(selectedNumbers) > 5 {
return 0, false, fmt.Errorf("incorrect number of selected numbers: %d", len(selectedNumbers))
}
bigSeed, ok := new(big.Int).SetString(seed, 16)
if !ok {
return 0, false, fmt.Errorf("failed to convert seed to big.Int")
}
// Simulate dice roll: number from 1 to 6
dice := new(big.Int).Mod(bigSeed, big.NewInt(6))
diceFace := dice.Int64() + 1
for _, num := range selectedNumbers {
if num == diceFace {
return diceFace, true, nil
}
}
return diceFace, false, nil
}
В игре Mines цель состоит в том, чтобы открыть как можно больше клеток, не попав на мину. Размещение мин определяется детерминированно с использованием криптографически безопасного игрового сида. Это обеспечивает честность и прозрачность, позволяя пользователю проверить, что игровое поле не было подвержено манипуляциям.
Входные данные:
seed: 64-символьная шестнадцатеричная строка, полученная путем комбинирования ClientSeed, ServerSeed и Nonce с использованием HMAC-SHA256.
numberOfMines: количество мин, размещаемых на поле.
maxCells: общее количество клеток на поле (например, 25 для сетки 5x5).
func generateMines(seedHex string, numberOfMines int) ([]int64, error) {
if numberOfMines > 25 {
return nil, fmt.Errorf("invalid number of mines: %d", numberOfMines)
}
var mines []int64
used := make(map[int]bool)
i := 0
for len(mines) < numberOfMines {
mac := hmac.New(sha256.New, []byte(seedHex))
mac.Write([]byte(fmt.Sprintf("mine-%d", i)))
sum := mac.Sum(nil)
val := binary.BigEndian.Uint32(sum)
pos := int(val%25) + 1
if !used[pos] {
used[pos] = true
mines = append(mines, int64(pos))
}
i++
}
return mines, nil
}Если numberOfMines = 3 и maxCells = 25, функция детерминированно вернет 3 уникальных индекса клеток (от 1 до 25), на которых размещены мины.
В игре Platforms результат каждого раунда определяется публичным, проверяемым хэшем, являющимся частью ранее опубликованной хэш-цепочки.
Каждый раунд использует хэш из цепочки для определения «последней платформы» — платформы, которая исчезнет в конце игры.
func generateLastPlatform(hash string) (int64, error) {
h, err := hex.DecodeString(hash)
if err != nil {
return 0, err
}
number := binary.BigEndian.Uint64(h[:8])
rng := rand.New(rand.NewSource(int64(number)))
randomNumber := rng.Intn(25) + 1 // Platforms are numbered 1 through 25
return int64(randomNumber), nil
}
Во время игрового процесса игроки стоят на разных платформах. Платформа, которая падает, выбирается случайным образом из доступных платформ, гарантируя, что это никогда не будет та же платформа, что упала ранее.
В игре Rocket результат каждого раунда (множитель краша) определяется с использованием публичного хэша из заранее сгенерированной хэш-цепочки (уникальной для каждой игры). Результат рассчитывается с помощью детерминированной функции, которая преобразует хэш в множитель.
func generateMultiplier(hash string) (float64, error) {
const N = 40
bigH, ok := new(big.Int).SetString(hash, 16)
if !ok {
return 0, fmt.Errorf("failed to convert seed to big.Int")
}
mod := new(big.Int).Mod(bigH, big.NewInt(N))
if mod.Cmp(big.NewInt(0)) == 0 {
return 1, nil
}
if len(hash) < 13 {
return 0, fmt.Errorf("hash too short")
}
h13Str := hash[:13]
bigH13, ok := new(big.Int).SetString(h13Str, 16)
if !ok {
return 0, fmt.Errorf("failed to convert first 13 hex digits to big.Int")
}
// Compute 100 * (2^52 - h) / (2^52 - h)
e := new(big.Int).Lsh(big.NewInt(1), 52) // 2^52
hundred := big.NewInt(100)
hundredE := new(big.Int).Mul(hundred, e)
numerator := new(big.Int).Sub(hundredE, bigH13)
denom := new(big.Int).Sub(e, bigH13)
numFloat := new(big.Float).SetInt(numerator)
denomFloat := new(big.Float).SetInt(denom)
ratio, _ := new(big.Float).Quo(numFloat, denomFloat).Float64()
floored := math.Floor(ratio)
result := floored / 100.0
return result, nil
}
Каждый игрок получает хэш перед раундом и может независимо проверить, что результат соответствует опубликованной формуле, обеспечивая полную прозрачность и доказуемую честность.
В игре Battle один игрок выбирается победителем на основе пропорционального размера его ставки к общему пулу. Это означает, что более крупные ставки приводят к более высоким шансам на выигрыш.
Мы используем детерминированный и доказуемо честный механизм выбора:
1. Публичный игровой хэш используется для генерации псевдослучайного числа в диапазоне [0.0, 1.0).
2. Каждому игроку присваивается сегмент диапазона, пропорциональный его ставке (например, ставка в 20% покрывает 20% диапазона).
3. Случайное число определяет выигрышный сегмент и, таким образом, победителя.
func pickWinner(hash string, chances []float64) (int, error) {
if len(chances) == 0 {
return -1, fmt.Errorf("zero participants")
}
total := 0.0
for _, chance := range chances {
if chance < 0 {
return -1, fmt.Errorf("chance cannot be negative")
}
total += chance
}
if total < 99.99 || total > 100.01 {
return -1, fmt.Errorf("sum of chances must be 100%%, got: %.2f%%", total)
}
randomValue, err := deterministicFloatFromHash(hash)
if err != nil {
return -1, fmt.Errorf("random generation failed: %w", err)
}
cumulative := 0.0
for i, chance := range chances {
cumulative += chance / 100.0
if randomValue < cumulative {
return i, nil
}
}
return -1, fmt.Errorf("failed to pick winner")
}
func deterministicFloatFromHash(hash string) (float64, error) {
bytes, err := hex.DecodeString(hash)
if err != nil {
return 0, err
}
if len(bytes) < 8 {
return 0, fmt.Errorf("hash too short")
}
num := binary.BigEndian.Uint64(bytes[:8])
return float64(num) / float64(math.MaxUint64), nil
}