I built a self-improvement app that turns your habits into RPG stats. You do pushups, you meditate, you go to bed earlier, and the app moves six attributes on a 0 to 100 scale: Strength, Wisdom, Discipline, Confidence, Morality, and an Overall that sits on top. It is called Lifemaxxing AI, I build it with my partner Benjamin Siegel, and it is a Flutter app.
The fun part was never the UI. The fun part, and the part that quietly ate a week, was the scoring math. Because the moment a real person touches a scoring system, it breaks in ways that look obvious in hindsight and are invisible while you are writing it.
Here is the story of how the numbers went past 100, why that is a harder problem than it sounds, and the one architecture decision that saved me anyway.
The good decision: game balance is data, not code
Before I get to what broke, the thing I got right.
Every task in the app lives in a single task_config.json file. Not in Dart, not hardcoded in a service, not scattered across screens. One file. A task looks like this:
{
"id": "pushUps",
"taskType": "count",
"initialAmounts": [2, 10, 20, 30, 50],
"initialFrequency": 2,
"amountChangeFrequency": 2,
"amountChangeRate": "conditional:if amount < 10 then +2; if amount between 10-20 then +5; if amount > 20 then +10",
"maxMinAmount": 150,
"maxFrequency": 4,
"ratingCategories": { "STRENGTH": 0.75 }
}
That config carries the whole game. initialAmounts is where a user starts based on their onboarding answer. amountChangeRate is how the difficulty ramps, and yes, it can be a conditional string that the engine parses, so pushups jump by 2 when you are weak and by 10 once you are strong. maxMinAmount is the 100 percent target, 150 pushups here. And ratingCategories is the interesting one: it maps a task to the attributes it feeds and how strongly.
Five task types share this shape. count for pushups and pages. time for clock targets like bedtime, stored as minutes from midnight so 1410 is 11:30pm and the target 1260 is 9pm. duration for things like meditation length. limit for social media caps. boolean for cold showers and the no-porn kind of yes or no habit.
The payoff is that tuning game balance is a data change, not a deploy. When 50 pushups by week three felt brutal, I did not touch a class. I edited a number. That decision is the only reason the next part did not sink the whole feature.
What broke: additive scoring is a trap
The first scoring model was the obvious one. Take each task, normalize its current amount to a 0 to 100 percent based on how far the user has progressed toward maxMinAmount, multiply by the task's weight for an attribute, and add it to a base rating.
for (final task in tasks) {
final boost = normalizedScore * weight;
attributeTotals[attribute] += boost; // keeps adding
}
It demos beautifully. It falls apart the instant a real user has more than one habit.
Problem one, weights do not sum to 1. Strength is fed by pushups at 0.75, running at 0.75, cold showers at 0.25, and workout at 0.5. Add those up and Strength has a total possible weight of 2.25. There is no rule anywhere saying an attribute's inputs should sum to a whole. I never wrote one, so the system never had one.
Problem two, unbounded accumulation. Because I was adding, a balanced user doing pushups at 50 percent and running at 66 percent lands at 30 plus 37.5 plus 49.5, which is 117 Strength. On a 0 to 100 scale. A specialist who maxes pushups alone gets 105. The number is not just wrong, it is meaningless, because 105 and 340 are equally "off the top of the bar."
Problem three, daily spam. Nothing stopped a user from completing the same task three times in a day and adding the points three times. The scoring rewarded farming, not living better, which is the exact opposite of the product.
None of these are exotic. Every one of them is what happens when you model a bounded, competitive score as a running sum. The failure is not in the code, the code does exactly what it says. The failure is that "add up the contributions" is the wrong mental model for a stat that has to mean something at 90.
The reframe: normalizing a game score is the actual product
The thing I under-weighted going in: in a gamified app, the scoring math is not plumbing behind the feature. It is the feature. A Strength of 78 has to feel earned and comparable to someone else's 78, or the entire loop of "grind, watch the bar move, feel it" is hollow. If any combination of habits can push a bar to 117, the bar means nothing, and a meaningless bar is a dead app.
So the rebuild started from constraints, not code:
- Every attribute stays inside 0 to 100, always, no matter how many tasks feed it.
- Multiple tasks hitting the same attribute have to share it, so weights get normalized per attribute rather than trusted to sum correctly.
- Frequency and consistency matter, not just raw amount. Doing a task every day should beat doing it hard once, because that is what actually changes a life and what the app is supposed to reward.
- The system has to resist gaming. Completing the same task ten times in a day cannot ten-times the reward. Contribution gets capped per task per day.
- The scale has to feel real. 30 to 50 is a beginner, 50 to 75 is intermediate, 90 plus should only be reachable through genuine long-term consistency, not a single maxed number.
And critically, all of that stays configurable. The ratingCategories weights, the base ratings, the caps, they live in the same JSON. The model changed. The principle that game balance is data survived, and that is what let me rip out the scoring core without touching the twenty screens that read from it.
What I would tell past me
Two things.
First, config-driven design is worth doing early, but it does not save you from a wrong model. I had a beautifully data-driven engine computing numbers that were nonsense. Flexibility in the wrong equation is just faster nonsense. The JSON let me fix it quickly, but it could not tell me the equation was broken. Only a real user with four habits could.
Second, if a number is shown to a user as a bounded score, treat the bound as a hard invariant from line one, not a thing you clamp at the end. attributeTotals[attribute] += boost has no idea 100 exists. I should have designed the whole calculation so that exceeding 100 was structurally impossible, not caught with a clamp after the fact. A clamp hides a broken distribution. It does not fix it.
The tracking, the plans, the RPG bars, all of that is live at lifemaxxingai.com. The scoring engine underneath is on its second model and honestly will probably see a third, because that is what happens when the math is the product. You do not get it right once. You get it a little less wrong every time a real person shows you where it bends.
If you are building anything that turns behavior into a score, do the boring thing early. Write down what the number is allowed to be before you write the line that produces it.
Top comments (0)