Quests with ordered stages, objectives that run side by side, and rewards to claim.
Import blitz.quests As Quests gives your program these, with Quests. in front. Each name leads to its part of the reference.
LastError:Str()ParseQuests:QuestBook(document:Int)LoadQuests:QuestBook(file:Str)CreateQuestLog:QuestLog(book:QuestBook, eventCapacity:Int = 1024)HasQuest:Int(log:QuestLog, quest:Str)QuestCount:Int(log:QuestLog)QuestIdAt:Str(log:QuestLog, index:Int)QuestState:Int(log:QuestLog, quest:Str)ObjectiveProgress:Progress(log:QuestLog, quest:Str, objective:Str)StartQuest:Int(log:QuestLog, quest:Str)NotifyQuest:Int(log:QuestLog, event:Str, subject:Str = "", amount:Int = 1)SetObjectiveValue:Int(log:QuestLog, quest:Str, objective:Str, value:Int)FailQuest:Int(log:QuestLog, quest:Str, reason:Str = "")QuestReward:Reward(log:QuestLog, quest:Str)AcknowledgeReward:Int(log:QuestLog, quest:Str, claimId:Str)NextEvent:QuestEvent(log:QuestLog)CaptureQuests:Int(log:QuestLog)RestoreQuests:QuestLog(book:QuestBook, snapshot:Int)QuestBookProgressValue:Int(), Target:Int()RewardClaimId:Str(), Count:Int(), EntryId:Str(index:Int), Amount:Int(index:Int)QuestEventKind:Int(), Quest:Str(), Objective:Str(), Stage:Int(), Value:Int(), Reason:Str()QuestLogStateLocked, Available, Active, Completed, Failed, RewardedEventKindObjectiveChanged, StageChanged, QuestCompleted, QuestFailedQuests keep track of what the player has been asked to do, and how far they've got. A quest has stages that come one after another, and each stage has objectives, such as visiting a camp, picking two herbs or defeating a sentinel. Your game tells the quest log what happens as the player plays, and the log works out which objectives that counts towards, when a stage is done, and when a quest is finished and its reward is due.

Open journal.bb and run it. Press the letters it shows to do each thing. Try H before V: the herb doesn't count yet, because picking herbs is the second stage.
; A quest journal. Press the letters it shows to visit the camp, pick herbs,
; defeat the sentinel and so on. Escape quits.
Dialect "modern"
Import blitz.quests As Quests
; Draws one objective as a bar that fills up, with its name beside it.
Function DrawObjective(log:Quests.QuestLog, quest:Str, objective:Str, label:Str, y:Int)
Local view:Quests.Progress = Quests.ObjectiveProgress(log, quest, objective)
Color 40, 90, 140
Rect 40, y, 120, 16, True
Color 90, 200, 120
Rect 40, y, 120 * view.Value() / view.Target(), 16, True
Color 255, 255, 255
Text 175, y + 1, label + " " + view.Value() + "/" + view.Target()
End Function
Graphics 640, 290, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79
; The quest book comes with the module's examples, in the folder beside this one.
Local book:Quests.QuestBook = Quests.LoadQuests("../examples/level.json")
If book = Null Then RuntimeError Quests.LastError()
Local log:Quests.QuestLog = Quests.CreateQuestLog(book)
Quests.StartQuest log, "supplies"
Local coins:Int
Local message:Str
While Not KeyDown(1)
; Tell the log what the player did. Only objectives under way count.
If KeyHit(47) Then Quests.NotifyQuest log, "visit", "camp" ; V
If KeyHit(35) Then Quests.NotifyQuest log, "collect", "herb" ; H
If KeyHit(32) Then Quests.NotifyQuest log, "defeat", "sentinel" ; D
If KeyHit(37) Then Quests.NotifyQuest log, "talk", "keeper" ; K
If KeyHit(33) Then Quests.NotifyQuest log, "collect", "key" ; F
; The gate opens once supplies is done, but only starts when you say so.
If Quests.QuestState(log, "gate") = Quests.State.Available Then Quests.StartQuest log, "gate"
; Pay each finished quest's reward, then tell the log it has been paid.
Local index:Int
For index = 0 To Quests.QuestCount(log) - 1
Local quest:Str = Quests.QuestIdAt(log, index)
Local reward:Quests.Reward = Quests.QuestReward(log, quest)
If reward <> Null
Local entry:Int
For entry = 0 To reward.Count() - 1
If reward.EntryId(entry) = "coins" Then coins = coins + reward.Amount(entry)
Next
Quests.AcknowledgeReward log, quest, reward.ClaimId()
EndIf
Next
; The log keeps a note of each change. Read the notes, and delete them.
Local note:Quests.QuestEvent = Quests.NextEvent(log)
While note <> Null
If note.Kind() = Quests.EventKind.QuestCompleted Then message = "Finished: " + note.Quest()
Delete note
note = Quests.NextEvent(log)
Wend
Cls
Color 255, 220, 0
Text 20, 20, "Supplies"
Text 20, 135, "The gate"
DrawObjective log, "supplies", "visit", "First, visit the camp (V)", 45
DrawObjective log, "supplies", "herbs", "Then pick two herbs (H)", 70
DrawObjective log, "supplies", "sentinel", "and defeat the sentinel (D)", 95
If Quests.QuestState(log, "gate") = Quests.State.Locked
Color 160, 170, 190
Text 40, 160, "Opens when supplies is done"
Else
DrawObjective log, "gate", "talk", "Talk to the keeper (K)", 160
DrawObjective log, "gate", "key", "or find the key (F)", 185
EndIf
Color 255, 255, 255
Text 20, 235, "Coins: " + coins
Text 20, 260, message
Flip
Wend
Delete log
Delete book
End
The game never tells the log which objective has moved on. It only says what
happened, such as "collect", "herb", and the log counts that towards every
objective under way that is waiting for it. When supplies is finished, its
reward is due, and the gate quest, which needs supplies done first, becomes
available. The log never hands out rewards itself: your game pays them, then
says so with AcknowledgeReward.
The program loads its quests from level.json, one of the module's examples.
Your game has a file of its own, listing every quest:
{
"schema": 1,
"id": "my-game",
"revision": 1,
"quests": [{
"id": "supplies",
"stages": [
{"mode": "all", "objectives": [
{"id":"visit", "event":"visit", "subject":"camp", "required":1}
]},
{"mode": "all", "objectives": [
{"id":"herbs", "event":"collect", "subject":"herb", "required":2},
{"id":"shell", "event":"collect", "subject":"shell", "required":1, "optional":true}
]}
],
"rewards": [{"id":"coins", "amount":25}]
}, {
"id": "gate",
"prerequisites": ["supplies"],
"stages": [
{"mode": "any", "objectives": [
{"id":"talk", "event":"talk", "subject":"keeper", "required":1},
{"id":"key", "event":"collect", "subject":"key", "required":1}
]}
]
}]
}
event and a subject, the same two words your
game passes to NotifyQuest, and needs it to happen required times.mode is all when it needs every objective, or any when one
will do. optional objectives never hold a stage up.prerequisites lists quests that must be finished before this one can start.rewards are names and amounts. The module doesn't know what coins are:
your game decides what each reward gives.schema is always 1. revision is a number of your own, which saves remember.
The module checks the file carefully: a misspelt field, an unknown quest in
prerequisites or a circle of quests that each wait for the next stops it
loading, and LastError says what's wrong.
A quest is available once its prerequisites are finished, but it only starts when you start it.
If Quests.QuestState(log, "gate") = Quests.State.Available
Quests.StartQuest log, "gate"
EndIf
Pass the number as well. A count stops at what the objective needs.
Quests.NotifyQuest log, "collect", "herb", 3
Make the objective a value objective, with "mode":"value", and tell it how
many the player has, every frame or whenever that might have changed. It can go
down as well as up until its stage is done. NotifyQuest never changes it.
{"id":"stock", "event":"inventory", "subject":"herb", "required":3, "mode":"value"}
Quests.SetObjectiveValue log, "pantry", "stock", herbsInBag
The log lists every quest in the book, in order. Read an objective's progress from its view, which is lent to you: don't delete it.
Local row:Int
For row = 0 To Quests.QuestCount(log) - 1
Local name:Str = Quests.QuestIdAt(log, row)
If Quests.QuestState(log, name) = Quests.State.Active Then Text 10, 10 + row * 20, name
Next
Local herbs:Quests.Progress = Quests.ObjectiveProgress(log, "supplies", "herbs")
Text 300, 10, "Herbs: " + herbs.Value() + " of " + herbs.Target()
Only a quest under way can fail. It stays failed, with its progress kept as it was, and the note the log leaves says why.
Quests.FailQuest log, "fragile", "The escort was lost"
Save the log in the same file as the rewards it has paid out, here the coins, so that a reward can never be paid twice or lost.
Local saved:Int = CreateJsonObject() Local part:Int = Quests.CaptureQuests(log) JsonObjectSet saved, "quests", part FreeJson part part = CreateJsonInteger(coins) JsonObjectSet saved, "coins", part FreeJson part If Not SaveJsonAtomic(saved, "journal.json") Then Print "Saving failed" FreeJson saved
RestoreQuests makes a new log from the saved part. Swap it in only when it
has worked.
Local loaded:Int = LoadJson("journal.json")
If loaded
Local questPart:Int = JsonObjectGet(loaded, "quests")
Local coinPart:Int = JsonObjectGet(loaded, "coins")
Local restored:Quests.QuestLog = Quests.RestoreQuests(book, questPart)
If restored <> Null And coinPart <> 0
Delete log
log = restored
coins = Int(JsonInteger(coinPart))
Else
Delete restored
EndIf
FreeJson questPart
FreeJson coinPart
FreeJson loaded
EndIf
A save still loads after you change the book, such as adding quests or changing rewards, unless the change removes or no longer fits progress the player had made.
Blip's tip
Something only counts for objectives that are under way. A herb picked before the quest starts, or before the stage that wants it, never counts. If the player might already have what a quest asks for, use a value objective and tell it how many they have.
Quests.HasQuest first.NotifyQuest returns False, until you read
them. Give CreateQuestLog a bigger number if you need to.Three more complete programs come with the module:
The reference below has every function and rule.
An unpublished Extended source library for semantic quest facts, ordered stages, parallel objectives and recoverable reward claims. No required module dependency, engine polling, callback dispatch, hidden clock or network service.
Blitz3D+ comes with this module, so any Extended program can Import blitz.quests.
It needs a compiler/runtime with object-lifecycle-v1 (runtime 1114 or newer;
module ABI 5).
Open examples/parcel.bb and run it. Its quest book is parcel.json, beside
it in the examples folder.
The example starts a delivery quest, sends its completion fact, adds five actual
coins to a game-owned wallet, records the claim ID, and acknowledges the reward.
Change amount in parcel.json to change the payment.
Dialect "modern"
Import blitz.quests As Quests
Local book:Quests.QuestBook = Quests.LoadQuests("parcel.json")
If book = Null Then RuntimeError Quests.LastError()
Local log:Quests.QuestLog = Quests.CreateQuestLog(book)
If Not Quests.StartQuest(log, "delivery") Then RuntimeError "Cannot start delivery"
If Not Quests.NotifyQuest(log, "deliver", "parcel") Then RuntimeError Quests.LastError()
Local reward:Quests.Reward = Quests.QuestReward(log, "delivery")
Print reward.ClaimId()
; Apply all reward entries and record this claim in your recipient, then:
; Quests.AcknowledgeReward log, "delivery", reward.ClaimId()
; See parcel.bb for the complete wallet implementation and event draining.
Delete log
Delete book
End
Other complete examples use the same shared/ directory:
island.bb: generated 2D level. Arrow keys move, Space defeats the sentinel or
talks to the keeper/cook, S captures the whole game and L restores it, F fails
the escort, Escape exits. Visit camp, collect two herbs and defeat the sentinel
in a parallel all stage; then talk to the keeper or collect the key in an
any stage. The pantry explicitly initializes current stock. Three rewards
really add 42 coins. A partial restore restores pickups and position as well as
quests and wallet; it cannot create a duplicated pickup.claim_recovery.bb: three file save/load boundaries demonstrate a real coin
reward before application, after application/before acknowledgement, and after
acknowledgement. Reapplying a recorded claim succeeds without adding coins.
A RewardRecipient interface is game-owned; another implementation can grant
equipment or experience without changing this library.All entries terminate with --verify. Island verification drives the same
interaction functions, checks partial restore and rewards, renders nine frames,
and cleans up. Claim recovery writes a uniquely named temporary JSON file and
removes it. The island's S/L snapshot is in memory. File examples use runtime
atomic file replacement.
{
"schema": 1,
"id": "journey",
"revision": 1,
"quests": [{
"id": "supplies",
"prerequisites": [],
"stages": [{
"mode": "all",
"objectives": [
{"id":"herbs", "event":"collect", "subject":"herb", "required":2},
{"id":"visit", "event":"visit", "subject":"camp", "required":1},
{"id":"shell", "event":"collect", "subject":"shell", "required":1, "optional":true}
]
}],
"rewards": [{"id":"coins", "amount":25}]
}]
}
schema is exactly 1; revision is a positive Int. Book/quest/objective/reward IDs
are case-sensitive, 1–64 ASCII letters, digits, _ or -. There are 1–100000
quests, 1–1024 stages per quest and 1–1024 objectives per stage. Objective IDs
are unique across all stages of a quest. Event names are 1–128 characters and
subjects 0–128 characters. Missing subject means the exact empty subject; it is
not a wildcard. Counts and reward amounts are integers in 1–2147483647.
prerequisites, rewards, subject, mode on objectives and optional may be
omitted. Unknown fields, wrong JSON kinds, duplicates, unknown prerequisites,
cycles and impossible thresholds are rejected with context in LastError().
Use the runtime parser's JSON rules; the library validates the parsed document.
Stages run in order. Within a stage, every objective is active in parallel.
all requires every non-optional objective; any requires at least one
non-optional objective. Optional objectives neither block nor satisfy a stage.
A stage must contain a non-optional objective. Leaving a stage freezes all its
counts, including optional and unchosen objectives. An optional final objective
therefore needs to happen before, or in the same notification as, completion.
Objectives default to mode: "count". A mode: "value" objective is updated only
by SetObjectiveValue; NotifyQuest ignores it. Its required event/subject
fields remain descriptive metadata. Value mode supports explicit current stock,
including decreases while its stage is still active. Setting a future/terminal
objective returns False. A completed stage never reopens.
Prerequisites are ANDed and satisfied by Completed or Rewarded quests. Availability changes automatically, but starting is always explicit. Failed quests and their progress remain frozen. Duplicate semantic notifications count again; the game must deduplicate repeated delivery of the same fact when its event source requires that policy. Notifications sent before activation do not count.
Programming errors (Null/deleted resources, unknown gameplay IDs, negative values,
invalid capacity or wrong mode) raise RuntimeError. Check quest IDs that come
from data, such as dialogue payloads or mods, with HasQuest first; it returns
False for an unknown ID. Definition/snapshot/file failures return Null and set
LastError(). Gameplay mutations return Int success: False plus empty LastError
is a normal unavailable/terminal/wrong-claim result; False plus a diagnostic means
a recoverable queue capacity rejection. Copy the error before the next fallible
call. Queries and CreateQuestLog do not change it.
| Operation | Contract and example |
|---|---|
LoadQuests(file) | Own returned book; Null/LastError on file or schema failure. LoadQuests("quests.json"). |
ParseQuests(document) | Borrow JSON only for this call; copy/compile definitions. ParseQuests(generatedDocument), then FreeJson generatedDocument. |
CreateQuestLog(book, eventCapacity=1024) | Own log, borrow immutable book. Capacity 1–1000000. Two logs sharing a book are independent. |
HasQuest(log,quest) | True when the book defines this exact, case-sensitive ID; False otherwise, never an error. If Quests.HasQuest(log, payload) Then Quests.StartQuest log, payload. |
QuestCount(log) / QuestIdAt(log,index) | List quest IDs in book order for a journal: indices 0 to QuestCount(log)-1; other indices are errors. |
StartQuest(log, quest) | Available -> Active; queues StageChanged(0). StartQuest(log,"supplies"). |
NotifyQuest(log,event,subject="",amount=1) | Exact semantic match; non-negative increments cap safely. Zero/unmatched succeeds without events. NotifyQuest(log,"collect","herb",2). |
SetObjectiveValue(log,quest,objective,value) | Set/cap a value-mode objective in its active stage. SetObjectiveValue(log,"pantry","stock",3). |
QuestState(log,quest) | One of State.Locked, Available, Active, Completed, Failed, Rewarded. |
ObjectiveProgress(log,quest,objective) | Borrow immutable Progress; view.Value() and view.Target() read the capped count and threshold. |
FailQuest(log,quest,reason="") | Freeze active quest, queue QuestFailed. Reason at most 1024 characters. No automatic restart, refund or reward. |
QuestReward(log,quest) | Borrow Reward only in Completed; otherwise Null. ClaimId(), Count(), EntryId(i), Amount(i) read it. Indices are zero-based. Query never consumes. |
AcknowledgeReward(log,quest,claimId) | Completed -> Rewarded only for matching claim, after recipient success. Matching duplicates succeed. Wrong/stale/empty IDs fail normally. |
NextEvent(log) | Transfer oldest QuestEvent, or Null. Caller deletes it. Kind(), Quest(), Objective(), Stage(), Value(), Reason() read scalar copies. |
CaptureQuests(log) | Own returned JSON, release with FreeJson. Pure capture; no file write, event drain or reward application. |
RestoreQuests(book,snapshot) | Borrow JSON during validation. Return new caller-owned log borrowing book; Null/LastError leaves existing logs intact. Accepts saves from other revisions of the same book; see below. |
LastError() | Main-thread last fallible-operation diagnostic, copied as a string. |
Read a view into a typed local before calling its methods. The current compiler does not support chaining a method directly after a module function call. There are no duplicate method/global spellings or public mutable completion flags.
Each log maintains a reverse Map from an unambiguous event/subject key to only
active count objectives. Notification planning reads that index without changing
it. It records only changed objectives and affected quests; cached required-count
counters resolve all/any without scanning inactive definitions. Only after
planning ends can new stages enter the index. Thus one talk fact cannot advance
several ordered talk stages. Unmatched dispatch is one Map lookup, with no
transaction allocation. Matching dispatch costs O(matched active objectives +
affected quests), plus index removal/addition when stages actually change. Lists
preserve order; removal from a heavily shared bucket has a linear list cost.
No wall clock or built-in random stream drives progress.
Preflight counts the entire event batch before mutating anything. Queue exhaustion returns False with LastError, retaining counts, stages, index, claims and original queued events. Drain and retry the same semantic fact. Choose a capacity at creation that can fit the largest expected notification even when empty; draining cannot make a six-event batch fit a five-slot queue. The game must also avoid committing its own side effect until the notification succeeds, or keep a retry outbox. Allocation failure/runtime faults are not recoverable queue backpressure; no out-of-memory transaction guarantee is claimed.
Event order: changed objectives in active-index insertion order, then for each affected quest in first-match order its StageChanged and, if final, QuestCompleted. StartQuest emits StageChanged(0); completion's stage value is the stage count. Objective events contain the destination count. FailQuest emits QuestFailed with the reason. Other irrelevant event fields are empty/zero. All events become visible after state commit. No acknowledgement event is needed.
There are no callbacks or reentrant library phases. A game processing a transferred event can notify, fail or delete its log; the transferred event survives. Do not delete borrowed views. All operations are main-thread only.
A new log gets one runtime UUID. A claim is logUUID:bookId:questId; it is derived
at completion and preserved by restore. Separate fresh logs do not share claims.
Clones restored from the same snapshot deliberately represent the same saved
player/quest instance and retain its claims. Acknowledgement is not reward delivery.
The game applies all declarative entries to its recipient only after checking
that every entry is supported and fits. It records the claim in the recipient's
ledger in the same game transaction. Retry checks the ledger before application.
Save recipient balances, ledger, quest log and relevant world state in one
atomic game snapshot. Never save between changing a balance and recording its
claim. Saving those systems independently cannot be made atomic by this module.
The examples demonstrate these rules with a real wallet and the game-owned
RewardRecipient.Apply interface; the library does not call it.
Snapshot schema 2 stores the book ID and revision, log UUID, queue capacity and, for each quest by ID, its state, stage, failure reason, claim and objective counts keyed by objective ID. Restore requires the same book ID and schema 2; schema 1 snapshots from earlier previews are rejected. The revision is recorded but need not match, so saves survive book edits: appending or reordering quests, editing quests the player has not started, and changing rewards. A quest added since the save starts fresh (Locked, or Available once its prerequisites are met); a new objective starts at zero. A removed quest or objective is ignored only if it had no saved progress; otherwise restore fails and names it.
Restore then validates every saved quest against its current definition:
counts within thresholds, earlier/current/future stage consistency, terminal
stage and claim, and the prerequisites of started quests. A save that the edited
rules make invalid, for example a completed quest that gained a stage or a count
above a lowered threshold, fails with a LastError() naming the quest and, where
relevant, the objective. Reward entries are read from the current book. When a
revision changes what saved progress means, compare the snapshot's revision
yourself and migrate the JSON before restoring; there is no migration API in v1.
Snapshots are validation boundaries, not signatures against malicious save edits.
Pending events are transient and omitted: restore never reemits old completions. Drain events before capture if they require durable game effects, and include those effects in the combined snapshot. Ready rewards remain queryable after restore even though completion events are not replayed. Capture does not mutate the current queue. Stable progress and claim IDs do not depend on notification history timing; a game-owned timer can send a semantic fact when it expires.
| Value | Owner / lifetime |
|---|---|
| QuestBook | Caller; owns all compiled definitions and maps. Delete only after its logs. Wrong-order deletion is a programming error. |
| QuestLog | Caller; owns progress/reward views, active index, untransferred events and containers. Borrows book. |
| Progress / Reward | Borrowed, read-only until next log mutation or deletion; never Delete. Copy scalar values/claim IDs if retaining. |
| QuestEvent from NextEvent | Caller; Delete after processing. Survives log and book destruction. |
| Captured JSON | Caller; FreeJson. Source JSON and restored snapshot are never retained. |
Delete releases resources only. It never fails a quest, acknowledges a reward, undoes game rewards, writes a save or commits a game transaction. Those actions stay explicit. There is no separate Stop or Cancel operation because this release has no running callbacks/jobs or reversible quest instance; use FailQuest for terminal gameplay failure. No Free/Close alias hides the normal Delete contract.
Repeatable quest instances, automatic starts, timer objectives, workers, engine/inventory polling, migration and package-specific adapters are deferred. Inventory/dialogue/save integrations remain game-owned and add no dependency to this core. Prerequisite graph validation uses an iterative Kahn traversal, so long chains cannot overflow a recursive call stack.
All new source uses Zlib, copyright 2026 Simon Harrison. See license.txt and
provenance.md; no binary assets or cancelled implementation were used.