Skip to content
🌊 You are viewing documentation for the Maelstrom pre-release channel. Pin a stable release for production.

Player Lifecycle

The PlayerLifecycle module provides centralized, server-side hooks that Riptide automatically calls on your modules when players join or leave.

Player Lifecycle is server-only. The hooks are only called on modules loaded on the server.

Instead of manually connecting to Players.PlayerAdded and Players.PlayerRemoving inside every service, expose these methods on your module table and Riptide calls them for you.

function MyService:OnPlayerAdded(Riptide: Riptide, player: Player)

Called when a player joins the server. Also called for all players already connected after the framework finishes module and plugin initialization.

function CoinsService:OnPlayerAdded(Riptide, player)
Riptide.State:SetForPlayer(player, "coins", 100)
print(player.Name .. " joined — gave 100 starting coins")
end

function MyService:OnPlayerRemoving(Riptide: Riptide, player: Player)

Called when a player is leaving the server (Players.PlayerRemoving). By this point, all modules have completed Init and the start phase has been dispatched, so it is safe to read fields prepared during Init.

function DataService:OnPlayerRemoving(Riptide, player)
local coins = Riptide.State:Get("coins", player)
self:SaveToDataStore(player.UserId, coins)
print(player.Name .. " leaving — saved " .. coins .. " coins")
end

Server starts
Init Phase (synchronous) ← self.* fields are set here
Plugin Start readiness barrier ← framework extensions become ready before player replay
PlayerLifecycle:Start() ← existing players replayed after plugin readiness
Start Phase (task.spawn) ← game logic begins
Players.PlayerAdded → OnPlayerAdded(...) ← new players
Players.PlayerRemoving → OnPlayerRemoving(...)
StateReplication:_onPlayerRemoving(player) ← automatic cleanup

Existing-player replay happens after module initialization and plugin Start dispatch, so lifecycle hooks can use fields prepared during Init and plugins can observe existing players.


The PlayerLifecycle module automatically triggers StateReplication:_onPlayerRemoving(player) after your OnPlayerRemoving hooks run. This allows your modules to safely read player-scoped state during shutdown, and then clears it from the StateReplication registry, preventing stale data and memory leaks.


If a hook throws an error, it is caught via xpcall and logged. Other modules’ hooks continue to execute — one failing module does not block the rest.

[PlayerLifecycle] Error in OnPlayerAdded for CoinsService:
<stack trace>

--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RiptidePkg = require(ReplicatedStorage.Packages.Riptide)
type Riptide = RiptidePkg.RiptideServer
local SessionService = {}
SessionService._sessions = {} :: { [number]: number }
function SessionService:Init(Riptide: Riptide)
-- nothing to inject
end
function SessionService:OnPlayerAdded(Riptide: Riptide, player: Player)
-- Existing-player replay runs after Init.
self._sessions[player.UserId] = os.time()
Riptide.State:SetForPlayer(player, "sessionStart", os.time())
end
function SessionService:OnPlayerRemoving(Riptide: Riptide, player: Player)
local startTime = self._sessions[player.UserId]
if startTime then
local duration = os.time() - startTime
print(player.Name .. " played for " .. duration .. "s")
end
self._sessions[player.UserId] = nil
end
return SessionService