Skip to content

QuestKit

QuestKit tracks quests from things that happen. A quest is data: a list of objectives, each counting a bus topic the server publishes — “5x Combat.Kill”, “enter zone Summit”, “10x Obby.Checkpoint”. The defining design decision is what the kit doesn’t have: a wire channel. Objectives only advance on topics the server itself publishes, so there is no client input to validate and no way to spoof progress. The exploit surface of the whole quest system is zero bytes.

Progress persists through session.Profile when a DataDriver is attached; without one it lives in transient session data for the visit.

At init, the kit builds a topic index once: every distinct Topic across every objective maps to the list of (quest, objectiveIndex) pairs listening to it. One bus subscription per distinct topic — a hundred quests sharing Combat.Kill cost one subscriber.

When a topic fires, the kit walks every live session and every consumer of that topic:

  1. Assigned and open? Sessions without an entry for the quest, or with Done = true, are skipped. Unassigned quests never count.
  2. Does this event count for this session? Three matching modes, below.
  3. Increment. The per-objective counter goes up by exactly 1 per event (regardless of how many args matched), capped at the objective’s Count. Each increment publishes Quest.Progress.
  4. Complete? When every objective in the quest has reached its Count, the entry flips Done, Quest.Completed publishes, and OnComplete runs in its own thread — task.spawned so a throwing reward handler can’t halt counting for other sessions. Repeatable quests then immediately reset to fresh (Done = false, counters cleared).
Mode Rule
Default The event counts when any published arg is the session or its player. Note the breadth: Bus:publish("Combat.Kill", killerSession, victimPlayer) counts for the killer and the victim — both appear in the args. When involvement alone is too broad (a kill quest must not advance on being killed), use Filter to pin the arg position.
Match = value Default involvement plus one published arg must equal Match — pin a zone name, a weapon id. { Topic = "Zone.Entered", Match = "Summit" } counts ("Summit", player) and ignores ("Pit", player).
Filter = fn Full override: fn(session, topic, ...) -> boolean decides alone; the default involvement check is skipped. Required for topics that carry no actor args, like Round.PhaseChanged(name, duration) — the default check finds no session in the args and never counts. The filter must return true exactly; any other value (including truthy tables) does not count.

The progress store is resolved per event, not pinned at join:

local Data = if Session.Profile then Session.Profile.Data else Session.Data

Profile load is async. A store captured at session start would keep pointing at transient session.Data after the profile attaches — progress would silently stop persisting. Resolving late means the same code path reads and writes the right table before and after the profile arrives.

When the profile does arrive (Kernel.ProfileLoaded), pre-profile progress accumulated in session.Data merges into the persisted table: completed entries win outright, otherwise each objective keeps the higher of the transient and persisted counts. Kills earned during the load window are never lost, and a stale transient table can never regress saved progress.

local ServerScriptService = game:GetService("ServerScriptService")
local QuestKit = require(ServerScriptService.ChloeKernelServer.Kits.QuestKit)
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)
return function(kernel: any)
local Inventory = InventoryKit.attach(kernel, {
Items = { WolfPelt = { Name = "Wolf Pelt", Stack = 10 } },
})
kernel:registerService(QuestKit.service({
Quests = {
WolfCull = {
AutoAssign = true,
Objectives = { { Topic = "Combat.Kill", Count = 5 } },
OnComplete = function(session)
session.Profile.Data.Coins += 500
Inventory:grant(session, "WolfPelt", 1)
end,
},
Summit = {
AutoAssign = true,
Objectives = { { Topic = "Zone.Entered", Match = "Peak" } },
},
DailyObby = {
AutoAssign = true,
Repeatable = true,
Objectives = { { Topic = "Obby.Checkpoint", Count = 10 } },
},
-- Actor-less topic: Filter is mandatory
Survivor = {
Objectives = {
{
Topic = "Round.PhaseChanged",
Count = 3,
Filter = function(session, _topic, name)
return name == "Results" and session.Data.Alive == true
end,
},
},
},
},
}))
kernel.Bus:subscribe("Quest.Progress", function(_, player, questId, index, count, required)
print(`{player.Name}: {questId} objective {index} — {count}/{required}`)
end)
kernel.Bus:subscribe("Quest.Completed", function(_, player, questId)
print(`{player.Name} completed {questId}`)
end)
end

Rewards hook up in OnComplete — grant items through InventoryKit, currency through the profile, loot through LootKit. The callback receives the session and runs after Quest.Completed publishes.

AutoAssign quests attach to every session on join (via kernel:onSession, which also covers players already in the game when the kit registers). Everything else is explicit:

local Quests = kernel:getService("QuestKit")
Quests:assign(Session, "Survivor") -- creates a fresh entry; no-op if already assigned
Quests:abandon(Session, "Survivor") -- deletes the entry — progress is gone
local Snapshot = Quests:progress(Session, "Survivor")
-- { Done = false, Objectives = { [1] = { Count = 1, Required = 3 } } }, or nil if unassigned

assign semantics per source:

  • Already assigned and in progress → no-op. Counters are never reset by re-assigning.
  • Already Done and the quest is Repeatable → resets to a fresh entry (this is how you re-offer a daily outside the automatic post-completion reset).
  • Already Done and not repeatable → no-op. Completed one-shot quests stay completed.
  • Unknown quest id → errors. Same for progress. Quest ids are config, not input — a typo should crash loudly at the call site.

There is no “accept” handshake in the kit. If your design gates quest acceptance behind an NPC dialog or a prerequisite quest, gate the assign call in your own code — for example, only assign WolfCull2 inside WolfCull’s OnComplete, which is exactly how per-source prerequisites chain:

WolfCull = {
Objectives = { { Topic = "Combat.Kill", Count = 5 } },
OnComplete = function(session)
kernel:getService("QuestKit"):assign(session, "WolfCull2")
end,
},

The kit deliberately owns no client channel — quest UI is a render problem. Forward the bus events as state pushes:

-- Server
local Net = kernel:net()
Net:defineState("QuestProgress", { "String", "NumberU8", "NumberU16", "NumberU16" })
Net:defineState("QuestDone", { "String" })
kernel.Bus:subscribe("Quest.Progress", function(_, player, questId, index, count, required)
Net:sendState("QuestProgress", player, questId, index, count, required)
end)
kernel.Bus:subscribe("Quest.Completed", function(_, player, questId)
Net:sendState("QuestDone", player, questId)
end)
-- Client
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local NetClient = require(ChloeKernel.Net.Client)
local Net = NetClient.new()
Net:onState("QuestProgress", { "String", "NumberU8", "NumberU16", "NumberU16" }, function(questId, index, count, required)
QuestUi.update(questId, index, count, required)
end)
Net:onState("QuestDone", { "String" }, function(questId)
QuestUi.complete(questId)
end)

For the initial state on join (or after a respawn wiped the UI), answer a request with progress() snapshots. Both directions stay server-authoritative: the client only ever renders what the server counted.

Field Type Default Description
Quests { [string]: QuestConfig } required Quest id → definition.
Field string? "Quests" Key in session.Profile.Data / session.Data where entries live.
Field Type Default Description
Objectives { Objective } required All objectives must reach their Count to complete. Objectives complete independently and in any order.
AutoAssign boolean? false Assign to every session on join.
Repeatable boolean? false Reset to fresh after completing instead of staying Done (dailies).
OnComplete ((session) -> ())? Reward callback; runs in its own thread per completion.
Field Type Default Description
Topic string required Bus topic to count. The built-in catalog is the menu; your own topics work identically.
Count number? 1 Events required.
Match any? Additionally require one published arg to equal this value.
Filter ((session, topic, ...any) -> boolean)? Full override of the counting decision.
Member Description
QuestKit.service(config: KitConfig) Returns a service definition for kernel:registerService. Builds the topic index once from config.Quests.
service:assign(session, questId: string) Create a fresh entry; no-op when already assigned, reset when done and repeatable. Errors on unknown ids.
service:abandon(session, questId: string) Delete the entry. Progress is discarded; re-assigning starts from zero.
service:progress(session, questId: string) UI snapshot { Done: boolean, Objectives: { { Count: number, Required: number } } }, or nil if unassigned. Errors on unknown ids.
service:stop() Disconnects all bus subscriptions and the session callback.

Published:

Topic Args When
Quest.Progress player, questId, objectiveIndex, count, required An objective’s counter advanced by 1.
Quest.Completed player, questId Every objective reached its count. Fires once per completion; repeatable quests can fire it again after resetting.

Consumed:

Topic Why
Every distinct Objective.Topic One subscription each, built at init.
Kernel.ProfileLoaded Merges pre-profile transient progress into the persisted table (done entries and higher counts win).