Опыт создания онлайн-настолки: от идеи до реализации

от автора

Пару лет назад единственной настольной игрой, в которую я играл онлайн с друзьями, была «Монополия». Со временем она начала надоедать, и мне захотелось чего‑то нового. Моим открытием стала Machi Koro — экономическая карточная игра, где победа зависит не столько от случайности, сколько от выбранной стратегии, что выгодно отличает её от «Монополии».

На тот момент я не нашёл достойных онлайн‑аналогов Machi Koro, что и подтолкнуло меня к созданию собственной реализации. В этой статье я подробно расскажу о технической стороне проекта: от составления требований до выбора стека технологий.

Прежде чем приступить к разработке, я сформулировал ключевые требования.

Функциональные требования:

  1. Авторизация пользователя

  2. Мультиплеерный режим

  3. Управление пользовательскими сессиями

  4. Поддержка многоязычности

  5. Система оформления тем

  6. Адаптация под мобильные устройства

Нефункциональные требования:

  1. Кросс-браузерная совместимость (Firefox, Chrome, Edge, Safari, Telegram TWA)

  2. Авторизация по UUID — бэкенд не хранит персональные данные, используя только сгенерированные идентификаторы

  3. Отказоустойчивость — сохранение сессий после перезагрузки сервера или переподключения клиента

  4. Socket.io для клиент-серверного взаимодействия

  5. SPA-архитектура (одностраничное приложение)

  6. PWA с возможностью установки на устройство

Выбор технологического стека

Бэкенд:

  • Node.js — выбран благодаря знакомству с JavaScript и хорошему балансу между скоростью разработки и производительностью

  • Socket.IO — библиотека, которая обеспечивает надежное соединение, автоматически переключаясь между веб-сокетами и другими технологиями (Long Polling и др.) при необходимости

Примечание: Подробнее о Socket.IO можно прочитать в этой статье на Хабре.

Фронтенд:

  • React — как основа интерфейса

  • Right Store — для управления состоянием

  • Socket.IO Client — для работы с веб-сокетами

  • Vite — сборщик проекта

Этот стек позволяет создавать сложные фронтенд-приложения с высокой производительностью. Основной компромисс — неидеальное SEO, но для игрового проекта где много сложной логики это не критично.

Реализация бесшовной авторизации без БД

Проблема традиционных подходов

Классические системы аутентификации (логин/пароль или через соцсети) создают несколько проблем для игрового проекта:

  1. Барьер входа — необходимость регистрации снижает конверсию

  2. Избыточность — для casual-игры не нужно хранить сложные профили

  3. Сложность инфраструктуры — требуется БД и системы восстановления паролей

Решение: анонимная авторизация через UUID. Я реализовал максимально простой процесс:

  • При первом посещении в браузере генерируется UUID

  • Идентификатор сохраняется в localStorage

  • При последующих посещениях используется тот же UUID

export const getAnonUserId = () => {      let userId = localStorage.getItem('userId');      if (!userId) {          userId = window.crypto.randomUUID?.() || Math.random().toString();          localStorage.setItem('userId', userId);      }      return userId;  }

Преимущества подхода:

  1. Нулевой порог входа для пользователя

  2. Не требуется ввод каких-либо данных

  3. Работает даже при отключенных cookies (используя localStorage)

  4. Кроссплатформенность (Web/Telegram TWA)

  5. Генерация никнеймов

  6. Для социализации игроков система автоматически генерирует запоминающиеся никнеймы вроде «Пухляш» или «Кексик». Это:

  7. Создает легкую идентификацию в лобби

  8. Не требует дополнительных полей ввода

Архитектурные преимущества

Такой подход полностью исключает необходимость:

  1. Подключения БД

  2. Реализации механизмов восстановления доступа

  3. Хранения персональных данных (соответствие GDPR)

  4. Серверной валидации учетных данных

Реализация мультиплеера: серверная архитектура

Основная логика сервера реализована в файле index.js, который выполняет:

  • Инициализацию Express-сервера

  • Настройку CORS-политики

  • Подключение Socket.IO

Обработку входящих соединений

import express from 'express'; import cors from 'cors'; import 'dotenv/config'; import { createServer } from 'http'; import { Server } from 'socket.io'; import { IS_DEV, SESSION_NAME } from './constants/constants.js'; import { onConnection } from './socket/index.js'; import './services/process.service.js';   const PORT = process.env.PORT;   const app = express(); app.use(express.json()); app.use('/api', routes);   const server = createServer(app);   const io = new Server(server, {   cors: {     origin: '*',     methods: ['GET', 'POST'],   }, });   io.on('connection', (socket) => {   onConnection(io, socket); });  server.listen(PORT, (error) => {   if (!error) {     console.log('Server is Successfully Running, and App is listening on port ' + PORT);   } else {     console.log("Error occurred, server can't start", error);   } }); 

Пользовательская сессия

Пользователи должны каким-то образом находить друг друга чтобы сыграть вместе. Для этого было реализовано Лобби. После того как пользователь выбрал кол-во игроков и нажал “Играть” он попадает в лобби. На сервере это реализовано максимально просто:

const sessions = new Map()   const joinSession = (sessionId, userId) => {   if (sessions.has(sessionId)) {     sessions.get(sessionId).join(userId);   } };   const createSession = (sessionId, userId) => {   sessions.set(sessionId, new Game(sessionId)); };   const leaveSession = (sessionId, userId) => {   sessions.get(sessionId).leave(userId); }; 

Игра начинается как только найдется необходимое кол-во игроков.

Весь основной флоу изображен на UML диаграмме:

Реализация многоязычности

Для реализации многоязычности не использовалась никакая CMS. Все переводы лежал в одном JSON файле на фронте, чтобы что то поменять достаточно изменить файл сделать коммит и запушить (Да не идеальное решение для больших компаний но сойдет для небольшое команды из двух разработчиков 🙂 ).

И собственно API переводов:

const AVAILABLE_LANGUAGES = {     'en-US': 0,     'en': 0,     'ru': 1, }   const AVAILABLE_LANGUAGES_KEYS = Object.keys(AVAILABLE_LANGUAGES)   export const getTranslateMap = lang => Object.entries(TRANSLATIONS).reduce((acc, [k, v]) => {     acc[k] = v[AVAILABLE_LANGUAGES[lang]]     return acc }, {})   export const translate = (translateMap, key) => translateMap[key]   export const getLanguageByLocale = () => {     const lang = window.navigator.language     return AVAILABLE_LANGUAGES_KEYS.includes(lang) ? lang : AVAILABLE_LANGUAGES_KEYS[0] } 

Данные переводов добавляются в React context и далее используя hook useTranslate можно осуществлять перевод.

import { createContext, useCallback, useContext } from "react" import { translate } from '../services/translate'   export const ContextTranslate = createContext()   export const useTranslate = () => {     const translateMap = useContext(ContextTranslate)     return useCallback(key => translate(translateMap, key), [translateMap]) } 

Пример использования useTranslate:

const TranslateExample= () => {   const t = useTranslate()     return (<p>{t('some.key')}</p>) } 

При изменении языка в меню приложения все зависимые компоненты будут отрисованы с учетом нового языка.

Система оформления тем

Темизация это легко! Достаточно на body повесить класс в котором все css переменные отвечающие за цвет и в нужный момент установить соответствующий класс!

А вот небольшой пример:

<!DOCTYPE html> <html lang="en"> <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>Habr</title> </head> <body>     <style>         body {             --bg-color: #fff;             --text-color: #000;               background-color: var(--bg-color);             color: var(--text-color);               transition: .3s;         }           body.dark {             --bg-color: #000;             --text-color: #fff;         }     </style>     <h1>Hello World</h1>       <button onclick="document.body.classList.toggle('dark')">Toggle Theme</button> </body> </html>

Теперь остается только использовать css переменные для изменения свойств при изменении класса.

Поддержка мобильных устройств: Mobile First подход

Почему Mobile First? При разработке интерфейса мы сознательно выбрали стратегию Mobile First, которая предполагает:

  1. Первоочередную разработку для мобильных устройств с последующей адаптацией для десктопов

  2. Прогрессивное улучшение (progressive enhancement) интерфейса

  3. Приоритет контента над декоративными элементами

Реализация PWA: превращаем сайт в устанавливаемое приложение

Почему PWA — это важно? Progressive Web Apps предоставляют ключевые преимущества для онлайн-игр:

  • Установка на домашний экран (как нативное приложение)

  • Оффлайн-доступность (с помощью Service Worker)

  • Push-уведомления (для вовлечения пользователей)

  • Автоматические обновления

Минимальные требования для PWA

  1. Manifest файл (manifest.json)

  2. Service Worker (для оффлайн-режима)

  3. HTTPS соединение (обязательное требование)

  4. Адаптивный дизайн (уже реализован через Mobile First)

Manifest файл (public/manifest.json)

{           id: "https://www.cardscity.online/",           name: "Cards City",           short_name: "Cards City",           display: "standalone",           description:             "Welcome to CardsCity - an exciting online card strategy game where you build your unique city, one card at a time. Your goal is to build 3 gold-colored enterprises. Create your own decks, open reward boxes to get more cards, and think strategically to win game battles.",           categories: ["entertainment", "games", "kids"],           launch_handler: {             client_mode: "auto",           },           orientation: "any",           dir: "ltr",           related_applications: [],           prefer_related_applications: true,           screenshots: [             {               src: "assets/screenshots/screenshot_mainScreen_narrow_375_667.png",               sizes: "375x667",               type: "image/png",               form_factor: "narrow",               label: "Игра Cards City",             },             {               src: "assets/screenshots/screenshot_mainScreen_wide_1920_1080.png",               sizes: "1920x1080",               type: "image/png",               form_factor: "wide",               label: "Игра Cards City",             },           ],           theme_color: "#ffffff",           background_color: "#ffffff",           icons: [             {               src: "./logo512.png",               sizes: "512x512",               type: "image/png",               purpose: "maskable",             },             {               src: "./logo256.png",               sizes: "256x256",               type: "image/png",             },             {               src: "./logo144.png",               sizes: "144x144",               type: "image/png",               purpose: "any",             },             {               src: "./logo128.png",               sizes: "128x128",               type: "image/png",             },             {               src: "./logo64.png",               sizes: "64x64",               type: "image/png",               purpose: "any",             },             {               src: "./logo32.png",               sizes: "32x32",               type: "image/png",               purpose: "any",             },           ],         } 

Я использовал VitePWA — мощное решение для PWA в Vite

Для нашего проекта я использовал vite-plugin-pwa — официальное плагин для Vite, который предоставляет:

  1. Автоматическую генерацию Service Worker

  2. Пре-кэширование ресурсов

  3. Стратегии кэширования

  4. Автоматическое обновление приложения

  5. Генерацию манифеста и иконок

Конфигурация VitePWA (vite.config.js)

{ registerType: "autoUpdate",         workbox: {           cleanupOutdatedCaches: true,           globPatterns: ["**/*.{js,css,html,ico,png,svg,gif,mp3,webp,webm}"],           skipWaiting: true,           clientsClaim: true,           maximumFileSizeToCacheInBytes: 300 * 1024 ** 2,           runtimeCaching: [             {               urlPattern: /\.(?:png|jpg|jpeg|svg|gif|mp3|webp|webm)$/,               handler: "CacheFirst",               options: {                 cacheName: "media-cache",                 expiration: {                   maxEntries: 100,                   maxAgeSeconds: 7 * 24 * 60 * 60,                 },               },             },           ],         }, manifest: {} } 

Но это еще не все! Вы также можете превратить сайт с PWA в нативное приложение используя https://www.pwabuilder.com/ 

Я создавал таким образом IOS и Android версии приложений, тут довольно все просто, действуйте согласно инструкции: https://docs.pwabuilder.com/#/builder/quick-start

React + Right Store: оптимальное решение для онлайн-игры

Для фронтенд-части проекта мы выбрали React по нескольким ключевым причинам:

1. Компонентный подход

  • Переиспользуемость компонентов (карточки, кнопки, модалки)

  • Четкое разделение ответственности между компонентами

  • Простота тестирования изолированных частей интерфейса

2. Производительность

  • Виртуальный DOM для эффективных обновлений

  • Возможность оптимизации через мемоизацию: (useMemo, useCallback, Pure Functions)

3. Экосистема

  • Богатый выбор дополнительных библиотек

  • Поддержка TypeScript из коробки

  • Интеграция с Vite для мгновенного обновления кода

Right Store — минималистичное решение для управления состоянием

Библиотека Right Store позволяет хранить состояние и подписываться компонентам на отдельные части хранилища, при любом изменении в хранилище только зависящие части будут изменены к тому же простое API:

import { useEffect } from 'react' import { createStore } from 'right-store'  type Count = number  const Store = createStore({     initialState: { count: 0 } })  const { useSelector, patchState, getState } = Store const Counter = () => {     const count: Count = useSelector(state => state.count)      // Watcher     useEffect(() => {         console.log('Count has been updated: ', getState().count)     }, [getState().count])      return (         <div>             <div>                 <button onClick={() => patchState('count', (count: Count) => count + 1)}>Increment</button>                 <button onClick={() => patchState('count', count - 1)}>Decrement</button>                 <button onClick={() => console.log(getState())}>Get State</button>             </div>             <h1>Count is: {count}</h1>         </div>     ) }

Итоги. Основные достижения проекта

  1. Успешная реализация полноценной онлайн-версии Cards City

  2. Стабильная работа при 500+ одновременных подключениях

  3. Кроссплатформенность (Web, PWA, iOS, Android)

  4. Положительные отзывы от сообщества настольных игр

Статистика использования

  • 70% пользователей — мобильные устройства

  • 10% установок через PWA

  • Средняя сессия — 14 минут

  • Retention (D7) — 35%

Главные уроки

  1. Не всегда нужно сложное решение

    • UUID-авторизация вместо традиционной

    • Right Store вместо Redux

    • JSON-переводы вместо i18n-библиотек

  2. Mobile First — это необходимость

    • 3 из 4 игроков используют мобильные

    • Упрощение UI привело к лучшей UX на всех платформах

  3. PWA — идеальный вариант для браузерных игр

    • Низкий барьер входа

    • Возможность публикации в магазинах

    • Оффлайн-возможности

  4. Сокеты решают для онлайн-игр

    • Минимальная задержка

    • Простота реализации игровой логики

    • Надежное восстановление соединений

Этот проект доказал, что небольшая команда (в нашем случае — два разработчика) может создать качественный multiplayer-продукт, используя современные и доступные технологии. Главное — делать осознанный выбор инструментов и фокусироваться на основных потребностях пользователей.

Спасибо за внимание! Надеюсь было полезно 🙂

А попробовать игру можно по ссылкам:

  1. Мультиплеер

  2. Однопользовательская на Яндекс Игры

  3. Telegram Web App


ссылка на оригинал статьи https://habr.com/ru/articles/925236/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *