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