quiver_achievement 0.2.0
Achievements for Starknet games: definitions on chain, progress reported as events, tiers derived by an indexer. No Dojo.
Achievements for Starknet games, reported as events: tasks, tiers, definitions kept on chain, progress derived by an indexer. Pure Cairo and Starknet, no Dojo. The accepted API is ARC-01 §3.10 and §3.11, as amended by the decision of 2026-09-29.
An achievement has:
start and end in seconds (0 = open on that side);Tiers are separate achievements on one task: a title of three tiers is three achievements on the same task, with three targets. Any number of achievements may use a task.
quiver_achievement has one mode: events (0.1.0 and 0.2.0).
progress and progress_many emit one
AchievementProgressed { player_id, task_id, count } per task with a non-zero count, and read
and write nothing else: no per-player storage, no completion, no claim, no hook.AchievementDefined, AchievementRetired and
AchievementProgressed (below).There is no storage mode, and nothing in the package lets a consumer ask for one. There is no
Mode type and no mode parameter, no per-player record, no completion event and no claim
entrypoint: the code that would do it does not exist, so a consumer cannot reach it by
configuration or by a call. The design accepted at A-G1 had a storage mode whose worst call is
about 200 M L2 gas, ten times the project's cap; it is not published.
A storage design is planned for a later version: per-task counters, one packed slot per (player, task), with the tiers as thresholds on that count (option (b) of the decision). It will come when a consumer needs an achievement that a rule of its contract reads. Its layout is not reserved: no storage member, key or bit is set aside for it, and a later version may add members of its own.
Since 0.2.0 the package is organised as the owner's rule D-143 says (docs/CAIRO.md §7), as
quiver_quest 0.2.0 is: the layout of the Arcade packages, without Dojo.
quiver_achievement::models
One file per stored entity: its struct (all in models::index), its constructor and behaviour (...Trait), its checks (...Assert), its errors, its storage (the slot types and their packing), its Tracked impl when it has an event
quiver_achievement::events
One file per event (the structs in events::index), each with its new
quiver_achievement::types
The value types that are not stored on their own: AchievementWindow, AchievementTask, TaskProgress and the batch
quiver_achievement::helpers
What belongs to no entity: bits, the powers of two of the packings
quiver_achievement::store
The only access to storage: get_x, set_x per model on the component's state; Tracked, AchievementTracking and its two ready choices
quiver_achievement::component::AchievementComponent
The Starknet component: storage, the hook, the trusted internal layer, the optional external ABI with its access control
quiver_achievement::interface
IAchievement and IAchievementView, with their dispatchers
quiver_achievement::errors, quiver_achievement::constants
The error strings (API) and the bounds
The models.
Model Key Slot Tracked, withAchievementDefinition { id, window, tasks, points }
id
A (window, first task, points), B (second and third tasks, only for 2 or 3)
AchievementDefined
AchievementStatus { id, defined, retired }
id
A (the status bits), shared with the definition: read with it in one read, written back as the whole of A
no
AchievementReporter { reporter, allowed }
reporter
the registry
AchievementReporterSet
Progress is not a model: nothing of it is stored. AchievementProgressed and
AchievementRetired are action events: the component emits them where 0.1.0 does, whatever the
consumer tracks. The slot types are named for their slots: HeadSlot (A), TasksSlot (B).
The unit tests of a module are in its file, under #[cfg(test)] mod tests (D-167): they are not
compiled into a consumer's build. tests/ holds what deploys a contract.
A tracked model's event is optional: whether a write emits it is the consumer's choice, made at
compile time, by its impl of quiver_achievement::store::AchievementTracking, one constant per
tracked model:
define emits AchievementDefined
set_reporter emits AchievementReporterSet
quiver_achievement::store::tracking::TrackAll (as 0.1.0)
yes
yes
quiver_achievement::store::tracking::TrackNone
no
no
An impl of its own (const DEFINITION: bool = true; const REPORTER: bool = false;)
as it says
as it says
The constant is folded by the compiler: a write the consumer does not track costs exactly the
write with no event code, and a tracked one the write plus the event (measured to the unit,
GAS.md). An indexer that derives the tiers reads the definitions from
AchievementDefined: it needs the definition tracked. AchievementProgressed is emitted
whatever the choice: in event mode it is the only record of progress.
Embed the component, implement AchievementHooksTrait (one function, authorize_admin), choose
what to track, and choose what to expose. This consumer, like Grim World's, tracks every model,
exposes only the views and calls the internal layer from its own entrypoints, after its own
checks:
#[starknet::contract]
mod Game {
use quiver_achievement::component::AchievementComponent;
use quiver_achievement::types::batch::TaskProgress;
use quiver_achievement::types::task::AchievementTask;
use quiver_achievement::types::window::AchievementWindow;
use starknet::storage::StoragePointerReadAccess;
use starknet::{ContractAddress, get_caller_address};
component!(path: AchievementComponent, storage: achievement, event: AchievementEvent);
#[abi(embed_v0)]
impl AchievementViewImpl = AchievementComponent::AchievementViewImpl<ContractState>;
impl AchievementInternalImpl = AchievementComponent::InternalImpl<ContractState>;
// The indexer reads AchievementDefined: every tracked model emits, as 0.1.0
impl AchievementTracking = quiver_achievement::store::tracking::TrackAll<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
achievement: AchievementComponent::Storage,
results: ContractAddress,
admin: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
AchievementEvent: AchievementComponent::Event,
}
impl AchievementHooks of AchievementComponent::AchievementHooksTrait<ContractState> {
// Used by the external AchievementImpl only, which this consumer does not embed
fn authorize_admin(
self: @AchievementComponent::ComponentState<ContractState>, caller: ContractAddress,
) -> bool {
false
}
}
#[external(v0)]
fn define_title_tier(
ref self: ContractState, achievement_id: u32, task_id: u32, total: u32, points: u16,
) {
assert(get_caller_address() == self.admin.read(), 'not admin');
let window = AchievementWindow { start: 0, end: 0 };
self.achievement.define(achievement_id, window, array![AchievementTask { task_id, total }].span(), points);
}
#[external(v0)]
fn submit_results(ref self: ContractState, player_id: felt252, progress: Span<TaskProgress>) {
assert(get_caller_address() == self.results.read(), 'not results');
// Aggregated by task id, at most MAX_ENTRIES entries: one call per player per transaction
self.achievement.progress_many(player_id, progress);
}
}
A consumer that wants the access-checked ABI embeds AchievementImpl as well:
#[abi(embed_v0)]
impl AchievementImpl = AchievementComponent::AchievementImpl<ContractState>;
What the indexer computes. For a player and an achievement:
AchievementDefined: its tasks and targets, its window, its points.count of the player's AchievementProgressed on that
task, counting an event only when its block's timestamp is in the window (start <= time and
end == 0 || time < end, as WindowTrait::is_active states) and before an
AchievementRetired of the achievement; each sum saturated at the task's target.The package checks neither windows nor retirement on progress: it reads no definition there, which is what keeps a progress call's cost independent of the achievements defined. A task no achievement uses is still emitted.
Counts are increments. The consumer reports what happened, not a total. A counter of
distinct things (design/13 T-2) reports count = 1 only when a new thing is counted; a track
that resets keeps its best tier by reporting only the increments above the best value reached.
AchievementImpl (external)
Through InternalImpl
define, retire, set_reporter
authorize_admin(caller), or 'Achievement: not admin'
Nothing is checked
progress, progress_many
A registered reporter (set_reporter), or 'Achievement: not reporter'
Nothing is checked
The internal layer is trusted. InternalImpl checks no caller: it is for the consumer's own
entrypoints, which make their own checks first. It is not reachable from outside unless the
consumer writes an entrypoint that calls it; embedding AchievementViewImpl alone exposes only
views. The consumer decides who its admin is, in authorize_admin. A reporter is revoked with
set_reporter(reporter, false) and refused from the next call. assert_reporter(caller) is
there for a consumer that wants the reporter registry on its own entrypoints.
Progress never comes from a client. Anyone who can call a progress entrypoint can emit
progress for any player_id, and an indexer trusts these events: register only the contracts
that cause the progress.
define refuses, in this order: 'Achievement: invalid id' (id 0), 'Achievement: invalid window' (end != 0 and end <= start), 'Achievement: invalid tasks' (none, more than 3, a task
id 0, a target 0, a task repeated), 'Achievement: already defined' (retired or not).
retire refuses 'Achievement: does not exist' and 'Achievement: retired'; it sets the
retired bit and emits AchievementRetired. A retired achievement cannot be defined again; its
definition stays readable, with retired set.
achievement_definition(id) returns slot A (HeadSlot: the window, the first task, points,
defined, retired) and the tasks, or reverts 'Achievement: does not exist': a slot never
written reads as undefined. Since 0.2.0 the struct carries points, one more felt in the output.
Achievement_definitions
achievement_id
Slot A, one felt: start [0, 64) · end [64, 128) · task_count [128, 130) · defined [130] · retired [131] · t0.task_id [132, 164) · t0.total [164, 196) · points [196, 212) (since 0.2.0); [212, 252) reserved
Achievement_extra_tasks
achievement_id
Slot B, one felt, only for 2 or 3 tasks: t1 [0, 64) · t2 [64, 128); [128, 252) reserved
Achievement_reporters
reporter address
bool
No member is keyed by a player. A single-task achievement, every tier of a title, is one slot.
Every loop is bounded:
Bound Value BoundsMAX_TASKS
3
Tasks per achievement; unrolled
MAX_ENTRIES
16
Entries of one progress_many call, counted before merging and checked first ('Achievement: too many entries'); the merge makes at most 16² comparisons
A task id 0 in a batch is refused ('Achievement: invalid task'). Duplicate task ids are merged,
their counts summed and saturated at 0xffffffff; zero counts are dropped.
One call per player per transaction. The consumer aggregates its results by task id, one
entry per task, and calls progress_many once per player per transaction. More than
MAX_ENTRIES entries revert, duplicates included: there is no fallback to a second call.
The network's limit. A Starknet transaction may use at most 1.1 × 10⁹ L2 gas ("Max L2 gas per transaction", docs.starknet.io, Learn > Cheatsheets > Chain info, https://docs.starknet.io/learn/cheatsheets/chain-info, read on 2026-09-28). The project's own cap is far lower: the worst call the package allows must stay under 20 × 10⁶ L2 gas (A-G1 amendment).
Measured through a dispatcher with snforge, under TrackAll
(GAS.md; the cost model in
GAS.md). The network's
estimate reprices each written slot: a created slot about 453 500 (snforge 474 106), an
overwritten one about 32 000 (snforge 72 106).
progress_many: 16 entries, the slowest merge, 16 events
1 821 093 / 1 821 093 (nothing written)
9.1 %
progress, 1 entry
213 636 / 213 636
1.1 %
Grim World's results transaction: 6 character and 2 account tasks, two calls
838 408 / 838 408
4.2 %
define, 3 tasks (2 slots created)
1 232 920 / 1 191 708
6.2 %
define, 1 task (1 slot created)
722 550 / 701 944
3.6 %
retire
262 730 / 222 624
1.3 %
set_reporter, a new reporter
620 810 / 600 204
3.1 %
Under TrackNone, define costs 70 180 less on 1 task and 94 760 less on 3 (no
AchievementDefined), set_reporter 41 200 less; progress and retire cost the same.
Progress costs the same whatever is defined. It reads the reporter (on the external ABI only), merges the batch and emits; about 69 000 per distinct entry. The achievements on a task cost a progress call nothing.
Definitions in bulk. Each define creates one or two slots. Defining Grim World's 26 tiers
in one transaction costs 18 916 390 (18 380 634 at the network's prices), 94.6 % of the cap:
define over several transactions, at most about 25 single-task achievements or 16 of three tasks
in one.
The consumer's transaction must fit. The whole transaction counts: the consumer's own entrypoint and logic, the package's calls, and the account's validation and execution.
The reporter check. The external progress and progress_many of AchievementImpl read the
reporter registry once (one storage read) before emitting. Called through the internal layer,
progress reads and writes nothing.
No storage is read or written outside the store; everything else is state in, state out:
types::window: AchievementWindow, WindowAssert::assert_valid, WindowTrait::is_active
(the indexer's rule);types::task: AchievementTask, TaskAssert::assert_valid;types::batch: TaskProgress, BatchTrait::merge and count_of;models::definition: DefinitionTrait::new (the checks of define, in their order),
is_active; DefinitionStorage::into_slots and from_slots; HeadSlotTrait::tasks; the slot
types HeadSlot and TasksSlot and their packing into one felt each (HeadPacking,
TasksPacking): a field wider than its bits is refused ('Packing: field out of range'), and
a felt with a reserved bit set is refused on unpacking ('Packing: reserved bits set');models::status: StatusTrait::retire, StatusAssert, StatusStorage (the status in A);models::reporter: ReporterAssert::assert_is_allowed.Every loop is bounded: BatchTrait::merge by MAX_ENTRIES (checked first; at most
MAX_ENTRIES² comparisons when a task id repeats or two ids are equal modulo 128, one pass
otherwise), count_of by the entries of a merged batch. Tasks are unrolled, without loops.
Every test has a budget; the figures are in GAS.md: the library's benchmarks in their
modules (types::batch::tests, models::definition::tests), the component's and the game's use
in test_component_bench, the tracking choices in test_tracking. Per entrypoint, the measure
and the budget are in
docs/BUDGETS.md.
Version 0.2.0
Uploaded 1 day ago
License MIT
Cairo version ^2.20.0
Size 42.9 KB
Run the following command in your project dir
scarb add quiver_achievement@0.2.0
Or add the following line to your Scarb.toml
quiver_achievement = "0.2.0"