A Community Odometer: Calibrating a Shared Goal Against a Live Player Base

You cannot set a community threshold by intuition, so the first run is the instrument. Ship it unmarketed on a low-traffic day, let it measure the floor, and set the real goals from the pessimistic case.

In-game event screen titled Community Mileage Challenge. A mechanical odometer reads 000,624,836 MI of community miles, with the player's own contribution at 7 MI below it. Three reward tiers sit underneath: Bronze at 600,000 miles marked complete, Silver at 700,000 miles marked current, and Gold at 800,000 miles locked. The event ran August 3 to August 5 and is marked as ended.
The instrument, reporting. 624,836 community miles against tiers set at 600,000, 700,000, and 800,000 — Bronze complete, Silver just out of reach, Gold locked. This is the first run of the event, deliberately unmarketed and scheduled on a low-traffic three-day window, which makes the number a floor rather than an average. Player-facing UI from a shipped public build of No Limit Drag Racing 2.

A community event is a shared goal: everybody races, everybody’s mileage adds to one total, and if the community collectively clears a threshold, everyone who joined gets the reward. Bronze, Silver, Gold, unlocked for the whole player base rather than for individuals.

It is a lovely mechanic and it has one ugly failure mode. Set the thresholds too high and nobody clears anything. Not “some players are disappointed” — the entire player base gets nothing, from a feature that spent its whole run displaying a progress bar that never filled. The event does not read as hard. It reads as broken.

And the threshold cannot be guessed, because it depends on a quantity nobody has ever measured: the aggregate race throughput of a live player base over a fixed window. Not a single player’s session, not a daily active count — the sum of what everyone actually does, in miles, across three days.

Making the first run the instrument

The way out is to stop treating the first event as a launch and treat it as a measurement.

So the first run shipped deliberately unmarketed, on a deliberately low-traffic day, with the initial tiers derived from week-over-week race data rather than from anyone’s sense of what felt achievable. No push notification, no store feature, no announcement. Just the event, quietly on, against whatever traffic happened to arrive.

That configuration is the point. What it measures is the floor: the minimum the community produces with no promotion at all. Every future goal gets set against a number produced under the worst realistic conditions, which means the pessimistic case is the baseline rather than the hope. If Bronze clears with no marketing on a slow day, Bronze clears.

There is a second reason to run it dark, which is that the event’s own correctness was unproven. A shared counter fed by every completed race in the game is a new failure surface, and I would rather discover an aggregation bug in front of the players who happened to open the app than in front of the ones a push notification brought in.

What it measured

624,836 community miles. Tiers at 600,000, 700,000, and 800,000, over a three-day window.

Bronze complete. Silver just out of reach. Gold locked.

That is close to the best possible outcome for a calibration run, and it is worth being precise about why. If nothing had cleared, the number would still have been useful but the run would have been a bad player experience. If all three had cleared, we would have learned only that the tiers were too low and still would not know where the ceiling was. One tier cleared, the next one narrowly missed, tells you the spacing was roughly right and locates the distribution — the community produces somewhere just past 600,000 miles with zero promotion, and the promoted thresholds belong above that.

The screenshot above is the artifact, and I like that the UI renders the total as a literal mechanical odometer, because that is exactly what the backend is: a counter that only goes up, read by everybody, that nobody can reset.

You cannot set a community threshold by intuition. Build the thing that can measure, ship it under the least flattering conditions you can arrange, and let the first run tell you where the goal goes.

I want to flag that instinct as a through-line rather than a one-off, because it shows up everywhere in my work. The waterfall consolidation rollout would not ship on a dashboard that moved after launch, so it shipped on deterministic treatment assignment and measured parity. Same shape here. Do not ship on conviction; build the thing that can measure, then decide.

Then the architecture

The calibration decision is the judgment call. It was only available because the system underneath it could be trusted to count correctly, and that part was more interesting than “increment a number.”

race telemetry

race completed

open / close

poll state

Game client

Telemetry ingest

Kafka

Data lake

durable record

Event service

dedupe + aggregate

Scheduler

Redis

per-user +

community totals

Event API

via game SDK

Two paths through one stream. Race telemetry leaves the client once and fans out in Kafka: to the data lake, which holds the durable record of every race, and to the event service, which deduplicates and aggregates it into the live totals. The scheduler owns the window rather than the event service, so opening and closing are ordinary state transitions instead of special cases. The loop on the right is the read path — the client polling for event state through the SDK — and it is the reason the community total is a serving projection in Redis rather than a query, since every player in the game is asking for the same counter at once.

A race can arrive twice

A race-completed event can be redelivered. That is not a defect in the pipeline, it is the delivery guarantee: at-least-once means at-least-once, and any consumer that assumes otherwise is one rebalance away from being wrong.

Double-counting mileage in a shared total is a correctness bug with two distinct costs. It inflates reward payouts, because tiers unlock earlier than they were earned. And it is visible: the UI shows each player their own contribution, so a player who runs one race and sees two races’ worth of miles has caught you at it, on their own screen, without needing access to anything.

So mileage is applied once per race identity:

// ApplyRace folds one completed race into the event totals. Safe to call
// repeatedly with the same race: redelivery is a no-op, not an error.
func (s *Service) ApplyRace(ctx context.Context, r RaceCompleted) error {
	window, err := s.windowFor(ctx, r.EventID)
	if err != nil {
		return err
	}
	if !window.Accepts(r.FinishedAt) {
		return nil // finished outside the window; not this event's mileage
	}

	// Claim the race before counting it. SET NX with a TTL that outlives the
	// window, so the claim cannot expire while the event is still open.
	first, err := s.store.ClaimRace(ctx, r.EventID, r.RaceID, window.ClaimTTL())
	if err != nil {
		return err
	}
	if !first {
		return nil
	}

	// Per-user contribution gates reward eligibility, so it lands first.
	if err := s.store.AddUserMiles(ctx, r.EventID, r.PlayerID, r.Miles); err != nil {
		return err
	}

	// The community odometer is one key by construction. INCRBY is atomic, so
	// concurrent writers are fine; the read path is where the work is.
	return s.store.AddCommunityMiles(ctx, r.EventID, r.Miles)
}

There is a real decision buried in the ordering, and it is worth naming because it is the kind of thing that gets waved past in a design review. Claiming the race before applying it means a crash between the claim and the increment loses that race’s mileage permanently — the retry sees the claim and declines to count. Applying first and claiming after means a crash between them double-counts on redelivery.

I took the lossy direction on purpose. Under-counting one race in a six-hundred-thousand-mile total is beneath the resolution anyone can perceive. Over-counting inflates a payout and shows up on a player’s own contribution line. And crucially, the lossy direction is recoverable: the durable record of every race lives in the data lake, so Redis holds a serving projection, not the system of record. If the two ever disagree, the lake is right and the projection can be rebuilt. Choosing the failure mode you can repair over the one you have to apologize for is most of what correctness work is.

The odometer is a hot key by construction

Every completed race in the game increments one counter. That sounds alarming and mostly is not: INCRBY is atomic, and the write rate is bounded by how fast humans can finish races, which is not a large number by infrastructure standards.

The read path is the problem. Every client polls for event state, and they are all polling for the same key — that is not incidental to the design, it is the entire mechanic. A shared goal means a shared read. The traffic profile of a community event is therefore one hot key read by the entire player base and written at a comfortable rate, which is the opposite of most caching problems and drives the whole design toward “serve the total from a projection, refresh it on a short interval, and never make the read path do arithmetic.”

Two consistency needs in one feature

The two numbers on that screen have completely different requirements, and treating them the same way is how you either overbuild or ship a bug.

The community total can lag several seconds and nobody notices. It is a number going up on a shared odometer; a player who sees 624,801 while the true value is 624,836 has not been harmed, and cannot tell.

The per-user contribution gates reward eligibility. A player who joined, raced, and is told they contributed zero miles has been told they do not qualify for a reward they earned. That number has to be right, and it has to be read back from the same place it was written.

So they do not share a consistency strategy: the community total is a cached projection with a refresh interval, and per-user contribution is read authoritatively. This is the same lesson I keep arriving at from different directions — pick your consistency guarantees per data class, by the cost of being wrong, not per system. An identity service resolving audience signal in an ad auction has exactly this split, where a segment can be stale for minutes and a consent decision cannot be stale at all. Different domain, identical reasoning.

The window has edges

A scheduled job opens and closes the event, which sounds trivial and generates three real questions.

What happens to a race in flight at the deadline? The client started a race inside the window and finished it after. Somebody has to decide whether that mileage counts, and the decision has to be made in one place — hence window.Accepts(r.FinishedAt) above, evaluated server-side against the race’s own completion time rather than against whenever the message arrived. Arrival time is a property of the pipeline, not of the player.

What does the total say after close? Tier evaluation has to be deterministic, so totals are snapshotted at close and the tier is resolved from the snapshot. A late-arriving redelivery cannot promote a community into Silver an hour after the event ended, which is exactly the kind of thing that would otherwise generate a support ticket nobody can explain.

What does the client see? Note the language in the screenshot: all joined players earn the reward for the highest tier reached when the event ends. That sentence is a contract, and the snapshot is what makes it true.

You cannot force-update a phone

The last constraint is the one that has nothing to do with distributed systems and everything to do with shipping on mobile: old app versions keep running.

You can put a new event schema behind an API, but you cannot make the install base adopt it. Some meaningful share of players will be on a build that shipped before the event existed, and they will keep calling the shape they were compiled against. So the event state API is versioned, old response shapes stay served, and the client-side SDK negotiates rather than assuming. The event’s capabilities are described to the client instead of implied by the client’s knowledge of them — which is more work up front and the only version of this that does not turn into a coordinated release.

What shipped

Two things, and I want to be careful about how they are stated.

The first is a reusable scheduling and aggregation service: a system that can open a window on a schedule, consume a race-completed stream, deduplicate it, maintain per-user and community aggregates with different consistency guarantees, snapshot at close, and serve event state to a versioned client through the SDK. It is not specific to mileage. The aggregate is a configuration, not a code path.

The second is the number. 624,836 community miles across three unmarketed days is a measured floor for what this player base produces with no promotion attached, and it is the number any threshold for this event has to be set against.

No promoted run happened while I was there, so this post ends where the evidence ends: at the baseline. I think that is the right place to stop, and I would rather stop here than gesture at outcomes that do not exist. The deliverable was never one event’s reward payout. It was a platform that can run this class of event, and a number nobody in the building had before — which is precisely what the first run was designed to produce.