Authoring Quests & Bounties
The shared quest file, boards of rotating contracts, and the older shape that still loads
Quests are authored in a shared schema the library owns, so the same file shape, the same field names and the same tooling serve quests and achievements alike. A contract on a board IS a quest: it reuses the quest groups verbatim and adds one group saying which boards it hangs on.
The shared quest file
Server/ZiggfreedCommon/Quests/<Namespace>/<Feature>/<Name>.jsonQuests/No Payload envelope and no Id field: the file IS the quest, and its id is the filename, lower-cased. The optional Name at the top is a human-readable echo and nothing reads it as authority.
YourPack.zip
├── manifest.json
└── Server/ZiggfreedCommon/
├── Quests/
│ └── MyStudio/
│ ├── Zones/Zone_Slay_Base.json
│ └── Zones/_Wilds/Trork_Trouble.json
├── QuestGenerators/
│ └── Zone_Slay.json
├── Boards/MyStudio/Daily.json
└── Bounties/MyStudio/Bounty_Boar_Hunt.jsonThat zip is the deliverable: author the tree as a folder, zip it with the manifest at the root, and drop it in your mods directory - see Building the zip.
Where a file goes, and what its id is
Folders under the type root are yours to arrange. The engine scans them recursively and, by default, they change nothing about the id - so Zones/Wilds/Trork_Trouble.json and Zones/Ashlands/Trork_Trouble.json are the same id twice, which the loader reports as a collision. A folder whose name starts with _ contributes its name to the id: drop the underscore, lower-case it, join with _.
Quests/MMOSkillTree/Zones/_Wilds/Trork_Trouble.json -> wilds_trork_trouble
Quests/MMOSkillTree/Zones/_Wilds/_Act1/Trork_Trouble.json -> wilds_act1_trork_trouble
Quests/MMOSkillTree/Zones/Trork_Trouble.json -> trork_troubleAn id is what a player's saved progress is filed under
Quests/MyStudio/...) so your files never sit beside somebody else's.The groups
{
"Name": "Forgot_Something",
"Text": { "TitleKey": "quest.forgot_something.title",
"FlavorKey": "quest.forgot_something.flavor" },
"Listing":{ "Category": "misc", "SortOrder": 199, "Tags": ["Tutorial", "Repeatable"] },
"Repeat": { "Cooldown": { "Hours": 1 } },
"Npc": { "ViewId": "Mmo_Hub" },
"Requires": { "Quests": ["getting_started"] },
"Objectives": {
"break_block": { "Kind": "BREAK_BLOCK", "Target": "", "Amount": 1,
"TextKey": "objective.text.quest.break_any" }
},
"Rewards": {
"Claim": [
{ "Kind": "Item", "Params": { "Item": "Mmo_Menu_Scroll", "Count": "1" } },
{ "Kind": "Item", "Params": { "Item": "Mmo_Quest_Book", "Count": "1" } }
]
},
"Meta": { "mmoskilltree": { "NpcOnly": true } }
}| Field | Type | Default | Description |
|---|---|---|---|
Text | group | optional | TitleKey, FlavorKey, DisplayName (an untranslated fallback only), and TextArgs - see TextArgs. |
Listing | group | optional | Category, SortOrder (low first; leave gaps of 10), Tags, Chains, Icon, plus the two visibility leaves: Hidden keeps it out of the open quest listings, and RequirePrerequisites means a giver lists it only once its requirements pass. |
Flow | group | optional | AutoAccept, AutoTrack, Sequential. Whether the reward waits to be collected is decided by which Rewards bucket it sits in, not here. |
Repeat | group | optional | Authoring the group AT ALL is what makes a quest repeatable - see Repeat. |
Npc | group | optional | ViewId (who offers it) and TurnInId (who it is handed to; defaults to the giver). |
TurnInAt | boolean or string | anywhere | Where the finished quest may be collected. Leave it out and it may be collected anywhere the game offers. Write true (or "giver") to send the player back to whoever offers it, or an npc id to send them to somebody else. |
CompletionDialogue | string (dialogue id) | none | A top-level leaf, not a group: the conversation that follows this quest settling at a character. It plays only where there is somebody to play it, so finishing from a quest log skips it. Author an empty string to drop one inherited from a Parent. |
Requires | group | optional | The shared gate - see The one Requires block. |
Objectives | map of step id to step | optional | A MAP, not an array, so a child under Parent can re-tune one step by key and inherit the rest. The key is what progress is filed under. See Objectives. A step can also watch any number the server tracks: STAT_THRESHOLD names a stat channel as its Target, which is how "reach total level 50" is written - see Shared Schemas: STAT_THRESHOLD. |
Rewards | group of two lists | optional | Two ordered lists of { Kind, Params }: Auto lands the instant the quest settles, Claim (the default) waits in the quest log until the player collects it. See the two buckets. |
Meta | map of namespace to extras | optional | Per-namespace knobs; nothing interprets a block it does not own - see Meta.mmoskilltree. |
Enabled | bool | true | False stops it being offered while leaving a player who already holds it able to finish. |
Abstract | bool | false | Marks a file that exists only to be a Parent. It is the one field that never carries down: inheriting from a skeleton makes a real quest. |
Repeat: what brings a quest back around
There is no flag to set. The block's presence says it comes back around, and what is inside says what holds it back. An empty block, "Repeat": {}, is a quest with no clock of its own, governed entirely by whatever offers it - which is exactly what a rotating board contract is.
| Field | Type | Default | Description |
|---|---|---|---|
Cooldown | {Days, Hours, Minutes, Seconds} | none | A rolling wait, written in whole units that add up: {"Hours": 24} for a day, {"Days": 7} for a week. |
CooldownFrom | "Claim" | "Complete" | Claim | Which instant that wait counts from. The default means a quest parked completed-but-unclaimed is not quietly burning its own cooldown. Write Complete for a quest belonging to a rotating offer. |
Reset | {Period, AtMinutes, Weekday, Times} | none | A calendar allowance: Daily or Weekly, plus how many minutes past the boundary it rolls over, which weekday, and how many FINISHES fit in one period (default 1). Anchored to the server clock in UTC. |
MaxCompletions | int | 0 (uncapped) | A lifetime cap on FINISHES. A player who has spent it sees the quest finished for good. |
ResetsOnComplete | string[] (quest ids) | [] | Quest ids whose progress is wiped when this one finishes, so a weekly can re-arm its dailies. |
Every leaf is an INDEPENDENT constraint and they all have to pass, so a daily that may also never be done more than fifty times is both leaves side by side, not a special mode.
Both allowances count FINISHES, not collections
ziggfreedcommon:quest_completions factor instead. Only a repeatable keeps that tally; gate a one-shot on ziggfreedcommon:quest_completed.Meta.mmoskilltree
The shared schema carries a Meta map from a namespace to whatever that namespace's owner wants to say, and nothing in the engine interprets a block it does not own - so a file authored for two mods still loads with only one of them installed. A key nothing here claims is reported by name at boot and the rest of the block still loads, so a typo costs one knob rather than the whole file.
| Field | Type | Default | Description |
|---|---|---|---|
NpcOnly | bool | false | Only let the quest be taken from its giver in person, never from the quest log. |
Feature | string[] | [] | Server feature ids that must ALL be on for the quest to be in circulation. With one off it is hidden rather than shown locked. See Feature gating. |
TextArgs: filling a written line's numbered slots
A whole ladder usually wants ONE written line - "Mine {0} ore" - with each rung supplying its own number, so the line is translated once instead of once per rung.
"Text": {
"FlavorKey": "achievement.kills_trork.desc",
"TextArgs": { "Title": [], "Flavor": ["@amount"] }
}Title fills TitleKey's slots and Flavor fills FlavorKey's, in order. Write @amount and the amount this content asks for is substituted; anything else is used exactly as typed.
Listing.Chains: ladders
A ladder lets a surface draw a whole climb as one entry instead of a row of near-identical ones. A piece of content may be a rung of several ladders at once, each with its own tier; the FIRST membership is the primary one as far as this mod's ladder UI is concerned.
"Listing": { "Chains": [ { "Id": "kills_trork", "Tier": 2 },
{ "Id": "combat_all", "Tier": 5 } ] }Reuse: native Parent inheritance
A file may name another as "Parent": "<id>" and inherit every field it does not mention. This is the engine's own inheritance, not a template DSL: there is no {{param}} substitution and no prune list.
{ "Parent": "Zone_Slay_Base", "Listing": { "SortOrder": 40 } }- Leaves and groups merge per leaf. A child authoring
Listing.SortOrderkeeps the parent'sCategory. Objectivesmerges per step id, so one step can be re-tuned.Rewards.Auto,Rewards.Claim,Listing.TagsandListing.Chainsare each ONE leaf. Author one and the inherited list is replaced whole - so a child re-authoringClaimkeeps the parent'sAutountouched.Metamerges per namespace, and a namespace you author replaces that whole block.
Write the Parent value exactly as the target file is named
"parent_id" fails to find Parent_Id.json and the child is dropped at load with a boot validation error. The verbatim spelling (the filename minus .json) works everywhere.Quest generators: one file, many quests
A Server/ZiggfreedCommon/QuestGenerators/<Name>.json writes a family of quests from one table, and each child it emits is an ordinary quest carrying Parent - so a generated quest behaves exactly like a hand-written one, and any member that later needs to be special can be written out as its own file under the same id.
{
"Base": "zone_slay_base",
"IdPattern": "{quest}",
"ForEach": [ { "Token": "quest", "Values": [
{ "quest": "wilds_trork_trouble", "giver": "Guide_Wilds", "sort": 3,
"after": "wilds_hearty_meal", "gate_stat": "MMO_TotalLevel", "gate_min": 0,
"target": "Trork", "amount": 8, "xp1_skill": "SWORDS", "xp1_amount": "800" }
] } ],
"Child": {
"Text": { "TitleKey": "quest.{quest}.title", "FlavorKey": "quest.{quest}.flavor" },
"Listing": { "SortOrder": "{sort}" },
"Npc": { "ViewId": "{giver}" },
"Requires": { "Quests": ["{after}"],
"Factors": [ { "Factor": "hytale:stat", "Param": "{gate_stat}", "Min": "{gate_min}" } ] },
"Objectives": { "main": { "Target": "{target}", "Amount": "{amount}" } },
"Rewards": {
"Claim": [ { "Kind": "Mmo_Xp", "Params": { "Skill": "{xp1_skill}", "Amount": "{xp1_amount}" } } ]
}
}
}Baseis the quest id every child inherits, so the shape lives in one ordinary (usuallyAbstract) quest file and the table carries only what varies.ForEachrows bind tokens;Childis a quest body with{token}placeholders substituted anywhere, key or value. A row value keeps its own TYPE, so write it the way the field it fills wants it (an amount unquoted, a reward parameter quoted).IdPatternnames each child. Generated ids do NOT take the_-folder prefix - the pattern is the whole id.- Adding a member is copying a row and writing its two lang lines. Changing a row's quest id starts anyone mid-quest over, for the same reason a rename does.
Switching a shipped quest off
Author its id with "Enabled": false and nothing else, either from a pack or from the owner folder mods/mmoskilltree/quests/ (which always wins). A disabled quest leaves the objective index, NPC quest lists and category listings, while the quest system itself stays on. Progress a player already made stays parked in their data and is never deleted.
// Server/ZiggfreedCommon/Quests/MyStudio/Craft_Starter_Tools.json
{ "Enabled": false }There are no Java default quests
Server/ZiggfreedCommon/Quests/MMOSkillTree/, so a server owner can read it, copy it, and override it by id like any pack's - and creating an owner file no longer costs you the built-ins. Precedence, bottom to top: the shared schema, then the older Server/MMOSkillTree/Quests pack layer, then the owner's own files under quests/.What the validator reports
/mmoconfig validate chats the aggregate audit, and the same findings are logged at boot.
| Finding | Severity | Means |
|---|---|---|
DUPLICATE_QUEST_ID | error | Two files resolve to one id. |
RESERVED_ID | error | An id contains one of | = : , #, which the progress store uses as separators. |
UNKNOWN_FACTOR_ID | warning | A Factors entry names a factor nothing answers, so the gate stays shut. |
UNKNOWN_REWARD_KIND | warning | A reward names a kind nothing pays out. |
UNKNOWN_QUEST_REF | warning | A prerequisite names a quest that does not exist here. |
UNKNOWN_GIVER_REF / UNKNOWN_TURNIN_GIVER / UNKNOWN_TALK_TARGET | warning | An NPC id nothing declares. |
UNRESOLVABLE_KILL_TARGET | warning | A kill step names a model no installed content ships. |
VISIBILITY_NOOP | warning | On the older shape only: a visibility permission or requireLevel set with hidden: false, which this mod's listings never read. Set hidden: true, or move the requirement into the quest's requirement block where every surface reads it. |
VISIBILITY_GIVER_GATED | warning | RequirePrerequisites set with Hidden: false: the giver lists it only once the requirements pass, while this mod's own listings show it from the start. |
Every NPC-id finding is a lenient warning, never an error. A character's id can be declared (a placement's identity, an identity overlay) or arrive by convention (any NPC answers to its own role id, lower-cased), and convention ids are not enumerable - so an id the validator cannot find is unverifiable, not wrong. The same leniency applies to factor and reward-kind ids, for the same reason.
Boards and bounties
A board is a posting of rotating contracts. It lives in the shared library, so the board itself and the contracts on it are two asset types under Server/ZiggfreedCommon/.
Server/ZiggfreedCommon/Boards/<ns>/<Id>.json and Bounties/<ns>/<Id>.jsonBoards/, Bounties/Filename is the id for both. The owner layer is mods/ziggfreedcommon/boards.json, which overrides a board by id, one leaf at a time.
The board file
| Field | Type | Default | Description |
|---|---|---|---|
Text | group | optional | TitleKey / FlavorKey for the board's own heading. |
Icon | string (item id) | none | |
Order | int | 0 | Sort order among boards, and the default-board tiebreak. |
Rotation | {Period, Every, OffsetMinutes, Weekday} | none | When the posting re-draws. "Period": "Daily" or "Weekly" for a calendar boundary, or "Every": { "Hours": 2 } for a rolling interval. Authoring both is a validation error rather than a silent precedence rule. |
Selection | {Type, Seed} | none | "Type": "Weighted_Random" is the shipped strategy. |
Slots | [{Difficulty, Count, Optional}] | [] | What the posting holds. Difficulty is a free content word matched case-insensitively against a contract's membership; Count defaults to 1; an Optional slot is skipped silently when it cannot be filled. A board with no Slots at all posts whatever it holds. |
Currencies | string[] (wallet ids) | none | The balances shown in the header while the player is browsing. |
Reroll | {Cost, MaxPerPeriod} | no paid reroll | Cost is the shared price object; MaxPerPeriod caps how often a player may pay it. |
Grades | {band: {TitleKey}} | library defaults | What each band is CALLED. The library ships a word for the common bands (training, easy, normal, hard, elite), so a board using those needs nothing; a band a pack invented reads as its own word until Grades names it. |
AcceptRequires | {band: Requires} | ungated | What each band takes to accept, as ordinary Requires blocks. It merges per band under Parent, so a child board raises one band's bar and keeps the rest. |
Requires | group | ungated | Who may open this board at all. |
Where | {Match, GameplayConfig, ExcludeMatch} | every world | Which worlds this board applies to. |
Enabled | bool | true | False takes the board down. |
Checked at accept, never at posting
{
"Text": { "TitleKey": "ui.bounty.board.daily", "FlavorKey": "ui.bounty.board.daily.desc" },
"Order": 0,
"Rotation": { "Period": "Daily" },
"Selection": { "Type": "Weighted_Random" },
"Slots": [
{ "Difficulty": "Training", "Count": 2 },
{ "Difficulty": "Easy" },
{ "Difficulty": "Normal", "Count": 2 },
{ "Difficulty": "Hard", "Optional": true }
],
"Currencies": ["Bounty_Token", "Life_Essence"],
"Reroll": { "Cost": { "Currencies": { "Bounty_Token": 25 } }, "MaxPerPeriod": 3 },
"AcceptRequires": {
"Normal": { "Factors": [ { "Factor": "hytale:stat", "Param": "MMO_CombatLevel", "Min": 25 } ] },
"Hard": { "Factors": [ { "Factor": "hytale:stat", "Param": "MMO_CombatLevel", "Min": 60 } ] }
}
}The contract file
A contract reuses the quest groups verbatim - Text, Listing, Objectives, Rewards (the same two-bucket group, and a contract's pay normally sits in Claim so it is collected at the board), Requires, Parent, Abstract, Enabled - and adds one group only a contract has:
| Field | Type | Default | Description |
|---|---|---|---|
Boards* | [{Board, Difficulty, Weight}] | - | Which boards this contract hangs on, at what band and what draw weight on each. It is ONE leaf as far as inheritance goes: authoring it replaces the inherited list whole, which is how a child of a shared skeleton moves boards. |
{
"Parent": "Bounty_Kill",
"Text": { "TitleKey": "quest.bounty_boar_hunt.title",
"FlavorKey": "quest.bounty_boar_hunt.flavor" },
"Boards": [ { "Board": "Daily", "Difficulty": "Easy", "Weight": 2 } ],
"Objectives": { "main": { "Target": "Boar", "Amount": 15 } },
"Rewards": {
"Claim": [
{ "Kind": "Currency", "Params": { "Currency": "bounty_token", "Amount": 75 } },
{ "Kind": "Mmo_Xp", "Params": { "Skill": "UNARMED", "Amount": 600 } }
]
}
}Five behaviours are stamped by the type and no file may author them, because each one is a bug that would otherwise be one careless file away:
| Stamped | Why no file may author it |
|---|---|
| Never handed out on its own (no auto-accept, no auto-track) | A contract exists because a board posted it; one that accepted itself would arrive from nowhere. |
| Hidden from open listings | A contract is read at its board; listing it as an open quest is a second, wrong door. The two visibility leaves a quest's Listing carries do not exist on a contract at all, so an author cannot write a no-op. |
| Repeat governed externally, clocked from FINISHING | A private cooldown outliving a posting would burn the next period's slot. |
| Collected at the accept SITE | Any board of that id answers, and nowhere else does. |
| Carried beside the quest log, not inside it | A contract is read, taken and collected at its board, so it must not spend a quest-log slot. |
The payout moment is the one thing the type leaves to the author, through the ordinary Rewards buckets: Claim parks the pay at the board it was taken from - where a contract's pay belongs, since it cannot then be lost to the board turning over - while Auto lands in the field the instant the work is done.
To take a board down without editing anybody's file, use the owner layer mods/ziggfreedcommon/boards.json: { "daily": { "Enabled": false } }. The audit runs from /zigcommerce validate, and /mmobounty validate is the alias over it. For the player-facing side, see Bounty Board.
The older shape (still read)
Everything written before 1.6.0 still loads. Server/MMOSkillTree/Quests/<id>.json, the QuestTemplates extends / params DSL, and a server owner's own mods/mmoskilltree/quests/ files all go through a compat reader that registers them into the same engine. It exists so nothing already shipped breaks, and it will die with the old format: author anything new in the shared shape above.
{
"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 } ]
}
}| Field | Type | Default | Description |
|---|---|---|---|
id* | string | - | The runtime id. A missing one logs a warning and the entry is dropped. |
titleKey / flavorKey | string | null | Localization keys. The raw displayName / description literals still parse as fallbacks but skip localization entirely. |
category / sortOrder / enabled | - | optional | The same job as the Listing group and the Enabled leaf above. |
requiresFeatures | string[] (or bare string) | [] | See Feature gating. |
autoAccept / autoTrack / sequential | bool | optional | The same job as the Flow group above. |
autoClaimRewards | bool | false | true files this quest's rewards in the Auto bucket, so they land the instant it settles; omitted or false parks the quest waiting to be collected, which is the Claim bucket. It is the older shape's spelling of the same choice. |
repeatable / cooldownSeconds | bool, long | optional | The same job as the Repeat group above. |
prerequisites | string[] or object | [] | A flat array is an AND-all of quest ids. The object form speaks the Requires vocabulary, with AnyOf for an either-or. |
objectives* | objective[] | - | An ARRAY here, keyed by each entry's own id. A quest with zero objectives and no turn-in is rejected. |
rewards | reward[] | [] | The older { "type": ..., ... } spelling; kinds translate on the way in. |
visibility | {hidden, requirePrerequisites, permission, requireLevel} | all off | |
npcViewId / turnInNpcId / completionDialogue | string | null | The same job as the Npc group, TurnInAt and CompletionDialogue above. |
tags | string[] | [] | Free-form. See the callout below about the old contract tags. |
resetsOnComplete | string[] | [] | Wipes the progress of those quests when this one completes. |
The tag-based contract form is retired
tags: ["bounty", "board:<id>", "diff:<x>", "weight:<n>"] against a board asset at Server/MMOSkillTree/BountyBoards/. That store no longer loads, and a contract is now its own file with a typed Boards membership. The web Migration Converter turns the old tags into a Boards block for you.The public packs that ship this content are the MMO Skill Bounty Pack and the MMO Skill Quest Pack; see Add-ons for install links. Quests, boards and contracts all resolve as