quiver_achievement 0.1.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 0.1.0 has one mode: events.
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 by 0.1.0: no storage member, key or bit of this version is set aside for it, and a later version may add members of its own.
quiver_achievement::logic
The pure library: types, their packing into one felt each, functions. State in, state out, no storage
quiver_achievement::component::AchievementComponent
The Starknet component: storage of the definitions and of the reporter registry, events, the hook, the trusted internal layer, the optional external ABI
quiver_achievement::interface
IAchievement and IAchievementView, with their dispatchers
quiver_achievement::errors, quiver_achievement::constants
The error strings (API) and the bounds
Embed the component, implement AchievementHooksTrait (one function, authorize_admin), and
choose what to expose. This consumer, like Grim World's, 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::logic::{AchievementTask, AchievementWindow, TaskProgress};
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>;
#[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 logic::window_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 the definition and its tasks, or reverts 'Achievement: does not exist': a slot never written reads as undefined.
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); [196, 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 (GAS.md). The network's estimate reprices each written slot: a created slot about 453 500 (snforge 459 106), an overwritten one about 32 000 (snforge 57 106).
Call snforge / network Against 20 M The worstprogress_many: 16 entries, the slowest merge, 16 events
1 816 813 / 1 816 813 (nothing written)
9.1 %
progress, 1 entry
209 236 / 209 236
1.0 %
Grim World's results transaction: 6 character and 2 account tasks, two calls
829 728 / 829 728
4.1 %
define, 3 tasks (2 slots created)
1 197 030 / 1 185 818
6.0 %
define, 1 task (1 slot created)
707 640 / 702 034
3.5 %
retire
240 760 / 215 654
1.2 %
set_reporter, a new reporter
607 410 / 601 804
3.0 %
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 525 730 (18 379 974 at the network's prices), 92.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.
quiver_achievement::logic is the pure library, without storage: state in, state out. It holds:
AchievementWindow, AchievementTask, AchievementDefinition,
AchievementExtraTasks and TaskProgress;StorePacking<T, felt252>): 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');definition_new, tasks_span, window_validate, window_is_active, batch_merge and
batch_count_of.Every loop is bounded: batch_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),
batch_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
test_bench, the component's and the game's use in test_component_bench. Per entrypoint, the
measure and the budget are in
docs/BUDGETS.md.
Version 0.1.0
Uploaded 17 hours ago
License MIT
Cairo version ^2.19.0
Size 26.5 KB
Run the following command in your project dir
scarb add quiver_achievement@0.1.0
Or add the following line to your Scarb.toml
quiver_achievement = "0.1.0"