Пару лет назад единственной настольной игрой, в которую я играл онлайн с друзьями, была «Монополия». Со временем она начала надоедать, и мне захотелось чего‑то нового. Моим открытием стала 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-продукт, используя современные и доступные технологии. Главное — делать осознанный выбор инструментов и фокусироваться на основных потребностях пользователей.
Спасибо за внимание! Надеюсь было полезно :-)
А попробовать игру можно по ссылкам:
