A cozy habit tracker that plays like a role-playing game.
Ordinary to-do lists have a delayed-gratification problem: reading a book or going to the gym takes months to show a result, so the list itself feels like a chore. Life RPG closes that gap. Finish something real — wash up, run 5k, ship the auth layer — and your character levels up this second: XP lands, an attribute grows, Seeds drop, and a streak stays alive.
The stats are not decorative. Everything is computed and stored server-side, so they cannot be faked from the browser.
Live app: add your deployment URL here Walkthrough video: add your video link here
cp .env.example .env # then fill in MONGODB_URI and AUTH_SECRET
npm install
npm run seed # optional: a populated demo account
npm run devOpen http://localhost:3000.
The seed creates a deterministic demo character — demo@liferpg.app / cozy-demo-2026 —
at level 12 with 267 finished quests across 41 days, 2,713 Seeds to spend, and an 8-day streak
that ends tonight. That last detail is on purpose: the stakes are visible the moment the
page opens, and ticking any one quest saves the streak.
To see the product from zero instead, sign up with your own email and finish one Hard quest —
that is 120 XP, which is an instant level-up.
| Requirement | How it works |
|---|---|
| Auth & security | Auth.js (NextAuth) credentials provider, bcrypt-hashed passwords, JWT session in an httpOnly cookie. Every query is scoped by the session's user id. |
| Schema & CRUD | Five MongoDB collections — users, characters, tasks, an append-only completion ledger, and per-day activity. Full create/read/update/delete on quests. |
| Non-linear levelling | xpForLevel(L) = 25L² − 25L + 100. Each level costs exactly 50L more than the last: 100, 150, 250, 400, 600 … |
| Streaks | Consecutive days with a finished quest, keyed to your local civil date. Worth up to a 1.5× multiplier. |
| Attributes | Nine quest categories feed six stats — Strength, Intellect, Vitality, Discipline, Charisma, Creativity — on their own gentler curve. |
| Economy | Finished quests pay Seeds. Spend them in The Little Shop on badges, desk trinkets, and themes that repaint the entire app. |
| Responsive & accessible | Mobile-first, fully keyboard navigable, semantic landmarks, and progress announced to screen readers. |
XP to advance from level L:
xpForLevel(L) = 25L² − 25L + 100 → difference is 50L, always positive
Cumulative XP to reach level L, closed form with n = L − 1:
totalXpToReach(L) = 25·n·(n² − 1)/3 + 100·n
| Level | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 15 |
|---|---|---|---|---|---|---|---|---|
| XP to next | 100 | 150 | 250 | 400 | 600 | 1,500 | 3,400 | 5,350 |
| Cumulative | 0 | 100 | 250 | 500 | 900 | 3,500 | 12,100 | 24,150 |
Rewards — Seeds are always exactly 2/5 of base XP, so the two economies cannot drift apart
during a re-balance:
| Trivial | Easy | Medium | Hard | Epic | |
|---|---|---|---|---|---|
| XP | 10 | 25 | 50 | 100 | 200 |
| Seeds | 4 | 10 | 20 | 40 | 80 |
| Attribute points | 1 | 1 | 2 | 4 | 8 |
A streak multiplies XP and Seeds by 1 + 0.05 × min(streak, 10), up to 1.5×, and the first
quest of each day adds a flat +15 XP / +5 Seeds.
Because level is derived from totalXp rather than incremented, one award that crosses three
thresholds runs the identical code path as one that crosses none — multi-level-ups are free.
Three decisions did most of the work.
A judge double-clicking "complete" and getting double XP is the most likely visible failure in a product like this, so completion has three independent defences. Any one of them alone prevents the dou AC2C ble award.
- An atomic compare-and-set on the task document. Concurrent duplicates serialise; the second re-evaluates the filter against the newly committed document, fails the day-key predicate, and matches nothing. This layer needs no index, so it holds even if index creation failed in production.
- A unique-index insert into the completion ledger, keyed
taskId:onceortaskId:YYYY-MM-DD— derived entirely from server-side facts, never from a client-supplied idempotency header. - A pure
$incon the character (never a read-modify-write, which loses updates under concurrency), followed by a$lt-guarded level write that is monotonic and convergent under any interleaving.
The order is claim → ledger → increment, so a crash mid-way under-awards rather than over-awards. A missed award is invisible; a doubled one is a visible cheat.
A duplicate returns 200 { awarded: false }, not a 4xx — double-clicking is a normal human
action, not an error worth a red toast.
Un-completing a quest puts it back on the board and deducts nothing. The ledger row survives
(flagged reverted), so its award key survives too — re-completing the same quest the same day
collides on the unique index and pays nothing.
That single decision closes the toggle-farm exploit and removes the whole class of bugs that come from a progress bar running backwards: negative widths, negative ranges, a level number that decreases mid-animation.
Streaks are keyed to YYYY-MM-DD in the user's IANA timezone, never UTC. UTC is wrong in both
directions: for an IST user, completions at 11pm and 1am the next evening fall on the same UTC
day (under-counts); for a US/Pacific user, a 5pm completion already belongs to the next UTC day
(over-counts).
The streak is written once, inside the same atomic update that grants the XP, but decayed at read time. Showing the stored value raw would tell someone who last acted five days ago that they still have a five-day streak — wrong data that looks deliberate, which is worse than a visible error. Decaying on read needs no cron job and no extra query.
- The client cannot influence an award.
POST /api/tasks/:id/completetakes an empty body by design. Every zod schema is.strict(), so{"xp": 99999}is rejected with a 400 rather than silently ignored. Persistence always builds an explicit$set; there is no{...body}spread anywhere in the codebase. - Ownership lives in the query filter, never in an
ifafter a fetch. Another user's quest matches zero documents, which is indistinguishable from "not found" — so probing leaks nothing. - No endpoint can return HTML. Every handler goes through
withRoute(), which maps zod → 400,CastError→ 404, duplicate key → 409 and everything else → a generic 500. This is the main anti-blank-screen measure: afetchexpecting JSON never receives Next's error page. GET /api/characterupserts. A 30-day session can outlive its documents; without this, such a user reaches the dashboard logged in and finds nothing. With it, the dashboard is renderable for any authenticated user, unconditionally.- Themes are real persistence. Equipping one writes to MongoDB and the root layout repaints
every surface from the new palette. No primary state is kept in
localStorage.
A single Next.js App Router monolith. Server Components read MongoDB directly — no fetch to our
own API, no useEffect data fetching. Mutations go through REST route handlers, which keeps every
endpoint independently testable.
src/
app/
page.tsx public landing (SEO)
(auth)/ login · signup
(app)/ authed shell — desk · quests · shop · journal
api/ route handlers
error.tsx · global-error.tsx · not-found.tsx · loading.tsx
components/ ui/ · layout/ · forms/ · features/
lib/
game/ constants.ts · progression.ts ← pure, no I/O
services/ character · tasks · completion · shop
db/models/ User · Character · Task · TaskCompletion · DailyActivity
api/ response envelope · withRoute wrapper
Authorization is enforced in layers. middleware.ts handles redirect UX using an edge-safe config
(no Mongoose, no bcrypt), but it is not the security boundary — every server component and
route handler independently resolves the session, because a mistake in the middleware matcher
would silently disable it.
| Collection | Key | |
|---|---|---|
users |
{ email: 1 } |
unique — duplicate signup is a 409, never a race |
characters |
{ userId: 1 } |
unique — 1:1 guaranteed at the database |
tasks |
{ userId: 1, status: 1, createdAt: -1 } |
the main list query (ESR order) |
tasks |
{ userId: 1, category: 1 } |
category filters |
tasks |
{ userId: 1, dueAt: 1 } |
partial — most quests have no due date |
taskcompletions |
{ userId: 1, awardKey: 1 } |
unique — the idempotency index |
taskcompletions |
{ userId: 1, completedAt: -1 } |
history, cursor-paginated |
taskcompletions |
{ userId: 1, dayKey: 1 } |
per-day aggregates |
dailyactivities |
{ userId: 1, dayKey: 1 } |
unique — makes "first of day" race-free |
The completion ledger is referenced, not embedded: it grows without bound. The shop inventory is the only embedded array, and only because the catalogue is a fixed constant, so it is provably bounded.
Every response uses one envelope:
| Method | Path | |
|---|---|---|
POST |
/api/auth/register |
creates user + character together |
* |
/api/auth/[...nextauth] |
sign in / out / session |
GET PATCH |
/api/character |
GET upserts; PATCH takes only displayName, timezone |
POST |
/api/character/equip |
403 if you don't own it |
GET POST |
/api/tasks |
cursor-paginated; limit clamped to 50 server-side |
GET PATCH DELETE |
/api/tasks/:id |
status limited to active|archived; DELETE is soft |
POST |
/api/tasks/:id/complete |
empty body by design |
POST |
/api/tasks/:id/uncomplete |
character unchanged |
GET |
/api/history |
the award ledger |
GET POST |
/api/shop · /api/shop/purchase |
|
GET |
/api/health |
{ db, latencyMs } |
npm run verify # typecheck + lint + game maths + colour contrast
npm run build
bash scripts/api-smoke.sh # 43 checks over real HTTP against a running serververify:progression covers the level table, multi-level jumps, attribute boundaries,
timezone-sensitive day keys, and every streak transition including a clock moving backwards —
132 assertions, no database needed.
verify:contrast checks every colour role against WCAG AA (4.5:1) in all five themes, including
white-on-accent for buttons. It found real failures on the first run — the currency pill was at
1.92:1 — and exposed a design conflict: dark themes use a light accent, so white button text
was unreadable there. That needed a dedicated --color-on-accent token rather than a colour
tweak. Run it with --fix to adjust failing colours by lightness alone, which preserves the hue.
verify:a11y audits server-rendered HTML for heading order, landmarks, unlabelled controls and
form fields, and layout hazards at phone width:
curl -s http://localhost:3000/desk > /tmp/desk.html && npm run verify:a11y /tmp/desk.htmlapi-smoke.sh is the one that matters for correctness under abuse. It requires npm run dev on
port 3100 and asserts, among other things:
- six concurrent completions of one quest award exactly once (+210 XP, not +1,260);
- three concurrent purchases of one item deduct exactly 40 Seeds and never go negative;
- complete → uncomplete → complete pays nothing the second time;
- another user's quest returns 404, not 403 and not the document;
{"xp": 99999}is rejected, not ignored;- a malformed ObjectId, an empty body and broken JSON each return a clean 4xx, never a 500;
- the history ledger survives deleting the quest it came from;
- no endpoint returns HTML to a caller expecting JSON, errors included.
Stopping MongoDB mid-session is also worth doing by hand: APIs return 503 JSON within ~5s
(never a hang, never HTML), pages return 200 with a server-rendered explanation rather than a
blank screen, and the app recovers on its own when the database comes back — no restart.
| Variable | Required | |
|---|---|---|
MONGODB_URI |
yes | MongoDB Atlas connection string |
AUTH_SECRET |
yes | ≥32 chars — openssl rand -base64 32 |
AUTH_URL |
production | public origin; inferred automatically on Vercel |
SEED_DEMO_EMAIL / SEED_DEMO_PASSWORD / SEED_DEMO_TIMEZONE |
no | demo account overrides |
.env is gitignored and must never be committed. Environment validation is lazy, so a missing
variable surfaces as a clean 503 at request time rather than crashing the build.
- Push to GitHub and import the repo on Vercel (or any Node host).
- Set
MONGODB_URI,AUTH_SECRETandAUTH_URL. - In Atlas → Network Access, allow your host's egress (
0.0.0.0/0for Vercel's dynamic IPs). - Open
/api/healthon the deployed URL — not localhost — to confirm the production backend reaches Atlas before you demo anything. - Optionally run
npm run seedagainst the production URI to create the demo account.
Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · MongoDB Atlas via Mongoose · Auth.js v5 · zod · Motion. No AI, no external APIs — every number is computed from the rules above.
{ "ok": true, "data": { … } } { "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "…", "details": { "title": ["…"] } } }