Клиент и сервер в Roblox: RemoteEvent без дыр
Почему в Roblox всё важное считает сервер, как устроен RemoteEvent и какие проверки надо написать, чтобы монеты в твоей игре нельзя было выдать себе одной строчкой из консоли.
Сложно 26 минут проверен 1 час назад
Что понадобится
Roblox Studio и основы Luau: функции, таблицы, события. Гайд «Luau с нуля» закрывает необходимый минимум.
Текст гайда «Клиент и сервер в Roblox: RemoteEvent без дыр»
Содержание
Каждая игра в Roblox работает в двух местах сразу. Сервер — общий для всех, он хранит настоящее состояние мира. Клиент — копия у каждого игрока, она рисует картинку и принимает нажатия. Между ними идёт постоянный обмен, и вся безопасность игры сводится к одному вопросу: что из происходящего решает сервер, а что — чужой компьютер.
Кто где живёт#
| Тип скрипта | Где выполняется | Для чего |
|---|---|---|
| Script в ServerScriptService | Сервер | Правила игры, награды, урон, сохранение |
| LocalScript в StarterPlayerScripts | Клиент игрока | Интерфейс, ввод, камера, звук |
| ModuleScript в ReplicatedStorage | Обе стороны | Общие функции и константы |
| ModuleScript в ServerStorage | Только сервер | Логика, которую клиенту видеть незачем |
| RemoteEvent в ReplicatedStorage | Мост между ними | Сообщение в одну сторону, без ответа |
| RemoteFunction в ReplicatedStorage | Мост между ними | Вопрос с ответом, ждёт результат |
Клиент — чужая территория
Игрок технически может выполнить у себя любой код и отправить серверу что угодно: другое число, другой тип, вызов сто раз в секунду. Это не значит, что все так делают, но проектировать надо так, будто делают. Всё, что приходит из FireServer, — это заявка, а не факт.
Как выглядит дырявый код#
Самая частая ошибка новичка — считать награду на клиенте и присылать серверу готовую цифру. Выглядит логично, работает мгновенно и ломается за минуту.
-- LocalScript
local event = game.ReplicatedStorage.GiveCoins
button.Activated:Connect(function()
event:FireServer(10) -- клиент сам решил, сколько ему монет
end)
-- Script на сервере
event.OnServerEvent:Connect(function(player, amount)
player.leaderstats.Coins.Value += amount -- сервер поверил на слово
end)Здесь сервер не проверяет ничего: ни величину, ни тип, ни частоту вызовов. Вместо десятки прилетит миллион, вместо числа — строка, а вместо одного нажатия — тысяча за секунду. Экономика игры кончается в тот же вечер.
Как выглядит правильный#
Правило одно: клиент сообщает о намерении, сервер решает, что из этого следует. Награду считает сервер, из своих данных.
-- LocalScript: только факт нажатия, без чисел
local event = game.ReplicatedStorage.ClaimReward
button.Activated:Connect(function()
event:FireServer()
end)
-- Script на сервере
local Players = game:GetService("Players")
local REWARD = 10
local COOLDOWN = 5
local lastClaim = {}
event.OnServerEvent:Connect(function(player)
local now = os.clock()
-- 1. Защита от спама: не чаще раза в пять секунд.
if lastClaim[player] and now - lastClaim[player] < COOLDOWN then
return
end
-- 2. Проверка состояния: награда вообще положена?
local stats = player:FindFirstChild("leaderstats")
if stats == nil then
return
end
lastClaim[player] = now
stats.Coins.Value += REWARD -- величину знает только сервер
end)
Players.PlayerRemoving:Connect(function(player)
lastClaim[player] = nil
end)Четыре проверки, которые нужны почти всегда#
- Тип и величина. Пришло не число или число вне допустимых границ — выходи молча, не пытайся исправить
- Частота. Таблица с временем последнего вызова на игрока закрывает большинство автоматических атак
- Право. Действительно ли этот игрок владеет предметом, находится в нужной зоне, прошёл нужный этап
- Расстояние. Клик по объекту в двухстах метрах — не клик: сравни позицию персонажа с позицией цели
local MAX_DISTANCE = 15
event.OnServerEvent:Connect(function(player, target)
-- Тип: клиент мог прислать что угодно, включая nil и строку.
if typeof(target) ~= "Instance" or not target:IsA("BasePart") then
return
end
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if root == nil then
return
end
-- Расстояние: до цели должно быть реально дотянуться.
if (root.Position - target.Position).Magnitude > MAX_DISTANCE then
return
end
-- Только теперь действие.
end)Первый аргумент подставляет платформа
В OnServerEvent первым всегда приходит player, и его подставляет сам Roblox — подделать это поле нельзя. Поэтому личность отправителя бери оттуда, а не из данных, которые он прислал. Игрок, приславший чужое имя в аргументе, — классическая попытка выдать себя за другого.
Что менять на клиенте не жалко#
Строгость нужна там, где меняется общее состояние. Всё остальное разумно оставить клиенту: так игра ощущается отзывчивой, и сервер не тратит время на мелочи.
| Действие | Кто решает |
|---|---|
| Нажатие кнопки, открытие меню | Клиент |
| Анимация, звук, вспышка на экране | Клиент |
| Движение камеры и подсветка объекта | Клиент |
| Начисление валюты и опыта | Сервер |
| Урон и смерть | Сервер |
| Выдача и удаление предметов | Сервер |
| Покупка за игровую валюту | Сервер |
| Телепорт и вход в закрытую зону | Сервер |
Отдельный случай — движение персонажа. Его в Roblox считает клиент, иначе управление было бы вязким. Поэтому скорость и полёт проверяют не запретом, а наблюдением: сервер смотрит, насколько далеко персонаж переместился за отрезок времени, и реагирует на явно невозможные значения.
RemoteFunction: когда нужен ответ#
RemoteFunction отличается тем, что ждёт результат. Клиент спрашивает — сервер отвечает, и код на клиенте стоит на паузе, пока ответ не придёт. Это удобно для запроса данных, но у него есть подвох.
-- Script на сервере
local request = game.ReplicatedStorage.GetProfile
request.OnServerInvoke = function(player)
local data = profiles[player]
if data == nil then
return nil
end
-- Отдаём только то, что клиенту положено видеть.
return { coins = data.coins, level = data.level }
end
-- LocalScript
local profile = request:InvokeServer()
if profile then
label.Text = ("Монет: %d"):format(profile.coins)
endСервер не должен вызывать клиента
InvokeClient ждёт ответа от чужого компьютера, а тот может не ответить никогда — например, если игрок закрыл игру ровно в этот момент. Поток на сервере повиснет. Нужно что-то сообщить клиенту — используй RemoteEvent и FireClient: он ничего не ждёт.
Мелочи, которые ловят на практике#
- Всё, что лежит в ReplicatedStorage, видно игроку — не храни там ответы на загадки и списки редких наград
- Один RemoteEvent на действие понятнее, чем один общий с параметром «что делать»
- Не отправляй событие каждый кадр: собери изменения и пришли пачкой
- Ошибку валидации логируй через warn, но игроку не показывай — подсказка помогает подбирать аргументы
- FireAllClients с большой таблицей на полном сервере — заметная нагрузка на сеть
- Проверяй, что player.Character вообще существует: между смертью и возрождением его нет
Когда граница между клиентом и сервером выстроена, следующий шаг — сохранить то, что сервер насчитал: «DataStore: как сохранить прогресс игрока». Разбор сообщений из Output собран в гайде про ошибки Luau, а весь курс и справочник — в разделе Studio.
Обсуждение
Пиши по делу и без личных данных. Ссылки на сторонние сайты, обмен и «бесплатные робуксы» мы скрываем — почему.
Комментарии пишут те, кто вошёл — так спокойнее всем.
ВойтиПока никто ничего не написал. Будь первым.