Мы с друзьями играем в 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 и 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 игроков это выглядит так:
| Стол потерял игроков | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| Доля каждому игроку стола, кто ещё в игре | 6 | 6 | 5 | 5 | 4 | 4 |
| Ставка каждому чужаку, которому платят | 9 | 10 | 11 | 11 | 11 | 11 |
Так что стол, который по дороге потерял двоих своих, получает по 5 вместо 6, а Mr. White, который продержался столько же и потом назвал слово, забирает 11. Очки никогда не делятся, а выбывший игрок не получает ни доли, ни ставки, даже если его сторона победила (исключение — догадка 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
Int32-битный.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 кадров в секунду, и никакого цвета роли на карточке, которую видит каждый игрок.

Маскот — Половинка: маленький спрайт, у которого правая сторона так до конца и не появилась, пиксели редеют в дизеринг, а потом сходят на нет. У неё шесть настроений. Вот четыре, которые крутятся по кругу:
ждёт
подглядывает
подозревает
ухмыляетсяПравило показа строгое: только целые масштабы, 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, пустой квадрат, и она такая же тёмная, как остальные, чтобы её свечение не выдало его через весь стол:



Буква 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» на пиксель за предел.

В английской версии этого поста заголовки набраны как раз этим пропатченным шрифтом. Кириллицы в 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 и ни у кого не просит аккаунт.
- Скачать: halfword.slnt-opp.xyz, там ссылки на App Store и Google Play
- Идеи, баги, слишком простая пара слов: info@slnt-opp.xyz
А если за твоим столом Mr. White всё равно крадёт вечер, напиши мне. У меня для этого есть скрипт.
