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