QuestKit
Tracks quests as declarative objectives counted off Bus topics the server publishes, with no client channel to spoof.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .QuestKit - Since
- v0.3.0
- Source
- Kits/QuestKit.luau
QuestKit tracks quests from things that happen on the server. A quest is data: a list of objectives, each counting a
Bus topic (“5 Combat.Kill”, “enter zone Summit”, “10 Obby.Checkpoint”). The kit has no network
channel. Objectives advance only on topics the server publishes, so there is no client input to validate and no way to
fake progress. AchievementKit counts topics the same way for one-time unlocks.
Progress lives in session.Profile.Data when a DataDriver is attached, and in session.Data
for the visit otherwise.
Mental model
Section titled “Mental model”When the service is created, the kit builds a topic index: every distinct objective Topic maps to the
(quest, objective) pairs that count it. It subscribes once per distinct topic, so a hundred quests counting
Combat.Kill cost one subscriber.
When a topic fires, the kit walks every live session and every consumer of that topic:
- Skip the session if the quest is not assigned or is
Done. Unassigned quests never count. - Decide whether the event counts for this session (the matching rules below).
- Add exactly 1 to the objective’s counter, up to its
Count, and publishQuest.Progress. - If every objective has reached its
Count, complete the quest: setDone, publishQuest.Completedand runOnComplete(session)on its own thread, so a throwing reward cannot stop counting for other sessions. ARepeatablequest then resets at once (Done = false, counters cleared).
Completion waits for the profile. While session.ProfilePending is true, counters still advance (in session.Data), but
no quest completes, so OnComplete never pays into data that is about to be replaced. The profile merge below completes
whatever the merged counts finish.
Matching rules
Section titled “Matching rules”| Rule | Counts when |
|---|---|
| Default | Any published argument is the session or its Player. This includes victims: Bus:publish("Combat.Kill", killerSession, victimPlayer) counts for the killer and for the victim. |
ActorArg = n |
The argument at position n is the session or its Player. Other positions are ignored. Set ActorArg = 1 on a Combat.Kill(killer, victim) objective to credit only the killer. Must be a positive integer; the service asserts it at creation. |
Match = value |
The actor rule above passes and one published argument equals value. { Topic = "Zone.Entered", Match = "Summit" } counts ("Summit", player) and ignores ("Pit", player). |
Filter = fn |
fn(session, topic, ...) returns exactly true. It replaces the actor and Match rules. Topics that carry no actor, such as Round.PhaseChanged(name, duration), need a Filter: the default rule finds no session in the arguments and never counts. |
Where progress lives
Section titled “Where progress lives”The store is resolved on every event, not pinned at join:
local Data = if session.Profile then session.Profile.Data else session.DataThe profile loads asynchronously. A store captured at session start would keep pointing at session.Data after the
profile attaches, and progress would stop persisting.
When Kernel.ProfileLoaded fires, progress gathered in session.Data merges into the profile and the transient table
is removed:
- A quest only in the transient table is copied over.
- A quest already
Donein the profile stays as it is. - A transient
Doneentry replaces an unfinished persisted one. - Otherwise each objective keeps the higher of the two counts.
The kit then completes every merged quest whose counts are all met, which runs OnComplete against the loaded profile.
Kills during the load window are kept, and a stale transient table cannot lower saved progress.
QuestKit is a service kit: add it to the server Registry.luau with its config from Game/Config. The service is
named QuestKit, so other services reach it with self.Kernel:getService("QuestKit").
--!strict-- Quests: every quest, counted off server Bus topics.
return { Quests = { WolfCull = { AutoAssign = true, -- Combat.Kill(killer, victim): credit the killer only Objectives = { { Topic = "Combat.Kill", Count = 5, ActorArg = 1 } }, OnComplete = function(session) -- Completion waits for the profile, so it exists here when a DataDriver is attached local Data = session.Profile.Data Data.Coins = (Data.Coins or 0) + 500 end, }, Summit = { AutoAssign = true, Objectives = { { Topic = "Zone.Entered", Match = "Summit" } }, }, DailyObby = { AutoAssign = true, Repeatable = true, Objectives = { { Topic = "Obby.Checkpoint", Count = 10 } }, }, Survivor = { Objectives = { { -- Round.PhaseChanged(name, duration) names no player: a Filter is required Topic = "Round.PhaseChanged", Count = 3, Filter = function(session, _topic, name) return name == "Results" and session.Data.Alive == true end, }, }, }, },}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local QuestKit = require(ServerScriptService.ChloeKernelServer.Kits.QuestKit)
return { require(script.Parent.Services.ComponentService), require(script.Parent.Services.DataService), QuestKit.service(require(script.Parent.Config.Quests)), require(script.Parent.Services.QuestFlowService),} :: { any }Rewards go in OnComplete (coins in the profile) or in a service that reacts to Quest.Completed, which can reach
other kits such as InventoryKit and LootKit.
Assign, abandon, progress
Section titled “Assign, abandon, progress”AutoAssign quests are assigned to every session through kernel:onSession, which also covers players already in the
server when the service starts. Everything else is explicit:
local Quests = self.Kernel:getService("QuestKit")
Quests:assign(session, "Survivor") -- fresh entry; nothing happens if already assignedQuests:abandon(session, "Survivor") -- deletes the entry and its progresslocal Snapshot = Quests:progress(session, "Survivor")-- { Done = false, Objectives = { [1] = { Count = 1, Required = 3 } } }, or nil if unassignedassign on a quest that is:
- already assigned and in progress does nothing. Assigning again never resets counters.
DoneandRepeatableresets it to a fresh entry, which is how a daily is offered again outside the automatic reset.Doneand not repeatable does nothing. A finished one-shot quest stays finished.- unknown raises an error, and so does
progress. Quest ids are config, so a typo should fail at the call site.
There is no accept handshake. To gate a quest behind a dialogue or a prerequisite, gate the assign call. Chains are a
service that assigns the next quest when the previous one completes:
--!strict-- QuestFlowService: quest chains.
local QuestFlowService = { Name = "QuestFlowService", Dependencies = { "QuestKit" },}
local Next: { [string]: string } = { WolfCull = "WolfCull2",}
function QuestFlowService.init(self: any, kernel: any) self.Kernel = kernelend
function QuestFlowService.start(self: any) local Quests = self.Kernel:getService("QuestKit") self.Connection = self.Kernel.Bus:subscribe("Quest.Completed", function(_topic, player, questId) local Session = self.Kernel:getSession(player) local Following = Next[questId] if Session and Following then Quests:assign(Session, Following) end end)end
function QuestFlowService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return QuestFlowServiceSyncing progress to the client
Section titled “Syncing progress to the client”The kit owns no client channel; quest UI is a presentation concern. Forward the Bus topics through State channels
declared in Game.Channels:
return Registry.define({ QuestProgress = { Kind = "State", Schema = { "String", "NumberU8", "NumberU16", "NumberU16" } }, QuestDone = { Kind = "State", Schema = { "String" } },})-- In a server service's start()self.Progress = self.Kernel.Bus:subscribe("Quest.Progress", function(_topic, player, questId, index, count, required) if player then self.Net.QuestProgress.send(player, questId, index, count, required) endend)self.Done = self.Kernel.Bus:subscribe("Quest.Completed", function(_topic, player, questId) if player then self.Net.QuestDone.send(player, questId) endend)-- In a client controller's start()self.Net.QuestProgress.on(function(questId, index, count, required) QuestUi.update(questId, index, count, required)end)self.Net.QuestDone.on(function(questId) QuestUi.complete(questId)end)For the initial state on join, answer a Request channel with progress() snapshots. The client only renders what the
server counted.
Configuration
Section titled “Configuration”QuestKit.service(config)
Section titled “QuestKit.service(config)”| Field | Type | Default | Description |
|---|---|---|---|
Quests |
{ [string]: QuestConfig } |
required | Quest id to definition. |
Field |
string? |
"Quests" |
Key in session.Profile.Data or session.Data that holds the entries. |
QuestConfig
Section titled “QuestConfig”| Field | Type | Default | Description |
|---|---|---|---|
Objectives |
{ Objective } |
required | Every objective must reach its Count. They advance independently and in any order. |
AutoAssign |
boolean? |
false |
Assign to every session on join. |
Repeatable |
boolean? |
false |
Reset to a fresh entry after completing instead of staying Done. |
OnComplete |
((session) -> ())? |
nil |
Reward callback, run on its own thread per completion. |
Objective
Section titled “Objective”| Field | Type | Default | Description |
|---|---|---|---|
Topic |
string |
required | Bus topic to count. The topic catalog lists the framework’s; game topics work the same way. |
Count |
number? |
1 |
Events required. |
ActorArg |
number? |
nil |
Only this argument position counts as the actor. |
Match |
any? |
nil |
Also require one published argument to equal this value. |
Filter |
((session, topic, ...any) -> boolean)? |
nil |
Replaces the actor and Match rules. Must return exactly true to count. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
QuestKit.service(config) -> service |
Returns a service definition named QuestKit. Builds the topic index and checks every ActorArg. |
service:assign(session, questId) |
Creates a fresh entry. Does nothing when already assigned; resets a Done repeatable quest. Errors on an unknown id. |
service:abandon(session, questId) |
Deletes the entry and its progress. Assigning again starts from zero. |
service:progress(session, questId) -> snapshot? |
{ Done, Objectives = { { Count, Required } } }, or nil if unassigned. Errors on an unknown id. |
service:stop() |
Disconnects every topic subscription, the session callback and the profile merge. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Direction | When |
|---|---|---|---|
Quest.Progress |
player, questId, objectiveIndex, count, required |
Published | An objective counter advanced by 1, including while the profile loads. |
Quest.Completed |
player, questId |
Published | Every objective reached its count and the profile is loaded. A repeatable quest can publish it again after its reset. |
Each objective Topic |
as published | Consumed | One subscription per distinct topic. |
Kernel.ProfileLoaded |
session, profile |
Consumed | Merges pre-profile progress and completes what the merge finishes. |