Игровые приложения предъявляют к сетевому взаимодействию особые требования:
Библиотека STOMP.js используется как клиентская реализация протокола STOMP поверх WebSocket и позволяет организовывать обмен игровыми событиями между браузером и сервером.
Игровая архитектура обычно состоит из следующих компонентов:
Игрок → STOMP.js → WebSocket → Broker → Игровой сервер
Типичный стек:
| Компонент | Назначение |
|---|---|
| STOMP.js | Работа с STOMP-протоколом |
| WebSocket | Постоянное двустороннее соединение |
| RabbitMQ / ActiveMQ | Message broker |
| Node.js / Java / Go | Игровой backend |
| Redis | Состояние игровых комнат |
| PostgreSQL | Хранение статистики |
Для игровых приложений особенно важна стабильная настройка клиента.
npm install @stomp/stompjs sockjs-client
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:8080/game',
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
debug(str) {
console.log(str);
}
});
client.activate();
В игровых системах удобно разделять каналы по типам событий.
/game/global
/game/rooms/1
/game/rooms/1/chat
/game/rooms/1/state
/game/player/52
/game/matchmaking
Такое разделение позволяет:
client.onConn ect = () => {
client.subscribe('/topic/game/rooms/1/state', message => {
const state = JSON.parse(message.body);
updateGame(state);
});
};
function updateGame(state) {
renderPlayers(state.players);
renderObjects(state.objects);
renderScore(state.score);
}
Игроки постоянно отправляют действия:
function movePlayer(x, y) {
client.publish({
destination: '/app/game/move',
body: JSON.stringify({
x,
y
})
});
}
function attack(targetId) {
client.publish({
destination: '/app/game/attack',
body: JSON.stringify({
targetId
})
});
}
В игровых приложениях существует два подхода:
Сервер регулярно отправляет весь state.
{
players: [],
bullets: [],
enemies: [],
score: {}
}
Преимущества:
Недостатки:
Передаются только изменения.
{
type: 'PLAYER_MOVED',
playerId: 12,
x: 450,
y: 210
}
Преимущества:
Недостатки:
При сетевых задержках движение объектов может быть рывками.
Интерполяция сглаживает анимацию между обновлениями.
function interpolate(current, target, alpha) {
return current + (target - current) * alpha;
}
player.x = interpolate(
player.x,
serverState.x,
0.1
);
player.y = interpolate(
player.y,
serverState.y,
0.1
);
В action-играх ожидание ответа сервера делает управление «тяжёлым».
Поэтому клиент заранее отображает действие локально.
Игрок нажал клавишу
↓
Клиент сразу двигает персонажа
↓
Сервер подтверждает действие
↓
Клиент корректирует позицию
function localMove(dx, dy) {
player.x += dx;
player.y += dy;
render();
client.publish({
destination: '/app/move',
body: JSON.stringify({ dx, dy })
});
}
Сервер остаётся единственным источником истины.
Если клиент предсказал состояние неверно:
function reconcile(serverState) {
player.x = serverState.x;
player.y = serverState.y;
}
Игровые события удобно разделять по типам.
client.subscribe('/topic/game/events', message => {
const event = JSON.parse(message.body);
switch (event.type) {
case 'PLAYER_JOIN':
handlePlayerJoin(event);
break;
case 'PLAYER_LEAVE':
handlePlayerLeave(event);
break;
case 'SHOT':
handleShot(event);
break;
case 'DAMAGE':
handleDamage(event);
break;
}
});
Чат обычно работает через отдельный канал.
client.subscribe('/topic/chat/1', message => {
const data = JSON.parse(message.body);
appendMessage(data);
});
function sendMessage(text) {
client.publish({
destination: '/app/chat',
body: JSON.stringify({
text
})
});
}
Подбор игроков удобно реализовывать через отдельную очередь.
client.publish({
destination: '/app/matchmaking/search',
body: JSON.stringify({
rank: 1200
})
});
client.subscribe('/user/queue/match', message => {
const match = JSON.parse(message.body);
connectToRoom(match.roomId);
});
Каждая игровая комната изолируется собственным набором каналов.
/topic/room/15/state
/topic/room/15/events
/topic/room/15/chat
Игроки могут:
const client = new Client({
brokerURL: 'ws://localhost:8080/game',
reconnectDelay: 3000
});
После реконнекта клиенту требуется повторная синхронизация.
client.onConn ect = () => {
client.publish({
destination: '/app/game/resync'
});
};
Heartbeat позволяет определять «мёртвые» соединения.
const client = new Client({
heartbeatIncoming: 5000,
heartbeatOutgoing: 5000
});
Если heartbeat не приходит:
Игровые приложения особенно чувствительны к объёму сообщений.
Вместо:
60 FPS = 60 сообщений/сек
используют:
20 обновлений/сек
Плохо:
{
players: [...],
enemies: [...],
map: [...]
}
Хорошо:
{
type: 'MOVE',
id: 15,
x: 120,
y: 300
}
JSON создаёт дополнительный overhead.
В высоконагруженных играх используют:
Tick-rate влияет на плавность игры.
| Tick rate | Назначение |
|---|---|
| 10 | Пошаговые игры |
| 20 | Казуальные multiplayer |
| 30 | Большинство realtime игр |
| 60 | Соревновательные action-игры |
Сервер обязательно проверяет:
if (speed > MAX_SPEED) {
rejectMove();
}
Это предотвращает:
При высокой нагрузке требуется:
Игровые события могут распределяться между несколькими серверами.
Игроки
↓
Load Balancer
↓
WebSocket Gateway
↓
RabbitMQ
↓
Game Servers
Игроки могут генерировать спам.
const lastMessage = {};
function canSend(playerId) {
const now = Date.now();
if (!lastMessage[playerId]) {
lastMessage[playerId] = now;
return true;
}
if (now - lastMessage[playerId] < 100) {
return false;
}
lastMessage[playerId] = now;
return true;
}
Игровые системы требуют аутентификации.
const client = new Client({
connectHeaders: {
Authorization: 'Bearer TOKEN'
}
});
STOMP поддерживает user-очереди.
client.subscribe('/user/queue/inventory', message => {
const inventory = JSON.parse(message.body);
updateInventory(inventory);
});
Инвентарь удобно синхронизировать отдельными событиями.
{
type: 'ITEM_ADD',
item: {
id: 15,
name: 'Sword'
}
}
{
type: 'ITEM_REMOVE',
itemId: 15
}
Лобби обычно содержит:
client.subscribe('/topic/lobby/5', message => {
const lobby = JSON.parse(message.body);
renderLobby(lobby);
});
client.onStompEr ror = frame => {
console.error(frame.headers.message);
};
client.onWebSocketCl ose = () => {
showDisconnectedScreen();
};
Через STOMP удобно отправлять:
client.subscribe('/user/queue/notifications', message => {
const notification = JSON.parse(message.body);
showNotification(notification);
});
Каждый объект может иметь:
{
id: 52,
type: 'enemy',
x: 120,
y: 350,
hp: 80
}
Сервер отправляет:
Событийная архитектура особенно эффективна для игр.
| Событие | Назначение |
|---|---|
| PLAYER_MOVE | Перемещение |
| PLAYER_ATTACK | Атака |
| OBJECT_SPAWN | Создание объекта |
| DAMAGE | Получение урона |
| CHAT_MESSAGE | Сообщение |
| MATCH_END | Завершение матча |
Сетевые обновления не должны напрямую вызывать тяжёлый render.
const queue = [];
client.subscribe('/topic/game', message => {
queue.push(JSON.parse(message.body));
});
function gameLoop() {
while (queue.length > 0) {
const event = queue.shift();
processEvent(event);
}
render();
requestAnimationFrame(gameLoop);
}
Наблюдатели получают состояние игры без участия.
client.subscribe('/topic/match/12/spectator', message => {
const state = JSON.parse(message.body);
renderSpectator(state);
});
STOMP.js подходит для:
client.subscribe('/user/queue/achievements', message => {
const achievement = JSON.parse(message.body);
unlockAchievement(achievement);
});
Через брокер сообщений удобно собирать:
Пример интеграции с игровым движком Phaser.
client.subscribe('/topic/player/move', message => {
const data = JSON.parse(message.body);
players[data.id].setPosition(
data.x,
data.y
);
});
С библиотекой PixiJS STOMP.js часто используется для multiplayer-rendering.
client.subscribe('/topic/enemies', message => {
const enemy = JSON.parse(message.body);
updateEnemy(enemy);
});
В игровых приложениях необходимо контролировать:
const start = Date.now();
client.publish({
destination: '/app/ping',
body: ''
});
client.subscribe('/user/queue/pong', () => {
console.log(Date.now() - start);
});
STOMP.js хорошо подходит для:
Однако для ultra-low-latency игр могут использоваться:
STOMP поверх WebSocket всё же остаётся удобным решением благодаря: