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.
Mental model
Section titled “Mental model”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:
- Assigned and open? Sessions without an entry for the quest, or with
Done = true, are skipped. Unassigned quests never count. - Does this event count for this session? Three matching modes, below.
- 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 publishesQuest.Progress. - Complete? When every objective in the quest has reached its
Count, the entry flipsDone,Quest.Completedpublishes, andOnCompleteruns in its own thread —task.spawned so a throwing reward handler can’t halt counting for other sessions.Repeatablequests then immediately reset to fresh (Done = false, counters cleared).
The three matching modes
Section titled “The three matching modes”| 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. |
Storage resolution
Section titled “Storage resolution”The progress store is resolved per event, not pinned at join:
local Data = if Session.Profile then Session.Profile.Data else Session.DataProfile 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)endRewards 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.
Assign, abandon, progress
Section titled “Assign, abandon, progress”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 assignedQuests:abandon(Session, "Survivor") -- deletes the entry — progress is gonelocal Snapshot = Quests:progress(Session, "Survivor")-- { Done = false, Objectives = { [1] = { Count = 1, Required = 3 } } }, or nil if unassignedassign semantics per source:
- Already assigned and in progress → no-op. Counters are never reset by re-assigning.
- Already
Doneand the quest isRepeatable→ resets to a fresh entry (this is how you re-offer a daily outside the automatic post-completion reset). - Already
Doneand 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,},Syncing progress to the client
Section titled “Syncing progress to the client”The kit deliberately owns no client channel — quest UI is a render problem. Forward the bus events as state pushes:
-- Serverlocal 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)-- Clientlocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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.
Configuration
Section titled “Configuration”QuestKit.service(config) — KitConfig
Section titled “QuestKit.service(config) — KitConfig”| Field | Type | Default | Description |
|---|---|---|---|
Quests |
{ [string]: QuestConfig } |
required | Quest id → definition. |
Field |
string? |
"Quests" |
Key in session.Profile.Data / session.Data where entries live. |
QuestConfig
Section titled “QuestConfig”| 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. |
Objective
Section titled “Objective”| 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. |
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”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). |