Skip to content

About

Высокопроизводительный 5×3 слот-движок: клиент на чистом Canvas2D и RGS-сервер на Node.js без внешних зависимостей. Provably Fair, RTP 96.008%, поддержка тем.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Сочи · Sochi Sunset

Слот 5×3 на 20 линий выплат: клиент на чистом Canvas2D и игровой сервер (RGS) на встроенных модулях Node. В боевом контуре — ноль внешних зависимостей: ни на клиенте, ни на сервере не подключается ни одна сторонняя библиотека.

Вся графика и весь звук сгенерированы кодом из исходников в tools/, поэтому на арт и аудио нет никаких лицензионных обременений — их можно использовать в коммерческом продукте без оговорок.

RTP                 96.008 % (точный расчёт, подтверждён 20 млн спинов)
Волатильность       высокая, σ = 7.08
Частота выигрыша    33.7 % (1 к 2.97)
Частота бонуса      1 к 164
Максимальный выигрыш 5000× ставки

Быстрый старт

Нужен Node 22.5 или новее — в нём появился встроенный node:sqlite. Проверить: node -v. Скачать: https://nodejs.org.

Windows: двойной клик по start.bat. Сервер поднимется, браузер откроется сам.

Вручную, любая ОС:

node server/src/index.js
# открыть http://localhost:3000

Установка зависимостей для запуска не требуется: ни одного npm install. Он нужен только если вы собираетесь пересобирать графику (см. «Пересборка ассетов»).

Проверить математику: двойной клик по check-math.bat — прогонит тесты, покажет отчёт по RTP и симуляцию на 5 млн спинов.

Что попробовать в игре

Откройте http://localhost:3000/?theme=neon — та же игра в другом оформлении: холодная палитра, засечная гарнитура, моноширинные табло, заметно более резкий темп и своя композиция панели. Ни одной строки в src/slot/ и src/engine/ для этого менять не пришлось; вся разница лежит в src/themes/neon/.

Автономный демо-режим (GitHub Pages / Client RGS Mock)

Если бэкенд Node.js недоступен (например, при запуске статики на GitHub Pages или открытии index.html напрямую), клиент автоматически переключается в автономный мок-режим (client/src/slot/net/mockApi.js).

Мок-режим также можно активировать принудительно передачей параметра ?mock=1 в URL.

  • Генерация спинов, математика выплат и серия фриспинов эмулируются прямо в браузере.
  • Демо-баланс игрока сохраняется в localStorage.
  • В ответах конфигурации и сессии выставляется флаг isMock: true, а в консоль выводится соответствующее уведомление.

Пробел или Enter — крутить, он же «Стоп» во время вращения. Стрелки вверх/вниз — ставка. T — турбо. M — звук. Esc — закрыть окно. Кнопка «i» вверху слева открывает таблицу выплат, часы — историю раундов, круговая стрелка внизу — автоигру.

Фриспины выпадают в среднем раз в 164 спина. Чтобы увидеть их быстрее, включите турбо и автоигру на 250 спинов.


Что внутри

client/                 клиент, работает без сборщика — нативные ES-модули
  index.html            точка входа, экран загрузки, экран ошибки
  styles.css            только оболочка; вся игра рисуется в canvas
  src/engine/           движок: граф сцены, рендер, ввод, время (см. ниже)
  src/audio/audio.js    звук на Web Audio: спрайт эффектов, кроссфейд музыки
  src/slot/             слот: барабаны, механики, автомат раунда, UI, сеть
    net/mockApi.js      клиентский эмулятор RGS для работы без бэкенда
  src/themes/sochi/     оформление: палитра, раскладки, тексты, фон, живность
  src/themes/neon/      вторая тема — доказательство, что оформление сменяемо
  assets/               сгенерированные атласы, фоны, звук, шрифты


server/                 игровой сервер (RGS)
  src/math/             МАТЕМАТИКА — источник истины
    gameConfig.js       символы, выплаты, линии, ленты
    engine.js           расчёт спина
    analytic.js         точный RTP без Монте-Карло
    optimize.js         подбор состава лент под целевой RTP
  src/game/round.js     раунд: базовый спин + серия фриспинов
  src/rng.js            криптографический ГПСЧ
  src/fair.js           провабли-фейр: commit-reveal и пересчёт раунда
  src/db/               SQLite: игроки, сессии, раунды, транзакции, семена
  src/wallet/           кошелёк: локальный и бесшовный (seamless) оператора
  src/ratelimit.js      лимиты частоты по адресу и по игроку
  src/http.js           HTTP-слой: роутинг, CORS, статика, заголовки защиты
  src/app.js            все эндпоинты
  src/index.js          точка входа: конфигурация, запуск, завершение
  src/tools/            симулятор и сертификационный отчёт
  test/                 тесты математики и ГПСЧ

tools/                  генерация ассетов (в проде не нужны)
  assets/               SVG → PNG-атласы через headless Chromium
  audio/synth.py        синтез всех звуков и музыки на numpy
  preview/              смоук-тесты и скриншоты в headless-браузере

Движок

client/src/engine/ — небольшой, но полноценный 2D-движок: граф сцены с матричными преобразованиями, спрайты, nine-slice, кешируемый текст, твины с пулом объектов, система частиц, тикер с защитой от скачков времени, менеджер ввода на Pointer Events и менеджер вьюпорта с двумя раскладками.

Три решения, от которых зависит, будет ли 60 fps на дешёвом Android. Во-первых, ctx.shadowBlur не используется в кадре вообще — всё свечение нарисовано заранее и лежит в атласе спрайтами. Во-вторых, текст рендерится в собственный offscreen-canvas и перерисовывается только при смене содержимого: fillText для полутора десятков надписей каждый кадр стоит дороже, чем весь остальной рендер вместе взятый. В-третьих, все символы на барабанах уходят в рендер одним узлом сцены — пакетом спрайтов, — а не пятнадцатью отдельными объектами с матрицей и проверкой видимости у каждого.

Чем рисовать, решает бэкенд: узел объявляет свой тип, отрисовщик под этот тип регистрируется в engine/render/drawables.js. Игровой слой не обращается к ctx ни разу, поэтому WebGL2 добавляется рядом, а не вместо игры.

Раскладка под экран

Холст не имеет фиксированной пропорции: client/src/themes/sochi/layout.js строит раскладку под фактическое соотношение сторон устройства. Раньше здесь были два жёстких размера, 1920×1080 и 1080×1920, и на телефоне 390×844 пятая часть высоты уходила в пустые полосы сверху и снизу — движок вписывал холст по меньшей стороне.

Размер кнопок тоже считается, а не подбирается: при масштабе 0.36 кнопка в 76 дизайнерских пикселей превращается в 27 экранных, вдвое меньше пригодного для пальца. touchBoost() возвращает множитель, при котором любой орган управления занимает на экране не меньше 48 пикселей, на каком бы устройстве игра ни открылась. Проверяется это tools/preview/phones.mjs на шести реальных размерах экрана — он же измеряет, не осталось ли незанятого места.

Барабаны

Прокрутка сделана «как в железе»: разгон, постоянная скорость, торможение с точной посадкой на границу символа и отдельный пружинный отскок, который не участвует в выравнивании сетки. Смаз в движении — заранее размытые копии символов (ctx.filter один раз при загрузке), с запасным вариантом через вертикальное растягивание для браузеров без поддержки фильтров.

Антисипация — когда на уже остановившихся барабанах лежат два скаттера, следующий барабан крутится дольше и подсвечивается.


Математика

Модель описана в server/src/math/gameConfig.js и продублирована человеческим языком в docs/MATH.md.

RTP считается точно, а не симуляцией. Барабаны независимы, а линия выплат берёт по одной ячейке с каждого барабана, поэтому матожидание выплаты равно полному перебору 12⁵ = 248 832 комбинаций, взвешенных вероятностями лент. Скаттеры считаются свёрткой распределений по барабанам, вклад фриспинов — как ветвящийся процесс с ретригером. Расчёт занимает 80 мс.

node server/src/tools/rtp-report.js         # отчёт для тестовой лаборатории
node server/src/tools/rtp-report.js --json  # то же машиночитаемо
node server/src/math/optimize.js --target 0.96
node server/src/tools/simulate.js --spins 20000000
node --test "server/test/**/*.test.js"

Клиент проверяется отдельно: node --test "client/test/**/*.test.js" разбирает импорты и падает, если движок потянул слот, а слот — тему, если в игровом слое снова появился прямой вызов ctx или если в движок и слот протёк цвет, шрифт либо имя кадра. Он же прогоняет каждую тему через проверку полноты и требует, чтобы темы отличались друг от друга. Договорённость, которую никто не проверяет, живёт около месяца.

Симуляция нужна не для того, чтобы узнать RTP, а чтобы доказать: боевой код спина реализует ровно ту модель, для которой RTP посчитан. На 20 млн спинов расхождение составило 0.04 σ.

Изменять состав лент вручную нельзя. Любая правка BASE_COUNTS или FREE_COUNTS обязана пройти optimize.js, затем rtp-report.js, затем simulate.js — иначе фактический возврат разойдётся с заявленным.


Проверка в браузере

Стенды в tools/preview/ гоняют настоящую игру в headless Chromium. Сначала поднимите сервер на порту 3111 (PORT=3111 WALLET_SECRET=test node server/src/index.js).

node tools/preview/smoke.mjs        # загрузка, спин, портрет, таблица выплат
node tools/preview/reelcorrect.mjs  # экран равен ответу сервера, сетка выровнена
node tools/preview/stopstress.mjs   # 60 спинов с «Стоп» в разных фазах
node tools/preview/flicker.mjs      # покадровое сравнение: мигания нет
node tools/preview/resizestorm.mjs  # 200 событий resize подряд
node tools/preview/bonusflow.mjs    # фриспины от запуска до конца
node tools/preview/screens.mjs      # автоигра с лимитами, разбор раунда
node tools/preview/integration.mjs  # iframe + postMessage + WebSocket
node tools/preview/security.mjs     # тикеты, чужие раунды, лимиты ставок
node tools/preview/currency.mjs     # JPY и USD (нужен отдельный сервер, см. файл)
node tools/preview/audiocheck.mjs   # спрайт полон, звук реально запускается
node tools/preview/clickable.mjs    # по каждому органу управления можно нажать
node tools/preview/intro.mjs        # заставка: показ, галочка, уход в игру
node tools/preview/phones.mjs       # шесть реальных размеров экрана, замер кнопок
node tools/preview/reconnect.mjs    # обрыв связи во фриспинах не залипает в бонусе

clickable.mjs появился после провала, который стоит знать. Проверка модальных окон дёргала обработчики напрямую — modal.startButton.onTap() — и показывала зелёные галочки, пока ни одна фишка автоигры вообще не нажималась: попадание указателя искалось только среди листьев графа сцены, а фишка — контейнер из подложки и надписи. Тест проверял проводку обработчиков и ровно не проверял то единственное, что было сломано. Теперь стенд кликает настоящей мышью по вычисленным координатам и отдельно проверяет, что нажатие мимо окна не проваливается на игру под ним.

server/test/launchers.test.js проверяет start.bat и check-math.bat на переводы строк CRLF. Это не педантизм: cmd.exe читает bat-файл как поток байт и при одних только LF склеивает команды в мусор — окно закрывается мгновенно, не показав ни строчки. Один раз так и вышло после массового переименования, поэтому теперь проверяется машинно, а .gitattributes не даёт git «починить» переводы строк при клонировании.

stopstress.mjs появился не просто так: он ловит редкую ошибку, при которой барабан по кнопке «Стоп» вставал на символах, отличных от присланных сервером. Промах был не визуальный — игрок видел не тот экран, за который ему заплатили. Обычный прогон её пропускал, поэтому стенд нажимает «Стоп» с шагом задержки по всей длине спина.


Сервер

Работает на node:http и node:sqlite. Отсутствие зависимостей здесь не эстетика: у сервиса, который держит деньги, каждая транзитивная зависимость это канал поставки уязвимостей.

Случайность берётся из crypto.randomBytes с отбраковкой по модулю. Math.random() в V8 — это xorshift128+, состояние которого восстанавливается по нескольким выданным значениям, то есть исходы спинов становятся предсказуемыми; для игры на деньги это дисквалифицирующий дефект.

Деньги везде хранятся целыми числами в минорных единицах. Баланс в double на миллионах спинов гарантированно разъезжается с бухгалтерией оператора.

Раунд — единица расчёта: ставка списывается на старте, выигрыш зачисляется при закрытии. Пока раунд не закрыт, новый спин запрещён. У каждого спина есть ключ идемпотентности, и записывается он до денежной операции, одной транзакцией с созданием раунда. Порядок «сначала деньги, потом ключ» выглядит безобиднее, но именно он позволяет списать ставку дважды: если зачисление выигрыша упало, ключа ещё нет — и повтор запроса начинает новый раунд. Теперь повтор доигрывает тот же раунд в любой точке сбоя, а отказ возвращается повтору тем же ответом.

Каждое движение денег оставляет строку в журнале транзакций — и у локального кошелька, и у бесшовного. Строка открывается до запроса к оператору и закрывается его результатом, поэтому после падения процесса видно, какие деньги остались в неизвестном состоянии; сверка возвращает по ним ставку или дозачисляет выигрыш.

Игра работает в валюте кошелька игрока, а не в абстрактных «кредитах». Номинал «монеты» ставки задан для каждой валюты отдельно: у иены и воны минорных единиц нет вовсе, и код, который делит баланс на 100 «потому что копейки», в них ошибается в сто раз. Лимиты ставки задаются оператором по валютам через BET_LIMITS.

Исход раунда доказуем: хеш серверного семени публикуется до спина, клиентское семя задаёт игрок, после закрытия раунда серверное семя раскрывается — и любой посторонний скрипт пересчитывает экраны сам. Случайность берётся из HMAC-SHA256 с той же отбраковкой по модулю, поэтому распределение, RTP и состав лент остаются прежними; схема меняет только способ получения чисел. Формула и эндпоинты — раздел 2в в docs/INTEGRATION.md, код — server/src/fair.js.

Эндпоинты: POST /api/session, GET /api/config, POST /api/spin, POST /api/freespin, GET /api/state, GET /api/history, GET /api/round/:id, GET /api/fair, POST /api/fair/commit, GET /api/fair/round/:id, POST /api/ws-ticket, POST /api/notify, GET /healthz. Подробности — в docs/INTEGRATION.md.

GET /api/round/:id отдаёт полную запись раунда: экран каждого спина, каждую сработавшую линию с её вкладом и число обращений к ГПСЧ. Тот же разбор виден игроку — на экране истории строка раунда открывается нажатием. Это требование почти любого регулятора и одновременно то, чем поддержка закрывает половину обращений «у меня не засчитался выигрыш».


Поставка: iframe, postMessage и WebSocket

Игра открывается лобби оператора в iframe — так поставляется весь игровой контент. Сервер отдаёт Content-Security-Policy: frame-ancestors со списком доменов лобби из FRAME_ANCESTORS; X-Frame-Options намеренно не используется, он не умеет списки и заблокировал бы встраивание.

С лобби игра общается через postMessage: сообщает о старте и завершении раунда, изменении баланса, начале фриспинов, нехватке средств и ошибках, а принимает команды паузы, звука, напоминания об ответственной игре и закрытия сессии. Живой пример — client/lobby-demo.html, откройте http://localhost:3000/lobby-demo.html.

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

Реализация сокета — свой RFC 6455 поверх node:http, без зависимостей, с heartbeat и защитой от переразмеренных кадров.

Апгрейд сокета авторизуется одноразовым тикетом, а не токеном сессии. Браузерный WebSocket не умеет слать заголовки, поэтому секрет неизбежно оказывается в query-строке — а она пишется в access-логи nginx, балансировщика и APM. Токен сессии живёт часами и дал бы доступ ко всему API любому, у кого есть доступ к логам. Тикет живёт 15 секунд, гасится первым же использованием и не открывает ни одного HTTP-эндпоинта.


Пересборка ассетов

Нужны Playwright с Chromium, sharp, Python с numpy и ffmpeg в PATH.

npm install                        # только для инструментов
node tools/assets/build.mjs        # символы, UI, фоны → атласы + manifest.json
python3 tools/audio/synth.py       # SFX-спрайт и две музыкальные петли
python3 tools/assets/scenery.py    # рисованные фоны (закат и ночь)

Графика описана как SVG в tools/assets/ и растеризуется через headless Chromium — так доступны настоящие SVG-фильтры (размытие, тени, блики), которых нет у лёгких растеризаторов. Сборка детерминирована: тот же исходник даёт побайтово те же атласы.

Фоны — не SVG, а растровый рендер: tools/assets/scenery.py. Пейзаж вектором убедительно не нарисовать, потому что главное в нём — рассеяние: дымка над водой, размытый контур дальней горы, ореол вокруг солнца, плёночное зерно. Это операции над пикселями, а не над кривыми. Сборка берёт готовые PNG из tools/assets/rendered/ и кладёт их в WebP — у зернистой картинки PNG сжимать нечего, и четыре фона весили бы больше, чем вся остальная игра.

Звук синтезируется с нуля, без единого чужого сэмпла. Тема — черноморский променад: нейлоновая гитара, аккордеон с расстроенными язычками, флюгельгорн, латинская перкуссия на кахоне и конгах, под всем этим шум прибоя. Гармония — андалузская каденция Am — G — F — E, тот самый оборот, который читается как «юг». Во фриспинах та же каденция квартой выше, ведёт вибрафон. Уровни партий выставлены по замеру спектра, а не на слух: build_music(theme, return_stems=True) возвращает партии по отдельности именно для этого.

Итог — 1.8 МБ графики и 1.7 МБ звука.

Хотите свой арт вместо сгенерированного? Положите готовые PNG в art/ (art/symbols/<ключ>.png, art/ui/<ключ>.png) и пересоберите: сборка подставит ваши файлы вместо отрисовки и сама впишет их в атлас. Подробности — art/README.md. Версия атласа считается по его содержимому и подставляется в адреса ассетов, поэтому новая графика доходит до игроков несмотря на годовое кеширование.


Что нужно сделать до запуска на реальные деньги

Код готов к продакшену технически, но запуск игры на деньги — это не только код. Обязательный минимум перечислен в docs/GO-LIVE.md; коротко: сертификация ГПСЧ и математики в аккредитованной лаборатории (GLI-19, BMM, iTech Labs), лицензия юрисдикции, подключение к кошельку оператора вместо локального, инструменты ответственной игры на стороне оператора и настроенный мониторинг расхождений фактического RTP с расчётным.

Я не юрист и не могу оценить требования конкретной юрисдикции — это вопрос к вашему регуляторному консультанту.


Лицензии

Код проекта — ваш. Шрифты Poppins и Lora поставляются под SIL Open Font License 1.1, что допускает коммерческое использование и распространение в составе продукта.

About

Высокопроизводительный 5×3 слот-движок: клиент на чистом Canvas2D и RGS-сервер на Node.js без внешних зависимостей. Provably Fair, RTP 96.008%, поддержка тем.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages