Studio

Клиент и сервер в 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, — это заявка, а не факт.

Как выглядит дырявый код#

Самая частая ошибка новичка — считать награду на клиенте и присылать серверу готовую цифру. Выглядит логично, работает мгновенно и ломается за минуту.

Так делать нельзя · Luau
-- 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)

Здесь сервер не проверяет ничего: ни величину, ни тип, ни частоту вызовов. Вместо десятки прилетит миллион, вместо числа — строка, а вместо одного нажатия — тысяча за секунду. Экономика игры кончается в тот же вечер.

Как выглядит правильный#

Правило одно: клиент сообщает о намерении, сервер решает, что из этого следует. Награду считает сервер, из своих данных.

Сервер считает сам · Luau
-- 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)

Четыре проверки, которые нужны почти всегда#

  1. Тип и величина. Пришло не число или число вне допустимых границ — выходи молча, не пытайся исправить
  2. Частота. Таблица с временем последнего вызова на игрока закрывает большинство автоматических атак
  3. Право. Действительно ли этот игрок владеет предметом, находится в нужной зоне, прошёл нужный этап
  4. Расстояние. Клик по объекту в двухстах метрах — не клик: сравни позицию персонажа с позицией цели
Проверка аргумента и расстояния · Luau
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 отличается тем, что ждёт результат. Клиент спрашивает — сервер отвечает, и код на клиенте стоит на паузе, пока ответ не придёт. Это удобно для запроса данных, но у него есть подвох.

Запрос данных с сервера · Luau
-- 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.

Обсуждение

Пиши по делу и без личных данных. Ссылки на сторонние сайты, обмен и «бесплатные робуксы» мы скрываем — почему.

Комментарии пишут те, кто вошёл — так спокойнее всем.

Войти

Пока никто ничего не написал. Будь первым.