Missions, achievements and a season pass for CS2 — one counting engine, driven by whatever your gamemodes emit.
Players earn progress by playing. Objectives are defined as an event plus a filter — "10 AK headshots", "kills in Long A", "3 bomb plants" — so a new mission is authored, not coded. Daily, weekly, monthly, all-time achievements and a seasonal pass are all the same machinery with different reset rules.
| Piece | Responsibility |
|---|---|
Progression.Core |
Pulls definitions, matches events locally, batches progress, runs reward commands. Owns buffering, retry and idempotency |
Progression.Shared |
IProgressionShared (what gamemodes reference) and IProgressionStore (what storage implements) |
Progression.Database |
Storage: MySQL. Install it and you need no backend at all |
Progression.Ui |
!missions menu and completion announcements. Delete it if you want a different UI |
Progression.Events.CS2 |
Standard CS2 events. The first satellite, not part of Core |
Core contains no game knowledge. It does not know what a kill is. Every event source is a
satellite — including the CS2 one — so adding a gamemode needs no change to Core. Deleting
Progression.Events.CS2 leaves Core building and running.
Reference YappersHQ.Progression.Shared with ExcludeAssets="runtime", resolve in
OnAllModulesLoaded, and declare what you can emit:
var progression = moduleManager
.GetOptionalSharpModuleInterface<IProgressionShared>(IProgressionShared.Identity)?.Instance;
progression?.RegisterEvent("duels.arena_won",
[
new ProgField("arena", ProgFieldKind.Int),
new ProgField("weapon", ProgFieldKind.String),
]);
// later, when it happens
progression?.Emit("duels.arena_won", steamId, new Dictionary<string, object?>
{
["arena"] = arenaId,
["weapon"] = weaponName,
});Missions filtering on duels.arena_won can then be authored on the website with no code change —
that is the entire point of the split.
Event keys are permanent. They are stored in mission definitions that live in a database for months, so namespace them by owner and never derive one from a runtime id.
Deploying it for the first time: docs/DEPLOY.md — ordered steps, each with a way to tell whether it worked, because several failures here are silent by construction (a wrong catalogue is empty, not an error).
Progression stores nothing itself. Choose how it persists:
| Option | What you run | When |
|---|---|---|
Progression.Database |
A MySQL server | You just want it to work. Owns its schema, migrates on load |
| Your own store | One class | You already have a database, or want Postgres/Redis/an existing API |
| Built-in HTTP client | A web service | You have a website and want authoring UI and a shared fleet |
With Progression.Database, docs/example-missions.sql gives you eight
working missions to start from — run it after the first boot, since objectives are only served for
events a server has reported it can emit.
Writing your own is one project implementing IProgressionStore,
registered in PostInit. Core resolves it in OnAllModulesLoaded and prefers it over HTTP.
You do not implement delivery. Buffering, idempotency keys, disk spill and retry stay in Core — a store is asked to write deltas and say what completed, never to re-derive exactly-once semantics. What it must guarantee is in docs/BACKEND_PROTOCOL.md.
Copy the examples in .assets/configs/ to <sharp>/configs/ and edit.
progression.json — Core:
| Key | Default | Meaning |
|---|---|---|
backend-url |
"" |
Backend base URL. Ignored when a store module is installed |
server-key |
"" |
Sent as Authorization: Bearer. Not shared between servers |
flush-seconds |
10 |
How often buffered progress is sent |
catalog-refresh-seconds |
900 |
Backstop for definition changes |
state-refresh-seconds |
60 |
How often the in-game mission list refreshes |
progression-database.json — only if using Progression.Database:
| Key | Default | Meaning |
|---|---|---|
connection-string |
"" |
MySQL. Needs CREATE TABLE on first run |
gamemode |
"" |
Scopes which objectives this server is offered. Empty = all |
timezone |
"UTC" |
Zone daily/weekly boundaries are measured in |
Servers match filters locally and post only progress deltas — posting raw events would turn every kill on every server into an HTTP call. Gameplay never blocks on the network: emissions land in a buffer, a timer flushes, and a backend outage spills to disk and replays rather than losing progress.
Rewards are decided by the backend but must run on a server, so pending grants ride back in the response to the flush the server already made. No RCON, no inbound connection, no port to open.
Every delta carries an idempotency key generated at enqueue time, so replaying a batch that partially landed is safe rather than merely unlikely to double-count.
| Command | What it does |
|---|---|
!missions · !mission · !progress · !daily |
Your current missions and progress |
Uses MenuManager when present and falls back to a chat listing when not. Mission titles resolve as locale keys, so a mission set can ship translations; a plain literal title still works.
dotnet build -c ReleaseOutputs .build/modules/ and .build/shared/Progression.Shared/.
To verify the MySQL store against a throwaway database (it writes rows — never point it at production):
dotnet run --project tools/StoreSelfTest -- "Server=127.0.0.1;User ID=u;Password=p;Database=throwaway"29 checks covering idempotent replay, compare-and-swap progress, single-claim completion, grant leasing and period boundaries.
Made with ❤️ by yappershq