Authoring Quests & Bounties
Every Quest field, bounty-board rotation, and turn-in bounty lifecycle
A quest is a per-entry pack asset with objectives and rewards. A bounty is just a repeatable quest tagged into a rotating board pool - there is no separate "Bounty" asset type. This page covers the Quest schema, then the BountyBoard schedule asset that pulls tagged quests into a rotation.
Server/MMOSkillTree/Quests/*.jsonQuests/Filename is PascalCase (a human echo only); the runtime id is the inner Payload.id, case-sensitive. Templates live in Server/MMOSkillTree/QuestTemplates/*.json.
Folder layout
YourPack.zip
├── manifest.json
└── Server/MMOSkillTree/
├── Quests/
│ └── Bounty_Cache_Copper.json
├── QuestTemplates/
│ └── Bounty_Kill_Standard.json
└── BountyBoards/
└── Daily.jsonField names are read for their shared schemas (Requirements, Cost, Reward, ObjectiveSpec) - see Shared Schemas for those tables; this page only lists the fields unique to Quest and BountyBoard.
Quest fields
| Field | Type | Default | Description |
|---|---|---|---|
id* | string | - | Read separately, not through the codec body. Rejects reserved delimiters. |
displayName | string | echoes id | Deprecated raw fallback - prefer titleKey. |
description | string | "" | Deprecated raw fallback - prefer flavorKey. |
titleKey | string | null | Localization key for the quest title. Convention: quest.<id>.title. |
flavorKey | string | null | Localization key for markdown flavor/lore text. Convention: quest.<id>.flavor. |
titleArgs / flavorArgs | string[] | [] | Ordered template args (@amount/@skill/literal) for the matching key. |
category | string | "misc" | UI grouping; built-ins sort main, daily, misc first, then custom alphabetically. |
enabled | bool | true | Raw config flag - use the quest's runtime availability check, not this alone, for gating (also folds requiresFeatures). |
requiresFeatures | string[] (or bare string) | [] | See Feature gating. |
sortOrder | int | 0 | UI sort order. |
autoAccept | bool | false | Auto-accepted for every eligible player at PlayerReady. |
autoTrack | bool | false | Auto-pinned to the tracker the moment it is accepted. |
sequential | bool | false | Legacy ordering flag - prefer per-objective order. |
repeatable | bool | false | Re-offerable after cooldownSeconds once COMPLETED. |
cooldownSeconds | long | 0 | Only relevant when repeatable is true. |
prerequisites | string[] or PrerequisiteGroup | [] | A flat array is an AND-all of quest ids. An object is a full recursive group - see PrerequisiteGroup. |
requiredAchievements | string[] | [] | Shorthand merged into the same prerequisite group as achievements. |
permission | string | null | Permission-node gate. |
requirements | object or array | optional | Legacy shorthand (minLevel/minSkill/minSkillLevel). Prefer the unified Requirements/prerequisites block on new content. |
requiresSkills | {SKILL: level} | {} | Alias-folds into the same skill-requirement set as requirements-derived entries. |
autoClaimRewards | bool | false | false parks rewards in COMPLETED_UNCLAIMED until claimed. Bounties force false regardless of this flag. |
objectives* | ObjectiveSpec[] | - | A quest with zero authored objectives and no turnInNpcId is rejected. See ObjectiveSpec. |
rewards | Reward[] | [] | See Reward. |
visibility | {hidden, permission, requirePrerequisites, requireLevel} | all false/null | hidden means never shown in the normal quest UI - bounties set this. |
tags | string[] | [] | Free-form. Bounties tag ["bounty","board:<id>","diff:<x>","weight:<n>"]. |
resetsOnComplete | string[] (quest ids) | [] | Resets those other quests' progress when this one completes. Self-reference is warned and ignored. |
variables | {name: value} | {} | Free-form key-value store, keys keep authored casing. |
npcViewId | string | null | The giver NPC's view id; also gates the quest into that NPC's listing. |
turnInNpcId | string | null | "giver" auto-appends a synthetic report-back TURN_IN objective at npcViewId (warned+skipped if no npcViewId); any other value turns in at a different NPC than the giver. Skipped if a TURN_IN objective is already hand-authored. |
completionDialogue | string | null | Dialogue id the NPC page hands off to on completion, instead of reopening the quest list. |
npcDisplayName | string | null | |
npcRequiredToAccept | bool | false | |
incompleteMarkdown / activeMarkdown / completeMarkdown | string | null | Per-state NPC-page detail markdown. |
matchMode default: CONTAINS, not EXACT
matchMode: "CONTAINS" when omitted - the opposite of achievement criteria, which default to EXACT. Always author matchMode explicitly. See ObjectiveSpec.Localization keys
Prefer titleKey/flavorKey (and, per objective, textKey) over the deprecated raw displayName/description/displayText literals - a raw literal skips localization entirely and cannot be translated by the localization pipeline. Leaving displayText unset on an objective lets the objective text renderer auto-generate a localized, pluralized sentence from type/target/amount, which is usually enough.
Template DSL
Quests support the shared extends/params template DSL (override field objectiveOverrides, extras field extraObjectives, matched by objective id). This is how the shipped bounty pack generates dozens of near-identical bounty entries from a handful of QuestTemplates/*.json skeletons. Full mechanics: Template Extension.
Minimal quest example
{
"Name": "Simple_Kill",
"Payload": {
"id": "simple_kill",
"category": "misc",
"objectives": [
{ "id": "main", "type": "KILL_ENTITY", "target": "*", "amount": 5, "matchMode": "CONTAINS" }
],
"rewards": [
{ "type": "XP", "skill": "SWORDS", "amount": 500 }
]
}
}Every-field example (real bounty template)
{
"Name": "Bounty_Gather_Standard",
"Payload": {
"category": "bounty",
"repeatable": true,
"cooldownSeconds": 79200,
"autoClaimRewards": false,
"npcViewId": "bounty_board",
"visibility": { "hidden": true },
"tags": ["bounty", "board:{{boards}}", "diff:{{difficulty}}", "weight:{{weight}}"],
"objectives": [
{ "id": "main", "type": "BREAK_BLOCK", "target": "{{target}}", "amount": 1, "matchMode": "CONTAINS" }
],
"rewards": [
{ "type": "CURRENCY", "currencyId": "bounty_token", "amount": 1 }
]
}
}{
"Name": "Bounty_Cache_Copper",
"Payload": {
"extends": "bounty_gather_standard",
"id": "bounty_cache_copper",
"params": { "boards": "daily", "difficulty": "normal", "weight": "2", "target": "Ore_Copper" },
"objectiveOverrides": { "main": { "amount": 30 } },
"rewards": [
{ "type": "CURRENCY", "currencyId": "bounty_token", "amount": 160 },
{ "type": "XP", "skill": "MINING", "amount": 1400 },
{ "type": "COMMAND", "command": "/give {player} Ingredient_Bar_Copper --quantity=8" }
]
}
}Bounty authoring: BountyBoard
The board asset is only the schedule - which tagged quests get drawn into which slots, on what cadence, and who can accept each difficulty. The bounty content itself is authored as ordinary Quest entries tagged ["bounty", "board:<id>", "diff:<x>", "weight:<n>"], npcViewId: "bounty_board", visibility.hidden: true.
Server/MMOSkillTree/BountyBoards/*.jsonBountyBoards/Filename (lowercased) is the runtime board id and the value bounty quests target with their board:<id> tag.
| Field | Type | Default | Description |
|---|---|---|---|
titleKey / descriptionKey | string | null | |
icon | string | null | |
enabled | bool | true | Per-board on/off toggle. |
order | int | 0 | Sort order and default-board tiebreak. |
rotation | object | daily interval | See Rotation, selection, slots, reroll. |
selection | object | weighted_random / period | |
slots | array | [] | Each slot's tier filter matches a bounty's diff:<x> tag (alias difficulty in a filter sub-object). |
rerollCost | object | RerollCost.NONE (no paid reroll) | |
requirements | Requirements | ungated | Who can even open this board. |
combatLevelRequirements | {difficulty: minCombatLevel} | {} | Per-difficulty accept gate, checked at accept time, not selection time - everyone sees the same board, low-level players just see locked rows. Keys are lower-cased difficulty names; an omitted difficulty is ungated. Negative/non-numeric values are warned and skipped (treated as ungated). |
Rotation, selection, slots, reroll (shared with ShopPool)
This same shape drives the Token Shop's rotating pools too - see Shop.
| Field | Type | Default | Description |
|---|---|---|---|
rotation.type | "interval" (only shipped type) | optional | |
rotation.period | "daily" | "weekly" | "<N>s" | optional | e.g. "7200s". |
rotation.anchor | "utc" | "utc_monday" | optional | |
rotation.offsetMinutes | int | optional | |
selection.type | "weighted_random" (only shipped type) | optional | |
selection.seed | "period" (only shipped seed) | optional | |
slots[].filter.tier | string | optional | Alias difficulty. Flat fields (tier/difficulty/tag directly on the slot, no nested filter) are also accepted. |
slots[].filter.tag | string | optional | |
slots[].count | int | 1 | Clamped to [1, 64]. |
slots[].optional | bool | false | Skipped silently if unfillable. |
rerollCost.currency | string (currency id) | optional | |
rerollCost.amount | long | optional | |
rerollCost.maxPerPeriod | int | 0 (unlimited) |
TURN_IN / deliver bounties and the claim-at-board lifecycle
A bounty can require the player to physically deliver items at the board rather than just complete an objective in the field:
- Author an explicit
TURN_INobjective inside the bounty quest'sobjectives[], scoped byturnInNpcId. Two shipped shapes: a plain deliver bounty is a singleTURN_INobjective against a concrete item id (theBounty_TurnIn_Standardtemplate, extended by theBounty_Deliver_*entries - the player brings items gathered elsewhere); a gather-and-deliver bounty is TWO objectives, aBREAK_BLOCKgather step plus aTURN_INdeliver step for a portion of the haul (theBounty_GatherDeliver_Standardtemplate, extended by entries likeBounty_Mine_Iron). - Because bounties force
autoClaimRewards: false, rewards park as COMPLETED_UNCLAIMED until the player opens the board again and claims them - the claim step happens at the board, not automatically on objective completion. - Deliver-style bounties reward CURRENCY and XP only by convention, not a hard engine constraint - a claimed board bounty can safely carry a
/giveitem reward (the Claim button is inventory-space-checked, the same as any other bounty), but doing so right after the player just emptied that inventory space to turn in items is awkward, so the shipped pack keeps deliver bounties CURRENCY + XP only.
Minimal board example
{
"Name": "Simple",
"Payload": {
"order": 5,
"slots": [ { "filter": { "difficulty": "normal" } } ]
}
}Every-field example (real: Daily board)
{
"Name": "Daily",
"Payload": {
"titleKey": "ui.bounty.board.daily",
"descriptionKey": "ui.bounty.board.daily.desc",
"order": 0,
"rotation": { "type": "interval", "period": "daily", "anchor": "utc", "offsetMinutes": 0 },
"selection": { "type": "weighted_random", "seed": "period" },
"combatLevelRequirements": { "normal": 25, "hard": 60 },
"slots": [
{ "filter": { "difficulty": "training" } },
{ "filter": { "difficulty": "training" } },
{ "filter": { "difficulty": "easy" } },
{ "filter": { "difficulty": "normal" } },
{ "filter": { "difficulty": "normal" } },
{ "filter": { "difficulty": "hard" }, "optional": true }
],
"rerollCost": { "currency": "bounty_token", "amount": 25, "maxPerPeriod": 3 }
}
}The public pack that ships this content is the MMO Skill Bounty Pack and the MMO Skill Quest Pack; see Add-ons for install links. Precedence for both Quest and BountyBoard content resolves as
mods/mmoskilltree/ always win.