Skip to content

Sessions

Gives every player one server-side session that owns their per-visit state and tears down everything bound to it when they leave.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Session
Since
v0.1.0
Source
Session.luau

A session is a per-player lifetime object: created when the player joins, destroyed when they leave, one per player, server only. Anything a system attaches with session:bind() (a connection, an instance, a cleanup function) is torn down on leave, so per-player state never leaks across joins and no system wires its own PlayerAdded and PlayerRemoving pair. A session also carries how the player arrived (JoinData), their engine identity (User) and, at the end, why they left (ExitReason). Sessions live in ServerScriptService.ChloeKernelServer and never replicate; the layer split is on Kernel & Boot. Moments that happen once per session, such as a loaded profile, are replayed to late subscribers by Milestones, and persistent data comes from DataDriver.

The server kernel wires sessions once, at boot:

  • Players.PlayerAdded creates a session and stores it in kernel.Sessions[player]. Players already in the game at boot get sessions immediately, so a kernel that boots late (a Studio play test, an in-place restart) misses no one.
  • Players.PlayerRemoving ends the session with the engine’s exit reason.
  • The live session count is written to the ChloeKernelSessions attribute on ReplicatedStorage at every join and leave, so tooling can watch population without touching the kernel.
  • No session is created once server shutdown has begun.

Each transition announces itself twice, on purpose: a hook first, then a Bus topic. Hooks run synchronously in priority order on one shared context table, so a hook handler that seeds per-player state finishes before any Bus subscriber sees the session.

On join:

  1. Session.new(player): Active = true, an empty Data table, JoinData read once from Player:GetJoinData() and User from Player.User.
  2. The session goes into kernel.Sessions, and the count and attribute update.
  3. Hook Session.Start fires with { Session, Player }.
  4. kernel.OnSessionStart fires. This is what onSession callbacks connect to.
  5. Bus topic Kernel.SessionStart publishes with the session.

On leave:

  1. The session leaves kernel.Sessions (getSession(player) already returns nil), the count and attribute update, and session.ExitReason is set.
  2. Hook Session.End fires with { Session, Player, ExitReason }. The session is still Active.
  3. Bus topic Kernel.SessionEnd publishes with (session, exitReason). Still Active.
  4. session:destroy() runs even if one of the steps above threw: Active becomes false, bindings tear down in insertion order, OnEnd fires and is destroyed, then the bindLast finalizers run in order.

Per-player behavior goes in a service’s start through onSession:

src/Server/Game/Services/StreakService.luau
--!strict
-- StreakService: counts spawns per visit.
local StreakService = {
Name = "StreakService",
}
function StreakService.init(self: any, kernel: any)
self.Kernel = kernel
end
function StreakService.start(self: any)
self.SessionConnection = self.Kernel:onSession(function(session)
session.Data.JoinedAt = os.clock()
session.Data.Spawns = 0
-- Disconnected on leave
session:bind(session.Player.CharacterAdded:Connect(function(character)
session.Data.Spawns += 1
character:SetAttribute("Spawns", session.Data.Spawns)
end))
-- Runs on leave, before OnEnd fires
session:bind(function()
print(session.Player.Name, "played for", os.clock() - session.Data.JoinedAt)
end)
end)
end
function StreakService.stop(self: any)
if self.SessionConnection then
self.SessionConnection:disconnect()
end
end
return StreakService

onSession runs fn for current and future sessions. Sessions that already exist are delivered at once, each on its own thread through task.spawn, and the returned Signal connection covers everyone who joins later. A system that registers late misses no one, which a plain Kernel.SessionStart subscription would: players present at boot get their sessions before any service registers.

Fetch a session anywhere after boot:

local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local PlayerSession = ServerKernel.current():getSession(SomePlayer)
if PlayerSession then
PlayerSession.Data.Spawns += 1
end

Three fields describe the visit itself (added in 0.9.0):

Field Source Notes
session.JoinData Player:GetJoinData(), read once when the session starts SourcePlaceId, TeleportData, ReferredByPlayerId, LaunchData, GameJoinContext, Members. {} when the call fails or returns something other than a table.
session.User Player.User The engine’s identity object for this player, or nil on engines that do not expose it.
session.ExitReason The Enum.PlayerExitReason passed by PlayerRemoving Unknown, PlatformKick or CreatorKick. nil while the session is active, and nil when the kernel ended the session during shutdown.

The exit reason also reaches the Session.End hook context and the Kernel.SessionEnd topic:

local Logger = require(game:GetService("ReplicatedStorage").ChloeKernel.Logger)
local Log = Logger.scope("Arrivals")
local LobbyPlaceId = 1234567890
kernel:onSession(function(session)
if session.JoinData.SourcePlaceId == LobbyPlaceId then
session.Data.FromLobby = true
end
end)
kernel.Bus:subscribe("Kernel.SessionEnd", function(_topic, session, exitReason)
if exitReason == Enum.PlayerExitReason.CreatorKick then
Log.info("kicked", session.Player.UserId)
end
end)

A session carries two state containers with different lifetimes:

Field Lifetime Backed by
session.Data This visit only. Starts as {} on every join and is dropped on leave. Nothing; a plain table.
session.Profile Persistent across visits. nil until a data driver sets it. DataDriver through DataDriver:attach.

Scratch state (combo counters, join timestamps, per-life flags) belongs in Data. Anything that must survive a rejoin belongs in Profile.Data. The source types Profile as the untyped Data.Profile?; cast it to your profile shape for typed access.

Profiles load asynchronously, so the load is still in flight when Session.Start fires. session.ProfilePending is true while DataDriver:attach loads the profile, and nil before a load starts, after it finishes (whether it succeeded or failed) and when no DataDriver is attached. While it is true, write nothing that must persist: a reward paid into a session without a profile is lost. The framework kits already account for it: achievement unlocks and quest completions wait for the profile, and timed crafts refuse with Loading.

Per-player setup that needs data waits for the profile with Milestones.on(kernel, "Kernel.ProfileLoaded", fn). It also replays profiles that loaded before the subscription, which a plain Bus subscription would miss. See Milestones.

Other modules add their own fields: Passes sets session.Passes, Policy sets session.Policy, and Milestones records session.Milestones.

session:bind(item) accepts four kinds of item and returns the item unchanged, so it can wrap an expression inline. On teardown each is released by type:

Item type Teardown Errors
function Called with no arguments. Swallowed by pcall, so one throwing cleanup cannot block the rest.
RBXScriptConnection :Disconnect(). Not wrapped; cannot throw.
Instance :Destroy(). Not wrapped.
table The first method found among disconnect, destroy and Destroy, called once. Swallowed by pcall.

The table case covers Signal connections (disconnect), kernel objects such as scopes (destroy) and Roblox-style objects (Destroy) without the session knowing their types. Bindings run in insertion order.

kernel:onSession(function(session)
-- Instance: destroyed on leave
local Marker = session:bind(Instance.new("Part"))
Marker.Anchored = true
Marker.Parent = workspace
-- Signal connection: disconnected on leave
session:bind(kernel.Bus:subscribe("Round.Ended", function()
session.Data.Streak = 0
end))
-- Scope: everything it owns closes on leave
local Lifetime = session:bind(kernel:createScope())
Lifetime:add(kernel.Scheduler:every(5, function()
session.Data.Ticks = (session.Data.Ticks or 0) + 1
end))
end)

Binding to an ended session errors cannot bind to an ended session, so a system holding a stale session reference fails loudly instead of leaking.

session:bindLast(fn) registers a function that runs after every binding and after the OnEnd listeners. DataDriver binds the player’s final save this way. Writes made by ordinary bindings, including cleanups bound inside a Kernel.ProfileLoaded handler, therefore land in that save. Before 0.9.0 the final save ran first and those leave-time writes were lost.

Game code uses bind. bindLast is for persistence layers that must run after everything else. It takes functions only, runs each under pcall and errors on an ended session the same way bind does.

Member Description
kernel:onSession(fn: (session) -> ()): Connection Runs fn for every current session (through task.spawn) and every future one. Asserts server kernel is shut down after shutdown.
kernel:getSession(player: Player): Session? The live session, or nil once the player has left (already during Session.End).
kernel.Sessions The live { [Player]: Session } map.
kernel.SessionCount Number of live sessions.
kernel.OnSessionStart The underlying Signal. onSession is this plus delivery of existing sessions.
Member Description
session.Player The Player.
session.Data Per-visit scratch table, empty at start.
session.Profile Persistent profile set by DataDriver:attach; nil until then.
session.ProfilePending true while DataDriver:attach loads the profile.
session.JoinData Player:GetJoinData() read at start, or {}.
session.User Player.User, or nil.
session.ExitReason Enum.PlayerExitReason from PlayerRemoving; nil while active and after a shutdown.
session.Active true from creation until destroy() begins.
session.Milestones Moments recorded by Milestones.publish, by name.
session.OnEnd Signal fired once with the session, after the bindings run.
session:bind(item): item Registers an item for teardown and returns it. Errors on an ended session.
session:bindLast(fn) Registers a function to run after every binding and OnEnd listener. Errors on an ended session.
session:destroy() Called by the kernel on leave. Idempotent.
Name Type Payload Fired
Session.Start Hook (observational) { Session, Player } On join, before the Bus topic. The kernel ignores the verdict, so a join cannot be vetoed, but handlers run synchronously: seed per-player state here and Bus subscribers already see it.
Session.End Hook (observational) { Session, Player, ExitReason } On leave, before the Bus topic and before teardown.
Kernel.SessionStart Bus topic session After Session.Start and after onSession callbacks are dispatched.
Kernel.SessionEnd Bus topic session, exitReason? After Session.End, before destroy(). exitReason is the third handler argument after the topic, and nil during shutdown.
Kernel.ProfileLoaded Bus topic (milestone) session, profile Published by DataDriver once session.Profile is set. Subscribe with Milestones.on to receive loads that already happened.
-- Hook: seed state synchronously, before anything else observes the session
kernel.Hooks:on("Session.Start", function(context)
context.Session.Data.Team = if context.Player.UserId % 2 == 0 then "Red" else "Blue"
end)
-- Bus: react to the fact
kernel.Bus:subscribe("Kernel.SessionEnd", function(_topic, session, exitReason)
kernel.Bus:publish("Lobby.Left", session.Player.Name, exitReason)
end)

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.