Aller au contenu
Forma Roblox

Rechercher dans la formation

Module 04 · Leçon 4 sur 5

Ne jamais faire confiance au client

Découvre ce qu’un tricheur peut faire depuis son appareil, puis apprends à valider chaque requête côté serveur (types, valeurs, distance, cooldown) sans punir les joueurs honnêtes.

  • ⏱️ 35 min
  • 📝 Quiz de 6 questions
  • 🔄 Vérifié le 24 octobre 2026
Sur cette page

🎯 Dans cette leçon, tu vas apprendre à…

  • Reconnaître les exploits les plus courants sur Roblox
  • Valider chaque requête côté serveur (type, valeur, contexte, débit)
  • Limiter le spam des remotes avec un cooldown par joueur
  • Détecter les comportements suspects et y répondre de façon proportionnée

Tu sais maintenant faire dialoguer le client et le serveur. Il est temps d’apprendre la règle d’or de la programmation multijoueur, celle que la documentation de Roblox place au sommet de ses principes de sécurité : ne jamais faire confiance au client. Dans cette leçon, tu vas voir comment pensent les tricheurs, puis construire une boîte à outils de validation que tu réutiliseras dans tout l’Obby des Nuages.

Ce qu’un tricheur peut faire

Un exploiteur (exploiter, en anglais) est un joueur qui utilise un logiciel de triche pour manipuler son client Roblox. La documentation officielle part d’un principe simple : il a le contrôle total de son appareil et de ce qu’il envoie. Il peut notamment :

  • lire le code de tous les LocalScripts et ModuleScripts répliqués (même ceux qui ne s’exécutent jamais sur le client) ;
  • prendre la propriété réseau de son personnage et des Parts non ancrées, donc se téléporter, voler, aller plus vite ;
  • déclencher des contacts (Touched) et des ProximityPrompts (les invites du type « appuie sur E » qui apparaissent près d’un objet) à n’importe quelle distance et à n’importe quelle fréquence ;
  • appeler n’importe quel RemoteEvent ou RemoteFunction, aussi souvent qu’il veut, avec n’importe quels arguments ;
  • modifier tout ce qui existe sur son client, sans déclencher les événements que tu attendrais.

Une seule chose lui échappe : le paramètre player que Roblox ajoute en premier côté serveur. Il est fiable.

Les exploits classiques

Exploit Exemple Défense côté serveur
Remote trop « généreux » DonnerPieces:FireServer(999999) Ne jamais laisser le client choisir une récompense
Prix falsifié Envoyer prix = 0 ou un prix négatif Lire le prix dans le catalogue du serveur
Arguments piégés Envoyer NaN, une table à la place d’une instance, un texte énorme Valider type, valeur, taille
Spam Appeler un remote 500 fois par seconde Cooldown ou limite de débit par joueur
Téléportation, vol, vitesse Sauter directement à la fin de l’obby Étapes obligatoires dans l’ordre, contrôles de distance
Contacts truqués Toucher toutes les pièces de la carte depuis le départ Vérifier la distance réelle côté serveur

Le client demande, le serveur décide

Regarde ce RemoteEvent, malheureusement très courant chez les débutants :

-- Script dans ServerScriptService — EXEMPLE À NE PAS SUIVRE
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local donnerPieces = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("DonnerPieces") :: RemoteEvent

-- Le client décide lui-même combien de pièces il gagne : n'importe qui peut devenir riche
donnerPieces.OnServerEvent:Connect(function(player: Player, montant: number)
	local leaderstats = player:FindFirstChild("leaderstats")
	local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
	if pieces and pieces:IsA("IntValue") then
		pieces.Value += montant
	end
end)

Le problème n’est pas un test manquant : c’est la conception. Ce remote ne devrait pas exister. Le client ne doit jamais envoyer un résultat (« donne-moi 50 pièces »), seulement une intention (« je touche cette pièce », « je veux acheter cet objet »). Le serveur vérifie ensuite si l’intention est possible et calcule lui-même le résultat.

La documentation résume le rôle du serveur en quatre étapes : recevoir la demande, vérifier qu’elle est possible et autorisée, exécuter l’action en mettant à jour son propre état, puis répliquer le résultat aux clients.

Les quatre couches de validation

Pour chaque message reçu d’un client, vérifie ces quatre points :

  1. Le type : est-ce bien un nombre, un texte, une instance ? Avec typeof(valeur).
  2. La valeur : est-elle dans les bornes attendues ? Un nombre fini, un entier positif, un texte de longueur raisonnable, un identifiant qui existe vraiment dans ton catalogue.
  3. Le contexte : le joueur a-t-il le droit de faire ça maintenant ? Est-il vivant ? Assez près ? A-t-il atteint cette étape ? Possède-t-il déjà cet objet ?
  4. Le débit : ne fait-il pas cette demande trop souvent ?

Si un seul test échoue, on ignore la demande (un simple return), sans faire planter le script. L’ordre exact peut varier : dans les exemples de ce module, le débit est vérifié juste après le type, pour refuser le spam avant de faire le moindre travail.

Le piège de NaN

Un tricheur peut envoyer NaN (« Not a Number », « pas un nombre »). Le danger : typeof(NaN) vaut bien "number", mais toute comparaison avec NaN est fausse. Il traverse donc tes tests de bornes sans être arrêté :

-- À tester dans un Script pour comprendre NaN
local nan = 0 / 0

print(typeof(nan)) --> number : le test de type passe !
print(nan < 0, nan > 1000) --> false false : le test de bornes ne l'arrête pas !
print(nan == nan) --> false : NaN n'est même pas égal à lui-même
print(math.isfinite(nan)) --> false : voilà la bonne vérification
print(math.isfinite(math.huge)) --> false : l'infini est rejeté aussi

La documentation recommande math.isfinite() pour rejeter NaN et l’infini.

Une boîte à outils de validation

Plutôt que de réécrire les mêmes tests partout, rassemble-les dans un ModuleScript. Il vit dans ServerScriptService : les clients ne peuvent ni le voir ni le lire.

-- ModuleScript « Validation » placé dans ServerScriptService (côté serveur uniquement)
local Players = game:GetService("Players")

local Validation = {}

-- Vrai si la valeur est un nombre fini (ni NaN ni infini) compris entre min et max
function Validation.estNombre(valeur: unknown, min: number, max: number): boolean
	if typeof(valeur) ~= "number" then
		return false
	end
	if not math.isfinite(valeur) then
		return false
	end
	return valeur >= min and valeur <= max
end

-- Vrai si la valeur est un nombre entier (1, 2, 3…) compris entre min et max
function Validation.estEntier(valeur: unknown, min: number, max: number): boolean
	if not Validation.estNombre(valeur, min, max) then
		return false
	end
	local nombre = valeur :: number
	return math.floor(nombre) == nombre
end

-- Vrai si la valeur est un texte non vide, pas trop long et en UTF-8 valide (le codage standard des textes)
function Validation.estTexte(valeur: unknown, longueurMax: number): boolean
	if typeof(valeur) ~= "string" then
		return false
	end
	if #valeur == 0 or #valeur > longueurMax then
		return false
	end
	return utf8.len(valeur) ~= nil -- utf8.len renvoie nil si le texte est invalide
end

-- Renvoie la HumanoidRootPart du joueur s'il a un personnage vivant, sinon nil
function Validation.racineVivante(player: Player): BasePart?
	local character = player.Character
	if character == nil then
		return nil
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	local racine = character:FindFirstChild("HumanoidRootPart")
	if humanoid and humanoid.Health > 0 and racine and racine:IsA("BasePart") then
		return racine
	end
	return nil
end

-- Vrai si le personnage du joueur est à moins de distanceMax studs de la position
function Validation.estAPortee(player: Player, position: Vector3, distanceMax: number): boolean
	local racine = Validation.racineVivante(player)
	if racine == nil then
		return false
	end
	return (racine.Position - position).Magnitude <= distanceMax
end

-- Crée un cooldown par joueur : la fonction renvoyée répond true au plus
-- une fois toutes les `delai` secondes pour un même joueur
function Validation.creerCooldown(delai: number): (Player) -> boolean
	local dernieresActions: { [Player]: number } = {}

	-- On oublie le joueur quand il part (sinon la table grossit sans fin)
	Players.PlayerRemoving:Connect(function(player: Player)
		dernieresActions[player] = nil
	end)

	return function(player: Player): boolean
		local maintenant = os.clock()
		local derniere = dernieresActions[player]
		if derniere and maintenant - derniere < delai then
			return false -- trop tôt : demande refusée
		end
		dernieresActions[player] = maintenant
		return true
	end
end

return Validation

Exemple complet : se téléporter à un checkpoint déjà atteint

Imaginons un menu qui permet de revenir à une étape déjà franchie. Le client envoie le numéro de l’étape voulue au RemoteEvent AllerAuCheckpoint (à créer dans ReplicatedStorage.Remotes). Voici comment le serveur applique les quatre couches. On suppose que le serveur range l’étape atteinte dans leaderstats.Etape : tu la créeras dans la leçon 5.

-- Script placé dans ServerScriptService (côté serveur)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Workspace = game:GetService("Workspace")

local Validation = require(ServerScriptService:WaitForChild("Validation"))

local allerAuCheckpoint = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AllerAuCheckpoint") :: RemoteEvent
local dossierCheckpoints = Workspace:WaitForChild("Checkpoints")

local peutSeTeleporter = Validation.creerCooldown(3) -- une téléportation toutes les 3 secondes maximum

allerAuCheckpoint.OnServerEvent:Connect(function(player: Player, numero: unknown)
	-- 1. Type et valeur : un entier raisonnable (ni NaN, ni infini, ni 2,5)
	if not Validation.estEntier(numero, 1, 1000) then
		return
	end

	-- 2. Débit : pas de spam
	if not peutSeTeleporter(player) then
		return
	end

	-- 3. Contexte : le personnage existe et il est vivant
	local character = player.Character
	if character == nil or Validation.racineVivante(player) == nil then
		return
	end

	-- 3 bis. Contexte : l'étape a déjà été atteinte, d'après le SERVEUR
	local leaderstats = player:FindFirstChild("leaderstats")
	local etape = leaderstats and leaderstats:FindFirstChild("Etape")
	if not (etape and etape:IsA("IntValue")) then
		return
	end
	if (numero :: number) > etape.Value then
		return -- il demande une étape qu'il n'a pas encore atteinte
	end

	-- 3 ter. Le checkpoint existe vraiment
	local checkpoint = dossierCheckpoints:FindFirstChild(tostring(numero))
	if not (checkpoint and checkpoint:IsA("BasePart")) then
		return
	end

	-- Tout est valide : c'est le SERVEUR qui déplace le personnage
	character:PivotTo(checkpoint.CFrame + Vector3.new(0, 4, 0))
end)

Remarque que le client ne choisit jamais une position : il choisit seulement un numéro, et le serveur décide où placer le personnage.

Limiter le débit

Le cooldown de notre module est la forme la plus simple de limitation de débit (rate limiting) : une action par joueur toutes les N secondes. C’est parfait pour un achat ou une téléportation.

Pour des actions qu’on peut faire plusieurs fois de suite (envoyer des messages, lancer un sort), la documentation présente l’algorithme du seau à jetons (token bucket). Chaque joueur a un seau de jetons, chaque action en coûte un, et le seau se remplit lentement. Cela autorise de petites rafales tout en empêchant le spam continu. Le code complet est sur la page « Securing the client-server boundary » citée en sources.

Pense aussi aux objets qui déclenchent du code serveur sans remote. D’après la documentation, un tricheur peut activer un ClickDetector à n’importe quelle distance (aucune vérification côté serveur) et, pour un ProximityPrompt, seul l’événement Triggered est vérifié en distance. Ajoute tes propres vérifications de distance et de cooldown dans leurs fonctions.

Téléportation et vitesse : détecter sans se tromper

Le client contrôle la physique de son personnage : c’est le point le plus délicat. La documentation précise qu’un tricheur peut régler la WalkSpeed (vitesse de marche) de son Humanoid comme il veut, et que cette propriété ne doit pas servir de mécanisme de sécurité. Le serveur peut en revanche observer où se trouve le personnage au fil du temps.

La documentation est honnête : il n’existe pas de solution universelle. Les heuristiques simples (des règles approximatives) peuvent signaler des joueurs innocents avec une connexion instable, et il faut prévoir des exceptions pour les téléportations légitimes. Voici un détecteur volontairement prudent : il journalise seulement (il note l’alerte dans l’Output), il ne punit pas.

-- Script placé dans ServerScriptService (côté serveur)
-- Détecteur simple de déplacements horizontaux impossibles : il journalise, il ne punit pas.
local Players = game:GetService("Players")

local INTERVALLE = 1 -- secondes entre deux mesures
local VITESSE_MAX = 16 * 1.5 -- vitesse de marche par défaut (16 studs/s) + 50 % de marge
local TOLERANCE = 10 -- studs de marge supplémentaire pour la latence

local dernieresPositions: { [Player]: Vector3 } = {}
local suspicions: { [Player]: number } = {}

-- On ne garde que X et Z : tomber ou sauter ne compte pas
local function positionAuSol(position: Vector3): Vector3
	return Vector3.new(position.X, 0, position.Z)
end

Players.PlayerAdded:Connect(function(player: Player)
	-- Réapparition = téléportation légitime : on repart de zéro
	player.CharacterAdded:Connect(function()
		dernieresPositions[player] = nil
	end)
end)

Players.PlayerRemoving:Connect(function(player: Player)
	dernieresPositions[player] = nil
	suspicions[player] = nil
end)

while true do
	task.wait(INTERVALLE)
	for _, player in Players:GetPlayers() do
		local character = player.Character
		local racine = character and character:FindFirstChild("HumanoidRootPart")
		if racine and racine:IsA("BasePart") then
			local actuelle = positionAuSol(racine.Position)
			local precedente = dernieresPositions[player]
			if precedente then
				local distance = (actuelle - precedente).Magnitude
				if distance > VITESSE_MAX * INTERVALLE + TOLERANCE then
					suspicions[player] = (suspicions[player] or 0) + 1
					warn(string.format("[Anti-triche] %s : %.0f studs en %d s (alerte n°%d)",
						player.Name, distance, INTERVALLE, suspicions[player]))
				end
			end
			dernieresPositions[player] = actuelle
		else
			dernieresPositions[player] = nil
		end
	end
end

Limites à connaître :

  • si ton jeu téléporte un joueur (bouton « retour au checkpoint », trampoline, tapis roulant rapide), le détecteur risque de le signaler : augmente les marges, ou préviens le détecteur (par exemple avec un BindableEvent, leçon 3) pour qu’il remette dernieresPositions[player] à nil juste après ;
  • la latence crée des « sauts » de position : d’où la tolérance et le fait de ne compter qu’un score de suspicion ;
  • un tricheur prudent peut rester juste sous la limite. Ce détecteur complète tes validations de conception (étapes obligatoires dans l’ordre, distances), il ne les remplace pas.

Réagir sans punir les innocents

Un faux positif (un joueur honnête signalé à tort) fait fuir tes meilleurs joueurs. La documentation de Roblox recommande une philosophie claire :

  • Le serveur décide. Ne sanctionne jamais sur la base d’une détection faite côté client.
  • Neutraliser d’abord. Ignorer une demande invalide, refuser un achat, ramener un joueur à sa dernière position valide : c’est souvent suffisant.
  • Être proportionné et réversible. Préfère des actions qu’on peut annuler à un bannissement définitif.
  • Croiser les signaux. Un seul signal n’est pas une preuve. Augmente un score de suspicion et n’agis qu’après plusieurs détections différentes (vitesse, temps de parcours impossible, gains anormaux…).

La documentation propose une échelle de réponses : journaliser en silence, neutraliser l’effet, restreindre temporairement certaines fonctions, puis seulement en dernier recours exclure le joueur (player:Kick()) ou le bannir. Pour les récidivistes, elle mentionne l’API de bannissement (Players:BanAsync), plus fiable qu’un simple Kick.

Ce qu’il faut retenir

  • Le tricheur contrôle tout sur son appareil : code, personnage, physique possédée, arguments et fréquence des remotes. Seul le player reçu par le serveur est fiable.
  • Le client envoie une intention, jamais un résultat ni un prix : le serveur calcule tout.
  • Valide chaque requête en quatre couches : type (typeof), valeur (math.isfinite, bornes, existence), contexte (vivant, distance, progression), débit (cooldown par joueur).
  • NaN est de type "number" mais échappe à toutes les comparaisons : rejette-le avec math.isfinite.
  • Détecte prudemment (score de suspicion, journalisation) et réagis de façon proportionnée et réversible.

Sources et pour aller plus loin

Quiz : vérifie tes acquis

6 questions · réussite à partir de 70 % · valide chaque réponse pour voir l’explication

  1. Question 1Vrai ou fauxLe test typeof(montant) == "number" suffit à garantir que montant est un nombre utilisable sans danger.
  2. Question 2QCMD’après la documentation de Roblox, que peut faire un tricheur depuis son appareil ? (plusieurs réponses possibles)

    Plusieurs réponses possibles : coche toutes les bonnes.

  3. Question 3QCMTon détecteur de vitesse signale un joueur une seule fois. Quelle réaction est la plus raisonnable ?
  4. Question 4QCMPour une boutique, que doit envoyer le client au serveur ?
  5. Question 5Trouve le bugCe Script serveur gère les achats. Un tricheur arrive pourtant à acheter des objets pour 0 pièce. Où est la faille ?
    local ReplicatedStorage = game:GetService("ReplicatedStorage")
    local Catalogue = require(ReplicatedStorage.Modules.Catalogue)
    local acheterObjet = ReplicatedStorage.Remotes.AcheterObjet
    
    acheterObjet.OnServerEvent:Connect(function(player, idObjet, prix)
    	local pieces = player.leaderstats.Pieces
    	if typeof(idObjet) ~= "string" or Catalogue[idObjet] == nil then
    		return
    	end
    	if pieces.Value >= prix then
    		pieces.Value -= prix
    		-- (ici, on ajoute l'objet à l'inventaire du joueur)
    	end
    end)
  6. Question 6Trouve le bugTrouve la faille dans ce code généré par IA. Cette fonction doit renvoyer true seulement pour un nombre fini compris entre min et max. Une valeur envoyée par un tricheur passe pourtant le test. Où est la faille ?
    -- Fonction générée par une IA, dans le ModuleScript « Validation » (ServerScriptService)
    local Validation = {}
    
    -- Doit renvoyer true seulement pour un nombre fini compris entre min et max
    function Validation.estNombre(valeur: unknown, min: number, max: number): boolean
    	if typeof(valeur) ~= "number" then
    		return false
    	end
    	if valeur < min or valeur > max then
    		return false
    	end
    	return true
    end
    
    return Validation