Aller au contenu
Forma Roblox

Rechercher dans la formation

Module 11 · Leçon 2 sur 5

Les erreurs techniques qui font fuir les joueurs

Pertes de données, remotes non validés, modèles gratuits piégés, bugs à plusieurs, lag mobile, erreurs ignorées. Le code fautif, le code corrigé et les garde-fous à mettre en place.

  • ⏱️ 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 six erreurs techniques les plus destructrices
  • Corriger du code fautif en appliquant les bons garde-fous
  • Tester les cas limites avant que les joueurs ne les découvrent

Un jeu bien conçu peut quand même mourir à cause d’un problème technique : une progression effacée, un tricheur qui ruine l’économie, un lag insupportable sur téléphone. Ces erreurs ont un point commun : elles n’apparaissent presque jamais quand tu testes seul dans Studio. Cette leçon passe en revue les six plus destructrices, avec le code fautif, le code corrigé et le garde-fou à mettre en place.

Erreur 1 : perdre les données des joueurs

Revenir dans un jeu et découvrir que sa progression a disparu, c’est l’une des pires expériences pour un joueur. Une cause classique, contre laquelle la documentation de Roblox met en garde, n’est pas une panne de Roblox, mais un script qui sauvegarde des données qui n’ont jamais été chargées.

Le code fautif

-- VERSION FAUTIVE : ne copie pas ce code !
-- Script dans ServerScriptService
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local magasin = DataStoreService:GetDataStore("DonneesJoueurs_v1")
local donneesJoueurs = {}

Players.PlayerAdded:Connect(function(player)
	local succes, donnees = pcall(function()
		return magasin:GetAsync("Joueur_" .. player.UserId)
	end)
	print("Chargement réussi ?", succes)
	-- Si GetAsync a échoué, donnees contient le message d'erreur...
	-- et le joueur repart de zéro sans que personne ne s'en rende compte.
	if typeof(donnees) ~= "table" then
		donnees = { etape = 0, pieces = 0, inventaire = {}, equipe = "" }
	end
	donneesJoueurs[player] = donnees
end)

Players.PlayerRemoving:Connect(function(player)
	-- On sauvegarde ce qu'on a en mémoire, même si le chargement avait échoué :
	-- les données de départ ÉCRASENT la vraie progression.
	magasin:SetAsync("Joueur_" .. player.UserId, donneesJoueurs[player])
	donneesJoueurs[player] = nil
end)

Le scénario catastrophe : le DataStore a un raté au moment où le joueur arrive. Le script lui donne des données de départ. Le joueur, surpris, quitte le jeu. Le script sauvegarde alors « étape 0, 0 pièce » par-dessus sa vraie progression. La documentation Roblox sur les données des joueurs met justement en garde : si tu utilises des données par défaut quand le DataStore est inaccessible, veille à ce qu’elles n’écrasent jamais les vraies données plus tard. Dans un fil d’entraide du DevForum (voir les sources), la réponse retenue explique ainsi une perte de données : le script sauvegardait des données qui n’avaient pas été chargées correctement (c’est un retour de développeur, pas une explication officielle).

Le code corrigé

-- VERSION CORRIGÉE — Script dans ServerScriptService
-- Version autonome et simplifiée de ce que font DonneesJoueurs et
-- GestionJoueurs au module 5. Si tu as suivi le module 5, tu as déjà
-- tout ça : n'ajoute PAS ce script en plus (deux scripts sauvegarderaient
-- la même clé).
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local magasin = DataStoreService:GetDataStore("DonneesJoueurs_v1")
local VERSION_DONNEES = 1 -- comme au module 5 : à augmenter quand la structure change
local NOMBRE_ESSAIS = 3
local DELAI_MAX_FERMETURE = 25 -- secondes : Roblox n'attend que 30 s à la fermeture

-- Ne contient QUE les joueurs dont le chargement a réussi.
-- Le reste du jeu modifie ces tables pendant la partie (voir module 5).
local donneesJoueurs = {}
-- Nombre de sauvegardes en cours (utile à la fermeture du serveur)
local sauvegardesEnCours = 0

local function cleDe(player: Player): string
	return "Joueur_" .. player.UserId
end

-- Données d'un nouveau joueur (même structure de base qu'au module 5)
local function donneesParDefaut()
	return { version = VERSION_DONNEES, etape = 0, pieces = 0, inventaire = {}, equipe = "" }
end

-- Renvoie (true, données) si le chargement a réussi, (false, nil) sinon
local function charger(player: Player)
	for essai = 1, NOMBRE_ESSAIS do
		local succes, resultat = pcall(function()
			return magasin:GetAsync(cleDe(player))
		end)
		if succes then
			-- nil = nouveau joueur : ce n'est PAS une erreur
			return true, resultat or donneesParDefaut()
		end
		warn("Chargement échoué (essai " .. essai .. ") : " .. tostring(resultat))
		if essai < NOMBRE_ESSAIS then
			-- on patiente un peu plus à chaque essai, plus un peu de hasard
			task.wait(2 ^ essai + math.random())
		end
	end
	return false, nil
end

local function sauvegarder(player: Player)
	local donnees = donneesJoueurs[player]
	if donnees == nil then
		return -- jamais chargé correctement : surtout ne rien écraser
	end
	sauvegardesEnCours += 1
	local succes, erreur = pcall(function()
		-- UpdateAsync lit la dernière valeur avant d'écrire (voir module 5)
		magasin:UpdateAsync(cleDe(player), function(ancienne)
			-- Sauvegarde écrite par une version plus récente du jeu : on n'y touche pas
			if ancienne ~= nil and (ancienne.version or 0) > VERSION_DONNEES then
				return nil -- renvoyer nil annule l'écriture
			end
			return donnees, { player.UserId }
		end)
	end)
	sauvegardesEnCours -= 1
	if not succes then
		warn("Sauvegarde échouée pour " .. player.Name .. " : " .. tostring(erreur))
	end
end

local function surArrivee(player: Player)
	local succes, donnees = charger(player)
	if not succes then
		player:Kick("Impossible de charger ta progression. Réessaie dans un instant.")
		return
	end
	if player.Parent == Players then -- le joueur a pu partir pendant le chargement
		donneesJoueurs[player] = donnees
	end
end

Players.PlayerAdded:Connect(surArrivee)
-- Les joueurs arrivés avant que ce script ne démarre
for _, player in Players:GetPlayers() do
	task.spawn(surArrivee, player)
end

Players.PlayerRemoving:Connect(function(player)
	sauvegarder(player)
	donneesJoueurs[player] = nil
end)

-- Fermeture du serveur : on sauvegarde tout le monde en parallèle,
-- puis on attend la fin de TOUTES les sauvegardes, y compris celles
-- lancées par PlayerRemoving (Roblox attend 30 secondes au maximum)
game:BindToClose(function()
	-- On parcourt les données en mémoire (pas GetPlayers) : à la fermeture,
	-- des joueurs peuvent déjà avoir quitté la liste des joueurs
	for player in donneesJoueurs do
		task.spawn(sauvegarder, player)
	end
	local debut = os.clock()
	while sauvegardesEnCours > 0 and os.clock() - debut < DELAI_MAX_FERMETURE do
		task.wait()
	end
end)

Les garde-fous ajoutés :

  • on distingue un échec (succes == false) d’un nouveau joueur (succes == true mais aucune donnée) ;
  • on réessaie avec une attente croissante et un peu de hasard, comme le recommande la documentation (exponential backoff avec jitter) ;
  • on ne sauvegarde jamais un joueur dont le chargement a échoué ;
  • on sauvegarde aussi à la fermeture du serveur avec game:BindToClose. D’après la référence de l’API, le serveur attend au maximum 30 secondes que les fonctions liées à BindToClose se terminent, puis s’arrête quand même : c’est pour ça qu’on lance les sauvegardes en parallèle plutôt qu’une par une. Le compteur sauvegardesEnCours permet aussi d’attendre les sauvegardes déjà lancées par PlayerRemoving quand les joueurs sont déconnectés au moment de la fermeture, avec une limite de 25 secondes pour rester sous les 30 secondes ;
  • comme au module 5, chaque sauvegarde porte un champ version, et UpdateAsync refuse d’écraser une sauvegarde écrite par une version plus récente du jeu.

Le module 5 va plus loin (sauvegarde automatique, fonction completer pour faire évoluer la structure, module partagé avec la boutique et les achats, explications sur le verrouillage de session) : relis Sauvegarder sans perdre de données.

Erreur 2 : faire confiance aux RemoteEvents

La documentation Roblox sur la sécurité est sans ambiguïté : il faut partir du principe que toute donnée envoyée par le client a pu être manipulée, inventée ou envoyée avec de mauvaises intentions. Un tricheur peut déclencher n’importe quel RemoteEvent, à n’importe quelle fréquence, avec n’importe quels arguments.

Le code fautif

-- VERSION FAUTIVE — Script dans ServerScriptService
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local acheterObjet = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent

acheterObjet.OnServerEvent:Connect(function(player: Player, idObjet, prix)
	local leaderstats = player:FindFirstChild("leaderstats")
	local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
	if pieces and pieces:IsA("IntValue") then
		-- Le CLIENT choisit le prix : un tricheur envoie 0, ou -1000000...
		pieces.Value -= prix
		print(player.Name .. " achète " .. tostring(idObjet))
	end
end)

Le code corrigé

-- VERSION CORRIGÉE, simplifiée — Script « Boutique » dans ServerScriptService
-- Mêmes vérifications que la boutique du module 5 (délai anti-spam par joueur,
-- type, prix, possession, solde). Ta boutique du module 5 fait déjà tout ça :
-- garde-la, ce script n'est qu'une illustration.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")

local acheterObjet = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent
-- Catalogue (module 5) : { TrailNuage = { nom = "Traînée Nuage", prix = 25, ... }, ... }
local Catalogue = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Catalogue") :: ModuleScript)
-- DonneesJoueurs (module 5) : les données en mémoire, dont l'inventaire
local DonneesJoueurs = require(ServerScriptService:WaitForChild("DonneesJoueurs") :: ModuleScript)

local DELAI_MIN = 1 -- secondes minimum entre deux achats d'un même joueur
local dernierAchat = {} -- [player] = os.clock() du dernier achat

acheterObjet.OnServerEvent:Connect(function(player: Player, idObjet: unknown)
	-- 1. Limiter le spam : un délai PAR joueur
	local maintenant = os.clock()
	local precedent = dernierAchat[player]
	if precedent and maintenant - precedent < DELAI_MIN then
		return
	end
	dernierAchat[player] = maintenant

	-- 2. Vérifier le TYPE de ce que le client envoie
	if typeof(idObjet) ~= "string" then
		return
	end

	-- 3. Le prix vient du catalogue du SERVEUR, jamais du client
	local objet = Catalogue[idObjet]
	local donnees = DonneesJoueurs.Obtenir(player)
	if objet == nil or donnees == nil then
		return -- objet inexistant, ou données pas encore chargées
	end

	-- 4. Vérifier l'état du joueur : objet pas déjà possédé, solde suffisant
	if donnees.inventaire[idObjet] then
		return
	end
	local leaderstats = player:FindFirstChild("leaderstats")
	local pieces = leaderstats and leaderstats:FindFirstChild("Pieces")
	if pieces == nil or not pieces:IsA("IntValue") then
		return
	end
	if pieces.Value < objet.prix then
		return
	end

	-- 5. Tout est valide : c'est le serveur qui applique l'achat
	pieces.Value -= objet.prix
	donnees.inventaire[idObjet] = true
	-- (au module 5, la boutique publie aussi l'inventaire avec des attributs)
end)

Players.PlayerRemoving:Connect(function(player)
	dernierAchat[player] = nil
end)

La règle à retenir : le client demande, le serveur vérifie et décide. Tu as tout appris dans Ne jamais faire confiance au client et appliqué à l’obby dans Sécuriser l’Obby des Nuages.

Erreur 3 : insérer des modèles gratuits piégés

La Toolbox (la boîte à outils de Studio) donne accès à des milliers de modèles gratuits du Creator Store. Pratique… et risqué. La documentation officielle avertit que certains modèles contiennent des scripts malveillants appelés backdoors (« portes dérobées »), qui donnent à leur auteur un accès côté serveur à ton jeu. Ces scripts se cachent souvent dans des modèles qui ont l’air parfaitement légitimes, et peuvent ne s’activer que dans certaines conditions. La modération de Roblox en retire, mais elle ne peut pas garantir de tous les retirer.

Les garde-fous recommandés par Roblox :

  1. Lis tous les scripts d’un modèle avant de le garder. La Toolbox indique quand un modèle contient des scripts.
  2. Méfie-toi du code obfusqué (volontairement illisible) ou anormalement compliqué, et du code caché loin à droite derrière des espaces.
  3. Rappelle-toi que la popularité ne garantit pas la sécurité.
  4. Isole les modèles douteux avec le sandboxing : propriété SandboxedInstance de Workspace réglée sur Experimental, propriété Sandboxed du modèle à true, puis seulement les Capabilities (autorisations) dont il a besoin. Cette fonctionnalité était encore en bêta au moment de la rédaction.

Pour fouiller un projet entier, la fenêtre Find All / Replace All de Studio cherche dans tous les scripts à la fois (Ctrl+Maj+F sur Windows, ⌘+Maj+F sur Mac). Des développeurs partagent sur le DevForum les mots qu’ils y recherchent : par exemple require suivi d’un numéro (qui charge du code publié hors de ton jeu), getfenv ou loadstring. Ce sont des retours d’expérience, pas une liste officielle : ces mots ont aussi des usages légitimes, mais leur présence dans une simple plateforme décorative doit t’alerter.

Erreur 4 : ne jamais tester à plusieurs

Certains bugs n’existent qu’à plusieurs joueurs. Un exemple classique : un verrou anti-répétition (debounce) partagé par tout le serveur au lieu d’un verrou par joueur.

-- VERSION FAUTIVE — Script dans ServerScriptService
local Players = game:GetService("Players")
local checkpoints = workspace:WaitForChild("Checkpoints")

local enTraitement = false -- UN SEUL verrou pour TOUS les joueurs !

for _, checkpoint in checkpoints:GetChildren() do
	if checkpoint:IsA("BasePart") then
		checkpoint.Touched:Connect(function(hit)
			local player = Players:GetPlayerFromCharacter(hit.Parent)
			if player == nil or enTraitement then
				return -- un autre joueur est « en cours » : celui-ci est ignoré
			end
			enTraitement = true
			print(player.Name .. " atteint l'étape " .. checkpoint.Name)
			task.wait(1)
			enTraitement = false
		end)
	end
end
-- VERSION CORRIGÉE — Script dans ServerScriptService (illustration : dans l'Obby
-- des Nuages, ce travail est déjà fait par ton Script « Progression » des modules 4 et 5)
local Players = game:GetService("Players")
local checkpoints = workspace:WaitForChild("Checkpoints")

local DELAI = 1 -- secondes
local dernierPassage = {} -- un horodatage PAR joueur

local function surContact(checkpoint: BasePart, hit: BasePart)
	local player = Players:GetPlayerFromCharacter(hit.Parent)
	if player == nil then
		return
	end
	local maintenant = os.clock()
	local precedent = dernierPassage[player]
	if precedent and maintenant - precedent < DELAI then
		return -- seul CE joueur est ralenti, pas les autres
	end
	dernierPassage[player] = maintenant

	local leaderstats = player:FindFirstChild("leaderstats")
	local etape = leaderstats and leaderstats:FindFirstChild("Etape")
	local numero = tonumber(checkpoint.Name)
	-- On n'accepte que l'étape suivante (voir module 4)
	if etape and etape:IsA("IntValue") and numero and numero == etape.Value + 1 then
		etape.Value = numero
	end
end

for _, checkpoint in checkpoints:GetChildren() do
	if checkpoint:IsA("BasePart") then
		checkpoint.Touched:Connect(function(hit)
			surContact(checkpoint, hit)
		end)
	end
end

Players.PlayerRemoving:Connect(function(player)
	dernierPassage[player] = nil -- on libère la mémoire
end)

Studio permet de simuler un serveur et plusieurs clients sur ton ordinateur : choisis le mode Server & Clients dans le menu des modes de test, indique le nombre de clients (jusqu’à huit), puis lance le test. Un ou deux clients suffisent la plupart du temps. Revois Tester son jeu comme un pro.

Erreur 5 : un jeu qui rame ou plante sur mobile

Ton ordinateur est sans doute bien plus puissant que le téléphone de la plupart de tes joueurs. Un jeu fluide dans Studio peut ramer, voire planter par manque de mémoire, sur un appareil modeste. Les causes typiques : trop de Parts non ancrées, des boucles qui tournent à chaque image, des effets et particules partout, des interfaces en pixels fixes (Offset) qui débordent de l’écran.

Garde-fous :

  • teste avec le Device Simulator de Studio, puis sur un vrai téléphone ;
  • après publication, surveille le tableau Performance du Creator Dashboard : taux de plantage, images par seconde, mémoire, par plateforme. La documentation conseille de le consulter avant et après chaque mise à jour (les graphiques n’apparaissent qu’à partir de 100 utilisateurs actifs par jour, au moment de la rédaction).

Tout le module 10 est consacré à ce sujet : commence par Penser mobile d’abord et Audit de performance de l’Obby des Nuages.

Erreur 6 : ignorer les erreurs de l’Output

Une ligne rouge dans l’Output signale une erreur : le code qui l’a provoquée s’est arrêté à cet endroit (tout le script si l’erreur survient au démarrage, ou seulement la fonction en cours si elle survient, par exemple, dans un événement Touched). Un avertissement orange « Infinite yield possible » signale un WaitForChild qui attend un objet qui n’arrive pas. Les ignorer, c’est laisser des fonctionnalités cassées sans le savoir.

Pire : un pcall dont on ne regarde jamais le résultat avale l’erreur. Le script ne plante pas, mais rien ne se passe et personne ne sait pourquoi. Dans le code corrigé de l’erreur 1, chaque échec est signalé avec warn.

Après la publication, tu as deux outils :

  • la Developer Console (touche F9 ou commande /console dans le chat) en jeu ; seuls le propriétaire du jeu et les membres du groupe autorisés à le modifier voient les messages du serveur ;
  • l’Error Report du Creator Dashboard (section Monitoring), qui affiche les erreurs et avertissements Luau récents côté serveur et côté client, avec un filtre « nouvelles erreurs depuis telle version », très utile après une mise à jour.

Tester les cas limites

Les bugs graves se cachent dans les situations que tu ne testes jamais seul. Avant chaque publication importante, essaie au moins celles-ci :

Cas limite Comment le tester Ce qui doit se passer
Deux joueurs touchent le même checkpoint Server & Clients, 2 clients Les deux étapes sont enregistrées
Un joueur part pendant le chargement Quitter tout de suite après l’arrivée Aucune donnée écrasée
Spam d’un bouton d’achat Cliquer très vite Un seul achat
Valeurs absurdes envoyées au serveur Command Bar côté client (prix négatif, mauvais type) Requête ignorée
Fermeture du serveur Arrêter le test avec des joueurs connectés Données sauvegardées
Petit écran tactile Device Simulator, puis vrai téléphone Boutons visibles et cliquables

Ce qu’il faut retenir

  • Ne sauvegarde jamais un joueur dont le chargement a échoué ; utilise pcall, des nouvelles tentatives et BindToClose.
  • Le client demande, le serveur vérifie et décide : type, existence, prix, solde, fréquence.
  • Lis chaque script d’un modèle gratuit avant de le garder ; la popularité ne prouve rien.
  • Teste à plusieurs clients : certains bugs n’existent qu’en multijoueur.
  • Surveille Output, Developer Console, Error Report et tableau Performance, surtout après une mise à jour.

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 1Trouve le bugCe script serveur gère l’achat d’une traînée. Un tricheur arrive pourtant à acheter toutes les traînées pour 0 pièce. Où est la faille ?
    local ReplicatedStorage = game:GetService("ReplicatedStorage")
    local Catalogue = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Catalogue") :: ModuleScript)
    local acheter = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("AcheterObjet") :: RemoteEvent
    
    acheter.OnServerEvent:Connect(function(player, idObjet, prixEnvoye)
    	local objet = Catalogue[idObjet]
    	if objet == nil then return end
    	local prix = prixEnvoye
    	local pieces = player.leaderstats.Pieces
    	if pieces.Value >= prix then
    		pieces.Value -= prix
    		-- (ici : ajout de la traînée à l'inventaire du joueur)
    	end
    end)
  2. Question 2Trouve le bugCe script valide les checkpoints. En test solo, tout marche. À plusieurs, certains joueurs ne voient pas leur étape enregistrée. Où est le bug ?
    local Players = game:GetService("Players")
    local checkpoints = workspace:WaitForChild("Checkpoints")
    local enTraitement = false
    
    for _, checkpoint in checkpoints:GetChildren() do
    	checkpoint.Touched:Connect(function(hit)
    		local player = Players:GetPlayerFromCharacter(hit.Parent)
    		if player == nil or enTraitement then return end
    		enTraitement = true
    		local etape = player.leaderstats.Etape
    		if tonumber(checkpoint.Name) == etape.Value + 1 then etape.Value += 1 end
    		task.wait(1)
    		enTraitement = false
    	end)
    end
  3. Question 3Vrai ou fauxSi GetAsync échoue au chargement d’un joueur, le plus sûr est de lui donner des données de départ ; elles seront sauvegardées normalement quand il partira.
  4. Question 4QCMTu as inséré depuis la Toolbox un modèle gratuit « plateforme magique ». Quelles précautions sont pertinentes ? (plusieurs réponses possibles)

    Plusieurs réponses possibles : coche toutes les bonnes.

  5. Question 5QCMOù peux-tu voir les erreurs Luau qui se produisent chez tes vrais joueurs, après publication, sans être dans leur partie ?
  6. Question 6Trouve le bugTrouve la faille dans ce code généré par IA. Ce Script serveur équipe la traînée choisie par le joueur (la pose sur le personnage est gérée ailleurs). Un tricheur porte la traînée Aurore de la boutique sans l’avoir jamais achetée. Où est la faille ?
    local ReplicatedStorage = game:GetService("ReplicatedStorage")
    local ServerScriptService = game:GetService("ServerScriptService")
    local DonneesJoueurs = require(ServerScriptService:WaitForChild("DonneesJoueurs") :: ModuleScript)
    local Validation = require(ServerScriptService:WaitForChild("Validation") :: ModuleScript)
    local equiper = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("EquiperObjet") :: RemoteEvent
    local peutEquiper = Validation.creerCooldown(0.5)
    equiper.OnServerEvent:Connect(function(player: Player, idObjet: unknown)
    	if not peutEquiper(player) or typeof(idObjet) ~= "string" then
    		return
    	end
    	local donnees = DonneesJoueurs.Obtenir(player)
    	if donnees then
    		donnees.equipe = idObjet
    	end
    end)