Every app with points has a line like this somewhere:

learner.Points += 50; await db.SaveChangesAsync(); 

It looks harmless. Then real traffic arrives:

  • The mobile app times out and retries "lesson completed". The learner gets 100 points.
  • A message queue delivers the same event twice, which at-least-once delivery is allowed to do. The learner gets 100 points again.
  • The learner double-taps "Redeem" on the last tutor session. Two requests both read "1 left", and both succeed.
  • Staff cancel a redemption, the request is retried, and the points are refunded twice.

None of these is unusual. Once points can buy something they behave like money, and code that handles money needs to be exact.

I'm adding a rewards programme to StrideWell, the platform for gyms and coaches I'm building. Members earn points for sessions and streaks, and spend them on rewards their gym pays for. I built the engine as a separate library, and I've now open-sourced it as ModelHub.Gamification. It's the second library I've extracted from StrideWell. The first was ModelHub.MultiTenancy.

dotnet add package ModelHub.Gamification.EntityFrameworkCore 

A worked example: an online learning platform

Imagine an online learning platform where each school is a tenant. Learners earn points like this:

Event Points Rule
LessonCompleted 50 up to 3 a day
QuizPassed 20 only for a score of 80 or more
StreakMilestone 150 for a 7-day streak
builder.Services.AddGamification<AppDb>(o => { o.DefaultRules.Add(new RuleDefinition("LessonCompleted", 50) { MaxPerDay = 3 }); o.DefaultRules.Add(new RuleDefinition("QuizPassed", 20) { Condition = """{"score":{"gte":80}}""" }); o.DefaultRules.Add(new RuleDefinition("StreakMilestone", 150) { Condition = """{"streakDays":7}""" }); o.TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/London"); }); 

Calling modelBuilder.ApplyGamificationModel() in OnModelCreating adds six tables to your own DbContext. Your app saves its own work first, then reports what happened:

db.QuizAttempts.Add(attempt); await db.SaveChangesAsync(); var result = await points.HandleAsync( new GamificationEvent(schoolId, learnerId, "QuizPassed", attempt.Id.ToString()) { Context = new Dictionary<string, object?> { ["score"] = attempt.Score }, }); // Credited, AlreadyCredited, NoMatchingRule, LimitReached or EarningDisabled 

One school, Northbridge Academy, wants to change two things:

  • an extra 10 points for lessons finished before 7am;
  • a cap of 300 points a day.
await programs.SaveRuleAsync(new PointsRule { TenantId = northbridge, EventType = "LessonCompleted", Points = 50, MaxPerDay = 3, }); await programs.SaveRuleAsync(new PointsRule { TenantId = northbridge, EventType = "LessonCompleted", Points = 10, Condition = """{"localHour":{"lt":7}}""", }); await programs.SaveSettingsAsync(new PointsProgram { TenantId = northbridge, DailyEarnCap = 300 }); 

A school's own rules for an event type replace the defaults for that event type. That's why Northbridge restates the 50-point rule. It's also how a school switches an event off: it saves one disabled rule. When an event would take a learner over the cap, it pays only what's left under the cap.

Learners spend their points on rewards:

var redeemed = await rewards.RedeemAsync(northbridge, learnerId, tutorSessionId); // redeemed.Redemption.Code == "RW-7K3QX9", which the learner reads out to staff 

That's the API. Now back to the four failures from the start of this post.

1. Paying once: let the database say no

The obvious fix is to check first: "has this event already paid?" On its own, that isn't enough. Two copies of the same event can arrive a few milliseconds apart. Both check, both see "no", and both insert.

So every credit carries an idempotency key, made from the event type and the reference you pass in, such as earn:QuizPassed:8841. A unique index makes the database refuse the second one:

e.HasIndex(l => new { l.WalletId, l.IdempotencyKey }).IsUnique(); 

The engine still checks first, which handles the usual case cheaply. The index handles the race. When the second insert fails, the engine does three things:

  1. It drops its own unsaved changes. Any of your app's unsaved changes stay as they were.
  2. It reads the wallet again.
  3. It finds the key and returns AlreadyCredited.

The same retry covers a learner's first two events arriving at once, because there can only be one wallet per tenant and user.

Some rows don't need a key, such as expiry and staff adjustments. They leave it null, so they never collide:

  • EF Core's SQL Server provider adds an IS NOT NULL filter to unique indexes on nullable columns.
  • Other databases treat NULLs as distinct from each other.

This is why the reference you pass matters. Use the id of the thing that happened, such as the quiz attempt, never Guid.NewGuid(). Get that right, and retrying becomes safe everywhere: in a mobile app, a queue consumer or a background job.

2. Spending once: concurrency tokens and a single save

The double-tap on the last tutor session is a different race. Two separate redemptions are each valid on their own, but they compete for the same stock and the same points.

PointsWallet.Balance and Reward.Stock are EF Core concurrency tokens. A redemption changes three things, and they're saved in one SaveChangesAsync. Simplified:

db.Set<Redemption>().Add(redemption); // carries the code the learner shows reward.Stock -= 1; // concurrency token wallet.Balance -= reward.Cost; // concurrency token, written with a ledger row await db.SaveChangesAsync(); // all of it, or none of it 

EF Core turns the stock change into UPDATE ... WHERE Stock = 1. If another request got there first, no row matches. EF Core then throws DbUpdateConcurrencyException and the whole save is rolled back. The engine reads again and returns the honest answer, OutOfStock.

One test covers exactly this. Two learners want one item, and one of the requests is working from stale data. One learner gets the session. The other gets OutOfStock and keeps all their points.

3. Refunding once

Staff can cancel a pending redemption. Cancelling refunds the points and puts the item back in stock, again in a single save. Two separate safeguards stop a double refund:

  • Redemption.Status is a concurrency token, so only one of fulfil, cancel or expire can succeed.
  • The refund has its own idempotency key, refund:<redemption id>. Even if a bug somewhere else tried, the same redemption can't be refunded twice.

Keep a ledger, not just a number

Each user has a wallet with a balance, but the ledger is the real record of their points. Every change is a row:

  • earn: points from the rules
  • award: a fixed prize outside the rules
  • redeem
  • refund
  • expire
  • adjust: a staff correction, which requires a note and a name

The balance only ever changes together with a ledger row. That gives you three things.

  • History. "Where did my points go?" is a query, not an investigation.
  • Levels that never go down. Levels come from lifetime points, so spending on a reward never costs a learner a level.
  • Fair expiry. If points expire after 30 days, which points expire? Spending should use the oldest points first. With a ledger, that's simple arithmetic: everything credited before the cutoff, minus everything that has ever left the wallet.
    • For example: earn 100 points on day 0, spend 30 on day 10, and earn 50 on day 20.
    • On day 31, 70 points expire, and the 50 from day 20 stay.

"Today" is not a UTC date

"Up to 3 lessons a day" sounds simple until you ask whose day you mean. Count by the UTC date, and a learner five hours ahead of UTC who finishes a lesson at 00:30 their time is still on "yesterday". An early-morning bonus would also fire at the wrong hour.

The engine works out where each day and week starts and ends in the configured time zone. It then queries the ledger using those boundaries converted to UTC.

It also adds two values to every event's context: localHour and dayOfWeek. That's how Northbridge's early-bird rule works.

An event reported late counts toward the day it happened, not the day it arrived.

One small trap: in a few time zones, midnight doesn't exist on the day the clocks change. On those days, the engine starts the day at the first valid time.

The bug the extraction found

Pulling code out of an app makes you read it with fresh eyes. StrideWell counted streaks within each week and reset them every Monday, which suits a weekly progress view. But the rewards rules also included a bonus for a 30-day streak. A streak that resets every Monday can never get past 7 days, so that bonus could never pay out.

The rewards programme hasn't launched yet, so no member missed out. But it's exactly the kind of bug that survives because each piece looks right on its own.

In the library, the two jobs are separate:

  • Streaks.Current counts consecutive days, with no week boundary.
  • Streaks.Week builds the weekly view.

A milestone's reference includes the date its run started, such as streak:2026-11-10:7. So each run pays its bonus once, and a new run can pay again.

Limitations

  • It runs after your save, not inside it. If your process stops between your SaveChangesAsync and the call to the points engine, that event isn't credited until something retries it. Retrying is always safe, because each event pays once. An outbox or a background job that replays events closes the gap.
  • One time zone per app. Each school can't have its own time zone yet.
  • CI runs on SQLite. The library only uses standard EF Core features (unique indexes and concurrency tokens), but SQL Server and PostgreSQL aren't tested in CI yet.
  • It's the accounting, not the game. There are no leaderboards, no UI and no cash value. Those go on top.
  • The expiry job saves one wallet at a time. That's fine for thousands of learners, but it isn't tuned for millions.

Try it

This is version 0.1, so feedback is very welcome. Four issues are labelled good first issue, ranging from an in operator for rule conditions to a test for points expiry across several schools. If you've built points or loyalty into a .NET app, I'd love to hear what broke first.

I'm Kehinde, founder of ModelHub Solutions in Wales. I'm building StrideWell, a platform for gyms and coaches, and open-sourcing the parts that might help other teams.