Пару лет назад единственной настольной игрой, в которую я играл онлайн с друзьями, была «Монополия». Со временем она начала надоедать, и мне захотелось чего‑то нового. Моим открытием стала Machi Koro — экономическая карточная игра, где победа зависит не столько от случайности, сколько от выбранной стратегии, что выгодно отличает её от «Монополии».
На тот момент я не нашёл достойных онлайн‑аналогов Machi Koro, что и подтолкнуло меня к созданию собственной реализации. В этой статье я подробно расскажу о технической стороне проекта: от составления требований до выбора стека технологий.
Прежде чем приступить к разработке, я сформулировал ключевые требования.
Функциональные требования:
-
Авторизация пользователя
-
Мультиплеерный режим
-
Управление пользовательскими сессиями
-
Поддержка многоязычности
-
Система оформления тем
-
Адаптация под мобильные устройства
Нефункциональные требования:
-
Кросс-браузерная совместимость (Firefox, Chrome, Edge, Safari, Telegram TWA)
-
Авторизация по UUID — бэкенд не хранит персональные данные, используя только сгенерированные идентификаторы
-
Отказоустойчивость — сохранение сессий после перезагрузки сервера или переподключения клиента
-
Socket.io для клиент-серверного взаимодействия
-
SPA-архитектура (одностраничное приложение)
-
PWA с возможностью установки на устройство
Выбор технологического стека
Бэкенд:
-
Node.js — выбран благодаря знакомству с JavaScript и хорошему балансу между скоростью разработки и производительностью
-
Socket.IO — библиотека, которая обеспечивает надежное соединение, автоматически переключаясь между веб-сокетами и другими технологиями (Long Polling и др.) при необходимости
Примечание: Подробнее о Socket.IO можно прочитать в этой статье на Хабре.
Фронтенд:
-
React — как основа интерфейса
-
Right Store — для управления состоянием
-
Socket.IO Client — для работы с веб-сокетами
-
Vite — сборщик проекта
Этот стек позволяет создавать сложные фронтенд-приложения с высокой производительностью. Основной компромисс — неидеальное SEO, но для игрового проекта где много сложной логики это не критично.
Реализация бесшовной авторизации без БД
Проблема традиционных подходов
Классические системы аутентификации (логин/пароль или через соцсети) создают несколько проблем для игрового проекта:
-
Барьер входа — необходимость регистрации снижает конверсию
-
Избыточность — для casual-игры не нужно хранить сложные профили
-
Сложность инфраструктуры — требуется БД и системы восстановления паролей
Решение: анонимная авторизация через 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; }
Преимущества подхода:
-
Нулевой порог входа для пользователя
-
Не требуется ввод каких-либо данных
-
Работает даже при отключенных cookies (используя localStorage)
-
Кроссплатформенность (Web/Telegram TWA)
-
Генерация никнеймов
-
Для социализации игроков система автоматически генерирует запоминающиеся никнеймы вроде «Пухляш» или «Кексик». Это:
-
Создает легкую идентификацию в лобби
-
Не требует дополнительных полей ввода
Архитектурные преимущества
Такой подход полностью исключает необходимость:
-
Подключения БД
-
Реализации механизмов восстановления доступа
-
Хранения персональных данных (соответствие GDPR)
-
Серверной валидации учетных данных
Реализация мультиплеера: серверная архитектура
Основная логика сервера реализована в файле 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, которая предполагает:
-
Первоочередную разработку для мобильных устройств с последующей адаптацией для десктопов
-
Прогрессивное улучшение (progressive enhancement) интерфейса
-
Приоритет контента над декоративными элементами
Реализация PWA: превращаем сайт в устанавливаемое приложение
Почему PWA — это важно? Progressive Web Apps предоставляют ключевые преимущества для онлайн-игр:
-
Установка на домашний экран (как нативное приложение)
-
Оффлайн-доступность (с помощью Service Worker)
-
Push-уведомления (для вовлечения пользователей)
-
Автоматические обновления
Минимальные требования для PWA
-
Manifest файл (
manifest.json) -
Service Worker (для оффлайн-режима)
-
HTTPS соединение (обязательное требование)
-
Адаптивный дизайн (уже реализован через 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, который предоставляет:
-
Автоматическую генерацию Service Worker
-
Пре-кэширование ресурсов
-
Стратегии кэширования
-
Автоматическое обновление приложения
-
Генерацию манифеста и иконок
Конфигурация 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> ) }
Итоги. Основные достижения проекта
-
Успешная реализация полноценной онлайн-версии Cards City
-
Стабильная работа при 500+ одновременных подключениях
-
Кроссплатформенность (Web, PWA, iOS, Android)
-
Положительные отзывы от сообщества настольных игр
Статистика использования
-
70% пользователей — мобильные устройства
-
10% установок через PWA
-
Средняя сессия — 14 минут
-
Retention (D7) — 35%
Главные уроки
-
Не всегда нужно сложное решение
-
UUID-авторизация вместо традиционной
-
Right Store вместо Redux
-
JSON-переводы вместо i18n-библиотек
-
-
Mobile First — это необходимость
-
3 из 4 игроков используют мобильные
-
Упрощение UI привело к лучшей UX на всех платформах
-
-
PWA — идеальный вариант для браузерных игр
-
Низкий барьер входа
-
Возможность публикации в магазинах
-
Оффлайн-возможности
-
-
Сокеты решают для онлайн-игр
-
Минимальная задержка
-
Простота реализации игровой логики
-
Надежное восстановление соединений
-
Этот проект доказал, что небольшая команда (в нашем случае — два разработчика) может создать качественный multiplayer-продукт, используя современные и доступные технологии. Главное — делать осознанный выбор инструментов и фокусироваться на основных потребностях пользователей.
Спасибо за внимание! Надеюсь было полезно 🙂
А попробовать игру можно по ссылкам:
ссылка на оригинал статьи https://habr.com/ru/articles/925236/
Добавить комментарий