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.
Mental model
Section titled “Mental model”The server kernel wires sessions once, at boot:
Players.PlayerAddedcreates aSessionand stores it inkernel.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.PlayerRemovingremoves the session from the map, announces the end, then destroys it.- The live session count publishes to the
ChloeKernelSessionsattribute onReplicatedStorage, 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:
Session.new(player)—Active = true, emptyData, empty bindings.- Hook
Session.Startfires with{ Session, Player }. kernel.OnSessionStartsignal fires (this is whatonSessionconnects to).- Bus topic
Kernel.SessionStartpublishes with the session.
And on leave:
- The session leaves
kernel.Sessions—getSession(player)already returnsnil. - Hook
Session.Endfires with{ Session, Player }— the session is stillActivehere. - Bus topic
Kernel.SessionEndpublishes — stillActive. session:destroy()—Activeflips false, bindings tear down in insertion order, thenOnEndfires once and is destroyed.
Register per-player behavior in your Bootstrap with onSession:
-- src/Server/Bootstrap.luaureturn 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)endonSession 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 += 1endData vs Profile
Section titled “Data vs Profile”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:attach — nil 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.
bind() semantics
Section titled “bind() semantics”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.
API reference
Section titled “API reference”ServerKernel session surface
Section titled “ServerKernel session surface”| 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. |
Session
Section titled “Session”| 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. |
Hooks and bus topics
Section titled “Hooks and bus topics”| 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 sessionKernel.Hooks:on("Session.Start", function(context) context.Session.Data.Team = pickTeam(context.Player)end)
-- Bus: react to the fact, decoupledKernel.Bus:subscribe("Kernel.SessionEnd", function(_, session) Announcer:say(session.Player.Name .. " left")end)