El día que a un jugador se le borra el progreso, no vuelve. Y el guardado en Roblox falla más de lo que la gente cree: la petición al servidor de datos puede tardar, dar error o llegar tarde a un servidor que ya se está apagando. Un DataStore escrito «a la primera» funciona en Studio y pierde partidas en producción.
En la demo puedes forzar los fallos que ocurren en producción (caída de la petición, cierre del servidor, dos guardados a la vez) y ver qué pasa con un script sin protección y con uno bien hecho.
Respuesta rápida
Envuelve todas las llamadas al DataStore en pcall, guarda en PlayerRemoving y en game:BindToClose(), y no guardes en cada cambio: guarda por eventos importantes y cada pocos minutos. Sin pcall, un error de red revienta el script y el jugador pierde la sesión entera.
Lo que necesitas antes de empezar
- Los datos ya montados en el jugador: si vienes de cómo crear un leaderboard de puntuación, es justo el paso siguiente.
- Activar el acceso a la API en Studio, o no vas a poder probar nada (paso 1).
- Unos 30 minutos, y ganas de probar los fallos a propósito.
Paso 1: activar el acceso a la API
En Studio: File → Game Settings → Security → Enable Studio Access to API Services. Sin eso, cualquier llamada a un DataStore desde Studio da error y te vuelves loco buscando el fallo en tu código.
Dos avisos:
- Solo se puede activar en un lugar ya publicado. Un archivo local sin subir no tiene a qué DataStore apuntar.
- Studio y el juego publicado usan los mismos datos. Si pruebas cosas raras en Studio con tu propia cuenta, te estás tocando tu propio guardado.
Paso 2: la estructura que sí aguanta
local DatosServicio = game:GetService("DataStoreService")
local Jugadores = game:GetService("Players")
local almacen = DatosServicio:GetDataStore("DatosJugador_v1")
local POR_DEFECTO = {
puntos = 0,
monedas = 0,
nivel = 1,
}
Ese _v1 del nombre no es decoración. El día que cambies la forma de los datos, subes a _v2 y los jugadores empiezan de cero en vez de cargar algo que tu código nuevo no sabe leer. Es feo, pero es mejor que un attempt to index nil en mitad de la carga.
La tabla POR_DEFECTO es igual de importante: es lo que recibe un jugador nuevo, y también el molde con el que rellenas campos que aún no existían cuando ese jugador guardó por última vez.
Paso 3: cargar con reintentos
local function cargar(jugador)
local clave = "jug_" .. jugador.UserId
local datos = nil
local exito = false
for intento = 1, 3 do
local ok, resultado = pcall(function()
return almacen:GetAsync(clave)
end)
if ok then
exito = true
datos = resultado
break
end
warn("Fallo al cargar (intento " .. intento .. "): " .. tostring(resultado))
task.wait(2)
end
if not exito then
return nil, false -- no pudimos leer: OJO, no es lo mismo que "no tiene datos"
end
if datos == nil then
datos = table.clone(POR_DEFECTO) -- jugador nuevo
end
-- Rellenamos campos que se anadieron despues
for campo, valor in pairs(POR_DEFECTO) do
if datos[campo] == nil then
datos[campo] = valor
end
end
return datos, true
end
La distinción del final es la más importante de todo el artículo:
| Situación | Qué devuelve GetAsync | Qué debes hacer |
|---|---|---|
| Jugador nuevo | nil, sin error | Darle los valores por defecto |
| Error de red | error, capturado por pcall | No darle valores por defecto |
Si tratas las dos igual, un fallo temporal de Roblox le pone el progreso a cero a un jugador veterano… y luego se lo guardas encima. Ese es el bug que borra cuentas.
Paso 4: qué hacer cuando la carga falla
Lo correcto es no dejarle jugar con datos falsos:
Jugadores.PlayerAdded:Connect(function(jugador)
local datos, ok = cargar(jugador)
if not ok then
jugador:Kick("No hemos podido cargar tu progreso. Vuelve a entrar en un minuto: tus datos estan a salvo.")
return
end
sesiones[jugador] = datos
montarLeaderstats(jugador, datos)
end)
Echar al jugador suena agresivo, pero es la opción amable: prefiere una molestia de treinta segundos a borrarle diez horas. Y en el mensaje, díselo claro.
Paso 5: guardar
local function guardar(jugador)
local datos = sesiones[jugador]
if not datos then return false end -- nunca cargo: no escribimos nada
local clave = "jug_" .. jugador.UserId
for intento = 1, 3 do
local ok, err = pcall(function()
almacen:SetAsync(clave, datos)
end)
if ok then return true end
warn("Fallo al guardar (intento " .. intento .. "): " .. tostring(err))
task.wait(2)
end
return false
end
Jugadores.PlayerRemoving:Connect(function(jugador)
guardar(jugador)
sesiones[jugador] = nil
end)
Ese if not datos then return false end es la otra mitad de la protección del paso 4: si no llegamos a cargar, no escribimos jamás. Sin esa línea, un fallo de lectura acaba escribiendo ceros encima de lo bueno.
Paso 6: el guardado que se olvida siempre
Cuando Roblox apaga un servidor (porque se ha quedado vacío o porque actualizas el juego), PlayerRemoving puede no llegar a completarse. Para eso está BindToClose:
game:BindToClose(function()
for _, jugador in ipairs(Jugadores:GetPlayers()) do
task.spawn(function()
guardar(jugador)
end)
end
task.wait(3) -- margen para que terminen las escrituras
end)
Roblox te da unos segundos antes de cortar. Ese task.wait(3) los usa. Sin BindToClose, la gente que estaba jugando cuando cerró el servidor pierde lo de la última sesión, y es un fallo que en Studio no se ve nunca.

Paso 7: UpdateAsync cuando hay dinero de por medio
SetAsync escribe encima. UpdateAsync lee y escribe en una sola operación, así que si dos servidores tocan al mismo jugador a la vez no se pisan:
local ok, err = pcall(function()
almacen:UpdateAsync(clave, function(anterior)
anterior = anterior or table.clone(POR_DEFECTO)
anterior.monedas = anterior.monedas + 50
return anterior
end)
end)
Para el progreso normal de un jugador, SetAsync va bien. Para monedas, compras o cualquier cosa que se pueda duplicar, usa UpdateAsync.
Paso 8: cada cuánto guardar
Hay un límite de peticiones por minuto y por jugador. Guardar en cada cambio te lo come y las escrituras empiezan a fallar. Un patrón que funciona:
task.spawn(function()
while true do
task.wait(120) -- cada dos minutos
for jugador, _ in pairs(sesiones) do
guardar(jugador)
end
end
end)
Más un guardado extra en los momentos que duelen si se pierden: una compra, subir de nivel, conseguir un objeto raro. Lo demás, que espere al reloj.
Rómpelo aquí
Los cuatro fallos que borran partidas
Elige qué se tuerce y compara el mismo momento en un script sin protección y en uno bien hecho. El jugador de la prueba lleva 1.240 monedas y 18 horas.
Errores habituales
En Studio no guarda nada y no hay error claro
No has activado Enable Studio Access to API Services en Game Settings → Security. Es el número uno.
104: Cannot store Instance in DataStore
Estás intentando guardar algo que no es un dato: un Part, un Color3, un Vector3 o un Instance. Los DataStores solo guardan números, textos, booleanos y tablas con esas cosas dentro. Convierte primero: un Color3 se guarda como {r = 255, g = 120, b = 0}.
Se pierde el progreso solo cuando se cierra el servidor
Falta BindToClose. Es exactamente ese síntoma.
Guarda a veces sí y a veces no
Estás guardando demasiado a menudo y te has comido el límite de peticiones, o no envolviste las llamadas en pcall y el primer error tumbó el script entero.
Las claves con nombre en vez de con UserId
Si usas jugador.Name como clave, el día que alguien se cambie el nombre pierde todo. Usa siempre jugador.UserId, que no cambia nunca.
El jugador aparece con 0 monedas de vez en cuando
El caso del paso 4: estás tratando un error de lectura como si fuera un jugador nuevo. Sepáralos.
Preguntas frecuentes
¿Puedo guardar una tabla con el inventario entero?
Sí, mientras dentro solo haya números, textos, booleanos y más tablas. Hay un límite de tamaño por clave: si tu inventario crece sin freno, guarda identificadores cortos ("esp_01") en vez de nombres largos y descripciones.
¿Los datos de Studio y los del juego publicado son los mismos?
Sí, salvo que uses un DataStore con nombre distinto para pruebas. Es buena idea: DatosJugador_v1 en producción y DatosJugador_test mientras desarrollas.
¿Qué es eso del session locking?
Marcar en el propio dato que el jugador tiene una sesión abierta, para que dos servidores no carguen y guarden a la vez el mismo perfil (pasa al saltar de servidor). Es lo que resuelven librerías como ProfileService. Si tu juego mueve objetos valiosos, mírala antes de escribirte la tuya.
¿Puedo recuperar unos datos que se han sobrescrito?
Con ListVersionsAsync y GetVersionAsync se pueden recuperar versiones anteriores dentro de la ventana de retención. Es una red de seguridad, no una excusa para no hacerlo bien.
¿Hace falta guardar cuando el jugador cambia de servidor dentro del mismo juego?
Sí: para el DataStore es como salir y volver a entrar. Es justo el momento donde más duplicaciones aparecen si no usas UpdateAsync: en el inventario se ve por qué duele tanto.
Última revisión: 1 de septiembre de 2026





Deja una respuesta