Слот 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/.
Если бэкенд 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 — так поставляется весь игровой
контент. Сервер отдаёт 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, что допускает коммерческое использование и распространение в составе продукта.