Skip to main content

Achievements

EffectStream ships storage primitives for tracking player achievements: a system table and two prepared queries you can call from your State Transition Functions. These are the building blocks for implementing PRC-1, the open standard for exposing in-game achievements.

What is and isn't built in

The engine provides the storage layer only — the effectstream.achievement_progress table and the getAchievementProgress / setAchievementProgress queries.

There is no built-in achievement HTTP API, and no Achievement / AchievementMetadata type or achievements export convention. Defining achievement metadata and serving PRC-1 endpoints is something you implement in your own API router. Earlier versions of this page described such an API; it does not exist in the engine.

The storage model

Progress is stored in one system table, keyed by account and achievement name:

CREATE TABLE effectstream.achievement_progress(
account_id INTEGER NOT NULL REFERENCES effectstream.accounts(id),
name TEXT NOT NULL,
completed_date TIMESTAMP,
progress INTEGER,
total INTEGER,
PRIMARY KEY (account_id, name)
);

Note that rows are keyed by account_id, not by wallet address. An account can have several addresses linked to it (see Accounts), so achievements follow the player, not a single wallet. Use getAddressByAddress from @effectstream/db to resolve an address to its account_id.

ColumnMeaning
account_idThe player's account (FK to effectstream.accounts).
nameYour identifier for the achievement, e.g. "win-10-battles".
completed_dateTimestamp when the achievement was completed; NULL while in progress.
progressCurrent progress value.
totalValue of progress that counts as complete.

Reading and writing progress

Both queries are exported from @effectstream/db:

  • getAchievementProgress({ account_id, names })names accepts a list of achievement names, or ["*"] to return every achievement for the account.
  • setAchievementProgress({ account_id, name, completed_date, progress, total }) — upserts on (account_id, name).

Inside an STF you run them through World.resolve, which queues the operation so it is applied atomically with the rest of the state transition:

import { Stm } from "@effectstream/sm";
import { World } from "@effectstream/coroutine";
import { getAchievementProgress, setAchievementProgress } from "@effectstream/db";

const TOTAL_BATTLES = 10;

stm.addStateTransition("battle_win", function* (data) {
const { accountId, blockTimestamp } = data;
const name = "win-10-battles";

// `accountId` is optional on the STF input — it is only set for inputs that
// arrived from a signed, account-linked address.
if (accountId == null) return;

// 1. Read the player's current progress.
const rows = yield* World.resolve(getAchievementProgress, {
account_id: accountId,
names: [name],
});
const current = rows[0];

// Already completed — nothing to do.
if (current?.completed_date) return;

// 2. Write the new progress, stamping the completion date on the final step.
const progress = (current?.progress ?? 0) + 1;
yield* World.resolve(setAchievementProgress, {
account_id: accountId,
name,
progress,
total: TOTAL_BATTLES,
// `blockTimestamp` is epoch milliseconds; the column is a TIMESTAMP.
completed_date: progress >= TOTAL_BATTLES ? new Date(blockTimestamp) : null,
});
});

Exposing achievements over HTTP

To make achievements readable by frontends or third-party tools, add routes to your own API router. A PRC-1 shaped implementation would define the static metadata (names, display names, descriptions, icons) in your application code and join it with the progress rows above.

See API for how to define routes, and PRC-1 for the response format the standard specifies.