Мы с друзьями играем в Undercover, это игра в слова. Если ты не играл: почти все за столом получают одно и то же тайное слово, скажем, кофе. Несколько игроков получают почти такое же, чай, и никто не говорит им, что они здесь белые вороны: это Undercover. А один игрок, Mr. White, не получает слова вовсе. Все по очереди описывают своё слово, не называя его, а потом стол голосованием кого-нибудь выгоняет. Скажешь слишком расплывчато — выглядишь подозрительно. Слишком точно — и Mr. White узнаёт слово.

Играли мы в другом приложении. А мне хотелось игру покрасивее, без рекламы и с парами слов, которые генерируются, а не берутся из готового списка. Первые два пункта в Halfword уже есть. Генерация пар ещё впереди.

Что внутри

Halfword — это Undercover на одном телефоне, который передают по кругу:

  • От 4 до 20 игроков, один телефон, iPhone или Android. Без аккаунта, без интернета, без рекламы и без отслеживания. Имена, игры и очки остаются на телефоне.
  • Раздача: каждый удерживает кнопку, чтобы открыть свою карточку, читает её в одиночку и прячет, прежде чем передать телефон дальше. Карточку можно открыть ещё раз, но весь стол увидит, что её открывали.
  • По-настоящему скрытые роли. Мирные жители и Undercover получают одну и ту же карточку, только слово на ней другое, так что никому не говорят, кто из них кто. Свою роль знает только Mr. White, а роль игрока стол узнаёт, когда тот выбывает. У пойманного Mr. White есть одна попытка угадать слово мирных, и верная догадка крадёт победу.
  • Три способа голосовать, и переключаться между ними можно в любой момент, даже посреди голосования: «Тайно» — настоящее тайное голосование, пока телефон идёт по кругу; «Открыто» — поднятые руки, которые ведущий отмечает на экране; или «Только итог».
  • Восемь особых ролей, которые можно подмешать: Богиня правосудия, Влюблённые, Мститель, Дуэлянты, Призрак, Mr. Meme, Бумеранг и Шут.
  • Правила стола: играть с Mr. White или без него и решить, на чьей стороне Undercover: среди чужаков вместе с ним или на стороне стола.
  • Очки на весь вечер в таблице лидеров. О них ниже.
  • Шесть языков: английский, немецкий, испанский, русский, белорусский и украинский, и для каждого по 150 пар слов, написанных на этом языке, а не переведённых. Язык приложения и язык слов выбираются отдельно, так что смешанная компания может читать интерфейс на одном языке, а играть на другом.
  • Сначала форма, потом цвет: круг у мирных, наклонённый треугольник у Undercover, пустой квадрат у Mr. White, так что цвет никогда не бывает единственной подсказкой.
  • Игру можно прервать на середине и продолжить позже ровно с того же места.

Игра уже вышла на iPhone и Android; ссылки на оба стора есть на halfword.slnt-opp.xyz.

Сегодня за столом: кто лидирует, и следующая игра в одно касание
Сегодня за столом: кто лидирует, и следующая игра в одно касание
Карточка Чена: кофе
Карточка Чена: кофе
У Дарио та же самая карточка, только на ней чай
У Дарио та же самая карточка, только на ней чай
Грета — Mr. White, и знает об этом только она
Грета — Mr. White, и знает об этом только она
Три способа голосовать; здесь открытое голосование поднятием рук
Три способа голосовать; здесь открытое голосование поднятием рук
Особые роли: Чен и Фатима были Влюблёнными, поэтому выбывают оба
Особые роли: Чен и Фатима были Влюблёнными, поэтому выбывают оба
Стол побеждает, и каждый, кто ещё в игре, получает долю
Стол побеждает, и каждый, кто ещё в игре, получает долю
Очки копятся весь вечер
Очки копятся весь вечер

А теперь самое интересное: то, над чем пришлось больше всего поломать голову.

Очки по скользящей шкале

Очки копятся за весь вечер, и в этом вся сложность. Мне хотелось, чтобы победа Mr. White ощущалась как победа, но не превращала этого игрока в лидера, которого до конца вечера уже никто не догонит.

В системе очков, к которой я пришёл, два главных слова. Мирные, то есть стол, играют на долю. Другая сторона, чужаки (по умолчанию Mr. White и Undercover), играет на ставку. Обе зависят от того, скольких своих игроков потерял стол, а потери считаются шагами, которые растут вместе с размером стола.

Вот суть, ужатая из докстринга скрипта, который подбирал числа:

step  = (n + 2) // 6       1 at 4-9 players, 2 at 10-15, 3 at 16-20
lost  = table seats out when the game ends, by any cause
k     = lost // step
share = max(1, 6 - k // 2)   table win: to each table seat still in
stake = 9 + min(2, k)        a right guess, or the hunted outlasting the table

От 4 до 9 игроков это выглядит так:

Стол потерял игроков012345
Доля каждому игроку стола, кто ещё в игре665544
Ставка каждому чужаку, которому платят91011111111

Так что стол, который по дороге потерял двоих своих, получает по 5 вместо 6, а Mr. White, который продержался столько же и потом назвал слово, забирает 11. Очки никогда не делятся, а выбывший игрок не получает ни доли, ни ставки, даже если его сторона победила (исключение — догадка Mr. White: когда он угадывает, он уже вне игры; Дуэлянты и Шут добавляют свои очки сверху).

Пять голубых кругов под вспышкой светаСтол побеждает: доля
Довольная собой Половинка между огромным розовым треугольником и квадратом Mr. WhiteЧужаки продержались дольше: ставка
Квадрат Mr. White под янтарным облачком репликиMr. White называет слово: ставка
Три концовки, как их рисует экран результатов

Числа не взяты с потолка. Скрипт-симуляция на Python, только стандартная библиотека, проигрывает игру как точную марковскую цепь по состояниям голосования. Ничья случается в одном раунде из десяти, голосование выбирает Mr. White, пока он в игре, чаще, чем кого-либо ещё, неудачная подсказка делает Undercover примерно вдвое подозрительнее мирного жителя, а пойманный Mr. White с каждым раундом угадывает чуть чаще. Каждое из этих допущений перебирается по диапазону (243 комбинации), потому что весь смысл в том, чтобы ответ не зависел от моих догадок о том, как люди голосуют.

Потом он играет 5 000 вечеров по девять игр по каждой схеме, включая схему из приложения, в котором мы играли раньше (10 очков Mr. White, если он победил, по 2 каждому, кто дожил до конца, в каждой игре, и 3 или 5 выжившим, когда они победили), и задаёт вопрос, который важен для целого вечера: если кто-то в одиночку выиграл первую игру за чужаков, как часто его так никто и не догоняет до конца вечера?

При шести игроках:

  • старое приложение: 18% вечеров;
  • первый план (доля падает на единицу с каждым шагом, ставка от 8 до 12): 20%, хуже старого приложения;
  • схема выше: 17%.

Выглядит скромным улучшением, пока не увидишь обратную сторону. В той же модели место Mr. White стоит почти вдвое больше места мирного жителя (в 1,92 раза больше очков за игру), а одна победа чужаков стоит 4,8 средней игры мирного. Так что победа за Mr. White заметно вознаграждается, и при шести игроках она решает исход вечера не чаще, чем в старом приложении.

Ещё скрипт обходит каждую достижимую позицию и ищет того, кому выгодно выгнать голосованием своего же союзника. С итоговыми числами таких нет, кроме Дуэлянтов, которые для этого и придуманы, и редких крайних случаев при самом большом числе игроков. Эта проверка похоронила несколько красивых на слух идей. Например, если делить ставку между победителями, Mr. White выигрывает от выбывания своего же Undercover: в 5 950 из 6 071 проверенной позиции. Старое «по 2 очка всем живым» отправилось туда же.

И из всего этого родилось одно правило, которое важно совсем в другом месте: очки никогда не зависят от бюллетеней. В ранней версии правил давалось очко за голос против того, кого надо, а для этого нужно знать, кто против кого голосовал. Без этого тайное голосование может быть по-настоящему тайным: кто против кого голосовал, остаётся в движке и в журнале игры на телефоне, без которого не воспроизвести игру заново, и ни один экран ни в какой момент не может это показать.

И переспросить дёшево: таблица очков считается меньше чем за секунду, всё исследование на выборках поменьше — примерно за две минуты, а полный прогон — за четыре-пять.

Одна кодовая база, два языка

Halfword написан один раз, на Swift и SwiftUI. Версия для Android получается через Skip, но не так, как будет у Fare Enough. Fare Enough использует режим Fuse, который компилирует Swift под Android нативно. Halfword использует Skip Lite: Swift переводится в исходники на Kotlin, и Android собирает уже их. Плата за это: можно использовать только тот Swift, который понимает Skip, а кое-что из него, став Kotlin, значит уже другое.

Поэтому тесты каждого модуля гоняются дважды, в двух колонках: как Swift на Mac и как Kotlin на JVM, потому что Skip транспилирует и тесты. Вот немного из того, что поймала колонка Android:

  • На Android Int 32-битный. Int из Swift становится Int в Kotlin, так что Int.max там 2 147 483 647, и при переполнении программа не падает, как в Swift, а число молча становится отрицательным. Один и тот же баг с переполнением на одной платформе роняет приложение, а на другой даёт неправильный ответ. Всё, что может вырасти больше, хранится в Int64, а намеренное переполнение пишется как &+.
  • Беззнаковые литералы не выживают. При a: UInt64 выражение a &* 3 в Kotlin не компилируется, а a &* UInt64(3) компилируется. UInt64(bitPattern:) не компилируется вообще. Поэтому генератор случайных чисел в игре — SplitMix64 на знаковом Int64, а его константы записаны как отрицательные битовые паттерны.
  • Сортировка должна быть строгой. sorted { $0 <= $1 } в Swift работает. На Android TimSort из Java бросает «Comparison method violates its general contract!». Поэтому правило такое: строгое <, а равенство разрешается по номеру места.
  • Строки равны по-разному. В Swift предкомпонованная é равна e с комбинирующим акцентом после неё, а в Kotlin нет, и "👍".count на iPhone равно 1, а на Android 2. Это настоящая проблема, когда Mr. White вводит свою догадку. Поэтому каждая догадка проходит через таблицу нормализации: сначала нижний регистр, потом каждый символ заменяется (ä на a, ß на ss, комбинирующий акцент на ничего), так что буквы любого набора слов нормализуются одинаково на обеих платформах. «Kaese» тоже засчитывается за «Käse», а в русском «ё» засчитывается за «е».
  • На Android rounded() округляет половину к чётному. (10.5).rounded() на iOS даёт 11, а на Android 10. Это важно для пиксель-арта, о нём ниже.

Мой любимый пример: #expect(a == 2 && b == 1) транспилируется в expectEqual(a == 2 && b, 1). Последнее == отрывается от всего выражения, так что при подходящих типах это компилируется и проверяет не то. Каждое такое правило записано вместе с тем, как его проверили, и их десятки.

Android добирался кружным путём. Первая версия движка, наборов слов и сохранения прошла все тесты в обеих колонках. Потом у моего Mac кончилась память на параллельных сборках, и я поставил Android на паузу, пока выходила версия для iPhone. Когда Android вернулся, тесты каждого модуля прошли в обеих колонках. Теперь Halfword есть в Google Play. А как экраны на Android проверяли без эмулятора — это отдельный пост.

Игра, которую можно воспроизвести заново

Правила живут в модуле GameCore, и это самый строгий код в приложении: он не импортирует ничего, кроме стандартной библиотеки Swift. Никакого Foundation, никаких часов, никакого ввода-вывода.

Игра — это редьюсер. Любой ввод — это действие, и apply — единственный способ что-либо изменить:

var game = try Game(seats: seats, config: config, pair: pair, rng: rng)
_ = try game.apply(.startGame)        // deals the cards
// … the reveals, the clues, the vote is called …
let events = try game.apply(.castBallot(voter: 2, target: 5))
// Screens draw game.snapshot: no words, no living player's role,
// never a secret ballot's target.

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

Всё случайное (кто Undercover, кому какая особая роль, кто говорит первым, кто в этом раунде объясняет жестами) тянется из одного генератора с сидом, в заранее записанном порядке. Это SplitMix64, на Int64 из-за правил Kotlin выше:

public mutating func next() -> Int64 {
    state = state &+ SeededRNG.gamma
    var z = state
    z = (z ^ SeededRNG.shiftRightLogical(z, 30)) &* SeededRNG.mix1
    z = (z ^ SeededRNG.shiftRightLogical(z, 27)) &* SeededRNG.mix2
    return z ^ SeededRNG.shiftRightLogical(z, 31)
}

Его выход на обеих платформах совпадает с опубликованными эталонными значениями Виньи. А поскольку словари в Swift обходятся в случайном порядке, а в Kotlin — в порядке вставки, ни одно решение не принимается циклом по словарю или множеству: места обходятся по номерам.

Польза в том, что сохранённая игра — это просто её сид, её настройки и список действий. Приложение сохраняет игру после каждого действия. Когда игра сохраняется впервые и ещё раз, когда она закончилась, хранилище воспроизводит её ровно из того, что собирается записать, и отказывается сохранять игру, которая не вернулась точно такой же. Так что если кому-то позвонили или телефон сел посреди тайного голосования, игра откроется ровно там, где была.

Тесты на это сильно опираются. Каждую из тысячи случайных игр играют дважды и воспроизводят из сохранённых действий, и все три прогона должны совпасть в каждом событии. Сотня из них сворачивается в одно число, зафиксированное в тесте, которое должны выдать и iOS, и Android. А один тест играет целый вечер из четырёх игр через модель приложения, так же, как это делают экраны, и по дороге шесть раз убивает и перезапускает приложение: посреди переигровки после ничьей, на середине тайного голосования, когда Mr. White вот-вот введёт свою догадку. И каждый раз всё должно вернуться точно как было.

Пиксели без файлов картинок

В Halfword всего шестнадцать цветов. Любая картинка в нём начинается как текстовый файл, один символ на пиксель. Вот глиф Undercover размером 16 на 16 (заголовок сокращён):

name: role_undercover_16
size: 16x16
outline: full
---
frame 0
................
..........K.....
.........KRK....
........KRRK....
.......KRRRrK...
......KRRRRrK...
.....KRRRRRrK...
....KRRRRRRrK...
...KRRRRRRRrrK..
..KRRRRRRRRrrK..
.KRRRRRRRRRrrK..
..KKRRRRRRRrrK..
....KKKKRRRRrrK.
........KKKKrrK.
............KK..
................

K — контур, R — розовый, r — его тёмный оттенок. Маленькая утилита pixelkit.py (снова Python на одной стандартной библиотеке) проверяет каждый файл по правилам рисования и экспортирует PNG: размеры кратны 8, только цвета палитры, маскот нарисован своими четырьмя цветами и анимирован ровно с частотой 8 кадров в секунду, и никакого цвета роли на карточке, которую видит каждый игрок.

Глиф Undercover: наклонённый розовый треугольник
Тот же глиф, каждый пиксель нарисован 6 на 6

Маскот — Половинка: маленький спрайт, у которого правая сторона так до конца и не появилась, пиксели редеют в дизеринг, а потом сходят на нет. У неё шесть настроений. Вот четыре, которые крутятся по кругу:

ждёт
подглядывает
подозревает
ухмыляется
Половинка ждёт, подглядывает, подозревает и ухмыляется, 8 кадров в секунду

Правило показа строгое: только целые масштабы, 2×, 3×, 4× или 6×, один масштаб на экран, никакого сглаживания, никаких поворотов. На iPhone это просто, потому что пункт — это ровно 2 или 3 пикселя. На Android нет. У обычного телефона плотность 2,625, так что «2× в dp» даёт одни пиксели шириной 5 физических пикселей, а другие — 6, а пиксель-арт разваливается, когда его пиксели разного размера.

Хуже того, Image из SkipUI вообще не умеет рисовать пиксель-арт на Android: .interpolation(.none) там ничего не делает, и каждый битмап проходит билинейную фильтрацию. Поэтому ни один спрайт не попадает в приложение картинкой. В приложение попадают те же строки ключей палитры, а каждый спрайт рисуется фигурами SwiftUI: один Path из прямоугольников в целый пиксель на каждый цвет. Тогда на Android исходный пиксель — это ровно n × n физических пикселей, где n = round(scale × density). Этот round прописан явно как «половина округляется от нуля», потому что, как мы видели выше, Kotlin округлил бы 10,5 вниз, до 10.

Правила зашиты и в карточки. Мирные и Undercover не должны знать, кто из них кто, поэтому карточка у них одна на всех, без цвета роли. Отличается только карточка Mr. White, пустой квадрат, и она такая же тёмная, как остальные, чтобы её свечение не выдало его через весь стол:

Рубашка карточки: янтарное облачко реплики, которое растворяется вправо
Карточка со словом: целое янтарное облачко реплики
Карточка Mr. White: пустой квадрат
Рубашка, карточка каждого, у кого есть слово, и карточка Mr. White

Буква C, которая читалась как скобка

Заголовки и реплики Половинки набраны Micro 5, пиксельным шрифтом с открытой лицензией. У него два изъяна. Заглавная C шириной в два пикселя и с прямыми углами, так что «Chen» читается как «[hen», а «Count the votes» — как «[ount the votes». G с одним срезанным углом читается как D: «Greta» превращается в «Dreta». Когда заголовки сделали вдвое крупнее, буквы стали больше, но не стали другими.

Поэтому в приложении пропатченный Micro 5. Скрипт хранит новые рисунки и пересобирает шрифт из исходного файла, каждый раз байт в байт: подменяет эти глифы (C и G, их варианты с диакритикой и типографские кавычки), а потом вручную пересобирает таблицы шрифта, ограничивающие прямоугольники и контрольные суммы, на одной стандартной библиотеке. Он сохраняет ширину каждого символа, потому что приложение меряет имена в пикселях шрифта, чтобы они влезали в одну строку заголовка («Marie-Christine» — ровно 52, это предел), и более широкая C сдвинула бы эту границу. У Micro 5 нет зарезервированного имени шрифта (Reserved Font Name), так что по OFL изменённая копия может сохранить название.

G починена: «Greta» читается как Greta. C стала лучше, но не починена: в полный размер она всё ещё немного похожа на круглую скобку, «(hen». Третий пиксель вылечил бы её, но вытолкнул бы «Marie-Christine» на пиксель за предел.

«Chen», «Count the votes» и «Greta», каждое сначала исходным Micro 5, потом пропатченным шрифтом
Исходный Micro 5, затем пропатченный; чёрточки отмечают перерисованные буквы

В английской версии этого поста заголовки набраны как раз этим пропатченным шрифтом. Кириллицы в Micro 5 нет, поэтому здесь они набраны системным шрифтом, как и заголовки в самом приложении, когда оно говорит по-русски.

Сначала макеты

Ещё до первой строчки кода у приложения были спецификация, визуальные гайдлайны, своя графика и дизайн-холст с нарисованными экранами. Гайдлайны задали правила, на которые этот пост уже опирался (шестнадцать цветов, целые масштабы, форма прежде цвета), и ещё два: пиксель-арт для содержимого, а нативные элементы управления для всего, на что нажимают; и каждый цвет текста должен проходить WCAG AA на своём фоне.

Заметки арт-дирекции появились, когда по этим правилам начали рисовать, и они поправляют правила измерениями. Гайдлайны предлагали amber-dk для янтарного текста в светлой теме; на фоне paper он даёт 3,29:1 и для основного текста не годится, поэтому цветной текст в светлой теме — green-dk или rose-dk, а янтарный там только заливка. Paper на rose — это 3,11:1, поэтому подписи на розовых кнопках тёмные. Расчёт размеров в физических пикселях, описанный выше, тоже начинался как одна из этих заметок: в собственном примере кода для Android из гайдлайнов спрайты задавались в dp. А раз круг, треугольник и квадрат принадлежат ролям, ни одна иконка не может их использовать: треугольный знак предупреждения читался бы как Undercover, поэтому иконка предупреждения — просто «!», а таймер — песочные часы, а не циферблат. Где заметки и гайдлайны расходятся, побеждают заметки.

На холсте 44 артборда. Большинство — экраны телефона размером 390 на 844 пункта, от заставки до таблицы лидеров, некоторые ещё раз в светлой теме. Остальные задают основу: палитру с измеренным контрастом, шесть состояний маскота, иконки, рисунки ролей, иллюстрации и иконку приложения. Каждый экранный артборд рассказывает один и тот же вечер: те же восемь игроков, кофе против чая, Дарио — Undercover, Грета — Mr. White, и очки, которые складываются от артборда к артборду. Каждый экран объявляет свой пиксельный масштаб, а линтер сверяет с ним каждую картинку на экране.

Каждый экран, у которого есть артборд, сделан по нему. Фикстуры приложения, то есть состояния, которые оно открывает сразу при запуске для тестов и скриншотов, проигрывают тот же самый вечер, и фикстура носит имя артборда, по которому её делали, Round-Voting или Resolve-Cascade, так что любой скриншот можно сверить с его рисунком. Скриншоты для сторов, в том числе те, что в этом посте, сделаны из тех же фикстур. Некоторые артборды теперь отстают от правил; пока их не перерисуют, эталон — скриншоты.

Вот и всё

Halfword говорит на шести языках, работает офлайн, есть для iPhone и Android и ни у кого не просит аккаунт.

А если за твоим столом Mr. White всё равно крадёт вечер, напиши мне. У меня для этого есть скрипт.