Дайджесты новостей
Многослойная тактильная диорама бумажного коллажа карточной онлайн-игры с разделением интерфейса React и графического холста PixiJS на Fastify.

Архитектура карточной дуэли в браузере: React, PixiJS и валидация на Fastify

Создание браузерной мультиплеерной игры в реальном времени — один из лучших краш-тестов для веб-разработчика. Здесь мгновенно вскрываются все архитектурные компромиссы, о которых можно годами не задумываться при верстке обычных интернет-магазинов или CRM-систем.

Представь задачу: нужно разработать пошаговую карточную дуэль двух игроков (в духе Hearthstone или Magic: The Gathering). Игрок перетаскивает карты из руки на стол, кастует огненные шары, атакует существ противника, таймер тикает, а противник на другом конце света должен видеть плавные движения карт и эффекты магии со стабильной частотой 60–120 кадров в секунду.

Первый порыв большинства фронтендеров — написать всё на любимом стеке: React, Framer Motion и Tailwind CSS. Карты — это просто <div className="card">, стол — это <div className="board">, перетаскивание — react-dnd.

Но через пару недель разработки проект неизбежно врезается в две железобетонные стены:

  1. Падение FPS (Performance Wall): когда на экране одновременно разыгрываются три заклинания, летят искры частиц, а таймер хода обновляет DOM каждую секунду, движок браузера захлебывается в циклах Recalculate Style, Layout и Paint. Частота кадров проседает до дерганых 20–30 FPS.
  2. Читерство (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.

Патентный чертеж парсинга протокола Redis RESP3: потоковый конечный автомат разбирает префиксы типов данных в цикле событий libuv.

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):

  1. Игрок отпускает карту: клиент локально вызывает duelReducer, мгновенно начинает анимацию падения карты на стол и убавляет полоску маны в UI.
  2. Параллельно сетевой сокет шлет экшен PLAY_CARD на сервер.
  3. Сервер одобряет действие и присылает подтверждение: клиент просто подтверждает транзакцию.
  4. Если же произошел редкий сбой или читер пытался подделать ману: сервер возвращает REVERT_ACTION. Клиент ловит этот сигнал и плавно возвращает карту обратно в руку с анимацией отката и сообщением об ошибке.

Такой гибрид дарит игроку мгновенный тактильный отклик и плавные 120 FPS, сохраняя абсолютную честность и стабильность соревновательного мультиплеера.