Anthony Wentzel

Designing a gamification points system people can't farm

In 2017 we built a fan engagement app that rewarded posts and check-ins. The real points system design work was all in the limits.

From 2017 · 3 min read · Published August 28, 2026

In 2017 I built the backend and points system for a sports fan engagement app. Fans joined their team, then earned points for things that helped the team's reach: posting about it on Facebook and Twitter with the right hashtags, sharing the app's download link, and checking in at games. Points fed team and fan leaderboards.

Any system that hands out points for actions will get gamed. The code that awards points is easy. The code that decides when not to award them is the product.

Score the content, not the click

A share earned points based on what it actually contained. The first version was straightforward string matching against the post text:

shares/tally.js (illustrative)
function tallyPoints(text, team, rules) {
  const message = text.toLowerCase()
  let points = 0
  if (message.includes(rules.appHashtag)) points += rules.points.hashTag.app
  if (message.includes(rules.downloadLink)) points += rules.points.download 
  if (message.includes(`#${team.name.toLowerCase()}`)) points += rules.points.hashTag.team
  return points
}

Each element was worth something on its own, so a post with the team hashtag, the app hashtag, and the download link was worth the most. That nudged fans toward exactly the post the team wanted, without anyone writing a rulebook.

Caps are the real points system design

The limits did more work than the rewards:

  • One check-in per day earned points. A second one the same day earned nothing.
  • A daily share limit. Past it, shares still recorded but scored zero.
  • Values should live in config. Share values did. The check-in reward was a constant in the handler, and when it dropped from 100 points to 25 in July 2017, that meant a code change. When the values are data, rebalancing is a config change, not a code change.

Recording zero-point shares instead of rejecting them mattered. Fans never hit an error for being enthusiastic, and we kept a complete history of activity for reporting, separate from what counted toward the leaderboard.

Store points on the event

Each share document carried its own points value, computed when it was created. There was no running total to keep in sync. A fan's score was the sum of their shares; a team's score was the sum of its fans' shares.

That made the leaderboard a MongoDB aggregation: group shares by user (or team), sum the points, sort, paginate.

leaderboard (illustrative)
Shares.aggregate([
  { $match: match },
  { $group: { _id: '$user', points: { $sum: '$points' }, count: { $sum: 1 } } },
  { $sort: { points: -1, count: -1 } }, 
  { $skip: page * limit },
  { $limit: limit },
])

One lesson from our version: sort before you paginate. Aggregation stages run in order. If $skip and $limit come before $sort, you get an arbitrary page of users that's then sorted, not the top of the leaderboard. It's an easy mistake when you add pagination to a query that already worked.

What carried forward

Storing the computed value on the event, capping per time window, and keeping the numbers in config all show up again in systems I've built since, from rewards in a rides app to budgets on AI agents. The shape is always the same: record everything, count selectively, and make the rules easy to change.


Related: Webhook best practices: acknowledge fast, process later · Facebook Pixel firing twice: one pixel, one init