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

Комментарии (0)