Streaks are a strange design problem. The moment you reward people for showing up every day, you also reward them for faking it.
GitHub's contribution graph is the classic example. A green square for an empty commit looks exactly the same as a green square for a real fix. So when I started building Vigilante, a free browser game where your GitHub pushes open the next day of the story, the reward rules mattered more to me than the 3D city did.
This post walks through the rules as they exist today, and why each one is there. The code is a simplified sketch in Java (my day job language) to show the shape of the logic, not a copy of production code.
The rule everything else hangs on: one verified day
You pick one repo and a daily commit target. When GitHub sends a push webhook for that repo, the game checks the commits. If enough of them are meaningful, that day is verified, and the next day of the game opens.
"Meaningful" is doing a lot of work there. Vigilante filters out:
- merge commits
- empty commits
- commits that only touch lock files or config
It also only looks at metadata: commit IDs, timestamps and file names. It never reads your code or diffs. Commit messages are only used if you opt in.
Here is roughly what that filter looks like:
record CommitInfo(String id, Instant timestamp, int parentCount, List<String> files) {} static final Set<String> LOCK_FILES = Set.of( "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock" ); static boolean isMeaningful(CommitInfo c) { if (c.parentCount() > 1) return false; // merge commit if (c.files().isEmpty()) return false; // empty commit return c.files().stream() .anyMatch(f -> !isLockOrConfigOnly(f)); // at least one real file } static boolean isLockOrConfigOnly(String path) { String name = Path.of(path).getFileName().toString(); return LOCK_FILES.contains(name) || isConfigFile(name); } The list of what counts as "config" is the part that needs the most judgment, and I'd rather keep it conservative than clever. The point isn't to catch every trick. The point is that the cheapest possible move (an empty commit) gets you nothing.
Rewards scale with consistency, not volume
Once a day is verified, you get a daily supply crate. The crate has a streak multiplier:
- 3 days in a row: x1.2
- 7 or more days in a row: x1.5
That's it. There is no bonus for pushing 40 commits instead of 4. Your daily target is the bar, and going way past it doesn't pay extra.
static double crateMultiplier(int streakDays) { if (streakDays >= 7) return 1.5; if (streakDays >= 3) return 1.2; return 1.0; } static Crate dailyCrate(int verifiedStreak) { return Crate.base().scaledBy(crateMultiplier(verifiedStreak)); } I like this shape for two reasons.
First, it rewards the thing that's actually hard. Writing a lot of code on one good Saturday is easy. Showing up on a tired Tuesday after office is the hard part, and that's what the multiplier is tied to.
Second, it caps out early. After a week, the reward stops climbing. Nobody needs to feel that day 60 is "worth more" than day 8. You just keep the x1.5 as long as you keep showing up.
Missing a day: repair, but never fake
Real life happens. Mondays happen. So there's an item called Revival Fluid that repairs a missed day.
The important part is what it does not do. A repaired day never counts as verified. It keeps your story from collapsing, but it doesn't pretend you coded.
enum DayStatus { VERIFIED, REPAIRED, MISSED } static boolean countsAsVerified(DayStatus s) { return s == DayStatus.VERIFIED; // REPAIRED is never verified } That separation shows up everywhere else too. The share card you get for each day is only for verified days, because the whole point of the card is that it's proof.
Money never buys a verified day
Vigilante is free to play, and every story day is included. There are optional purchases (Revival Fluid, and a Pro pass with no auto-renewal), but buying anything never unlocks a day and never counts as verified work.
If you build anything with streaks and payments, I'd treat this as a hard wall in the data model, not a policy line in the FAQ. Keep "what the user paid for" and "what the user actually did" in separate fields, and never let one write to the other.
// Purchases can add items to the inventory. // They can never change a DayStatus. void applyPurchase(Player p, Item item) { p.inventory().add(item); } Small daily loops around the big one
Around the main loop there are a few lighter ones: a 7-day check-in calendar, 3 daily missions, and a daily mystery coding question. These are there so that opening the game feels like something, even on a day you haven't pushed yet.
But none of them can stand in for a push. The story only moves when GitHub says you shipped something real.
What I'd tell anyone designing streaks
- Decide what the cheapest fake looks like, and make it worth zero. For commits that's empty and merge commits.
- Reward consistency, not volume. A multiplier based on streak length is enough. Don't pay for raw commit counts.
- Cap the multiplier. Make the reward flat after a point so people aren't scared of breaking a huge number.
- Let people recover without lying. A "repaired" state is kinder than a reset, as long as it's clearly not "verified".
- Wall off payments from progress. In the schema, not just in the copy.
If you want to see how these rules feel from the player side, Day 1 is free in the browser with no sign-up: play Day 1 of Vigilante. Full disclosure, I built it.
How do you handle missed days in your own habit or streak systems? Reset to zero, or something softer?