A 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 claim

Declaring 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
end

Or 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
end

Every 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

periodKeyResets
noneallnever
daily2026-08-04UTC midnight
weekly2026-W32ISO 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
end

The 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)
end

Only 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:

CodeStatus
quests.not_found404
quests.not_completed409
quests.already_claimed409
quests.invalid_definition422
quests.invalid_params400
quests.failed500

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.