Events as stream
Принципиальный архитектурный паттерн: раунд = упорядоченный поток событий, где каждое событие — иммутабельный snapshot изменения состояния игры. Бэк решает математику и эмитит события; фронт — pure renderer, который воспроизводит раунд через exhaustive switch по типам.
Идиома взята из Stake math-sdk (src/events/events.py); универсальна для любого подхода к math.
Контракт
Один раунд /play возвращает (упрощённо):
{
"id": 58,
"payoutMultiplier": 10,
"events": [
{"index": 0, "type": "reveal", "board": [...], "paddingPositions": [...], "gameType": "basegame", "anticipation": [0,0,0,1,2]},
{"index": 1, "type": "winInfo", "totalWin": 10, "wins": [{"symbol": "L5", "kind": 3, "win": 10, "positions": [...], "meta": {}}]},
{"index": 2, "type": "setWin", "amount": 10, "winLevel": 2},
{"index": 3, "type": "setTotalWin", "amount": 10},
{"index": 4, "type": "finalWin", "amount": 10}
]
}Каждое событие:
index— монотонный счётчик внутри раунда.type— дискриминатор для switch-renderer-а.- Остальные поля — payload, типизированный per-
type.
Important
Что не в
events— фронт не отрисует. Это значит: всё, что нужно показать игроку (анимации, счётчики, multiplier-апдейты, banner-ы, end-of-feature summary), должно быть явно эмитировано бэком. Фронт не запускает таймеры, не пересчитывает math, не «знает», что после tumble надо обновить banner — он реагирует на event.
Базовые типы событий (Stake-baseline)
type | Назначение | Когда эмитится |
|---|---|---|
reveal | Новый board открывается. Содержит board, paddingPositions, gameType, anticipation. | После drawBoard(). |
winInfo | Список выигрышных комбинаций на текущем reveal. | После evaluateWins, если totalWin > 0. |
setWin | Cumulative win для одного reveal (после всех tumble-ов). | После закрытия tumble-цикла. |
setTotalWin | Cumulative win для раунда (basegame + все freespin’ы). | После каждого setWin. |
tumbleBoard | Какие позиции «взорвались» и какие символы упали сверху. | На каждом шаге tumble. |
tumbleBanner | Banner с cumulative tumble-выигрышем (опционально с globalMult). | После цепочки tumble-ов. |
freespinTrigger | Триггер freespin’ов из basegame или retrigger. | Когда выпало достаточно scatter-ов. |
updateFreespin | Текущий и максимальный спин freegame. | Перед каждым freespin reveal. |
freespinEnd | Конец freegame, итоговый payout фичи, winLevel. | После последнего freespin. |
updateGlobalMult | Изменение глобального множителя (если есть). | На увеличении (например, на каждый tumble во freegame). |
wincap | Достигнут max-win. | Когда runningBetWin >= maxWin. |
finalWin | Финальный payout раунда. | В самом конце runSpin. |
Игра свободно расширяет набор: prize-collect, sticky-wild expansion, jackpot-trigger, mystery-symbol reveal — любой game-specific type.
Что НЕ должно быть на фронте
- Win-evaluation. Фронт не пересчитывает кластеры/линии/ways — он берёт
winsизwinInfo. - Распределение payout. Фронт не делит
payoutMultiplierна base/free — для этого естьwinInfo/setWinper-reveal. - RNG. Фронт не дёргает
Math.random()для анимаций, влияющих на исход (только для cosmetic decorations, и то желательно через server-provided seed для replay). - Math-конфиг. Фронт не знает paytable; он видит названия символов и payout-числа из событий.
Что НЕ должно быть на бэке
- Тайминги анимаций. Бэк не указывает «эта анимация идёт 800ms» — фронт сам решает, как долго играть
winInfoдля big-win-symbol-а. - Звуковые / визуальные cue. Бэк не эмитит «играй sound A». Фронт по
winLevelсам выбирает. - UX-логика «показать ли buy-bonus disclaimer» — это фронт.
Почему такой контракт
- Воспроизводимость: replay раунда = воспроизведение того же
events-массива. Никакой нелинейной зависимости от UI-state. Cert-friendly. - Тестируемость: math-юнит-тесты проверяют, что для board X эмитится exactly events Y. Без UI, без таймеров.
- Изоляция изменений math: можно поменять reelstrip / paytable / триггер-таблицу — фронту всё равно, пока структура events не меняется.
- Изоляция изменений UI: можно полностью переписать рендерер (PixiJS → Phaser, 2D → 3D) без правок в бэке.
- Multi-platform: одни и те же
eventsрендерятся в web, mobile-native, demo-fixtures, headless replay-tool.
Идиомы для TS
type GameEvent =
| RevealEvent
| WinInfoEvent
| SetWinEvent
| SetTotalWinEvent
| TumbleBoardEvent
| TumbleBannerEvent
| FreespinTriggerEvent
| UpdateFreespinEvent
| FreespinEndEvent
| UpdateGlobalMultEvent
| WincapEvent
| FinalWinEvent;
interface BaseEvent {
index: number;
type: GameEvent["type"];
}
interface RevealEvent extends BaseEvent {
type: "reveal";
board: SymbolName[][];
paddingPositions: { top: SymbolName[]; bottom: SymbolName[] };
gameType: "basegame" | "freegame";
anticipation: number[];
}
interface WinInfoEvent extends BaseEvent {
type: "winInfo";
totalWin: number;
wins: WinDetail[];
}
interface WinDetail {
symbol: SymbolName;
kind: number;
win: number;
positions: { reel: number; row: number }[];
meta: Record<string, unknown>;
}
// ... и т.д.RoundContext.emit(event) пушит в book.events. Фронт:
function renderRound(events: GameEvent[]) {
for (const event of events) {
switch (event.type) {
case "reveal": renderReveal(event); break;
case "winInfo": renderWins(event); break;
case "setWin": updateSpinBanner(event); break;
// ... exhaustive
default: assertNever(event);
}
}
}assertNever(x: never) — TS-fence: добавили новый event-тип на бэке, забыли на фронте → CI падает.
Версионирование
С ростом игры структура событий эволюционирует. Стратегии:
- Additive only: новые поля добавляются как optional. Старые фронты игнорируют — работают.
schemaVersionна уровне раунда: фронт умеет несколько версий, переключается по полю. Полезно при больших breaking changes.- Replay-стабильность: записанные раньше books должны рендериться текущим фронтом неограниченно долго (cert-evidence, dispute resolution через 2 года). Бэк не должен «удалять» event-типы без миграции.