Module 04 · Leçon 5 sur 5
Sécuriser l’Obby des Nuages
Applique tout le module à ton obby, avec des checkpoints validés dans l’ordre, des pièces vérifiées par le serveur et un achat en boutique impossible à truquer, puis teste avec deux clients.
- ⏱️ 45 min
- 📝 Quiz de 6 questions
- 🔄 Vérifié le 24 octobre 2026
Sur cette page
- Préparer l’Explorer
- Des checkpoints validés dans l’ordre
- Des pièces ramassées par le serveur
- Une boutique impossible à truquer
- Le catalogue
- Le script serveur de la boutique
- Le côté client
- Tester ses protections
- Test 1 : deux joueurs, une pièce
- Test 2 : un faux solde
- Test 3 : un client tricheur
- Quiz de fin de leçon
🎯 Dans cette leçon, tu vas apprendre à…
- Valider les checkpoints côté serveur, uniquement dans l’ordre
- Faire ramasser les pièces par le serveur (distance et cooldown par joueur)
- Implémenter un achat en boutique sûr via le RemoteEvent AcheterObjet
- Tester ses protections avec deux clients et un client « tricheur »
C’est l’heure de mettre en pratique tout le module. L’Obby des Nuages va gagner des checkpoints validés par le serveur, des pièces impossibles à aspirer à distance et une boutique où l’on ne peut ni choisir son prix ni dépenser des pièces qu’on n’a pas. On terminera par une séance de tests avec deux clients, dont un qui triche volontairement.
Préparer l’Explorer
Vérifie que ton projet contient ces éléments (crée ceux qui manquent) :
Workspace
├── Checkpoints (Folder) → Parts ancrées nommées "1", "2", "3"…
└── Pieces (Folder) → Parts ancrées nommées "Piece"
ReplicatedStorage
├── Modules (Folder)
│ └── Catalogue (ModuleScript)
└── Remotes (Folder)
├── AcheterObjet (RemoteEvent)
└── Notification (RemoteEvent)
ServerScriptService
├── Validation (ModuleScript, leçon 4)
├── Progression (Script)
├── Pieces (Script)
└── Boutique (Script)
Le Script « Checkpoints » du module 3 (qui rangeait l’étape dans un attribut Etape du joueur) est remplacé par le Script « Progression » ci-dessous : désactive-le ou supprime-le, sinon les deux scripts réagiraient aux mêmes checkpoints. Le Script « PiecesTournantes » du module 3, lui, peut rester : il ne fait que tourner les pièces.
Des checkpoints validés dans l’ordre
La documentation de Roblox donne justement l’obby en exemple de conception défensive : plutôt que de chercher à détecter un joueur qui se téléporte à l’arrivée, on impose des checkpoints obligatoires, validés dans l’ordre par le serveur. Se téléporter au checkpoint 10 ne sert alors à rien.
Les règles du serveur :
- le contact vient bien d’un personnage de joueur ;
- le checkpoint touché est exactement l’étape suivante (
Etape + 1) ; - le personnage est vraiment proche du checkpoint (un contact peut être simulé) ;
- un passage anormalement rapide est journalisé, pas puni.
-- Script « Progression » placé dans ServerScriptService (côté serveur)
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Workspace = game:GetService("Workspace")
local Validation = require(ServerScriptService:WaitForChild("Validation"))
local dossierCheckpoints = Workspace:WaitForChild("Checkpoints")
local notification = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Notification") :: RemoteEvent
local DISTANCE_MAX = 15 -- studs entre le personnage et le centre du checkpoint (augmente-la si tes checkpoints sont grands)
local TEMPS_MIN_ETAPE = 2 -- secondes : un passage plus rapide est suspect (journalisé seulement)
local dernierPassage: { [Player]: number } = {}
-- Renvoie l'IntValue « Etape » du joueur, ou nil si elle n'existe pas encore
local function obtenirEtape(player: Player): IntValue?
local leaderstats = player:FindFirstChild("leaderstats")
local etape = leaderstats and leaderstats:FindFirstChild("Etape")
if etape and etape:IsA("IntValue") then
return etape
end
return nil
end
-- Leaderstats minimales et réapparition au dernier checkpoint
local function initialiserJoueur(player: Player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local etape = Instance.new("IntValue")
etape.Name = "Etape"
etape.Value = 0 -- 0 = aucun checkpoint atteint (départ sur la SpawnLocation)
etape.Parent = leaderstats
local pieces = Instance.new("IntValue")
pieces.Name = "Pieces"
pieces.Value = 0
pieces.Parent = leaderstats
leaderstats.Parent = player
player.CharacterAdded:Connect(function(character: Model)
if etape.Value == 0 then
return -- premier départ : la SpawnLocation suffit
end
local checkpoint = dossierCheckpoints:FindFirstChild(tostring(etape.Value))
if not (checkpoint and checkpoint:IsA("BasePart")) then
return
end
-- CharacterAdded se déclenche AVANT que le personnage soit dans le Workspace,
-- et Roblox le place sur la SpawnLocation juste APRÈS l'y avoir mis.
-- On attend qu'il y soit, puis une image de plus, sinon notre placement serait écrasé.
if not character:IsDescendantOf(Workspace) then
character.AncestryChanged:Wait()
end
task.wait()
if not character:IsDescendantOf(Workspace) then
return -- le personnage a déjà disparu (joueur parti, nouvelle réapparition…)
end
character:PivotTo(checkpoint.CFrame + Vector3.new(0, 4, 0))
end)
end
local function surContactCheckpoint(checkpoint: BasePart, partieTouchee: BasePart)
-- 1. Le contact vient-il d'un personnage de joueur ?
local player = Players:GetPlayerFromCharacter(partieTouchee.Parent)
if player == nil then
return
end
local numero = tonumber(checkpoint.Name)
local etape = obtenirEtape(player)
if numero == nil or etape == nil then
return
end
-- 2. Uniquement l'étape SUIVANTE : ni retour en arrière, ni saut d'étapes
if numero ~= etape.Value + 1 then
return
end
-- 3. Distance réelle : un tricheur peut provoquer un contact à distance
if not Validation.estAPortee(player, checkpoint.Position, DISTANCE_MAX) then
return
end
-- 4. Heuristique : passage trop rapide → on note, on ne punit pas
local maintenant = os.clock()
local precedent = dernierPassage[player]
if precedent and maintenant - precedent < TEMPS_MIN_ETAPE then
warn(string.format("[Anti-triche] %s : étape %d atteinte en %.1f s", player.Name, numero, maintenant - precedent))
end
dernierPassage[player] = maintenant
-- Tout est valide : le SERVEUR met à jour la progression
etape.Value = numero
notification:FireClient(player, "Étape " .. numero .. " atteinte !")
end
-- On branche chaque checkpoint du dossier
for _, checkpoint in dossierCheckpoints:GetChildren() do
if checkpoint:IsA("BasePart") then
checkpoint.Touched:Connect(function(partieTouchee: BasePart)
surContactCheckpoint(checkpoint, partieTouchee)
end)
end
end
Players.PlayerAdded:Connect(initialiserJoueur)
-- Joueurs déjà présents si le script démarre après eux (rare, mais possible en test)
for _, player in Players:GetPlayers() do
task.spawn(initialiserJoueur, player)
end
Players.PlayerRemoving:Connect(function(player: Player)
dernierPassage[player] = nil
end)
Des pièces ramassées par le serveur
Pour une pièce, le serveur vérifie que le joueur est assez près et applique un cooldown par joueur : même en se téléportant partout, un tricheur ne peut pas vider la carte en une fraction de seconde. La pièce disparaît pour tout le monde, puis réapparaît au bout de quelques secondes.
-- Script « Pieces » placé dans ServerScriptService (côté serveur)
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Workspace = game:GetService("Workspace")
local Config = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Config"))
local Validation = require(ServerScriptService:WaitForChild("Validation"))
local dossierPieces = Workspace:WaitForChild("Pieces")
local VALEUR_PIECE = Config.VALEUR_PIECE -- réglage commun du module Config (valeur décidée par le SERVEUR)
local DISTANCE_MAX = 12 -- studs
local DELAI_REAPPARITION = 10 -- secondes avant que la pièce revienne
-- Un même joueur ne peut ramasser qu'une pièce toutes les 0,2 seconde
local peutRamasser = Validation.creerCooldown(0.2)
local function preparerPiece(piece: BasePart)
local transparenceInitiale = piece.Transparency
local disponible = true -- état connu du serveur seulement
piece.Touched:Connect(function(partieTouchee: BasePart)
if not disponible then
return -- déjà ramassée (évite aussi les contacts multiples d'un même personnage)
end
local player = Players:GetPlayerFromCharacter(partieTouchee.Parent)
if player == nil then
return
end
-- Le personnage est-il vraiment à côté de la pièce ?
if not Validation.estAPortee(player, piece.Position, DISTANCE_MAX) then
return
end
-- Ramassage trop rapide pour ce joueur ?
if not peutRamasser(player) then
return
end
local leaderstats = player:FindFirstChild("leaderstats")
local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
if not (pieces and pieces:IsA("IntValue")) then
return
end
-- On désactive la pièce AVANT de récompenser : un second contact ne passera pas
disponible = false
piece.Transparency = 1
piece.CanTouch = false
pieces.Value += VALEUR_PIECE
task.delay(DELAI_REAPPARITION, function()
disponible = true
piece.Transparency = transparenceInitiale
piece.CanTouch = true
end)
end)
end
for _, piece in dossierPieces:GetChildren() do
if piece:IsA("BasePart") and piece.Name == "Piece" then
preparerPiece(piece)
end
end
Pourquoi deux protections ? La variable disponible empêche qu’une même pièce soit comptée deux fois (par deux parties du corps, ou par deux joueurs en même temps). Le cooldown par joueur, lui, limite la vitesse de ramassage d’un même joueur.
Une boutique impossible à truquer
Le catalogue
Le catalogue est un ModuleScript dans ReplicatedStorage.Modules : le client peut le lire pour afficher les prix, et le serveur l’utilise pour décider. Un tricheur peut modifier sa copie locale, mais cela ne change rien à la copie du serveur.
-- ModuleScript « Catalogue » placé dans ReplicatedStorage > Modules (partagé client/serveur)
export type Objet = {
nom: string, -- nom affiché (accents autorisés)
prix: number, -- prix en pièces
}
local Catalogue: { [string]: Objet } = {
TrailNuage = { nom = "Traînée Nuage", prix = 25 },
TrailSoleil = { nom = "Traînée Soleil", prix = 75 },
TrailAurore = { nom = "Traînée Aurore", prix = 150 },
}
return Catalogue
Ces identifiants (TrailNuage, TrailSoleil, TrailAurore) sont ceux du module 5, qui complétera ce catalogue avec les couleurs des traînées.
Le script serveur de la boutique
Le client envoie uniquement l’identifiant de l’objet, par exemple "TrailNuage". Le serveur répond sur le même RemoteEvent avec FireClient pour indiquer si l’achat a réussi.
-- Script « Boutique » placé dans ServerScriptService (côté serveur)
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Validation = require(ServerScriptService:WaitForChild("Validation"))
local Catalogue = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Catalogue"))
local acheterObjet = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent
local LONGUEUR_ID_MAX = 50
local peutAcheter = Validation.creerCooldown(1) -- un achat par seconde maximum
-- Objets possédés, gardés sur le serveur (sauvegardés au module 5)
local objetsPossedes: { [Player]: { [string]: boolean } } = {}
local function acheter(player: Player, idObjet: unknown): (boolean, string)
-- 1. Type et taille
if not Validation.estTexte(idObjet, LONGUEUR_ID_MAX) then
return false, "Requête invalide."
end
local id = idObjet :: string
-- 2. Débit
if not peutAcheter(player) then
return false, "Doucement ! Réessaie dans un instant."
end
-- 3. L'objet existe : le PRIX vient du catalogue du serveur, jamais du client
local objet = Catalogue[id]
if objet == nil then
return false, "Cet objet n'existe pas."
end
-- 4. Pas déjà possédé
local possedes = objetsPossedes[player]
if possedes == nil then
return false, "Tes données ne sont pas encore prêtes."
end
if possedes[id] then
return false, "Tu possèdes déjà cet objet."
end
-- 5. Solde réel, lu sur le serveur
local leaderstats = player:FindFirstChild("leaderstats")
local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
if not (pieces and pieces:IsA("IntValue")) then
return false, "Tes données ne sont pas encore prêtes."
end
if pieces.Value < objet.prix then
return false, "Il te manque " .. (objet.prix - pieces.Value) .. " pièce(s)."
end
-- 6. Transaction : on retire le prix et on ajoute l'objet, sans pause entre les deux
pieces.Value -= objet.prix
possedes[id] = true
return true, objet.nom .. " achetée !"
end
-- Les arguments en trop (un « prix » envoyé par un tricheur, par exemple) sont simplement ignorés
acheterObjet.OnServerEvent:Connect(function(player: Player, idObjet: unknown)
local succes, message = acheter(player, idObjet)
acheterObjet:FireClient(player, succes, message)
end)
Players.PlayerAdded:Connect(function(player: Player)
objetsPossedes[player] = {}
end)
for _, player in Players:GetPlayers() do
objetsPossedes[player] = {}
end
Players.PlayerRemoving:Connect(function(player: Player)
objetsPossedes[player] = nil
end)
Les étapes 5 et 6 se suivent sans aucune pause (pas de task.wait ni d’appel réseau entre elles). Deux demandes ne peuvent donc pas « passer entre les gouttes » pour dépenser deux fois les mêmes pièces.
Le côté client
En attendant l’interface du module 5, un LocalScript de démonstration achète la Traînée Nuage au bout de quelques secondes et affiche la réponse du serveur :
-- LocalScript placé dans StarterPlayer > StarterPlayerScripts (côté client)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local acheterObjet = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent
-- Réponse du serveur
acheterObjet.OnClientEvent:Connect(function(succes: boolean, message: string)
if succes then
print("Achat réussi :", message)
else
warn("Achat refusé :", message)
end
end)
-- Démonstration : on DEMANDE l'achat par son identifiant (au module 5, ce sera un bouton)
task.wait(5)
acheterObjet:FireServer("TrailNuage")
Tester ses protections
Une protection non testée est une protection qui ne marche pas. Utilise le mode Server & Clients de Studio : dans le menu déroulant des modes de test, choisis-le, règle 2 clients et lance. Studio ouvre une fenêtre serveur et une fenêtre par client.
Test 1 : deux joueurs, une pièce
Avec les deux clients, approche-toi de la même pièce. Un seul joueur la récupère, elle disparaît dans les deux fenêtres, et elle revient après 10 secondes. Vérifie les compteurs dans la fenêtre serveur (Explorer → Players → ton joueur → leaderstats).
Test 2 : un faux solde
Dans la fenêtre du client 1, ouvre Players → ton joueur → leaderstats → Pieces et mets Value à 9999 dans Properties. Puis fais acheter "TrailAurore" (change l’identifiant dans le LocalScript de démonstration). Tant que ton vrai solde est inférieur à 150 pièces, l’achat est refusé : le serveur voit toujours ton vrai solde. Dans la fenêtre serveur, Pieces n’a pas bougé.
Test 3 : un client tricheur
Ajoute temporairement ce LocalScript, qui imite un logiciel de triche :
-- LocalScript TEMPORAIRE « TestTriche » (StarterPlayerScripts) : simule un client malveillant
-- À SUPPRIMER après les tests !
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local acheterObjet = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent
local player = Players.LocalPlayer
task.wait(8)
-- Arguments piégés : tous doivent être refusés par le serveur
acheterObjet:FireServer(nil)
acheterObjet:FireServer(0 / 0)
acheterObjet:FireServer({ prix = 0 })
acheterObjet:FireServer(string.rep("A", 10000))
acheterObjet:FireServer("ObjetInexistant")
task.wait(1.5) -- on attend la fin du cooldown d'achat (1 s) pour isoler le test suivant
acheterObjet:FireServer("TrailAurore", 0) -- le « prix » est ignoré : le serveur lit 150 dans son catalogue
task.wait(1.5)
-- Spam : au plus un achat par seconde passe le cooldown
for _ = 1, 20 do
acheterObjet:FireServer("TrailNuage")
end
-- Téléportation : le client déplace son propre personnage (la position est répliquée !)
local character = player.Character or player.CharacterAdded:Wait()
local dernierCheckpoint = Workspace:WaitForChild("Checkpoints"):FindFirstChild("3")
if dernierCheckpoint and dernierCheckpoint:IsA("BasePart") then
character:PivotTo(dernierCheckpoint.CFrame + Vector3.new(0, 3, 0))
end
Résultats attendus dans l’Output :
- chaque argument piégé reçoit un refus : « Requête invalide » pour
nil, NaN, la table et le texte géant, « Cet objet n’existe pas » pour l’identifiant inconnu ; - l’achat de
"TrailAurore"avec un prix de 0 est refusé (« Il te manque… ») si tu as moins de 150 pièces : le prix envoyé n’a servi à rien ; - sur les 20 demandes de spam, une seule passe le cooldown (et l’achat ne réussit que si tu as assez de pièces et ne possèdes pas déjà la traînée), les 19 autres reçoivent « Doucement ! » ;
- le personnage apparaît bien sur le checkpoint 3 (le client contrôle sa position), mais ton
Etapene passe pas à 3 si tu n’as pas validé les étapes 1 et 2. La téléportation ne rapporte rien ; - si le détecteur de déplacements de la leçon 4 est actif, il signale cette téléportation dans l’Output, sans exclure personne.
Ce qu’il faut retenir
- Les checkpoints n’acceptent que l’étape suivante (
Etape + 1) et vérifient la distance réelle : se téléporter ne sert à rien. - Les pièces sont ramassées par le serveur : distance, cooldown par joueur, pièce désactivée avant la récompense.
- Le client envoie seulement l’identifiant de l’objet ; le serveur vérifie le type, le débit, l’existence, la possession et le solde réel, avec le prix de son catalogue.
- Retirer les pièces et ajouter l’objet se fait sans pause entre les deux.
- On teste avec Server & Clients et un LocalScript « tricheur » temporaire, à supprimer avant publication.
Sources et pour aller plus loin
- Defensive design tactics — documentation Roblox (exemple de l’obby)
- Security and cheat mitigation tactics — documentation Roblox (exemple d’achat)
- Securing the client-server boundary — documentation Roblox
- Server-side detection and consequencing — documentation Roblox
- Studio testing modes — Server & Clients, documentation Roblox
- Remote events and callbacks — documentation Roblox
- Player — référence de l’API (CharacterAdded, ordre de chargement du personnage)