Создание браузерной мультиплеерной игры в реальном времени — один из лучших краш-тестов для веб-разработчика. Здесь мгновенно вскрываются все архитектурные компромиссы, о которых можно годами не задумываться при верстке обычных интернет-магазинов или CRM-систем.
Представь задачу: нужно разработать пошаговую карточную дуэль двух игроков (в духе Hearthstone или Magic: The Gathering). Игрок перетаскивает карты из руки на стол, кастует огненные шары, атакует существ противника, таймер тикает, а противник на другом конце света должен видеть плавные движения карт и эффекты магии со стабильной частотой 60–120 кадров в секунду.
Первый порыв большинства фронтендеров — написать всё на любимом стеке: React, Framer Motion и Tailwind CSS. Карты — это просто <div className="card">, стол — это <div className="board">, перетаскивание — react-dnd.
Но через пару недель разработки проект неизбежно врезается в две железобетонные стены:
- Падение FPS (Performance Wall): когда на экране одновременно разыгрываются три заклинания, летят искры частиц, а таймер хода обновляет DOM каждую секунду, движок браузера захлебывается в циклах Recalculate Style, Layout и Paint. Частота кадров проседает до дерганых 20–30 FPS.
- Читерство (Security Wall): если логика проверки стоимости карты и наличия маны написана только на клиенте, любой игрок может открыть консоль DevTools, вызвать функцию
playCard(legendaryDragon)без маны и сломать матч.
Давай разберем архитектурный рецепт, который решает обе эти проблемы: разделение интерфейса на React и PixiJS 8, единый изоморфный редуктор состояния на TypeScript и строгую валидацию ходов на Fastify.
Принцип разделения ответственности: React + PixiJS
Чтобы избавиться от лагов DOM-дерева, нужно четко разделить элементы экрана по их физической природе:
- Статический UI (React DOM): панель профиля игрока, аватарки, счетчик жизней, текстовый чат, кнопка «Настройки», всплывающие модальные окна подсказок. Эти элементы меняются редко, не требуют миллисекундных интерполяций и отлично верстаются на обычном HTML/CSS с поддержкой доступности (a11y).
- Динамическая игровая сцена (PixiJS 8 на WebGL / WebGPU): игровое поле, карты в руке, траектории полета снарядов, визуальные эффекты свечения, шейдеры растворения и физика перетаскивания. Рендеринг выносится в единый элемент
<canvas>, управляемый графическим движком PixiJS.

PixiJS 8 работает напрямую с GPU через WebGL или WebGPU, выполняя батчинг спрайтов (Sprite Batching) и отрисовывая тысячи интерактивных объектов без единого обращения к дереву DOM. В результате игра выдает железобетонные 120 FPS даже на мобильных смартфонах.
О том, как декларативные правила и игровые автоматы помогают проектировать сложные интерфейсы, мы подробно рассуждали в материале чему веб-разработка может поучиться у геймдева.
Архитектурное ядро: изоморфный детерминированный редуктор
Самая частая ошибка распределенных игр — дублирование игровой логики: на клиенте пишут правила на TypeScript, а на бэкенде пытаются повторить ту же логику на Python, Go или Java. Рано или поздно правила расходятся, и начинается рассинхронизация (Desync).
Элегантное решение — изоморфный чистый редуктор (Shared State Reducer) на чистом TypeScript. Этот модуль компилируется в виде общего NPM-пакета и импортируется как в клиентское веб-приложение, так и в серверный сервис на Fastify.
Функция редуктора строго детерминирована:
duelReducer(currentState, action) -> newState
При одинаковых входных данных она всегда возвращает абсолютно идентичный результат, не имея побочных эффектов.
Давай посмотрим на реализацию ядра редуктора:
// shared/duelReducer.ts — общий изоморфный модуль
export interface PlayerState {
id: string;
mana: number;
maxMana: number;
health: number;
hand: Card[];
board: Card[];
}
export interface GameState {
matchId: string;
turnPlayerId: string;
turnNumber: number;
players: Record<string, PlayerState>;
actionHistory: CardAction[];
}
export type CardAction =
| { type: 'PLAY_CARD'; playerId: string; cardId: string; targetId?: string; timestamp: number }
| { type: 'END_TURN'; playerId: string; timestamp: number };
export function duelReducer(state: GameState, action: CardAction): GameState {
switch (action.type) {
case 'PLAY_CARD': {
// 1. Проверяем очередность хода
if (state.turnPlayerId !== action.playerId) {
throw new Error('Не твой ход!');
}
const player = state.players[action.playerId];
const cardIndex = player.hand.findIndex(c => c.id === action.cardId);
// 2. Проверяем наличие карты в руке
if (cardIndex === -1) {
throw new Error('Карта отсутствует в руке игрока');
}
const card = player.hand[cardIndex];
// 3. Проверяем ману
if (player.mana < card.cost) {
throw new Error('Недостаточно маны для розыгрыша');
}
// 4. Формируем новое иммутабельное состояние
const nextHand = [...player.hand];
nextHand.splice(cardIndex, 1);
return {
...state,
players: {
...state.players,
[action.playerId]: {
...player,
mana: player.mana - card.cost,
hand: nextHand,
board: [...player.board, card]
}
},
actionHistory: [...state.actionHistory, action]
};
}
case 'END_TURN': {
const opponentId = Object.keys(state.players).find(id => id !== action.playerId)!;
const opponent = state.players[opponentId];
const nextMaxMana = Math.min(10, opponent.maxMana + 1);
return {
...state,
turnPlayerId: opponentId,
turnNumber: state.turnNumber + 1,
players: {
...state.players,
[opponentId]: {
...opponent,
maxMana: nextMaxMana,
mana: nextMaxMana
}
},
actionHistory: [...state.actionHistory, action]
};
}
default:
return state;
}
}
Серверная валидация на Fastify и WebSocket
Клиент никогда не считается источником истины (Never Trust the Client). Сервер выступает в роли неподкупного судьи.
Мы используем веб-фреймворк Fastify с плагином @fastify/websocket. Fastify выбран из-за минимальных накладных расходов на цикл событий V8 и высокой пропускной способности сериализации JSON.
Вот как выглядит обработка игрового пакета на сервере:
// server/gameSocket.ts
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
import { duelReducer, GameState, CardAction } from '../shared/duelReducer';
const app = Fastify({ logger: true });
app.register(websocket);
// Хранилище активных игровых комнат в памяти процесса
const activeMatches = new Map<string, GameState>();
app.register(async function (fastify) {
fastify.get('/ws/duel/:matchId', { websocket: true }, (connection, req) => {
const { matchId } = req.params as { matchId: string };
connection.socket.on('message', (rawMessage: string) => {
try {
const action: CardAction = JSON.parse(rawMessage.toString());
const currentState = activeMatches.get(matchId);
if (!currentState) {
connection.socket.send(JSON.stringify({ error: 'Матч не найден' }));
return;
}
// Применяем общий редуктор на сервере!
// Если ход невалиден, редуктор выбросит исключение
const nextState = duelReducer(currentState, action);
activeMatches.set(matchId, nextState);
// Рассылаем подтвержденное каноническое состояние всем участникам дуэли
broadcastToMatch(matchId, {
type: 'STATE_UPDATE',
state: nextState,
lastAction: action
});
} catch (err: any) {
// Ход был нелегальным: отправляем сигнал отката
connection.socket.send(JSON.stringify({
type: 'REVERT_ACTION',
reason: err.message
}));
}
});
});
});
Оптимистичные обновления и плавный откат
Что видит игрок, когда бросает карту на стол? Если ждать сетевого ответа от сервера (раундтрип 50–150 мс), игра покажется «ватной» и неотзывчивой.
Поэтому клиент использует оптимистичный апдейт (Optimistic UI):
- Игрок отпускает карту: клиент локально вызывает
duelReducer, мгновенно начинает анимацию падения карты на стол и убавляет полоску маны в UI. - Параллельно сетевой сокет шлет экшен
PLAY_CARDна сервер. - Сервер одобряет действие и присылает подтверждение: клиент просто подтверждает транзакцию.
- Если же произошел редкий сбой или читер пытался подделать ману: сервер возвращает
REVERT_ACTION. Клиент ловит этот сигнал и плавно возвращает карту обратно в руку с анимацией отката и сообщением об ошибке.
Такой гибрид дарит игроку мгновенный тактильный отклик и плавные 120 FPS, сохраняя абсолютную честность и стабильность соревновательного мультиплеера.
