Skip to content

Sessions

A session is a per-player lifetime object: created when the player joins, destroyed when they leave, one per player, server-only. The defining design decision is bind() — anything a system attaches to a session (connections, instances, cleanup functions) is torn down automatically on leave, so per-player state never leaks across joins and no system wires its own PlayerAdded/PlayerRemoving pair. Sessions live in ServerScriptService.ChloeKernelServer and never replicate; see Kernel & Boot for the layer split.

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 — a kernel that boots mid-game (Studio play test, in-place restart) misses no one.
  • Players.PlayerRemoving removes the session from the map, announces the end, then destroys it.
  • The live session count publishes to the ChloeKernelSessions attribute on ReplicatedStorage, so tooling can watch population without touching the kernel.

Each transition announces itself twice, deliberately: a hook first, then a bus topic. Hooks run synchronously in priority order and share one context table, so a hook handler that seeds per-player state is guaranteed to finish before any bus subscriber sees the session (see Hooks vs the Bus for the general split). The exact order on join:

  1. Session.new(player)Active = true, empty Data, empty bindings.
  2. Hook Session.Start fires with { Session, Player }.
  3. kernel.OnSessionStart signal fires (this is what onSession connects to).
  4. Bus topic Kernel.SessionStart publishes with the session.

And on leave:

  1. The session leaves kernel.SessionsgetSession(player) already returns nil.
  2. Hook Session.End fires with { Session, Player } — the session is still Active here.
  3. Bus topic Kernel.SessionEnd publishes — still Active.
  4. session:destroy()Active flips false, bindings tear down in insertion order, then OnEnd fires once and is destroyed.

Register per-player behavior in your Bootstrap with onSession:

-- src/Server/Bootstrap.luau
return function(kernel)
kernel:onSession(function(session)
session.Data.JoinedAt = os.clock()
session.Data.Streak = 0
-- auto-disconnected on leave
session:bind(session.Player.CharacterAdded:Connect(function(character)
character:SetAttribute("Streak", session.Data.Streak)
end))
-- runs on leave, before OnEnd fires
session:bind(function()
print(session.Player.Name, "played for", os.clock() - session.Data.JoinedAt)
end)
end)
end

onSession fires for current and future sessions: already-present players are delivered immediately (each on its own thread via task.spawn), then the returned connection covers everyone who joins later. Late-registering systems never miss anyone. The return value is a Signal connection — call :disconnect() if the system itself shuts down.

Fetch a session anywhere after boot:

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

A session carries two state containers with different lifetimes:

Field Lifetime Backed by
session.Data This visit only. Starts {} on every join, garbage on leave. Nothing — plain table, put anything in it.
session.Profile Persistent across visits. Set by DataDriver:attachnil until a data driver attaches one. See DataDriver.

Scratch state (combo counters, join timestamps, per-life flags) belongs in Data. Anything that must survive a rejoin belongs in Profile.Data, where the DataDriver’s loss-hardening applies. The source ships Profile as an untyped Data.Profile?; cast it to your own profile shape for typed access.

session:bind(item) accepts four kinds of item and returns the item unchanged (so you can bind inline). On teardown, each is released according to its type:

Item type Teardown Errors
function Called with no arguments. Swallowed (pcall) — one throwing cleanup cannot block the rest.
RBXScriptConnection :Disconnect(). Not wrapped — cannot throw.
Instance :Destroy(). Not wrapped.
table First matching method of disconnect, destroy, Destroy — checked in that order, called once. Swallowed (pcall).

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

kernel:onSession(function(session)
-- Instance: destroyed on leave
local Board = session:bind(Instance.new("Part"))
Board.Parent = workspace
-- Signal connection (table with disconnect): disconnected on leave
session:bind(Kernel.Bus:subscribe("Round.Ended", function()
session.Data.Streak = 0
end))
-- function: runs last-resort cleanup logic
session:bind(function()
ScoreCache[session.Player.UserId] = session.Data.Streak
end)
end)

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

Member Description
kernel:onSession(fn: (session) -> ()): Connection Runs fn for every current session (via task.spawn) and every future one. Returns a Signal connection.
kernel:getSession(player: Player): Session? The live session, or nil once the player has left (or during Session.End).
kernel.Sessions The live { [Player]: Session } map — iterate for “all players” sweeps.
kernel.OnSessionStart The underlying Signal; onSession is this plus immediate delivery.
Member Description
session.Player The Player.
session.Data Per-visit scratch table, starts empty.
session.Profile Persistent profile, set by DataDriver:attach; nil until then.
session.Active true from creation until destroy() begins teardown.
session.OnEnd Signal, fires exactly once with the session — after bindings run. Double-destroy is ignored.
session:bind(item): item Registers an item for teardown; returns it. 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 — you cannot veto a join — but handlers run synchronously, so seed per-player state here and bus subscribers already see it.
Session.End Hook (observational) { Session, Player } 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 After Session.End, before destroy().
-- Hook: seed state synchronously, before anything else observes the session
Kernel.Hooks:on("Session.Start", function(context)
context.Session.Data.Team = pickTeam(context.Player)
end)
-- Bus: react to the fact, decoupled
Kernel.Bus:subscribe("Kernel.SessionEnd", function(_, session)
Announcer:say(session.Player.Name .. " left")
end)