Skip to content

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.

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:

  1. Skip the session if the quest is not assigned or is Done. Unassigned quests never count.
  2. Decide whether the event counts for this session (the matching rules below).
  3. Add exactly 1 to the objective’s counter, up to its Count, and publish Quest.Progress.
  4. If every objective has reached its Count, complete the quest: set Done, publish Quest.Completed and run OnComplete(session) on its own thread, so a throwing reward cannot stop counting for other sessions. A Repeatable quest 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.

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.

The store is resolved on every event, not pinned at join:

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

The 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 Done in the profile stays as it is.
  • A transient Done entry 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").

src/Server/Game/Config/Quests.luau
--!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,
},
},
},
},
}
src/Server/Game/Registry.luau
--!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.

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 assigned
Quests:abandon(session, "Survivor") -- deletes the entry and its progress
local Snapshot = Quests:progress(session, "Survivor")
-- { Done = false, Objectives = { [1] = { Count = 1, Required = 3 } } }, or nil if unassigned

assign on a quest that is:

  • already assigned and in progress does nothing. Assigning again never resets counters.
  • Done and Repeatable resets it to a fresh entry, which is how a daily is offered again outside the automatic reset.
  • Done and 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:

src/Server/Game/Services/QuestFlowService.luau
--!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 = kernel
end
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()
end
end
return QuestFlowService

The kit owns no client channel; quest UI is a presentation concern. Forward the Bus topics through State channels declared in Game.Channels:

src/Shared/Game/Channels.luau
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)
end
end)
self.Done = self.Kernel.Bus:subscribe("Quest.Completed", function(_topic, player, questId)
if player then
self.Net.QuestDone.send(player, questId)
end
end)
-- 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.

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.
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.
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.
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.
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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.