Quests
View SourceA quest is a counter with a target, a window and a reward. Nothing more.
counter the gameplay event it listens for, e.g. "kills"
target how many
period none | daily | weekly
reward a currency and an amount, granted on claimDeclaring quests
From a game script's init(). Defining is idempotent on quest_key, which
matters because init() runs on every match or world boot:
function init(state)
game.quests.define({
quest_key = "daily_kills",
title = "Ten kills",
counter = "kills",
target = 10,
period = "daily",
reward_currency = "gold",
reward_amount = 100
})
return state
endOr from Erlang, which is what a host with its own admin path uses:
asobi_quests:define(#{
quest_key => ~"daily_kills", title => ~"Ten kills",
counter => ~"kills", target => 10, period => ~"daily",
reward_currency => ~"gold", reward_amount => 100
}).quest_key and counter are [A-Za-z0-9_.-], at most 64 characters.
A positive reward_amount without a reward_currency is rejected: it reads as
a reward and pays nothing.
Reporting progress
Report the event, not the quest:
function on_kill(player_id, state)
local result = game.quests.progress(player_id, "kills", 1)
for _, q in ipairs(result.ok) do
if q.newly_completed then
game.broadcast("quest_complete", { player = player_id, quest = q.quest_key })
end
end
return state
endEvery active quest listening for kills advances, each in its own window. An
unwatched counter is not an error - it means no quest cares yet - so a script
can report freely and quests can be added later without touching it.
newly_completed is true exactly once per quest per window, on the report that
crossed the target. completed stays true afterwards while the counter keeps
climbing.
The increment is one INSERT ... ON CONFLICT DO UPDATE statement, so two match
processes reporting a kill in the same millisecond cannot lose one.
Periods
period | Key | Resets |
|---|---|---|
none | all | never |
daily | 2026-08-04 | UTC midnight |
weekly | 2026-W32 | ISO week, Monday 00:00 UTC |
Nothing runs at midnight. A new window resolves to a new key, finds no row and starts at zero, so a reset cannot be missed, double-applied or skewed by a restart. UTC rather than a per-deployment timezone: a boundary that moves with configuration hands every player a second daily reward on the day it moves.
target is copied onto the progress row when it is created. Retuning a live
quest from 10 to 20 does not un-complete the players who already finished it,
which would let them claim twice.
Claiming
local reward = game.quests.claim(player_id, "daily_kills")
if reward.ok then
-- reward.ok.currency, reward.ok.amount
endThe claim update is guarded (completed_at IS NOT NULL AND claimed_at IS NULL)
and the economy grant runs in the same transaction, so a failed grant cannot
leave a quest marked claimed and unpaid. A second claim is told
already_claimed without paying again, whichever call arrives first.
Failure reasons: quest_not_found, not_completed, already_claimed.
Reading state
for _, q in ipairs(game.quests.status(player_id).ok) do
print(q.quest_key, q.counter, "/", q.target, q.completed)
endOnly the player's current window is reported. A row from a window that has closed is invisible.
Over the wire
A client calls these as an rpc.call frame on the game socket. The spelling
is the SDK's, not asobi's - ws.rpc(...) in JS, rpc_call(...) in Godot,
realtime:rpc(...) in Defold and LOVE:
const { quests } = await ws.rpc("quests.list", {});
const { currency, amount } = await ws.rpc("quests.claim", { quest_key: "daily_kills" });realtime.rpc_call("quests.claim", {"quest_key": "daily_kills"}, func(ok, data):
if ok:
print("claimed ", data)
)The calling player comes from the authenticated session, never from params.
Errors carry asobi's error object with a quests.* code:
| Code | Status |
|---|---|
quests.not_found | 404 |
quests.not_completed | 409 |
quests.already_claimed | 409 |
quests.invalid_definition | 422 |
quests.invalid_params | 400 |
quests.failed | 500 |
not_ready (503) until core's migrations have finished, because the route
table compiles before they run.
Operations
Recurring quests leave one row per player per quest per window. Enqueue the
rollover worker on whatever schedule suits - it deletes rows from windows
nobody can advance or claim any more, and never touches a none quest's
lifetime record:
asobi_quests_rollover_worker:enqueue(#{retain_days => 30}).It runs on the quests queue, so give shigoto one:
{shigoto, [{queues, [{~"default", 10}, {~"quests", 1}]}]}Definitions are cached in ETS and refreshed every 60 seconds, settable:
{asobi_quests, [{refresh_interval_ms, 60000}, {retain_days, 30}]}A write through asobi_quests:define/1 or deactivate/1 invalidates
immediately; a write straight to the table does not.
Tables
quests holds definitions. quest_progress holds one row per player, quest
and window, and that row is the counter. Both foreign keys cascade: deleting a
player removes its progress, which asobi's guest reaper requires.
Removing this extension leaves both tables in place. Player progress is not destroyed by a dependency change.