Tienes una habilidad que hace mucho daño y quieres que solo se pueda usar cada tres segundos. Lo montas, funciona, lo pruebas tú y va perfecto. Y un día alguien entra con un exploit, dispara sesenta veces por segundo y se lleva el juego por delante.
El fallo casi siempre es el mismo: el cooldown está en el cliente. Y el cliente es del jugador, no tuyo.
En la demo puedes simular las dos situaciones sobre tu propio servidor: alguien pulsando como una persona normal y alguien mandando veinte peticiones por segundo, contra un servidor que se fía y contra uno que no.
Respuesta rápida
El cooldown vive en el servidor, en una tabla indexada por jugador, y se comprueba en el OnServerEvent antes de hacer nada. El del cliente es solo para que el botón se ponga gris: decoración.
Lo que necesitas antes de empezar
- Saber cómo funciona un RemoteEvent: cómo hablan el cliente y el servidor.
- Unos 25 minutos.
Paso 1: el cooldown que no sirve
Este es el que sale en casi todos los tutoriales, en un LocalScript:
local puedo = true
boton.Activated:Connect(function()
if not puedo then return end
puedo = false
evento:FireServer() -- el servidor no comprueba nada
task.wait(3)
puedo = true
end)
Funciona de maravilla… mientras el jugador use tu botón. Un exploit no usa tu botón: llama directamente al RemoteEvent. Tu variable puedo ni se entera.
Regla general: si una comprobación se puede borrar desde la máquina del jugador, no es una comprobación.

Paso 2: el cooldown que sí aguanta
En el servidor, una tabla con la última vez que cada jugador lo usó:
local Jugadores = game:GetService("Players")
local evento = game.ReplicatedStorage:WaitForChild("UsarHabilidad")
local ESPERA = 3
local ultimoUso = {}
evento.OnServerEvent:Connect(function(jugador)
local ahora = os.clock()
local anterior = ultimoUso[jugador]
if anterior then
if ahora - anterior < ESPERA then
return -- todavía no toca: se ignora y ya está
end
end
ultimoUso[jugador] = ahora
-- aquí va la habilidad de verdad
lanzarBolaDeFuego(jugador)
end)
Jugadores.PlayerRemoving:Connect(function(jugador)
ultimoUso[jugador] = nil -- limpieza, o la tabla crece para siempre
end)
Tres detalles:
os.clock(), notick()nios.time().os.clockes un reloj monótono con decimales: no lo mueve el reloj del sistema y tiene precisión de sobra.os.timesolo da segundos enteros, que para un cooldown de 0,5 s no vale.- Se guarda el momento, no un booleano. Un booleano con
task.wait(3)dentro te obliga a mantener una corrutina viva por jugador; una marca de tiempo es un número y ya. - Se ignora en silencio. No hace falta contestarle al que se pasa de listo.
Paso 3: decírselo al jugador honrado
El servidor decide, pero el jugador merece ver algo. Devuélvele lo que le queda:
-- servidor
if ahora - anterior < ESPERA then
evento:FireClient(jugador, "espera", ESPERA - (ahora - anterior))
return
end
evento:FireClient(jugador, "va")
-- cliente
evento.OnClientEvent:Connect(function(estado, restante)
if estado == "espera" then
aviso.Text = string.format("Espera %.1f s", restante)
else
enfriar(boton, ESPERA) -- animación de recarga, solo estética
end
end)
Así el cliente hace lo que se le da bien —enseñar cosas— y el servidor hace lo que solo él puede hacer: decidir.
Paso 4: cooldowns distintos por habilidad
Con una tabla dentro de otra:
local ESPERAS = {
bola = 3,
curar = 12,
dash = 0.8,
}
local usos = {} -- usos[jugador][habilidad] = momento
local function puedeUsar(jugador, habilidad)
local espera = ESPERAS[habilidad]
if not espera then return false end -- habilidad inventada
usos[jugador] = usos[jugador] or {}
local ahora = os.clock()
local anterior = usos[jugador][habilidad]
if anterior then
if ahora - anterior < espera then return false end
end
usos[jugador][habilidad] = ahora
return true
end
Fíjate en if not espera then return false end: el cliente manda el nombre de la habilidad, así que puede mandar cualquier cosa. Si no está en tu tabla, fuera.
Paso 5: el límite que le falta a casi todos los juegos
Un cooldown por habilidad no te protege de que alguien mande mil peticiones por segundo de una habilidad que sí tiene lista. Aunque las rechaces, cada una gasta trabajo del servidor. Pon un límite general por jugador:
local PETICIONES_POR_SEGUNDO = 20
local contador = {}
local function pasaElFiltro(jugador)
local ahora = os.clock()
local c = contador[jugador]
if not c then
contador[jugador] = {ventana = ahora, n = 1}
return true
end
if ahora - c.ventana > 1 then
c.ventana = ahora
c.n = 1
return true
end
c.n = c.n + 1
if c.n > PETICIONES_POR_SEGUNDO then
return false
end
return true
end
Lo llamas lo primero de todo en cada OnServerEvent. A un jugador normal no le afecta jamás: nadie pulsa veinte veces en un segundo con la mano.
Paso 6: la habilidad entera
evento.OnServerEvent:Connect(function(jugador, habilidad)
if not pasaElFiltro(jugador) then return end
if type(habilidad) ~= "string" then return end
if not puedeUsar(jugador, habilidad) then
evento:FireClient(jugador, "espera")
return
end
local personaje = jugador.Character
if not personaje then return end
-- Y una última: ¿está donde dice estar?
local raiz = personaje:FindFirstChild("HumanoidRootPart")
if not raiz then return end
ejecutar(habilidad, jugador, raiz.Position)
end)
El orden importa: primero lo barato (el filtro, el tipo), luego lo caro (buscar el personaje, calcular).
Ponte en el peor caso
Una pulsación normal contra veinte por segundo
La misma habilidad contra dos servidores: uno que se fía del cooldown del cliente y otro que lleva el suyo. Cooldown: 3 segundos.
Errores habituales
El cooldown funciona hasta que alguien lo salta
Está en un LocalScript. Cópialo al servidor; el del cliente se queda solo para el botón gris.
Usa tick() y a veces se comporta raro
tick() está desaconsejado desde hace tiempo. Para medir diferencias de tiempo, os.clock().
La tabla de cooldowns crece hasta llenar la memoria
Falta borrar la entrada en PlayerRemoving. En un servidor que lleva días con gente entrando y saliendo se nota.
Al reaparecer, el jugador puede usar la habilidad al momento
Si eso te molesta, guarda el cooldown también en CharacterAdded. Y si prefieres lo contrario (que morir sea un alivio), déjalo como está: es una decisión de diseño, no un fallo.
Dos jugadores comparten el cooldown
Estás usando una sola variable en vez de una tabla por jugador. Clásico de copiar y pegar el ejemplo del LocalScript.
El cooldown se salta cambiando de servidor
Si es una habilidad importante (una recompensa, un cofre diario), el cooldown tiene que ir al DataStore, no a una tabla en memoria: cómo guardar los datos.
Preguntas frecuentes
¿Debería contestar al que manda peticiones de más?
No. Cuanta menos información le des a un exploit sobre qué le has detectado, mejor. Ignóralo.
¿Y si quiero echar del juego a quien lo intente?
Ten cuidado: la latencia y los reenvíos hacen que a veces lleguen dos peticiones legítimas casi juntas. Echar a alguien por eso es como cazar moscas a cañonazos. Cuenta los intentos y actúa solo con números claramente absurdos.
¿Se puede hacer el cooldown con un atributo en vez de una tabla?
Sí: jugador:SetAttribute("ultimaBola", os.clock()). Es cómodo porque se ve en el explorador mientras depuras, pero el cliente puede leerlo (no escribirlo). Para cooldowns normales da igual.
¿Cuánto debería durar un cooldown?
El que haga que la habilidad se note especial sin aburrir. Empieza por lo que te parezca y ajústalo mirando cuánta gente la usa: si es la única que se usa, es corta; si no la usa nadie, es larga.
Última revisión: 1 de septiembre de 2026





Deja una respuesta