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
- Erreur 1 : perdre les données des joueurs
- Le code fautif
- Le code corrigé
- Erreur 2 : faire confiance aux RemoteEvents
- Le code fautif
- Le code corrigé
- Erreur 3 : insérer des modèles gratuits piégés
- Erreur 4 : ne jamais tester à plusieurs
- Erreur 5 : un jeu qui rame ou plante sur mobile
- Erreur 6 : ignorer les erreurs de l’Output
- Tester les cas limites
- Quiz de fin de leçon
🎯 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 == truemais 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 àBindToClosese 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 compteursauvegardesEnCourspermet aussi d’attendre les sauvegardes déjà lancées parPlayerRemovingquand 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, etUpdateAsyncrefuse 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 :
- Lis tous les scripts d’un modèle avant de le garder. La Toolbox indique quand un modèle contient des scripts.
- Méfie-toi du code obfusqué (volontairement illisible) ou anormalement compliqué, et du code caché loin à droite derrière des espaces.
- Rappelle-toi que la popularité ne garantit pas la sécurité.
- Isole les modèles douteux avec le sandboxing : propriété
SandboxedInstancede Workspace réglée surExperimental, propriétéSandboxeddu 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
/consoledans 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 etBindToClose. - 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
- Data stores — documentation Roblox (accès depuis Studio)
- Data stores best practices — documentation Roblox
- Player data and purchasing systems (données par défaut, verrouillage de session) — documentation Roblox
- DataModel:BindToClose (limite de 30 secondes) — référence de l’API Roblox
- Script Editor (Find All / Replace All) — documentation Roblox
- Version History — documentation Roblox
- Security tactics — documentation Roblox
- Vulnerabilities from third-party assets — documentation Roblox
- Testing modes (Server & Clients, Device Simulator) — documentation Roblox
- Error Report — documentation Roblox
- Performance dashboard — documentation Roblox
- Developer Console — documentation Roblox
- « Datastore completely wiped » — fil d’entraide du DevForum (retour d’expérience)
- « Backdooring Explained [MEGATHREAD] » — fil communautaire du DevForum