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.
setWinCumulative win для одного reveal (после всех tumble-ов).После закрытия tumble-цикла.
setTotalWinCumulative win для раунда (basegame + все freespin’ы).После каждого setWin.
tumbleBoardКакие позиции «взорвались» и какие символы упали сверху.На каждом шаге tumble.
tumbleBannerBanner с 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/setWin per-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» — это фронт.

Почему такой контракт

  1. Воспроизводимость: replay раунда = воспроизведение того же events-массива. Никакой нелинейной зависимости от UI-state. Cert-friendly.
  2. Тестируемость: math-юнит-тесты проверяют, что для board X эмитится exactly events Y. Без UI, без таймеров.
  3. Изоляция изменений math: можно поменять reelstrip / paytable / триггер-таблицу — фронту всё равно, пока структура events не меняется.
  4. Изоляция изменений UI: можно полностью переписать рендерер (PixiJS → Phaser, 2D → 3D) без правок в бэке.
  5. 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-типы без миграции.

Связанные страницы