A branching, stat-driven day-in-the-life career simulator — Sims-style mechanics (tracked needs, a living animated character, a day that visibly moves from dawn to night) applied to the question "what does this job actually feel like, minute to minute?" 30 careers are fully playable end to end, sharing one engine.
Before you read further: see
SECURITY.md. It explains what security measures are actually in place, and — just as important — what "secure" can and can't mean for a piece of software. Please read it before deploying this anywhere real users will use it.Ready to put this on the internet?
DEPLOYMENT.mdis the complete, current, step-by-step guide — Neon, Vercel, environment variables, and a troubleshooting section for the most common first-deploy failure.
| Feature | Status |
|---|---|
| Landing page (animated hero, rotating live career preview pulled from real content, entrance animations), career picker, difficulty picker | ✅ |
| 30 fully playable careers (~15-19 scenes each, a signature high-stakes 4-way decision, 5 possible endings, one randomized "life happens" beat for replay variety): Trauma Surgeon, Astronaut, Detective, Michelin Chef, Pilot, Wildlife Photographer, Investment Banker, Air Traffic Controller, Firefighter, Teacher, Paramedic, Software Engineer, Lawyer, Police Officer, Journalist, Architect, Veterinarian, Marine Biologist, Cruise Ship Captain, Zookeeper, Wedding Planner, Poker Player, Stunt Performer, Museum Curator, Dairy Farmer, Construction Foreman, Diplomat, Hostage Negotiator, Bomb Disposal Technician, Park Ranger | ✅ |
| Live stat tracking (stress / energy / reputation / pay / a career-specific highlight counter) | ✅ |
An animated visual character (CareerAvatar.tsx) — not text, an actual SVG figure with a career-specific prop icon, whose posture, expression, and pace change with mood and react to the big-decision moments — see below |
✅ |
A full animated scene vignette for every scene (SceneStage.tsx) — not an icon, a small multi-element illustrated scene (moving car, twinkling stars, a pulsing ECG line, a rotating gear...) matched to each scene's environment tag — see below |
✅ |
| Signature UI: an animated ECG line whose speed and color react to your stress in real time | ✅ |
| Sims-style "living" text/UI animation layer (typewriter narration, floating stat pop-ups, day/night ambient background) — see below | ✅ |
| Replay variety — each career has at least one randomized branch point so two playthroughs of the same career don't play out identically | ✅ |
| End-of-shift report with a compatibility score that's an honest percentile against real random play, not a formula that quietly can't go below 50 (see "Fixing the compatibility score") | ✅ |
Shift history page (/history) — every past run, with career, ending, date, and a recomputed compatibility score, linked from the careers page, the ending screen, and the landing page nav |
✅ |
Leaderboard (Leaderboard.tsx, sidebar on /careers) — top 20 runs by score, a smooth animated toggle between global and personal views, plus career and difficulty filters; no other player's identity is ever shown, only "· you" on your own global entries |
✅ |
| Difficulty-fair scoring — each career's score is calibrated separately per difficulty (normal/realistic/chaos), matching the multiplier the engine actually applies at runtime, so a 90% in chaos and a 90% in normal mean the same thing — see "Fixing the compatibility score" | ✅ |
Achievements (Achievements.tsx, on /history) — 8 badges computed from real run history, no new tracked events needed |
✅ |
Career recommendations (lib/recommendations.ts, on /history) — deterministic, not an AI call: aggregates which trait tags correlate with your best scores and suggests unplayed careers sharing that trait, once you have 3+ runs |
✅ |
Per-run analytics (/history/[runId]) — a stress/energy/reputation timeline for that specific shift, replayed scene-by-scene from the stored decisions |
✅ |
Shareable results (/share/[runId], /api/og/[runId]) — a public page and a dynamically generated Open Graph image per run, for sharing outside the app; shows only career/difficulty/ending/score, never who played it |
✅ |
Synthesized sound effects (lib/sound.ts) — Web Audio API tones on stat changes and endings, muteable, no audio file assets (see the design note below for why) |
✅ |
| Accounts via Clerk (email + social login), session-gated routes | ✅ |
| Server-side scoring — the server replays your decisions itself rather than trusting a client-submitted score | ✅ |
Run history persisted per user (Postgres/SQLite via Prisma) — see the /history page above |
✅ |
| Optional AI narration hook for one scene (OpenAI), with a static fallback so the game is fully playable with zero API keys | ✅ |
| Every career in the world, NPC conversations, voice narration, multiplayer, a full 3D/graphical world | 🔜 see "Future enhancements" and "On 'every career in the world'" below |
Every scene carries an environment tag (commute, briefing, rest,
alert, social, outdoors, paperwork, work), and SceneStage.tsx
renders a small hand-built animated scene for it — several layered SVG
shapes moving together, not a single bouncing icon: a car with wheels
driving past sliding road markings, three silhouettes with a speech bubble
rotating between them, a crescent moon under slowly twinkling stars, an ECG
line sweeping under a pulsing warning triangle, two speech bubbles trading
a back-and-forth bounce, a sun rotating over swaying trees, a pen "writing"
across a clipboard with a checkmark landing, a turning gear throwing off
sparks. Each environment's motion means something rather than just
looking different — commute drifts sideways like movement, alert is sharp
and fast, rest is slow and soft. All 463 scenes across the 30 careers were
tagged (auto-classified from scene content, with every signature 4-choice
decision forced to alert regardless of wording) and verified structurally
sound the same way the rest of the content is (see
scripts/validate_careers.py).
Why this is original vector animation and not video/GIF clips: sourcing real footage or GIFs for ~200 scenes runs straight into copyright licensing problems for anything actually worth using, there's no tool available here to generate real video, and baked-in video/GIF assets would be megabytes per scene instead of a few KB of SVG markup — for zero payoff, since a fixed clip can't react to game state the way vector animation already does elsewhere in this app (the character's mood, the ECG line's speed, the day/night background). Original animated illustration gets the "this feels alive" result the request was actually after, without any of those costs.
CareerAvatar.tsx is a hand-built SVG figure — not an image asset, not a
GIF, an actual vector character animated with Framer Motion — that's meant
to feel like a small game character rather than a UI decoration:
- Idle motion: a continuous gentle bob and an occasional blink, so the character reads as "alive" even when nothing's happening.
- Mood-driven posture: arm position, head tilt, eyebrow angle, and mouth
shape all shift based on the current mood computed from your stats
(
lib/mood.ts) — slumped and heavy-lidded when exhausted, tense and wide-eyed when overwhelmed, upright with a soft green glow when things are going well. - Tension reaction: during each career's signature 4-choice decision, a pulsing amber ring appears around the character — the same visual language as the tension glow around the scene text, now on the character too.
- A career-specific prop: a small icon badge (stethoscope, rocket, chef's
hat, radar dish, and so on, via
lucide-react) rendered on the character, so the same base figure reads as a different job per career without needing twelve hand-drawn illustrations. - A final pose on the ending screen: arms raised for the best ending, slumped forward for burned out, arms drawn in for written up — the character visibly reacts to how the day went, not just the text around it.
This is the part that makes the surrounding screen feel like a day is passing, not a form being filled out:
- Typewriter narration — scene text reveals a couple of characters at a time; choices fade in only once it's finished.
- Floating stat pop-ups (
FloatingDeltas.tsx) — "+10 REP" / "-8 ENERGY" badges fly up and fade the moment a choice lands. - A day/night ambient background (
lib/dayCycle.ts) — the screen's background gradient shifts through dawn → day → golden hour → night as the in-game clock advances. - Tension staging — the signature decision in every career gets a slow pulsing amber glow around the scene text as well as the character.
- Ending flourishes — on-brand confetti for the best ending, a brief
shake for the worst, in
EndingReport.tsx.
Everything above respects prefers-reduced-motion: the typewriter reveals
instantly, the character's idle bob and blink stop, and spring/pop-up
animations collapse to near-zero duration for anyone with that OS setting on.
Every career has at least one scene marked "randomized": true
(src/data/careers/*.json). Instead of showing choice buttons, the game
auto-picks one of that scene's 2-3 minor outcomes at random and narrates it
— a rookie's good question, a supply delivery running early, a radio
crackling with static — small texture that means two playthroughs of the
same career don't unfold identically. The random pick is recorded in the
decisions list exactly like a click would be
(pickRandomChoice in simulationEngine.ts), so server-side replay and
scoring don't need to know or care that it wasn't a manual choice.
First attempt. An early version of the score used one continuous formula over your final stats. Testing it against every possible playthrough of the trauma-surgeon career (2,048 distinct paths) showed it never dropped below the mid-50s even on the most reckless path — technically working, but meaningless in practice.
That fix wasn't enough either. The next version tied the score to a band based on which of 5 endings you got. That solved the "never below 50" case for deliberately, maximally bad play — but random-sampling 10,000 ordinary (randomly-chosen-choice) playthroughs showed 93% of them still landed in the top two tiers, with mean final reputation at 81/100 against a starting value of 60. In other words: is everyone compatible with this job? The numbers said yes, which was exactly the complaint — a system that can't tell an average attempt from a good one isn't measuring anything.
The percentile-calibration fix (scripts/calibrate_careers.py +
compatibilityScore/determineEnding in simulationEngine.ts) replaced
fixed thresholds with per-career percentile calibration: for each
career, 20,000 simulated random playthroughs are run offline, a
"performance index" (reputation-weighted, composure and energy contributing
less) is computed for each, and the resulting percentile checkpoints are
stored in that career's JSON under "calibration". At runtime, your score
is your percentile rank against that baseline — "better than N% of people
who tried this shift" — and the ending tier is read off the same rank. This
was verified by simulating 20,000 random playthroughs against the finished
system: mean and median score of exactly 50.0, 50% of random players scoring
below 50, by construction.
Still one gap: difficulty wasn't accounted for. That calibration ran
once per career, at the base (normal-difficulty) effect magnitudes. But
applyEffects() amplifies negative stress/energy effects by 1.15x on
realistic and 1.35x on chaos (DIFFICULTY_MULTIPLIER) — meaning a chaos
player was always going to face harder numbers than the baseline they were
being graded against, so normal-mode players had a systematically easier
path to a high score for equally good decisions. The fix:
scripts/calibrate_careers.py now runs the Monte Carlo simulation three
times per career, once per difficulty, applying the exact same multiplier
applyEffects() uses at runtime — calibration in each career's JSON is
now keyed by difficulty ({ normal: {...}, realistic: {...}, chaos: {...} }),
and compatibilityScore/determineEnding both take a difficulty argument
to look up the right one. Verified the same way as the first fix: simulating
20,000 random playthroughs under each difficulty independently now gives a
mean score of ~50 for all three, not just for normal. A 90% in chaos and a
90% in normal now mean the same thing: better than 90% of people who tried
that exact combination.
- Framework: Next.js 16 (App Router, TypeScript, strict mode, Active LTS as of this writing — see "Keeping this current" below)
- Auth: Clerk — hosted sign-in/sign-up UI, session management, and edge middleware. (Auth0 was the other option named in the brief; see "Using Auth0 instead" below if you'd rather use that.)
- Database: PostgreSQL in production, SQLite for zero-setup local dev — both through Prisma, so every query is parameterized and there is no hand-written SQL anywhere in the app.
- Styling/animation: Tailwind CSS, Framer Motion,
lucide-react(career prop icons on the animated avatar) - Validation: Zod on every API input
- Optional AI: OpenAI's Chat Completions API for one dynamic scene per career (entirely optional — the app works with zero AI keys configured)
- Career tooling:
scripts/validate_careers.py(structural checks) andscripts/calibrate_careers.py(Monte Carlo scoring calibration) — run both after adding or editing any career JSON
npm install- Create a free account at clerk.com and a new application.
- Copy
.env.exampleto.env(not.env.local— see the callout below for why that distinction matters here). - Paste your Publishable key and Secret key into
.env. The publishable key is safe for the browser; the secret key must never be committed or exposed to client code (seeSECURITY.md).
Use
.env, not.env.local, in this project. Next.js happily reads either one. The Prisma CLI does not:prisma migrate dev,prisma studio, and every other directprismacommand load environment variables viadotenv, which only auto-loads a file literally named.envin the project root — it has no idea.env.localis a Next.js convention. PuttingDATABASE_URLin.env.localmakes the app itself work fine while every Prisma CLI command fails withError: Environment variable not found: DATABASE_URL(P1012), which is exactly the confusing state to end up in if you split the difference. Both files are already git-ignored, so there's no downside to just using.envfor everything here.
Local dev needs no setup — SQLite is the default:
npx prisma migrate dev --name initThis creates prisma/dev.db (already git-ignored) and generates the Prisma
client.
Why
decisionsis aString, not Prisma'sJsontype: SQLite's Prisma connector doesn't supportJsonat all — an earlier version of this schema used it and failed schema validation immediately (P1012) the first time someone actually ran the migration.decisionsis stored as a JSON-stringified string instead (JSON.stringify/JSON.parseinsrc/app/api/simulation/route.ts), which works identically on SQLite, Postgres, and MySQL. If you add your ownJson-shaped column later, keep this pattern rather than reaching for Prisma'sJsontype, unless you've already committed to Postgres/MySQL for local dev too.
For production, switch provider in prisma/schema.prisma from sqlite to
postgresql, point DATABASE_URL at your instance, and re-run
prisma migrate dev (or prisma migrate deploy in CI/CD). No other code
changes are needed — every query goes through Prisma's query builder, so
nothing in the app is SQLite-specific.
Use a least-privilege database role for the app in production: it only
ever needs SELECT/INSERT/UPDATE/DELETE on its own tables, never
schema-admin or superuser rights.
npm run devVisit http://localhost:3000.
Leave OPENAI_API_KEY unset and the game runs fully offline with a static
fallback line for the one AI-eligible scene. To turn on dynamic narration,
add your key to .env. The key is read server-side only
(src/app/api/narrate/route.ts) and is never sent to the browser.
The brief named either as acceptable. Clerk was chosen here because its Next.js App Router middleware integration needs the least custom code (which means less custom code to get wrong). If you'd rather use Auth0:
- Replace
@clerk/nextjswith@auth0/nextjs-auth0inpackage.json. - Replace
src/proxy.ts'sclerkMiddleware/auth.protect()calls with Auth0'swithMiddlewareAuthRequired. - Replace
auth()/currentUser()calls in the two API routes (src/app/api/simulation/route.ts,src/app/api/narrate/route.ts) with Auth0'sgetSession(). - Swap the
<SignIn>/<SignUp>/<UserButton>components for Auth0's hosted login redirect and a plain logout link.
The rest of the app (Prisma models, the simulation engine, the UI) is auth-provider-agnostic and does not need to change.
shiftwork/
├── scripts/
│ ├── validate_careers.py # Structural checks (run before shipping a career)
│ └── calibrate_careers.py # Monte Carlo scoring calibration (run after any edit)
├── prisma/
│ └── schema.prisma # User + SimulationRun models
├── src/
│ ├── proxy.ts # Clerk route protection (Next.js 16's renamed middleware.ts; runs before every request)
│ ├── app/
│ │ ├── page.tsx # Public landing page (animated hero + rotating preview)
│ │ ├── careers/page.tsx # Career picker (protected) — generated from CAREER_GRAPHS
│ │ ├── history/page.tsx # Past runs, achievements, recommendations (protected)
│ │ ├── history/[runId]/page.tsx # Per-run detail: stats + stress/energy timeline (protected, ownership-checked)
│ │ ├── share/[runId]/page.tsx # Public shareable run result, OG metadata (no auth — see proxy.ts)
│ │ ├── simulation/page.tsx # Redirects /simulation -> /simulation/trauma_surgeon
│ │ ├── simulation/[career]/page.tsx # The game itself (protected, validates career id)
│ │ ├── sign-in/, sign-up/ # Clerk-hosted auth pages
│ │ └── api/
│ │ ├── simulation/route.ts # Save/list runs — auth + validation + server-side scoring
│ │ ├── narrate/route.ts # Optional AI narration — auth + rate limit + safe fallback
│ │ └── og/[runId]/route.tsx # Dynamic Open Graph image for a shared run (no auth)
│ ├── components/
│ │ ├── LandingHero.tsx # Landing page's animated hero + rotating live career preview (client)
│ │ ├── CareerAvatar.tsx # The animated SVG character (mood, tension, ending poses)
│ │ ├── SceneStage.tsx # Full animated per-scene vignette (commute/rest/alert/...), not just an icon
│ │ ├── VitalsMonitor.tsx # ECG signature element + mood face + sound toggle + stat readouts
│ │ ├── SceneView.tsx # Typewriter narration + staggered, tension-staged choices, randomized-scene auto-advance
│ │ ├── FloatingDeltas.tsx # Sims-style floating "+10 REP" stat pop-ups
│ │ ├── SimulationClient.tsx # The game's client-side state machine, per career
│ │ ├── SoundToggle.tsx # Mute/unmute for the synthesized sound effects
│ │ ├── StatTimeline.tsx # Hand-rolled SVG line chart for the per-run analytics page
│ │ ├── Achievements.tsx # Badge row on /history
│ │ ├── RecommendationCard.tsx # "Based on your shifts" suggestion on /history
│ │ ├── CareerCard.tsx
│ │ ├── Leaderboard.tsx # Global/personal top-20 sidebar, career + difficulty filters, smooth toggle
│ │ └── EndingReport.tsx # Ending copy, compatibility score, confetti/shake, share link, ending chime
│ ├── lib/
│ │ ├── simulationEngine.ts # Career registry + pure functions: effects, replay(WithHistory), ending, calibrated score
│ │ ├── leaderboard.ts # Turns raw runs into ranked, scored leaderboard entries
│ │ ├── achievements.ts # 8 badge definitions + evaluation over run history
│ │ ├── recommendations.ts # Deterministic trait-based career suggestions
│ │ ├── sound.ts # Synthesized SFX via the Web Audio API (no audio file assets)
│ │ ├── careerIcons.tsx # Career id -> prop icon for the avatar
│ │ ├── sceneEnvironments.tsx # Scene environment tag -> icon + label for the backdrop
│ │ ├── dayCycle.ts # Scene time -> hour-of-day -> ambient gradient
│ │ ├── mood.ts # Stats -> mood + label (drives both the mood face and the avatar's posture)
│ │ ├── validation.ts # Zod schemas for every API input
│ │ ├── rateLimit.ts
│ │ └── db.ts # Prisma client singleton + shared SimulationRunRow type
│ └── data/careers/ # One JSON per career — see "Adding a new career"
│ ├── trauma-surgeon.json
│ ├── astronaut.json
│ ├── detective.json
│ ├── lawyer.json
│ ├── bomb-disposal-technician.json
│ └── ... 25 more (30 total — see the feature table above for the full list)
└── SECURITY.md
The whole point of the JSON-scene approach from the original design doc is that a new career is content, not code. As of this version, the engine is a proper registry — there is no per-career code to write:
- Create
src/data/careers/<career-id>.jsonwith this shape (see any existing file for a full example): - Register it in
src/lib/simulationEngine.ts: add oneimportline and one line in theCAREER_GRAPHSobject. That's the entire integration —/careerspicks it up automatically (the career cards are generated from this registry, not a hardcoded list),/simulation/<career-id>becomes a valid route, and the API route's Zod schema accepts it automatically becausesaveRunSchemaderives its enum fromCAREER_IDS. - Give it a prop icon: add one line to the
switchinsrc/lib/careerIcons.tsx(pick anything from lucide.dev) so the animated avatar has something to hold for this career. It falls back to a generic briefcase icon if you skip this step, so it's not blocking. - Run the two scripts, in this order, from the project root:
Both need to pass/run clean before the career is playable end to end.
python3 scripts/validate_careers.py # structural check: dangling links, # unreachable scenes, duplicate # choice ids, missing ending keys python3 scripts/calibrate_careers.py # writes this career's "calibration" # block (see "Fixing the # compatibility score" above) — # without this, the new career's # score will error at runtime
No changes to the database schema, auth, API routes, or other UI components
are needed — career is already a free-text column validated against the
registry at the API boundary, and every component that displays
career-specific text (highlightLabel, endings) reads it from the graph
rather than having it hardcoded.
Roughly in the order they'd add the most value:
- More careers. 30 are built now (the original brainstorm's own examples — astronaut, trauma surgeon, investment banker, air traffic controller — plus 26 more spanning law, medicine, media, the trades, entertainment, and public service). See "On 'every career in the world'" below for the honest scope on this.
- Richer randomized events. Right now each career has exactly one
randomized beat and "chaos mode" scales existing effect magnitudes. The
original design doc's Feature 4 envisioned a broader pool of injectable
events (equipment failure, a walk-in emergency) usable at more points in a
shift, not just one fixed beat per career. The data model already
supports adding more
"randomized": truescenes anywhere in a graph — this is more content work than engine work at this point. - NPC conversations (Feature: "Talk to your boss/patients/crewmates") —
a natural extension of the existing optional-AI-narration pattern in
api/narrate/route.ts, but as a back-and-forth exchange instead of a single generated beat, gated the same way (works with zero AI keys, degrades to scripted dialogue if none are configured). - A richer character.
CareerAvatar.tsxis deliberately a simple, abstract figure (see "The animated character" above) so it scales to any number of careers without needing bespoke art. A fuller version — walking between locations, more than one pose per mood, actual per-career outfits instead of a single prop icon — is a natural next step once there's a reason to invest in more elaborate art direction for a specific career. - Richer sound design. What's built now (
lib/sound.ts) is short synthesized tones on stat changes and endings — deliberately not ambient background audio, which is much harder to get right without being able to actually listen to it while building (loop points, volume balance across 12 very different careers, browser autoplay policy edge cases). A real ambient layer perSceneStageenvironment tag is a reasonable next step, but wants a proper listening/tuning pass, not a blind first attempt. - Voice narration, multiplayer, a full 3D/graphical world — all named in the original brainstorm's "Future Features" list. Each is a substantially larger project in its own right (voice needs a TTS pipeline and per-scene audio direction; multiplayer needs a real-time transport and a shared-state model; a 3D world is closer to a second product than an extension of this one) — worth scoping separately once the current version has real usage data to justify them.
A few things that were on this list in earlier versions of this README are
done now, in case you're comparing against an old copy of this file:
achievements (lib/achievements.ts, 8 badges on /history, computed
from existing run data, no new tracked events), shareable result cards
(/share/[runId] + a dynamically generated Open Graph image at
/api/og/[runId]), per-run analytics (/history/[runId], a stress/
energy/reputation timeline replayed from the stored decisions), career
recommendations (lib/recommendations.ts — deterministic, aggregated from
which trait tags correlate with your best scores, not an AI call), and
leaderboard segmentation (career and difficulty filters on
Leaderboard.tsx, alongside the difficulty-fairness calibration fix — see
"Fixing the compatibility score" above for why that fix mattered before the
filters were worth adding at all).
Worth being direct about this rather than overselling it: there is no version of "every career in the world" that gets hand-authored in a chat session, or realistically by any small team — O*NET alone lists roughly a thousand distinct occupations, and this project's bar for a career (15+ scenes, a real signature decision, calibrated scoring, a distinct voice) is intentionally higher than a one-line description would need. 30 careers is real progress against that thousand, not a rounding error, but it's still 3%, and that's worth saying plainly rather than letting "30 careers" imply more coverage than it is. What is true now: adding one is a bounded, mechanical, well-documented process — write one JSON file, run two scripts, done (see "Adding a new career") — rather than something that requires touching the engine, the database, or the UI. That's the honest version of "scales to any career": the infrastructure to add the 31st, the 100th, or the 500th career is now fully in place and doesn't get harder as the library grows; actually writing that many is a content project, not a software one, and would reasonably be its own multi-session effort (or a good candidate for a contribution process if this were opened up beyond one person).
Next.js 14 — a very common default in tutorials and templates — reached end of life in October 2025 and no longer receives security patches. This project is built on Next.js 16, the current Active LTS line, specifically because of that. That won't stay true forever:
- Next.js 16 is Active LTS until the next major version ships, then becomes Maintenance LTS, then eventually EOL — the same cycle 14 just went through.
- Check the Next.js support policy and endoflife.date/nextjs periodically, and plan a major-version upgrade while your current line is still supported, not after.
- The same applies to Clerk, Prisma, and every other dependency —
.github/dependabot.ymlandnpm run audit(seeSECURITY.md) are what catch this on an ongoing basis. A README can only describe the state of the world on the day it was written.
npm run dev # local dev server
npm run build # runs ESLint, then a production build
npm run start # run the production build
npm run lint # ESLint (flat config, eslint.config.mjs)
npm run lint:fix # ESLint with autofix
npm run prisma:studio # visual DB browser
npm run audit # npm audit, production deps onlyBefore touching Neon or Vercel at all, confirm the local SQLite setup is actually persisting data:
npx prisma migrate dev --name init(if you haven't already).npm run dev, sign in, play one career through to an ending.- In a second terminal:
npx prisma studio. This opens a browser GUI athttp://localhost:5555for the local database directly — clickSimulationRunand you should see the row you just created, with your realendingKey, stats, and adecisionscolumn containing the JSON-stringified choice list.
If that row is there, local storage is confirmed working end to end — API route → Prisma → SQLite file → readable back out.
The full, current, step-by-step walkthrough — Neon setup, environment
variables, running the production migration, switching Clerk to a
production instance, and a troubleshooting section for the most common
first-deploy failure (a bare "Internal Server Error" on every route) —
lives in DEPLOYMENT.md, not here, so there's one
authoritative version instead of this getting out of sync with it.
Quick summary if you just need the shape of it: Neon gives you a
Postgres DATABASE_URL → flip provider to "postgresql" in
prisma/schema.prisma and regenerate the migration against it → push to
GitHub and import into Vercel → add every variable from .env.example in
Vercel's environment variable settings (this step is the one that's easy
to miss, and missing it is almost always why a fresh deploy 500s
immediately) → run prisma migrate deploy once against the production
database → switch Clerk to a production instance before sending the URL
to anyone. See DEPLOYMENT.md for the actual commands and the
troubleshooting steps if any part of that fails.
{ "id": "your_career_id", "title": "A Day as a ...", "emoji": "🧑🎨", "tagline": "One line describing the day's core tension.", "highlightLabel": "Something tracked", // e.g. "Cases solved" "traits": ["high-pressure", "analytical"], // drives lib/recommendations.ts — reuse existing trait strings from other careers where they genuinely apply, rather than inventing new ones each time, or recommendations will never group two careers together "startScene": "wake_up", "scenes": { "wake_up": { "time": "06:00 AM", "text": "...", "environment": "rest", // optional: commute|briefing|rest|alert|social|outdoors|paperwork|work — drives the animated scene badge, defaults to "work" if omitted "choices": [ { "id": "...", "text": "...", "next": "next_scene_id", "effects": { "stress": 5, "energy": -5, "rep": 5, "money": 0, "highlights": 0 } } ] }, "a_random_beat": { "time": "08:30 AM", "text": "...", "randomized": true, // optional: UI auto-picks one outcome instead of showing buttons "choices": [ /* 2-3 minor, low-stakes outcomes — see "Replay variety" above */ ] } // ... 14-18 scenes total reads well; include one scene with 4 choices // for the "signature decision" moment and the tension-glow treatment // (tag it "environment": "alert" too), and at least one randomized // beat for replay variety }, "endings": { "triumphant": { "title": "...", "blurb": "..." }, "steady_hand": { "title": "...", "blurb": "..." }, "ordinary_day": { "title": "...", "blurb": "..." }, "burned_out": { "title": "...", "blurb": "..." }, "written_up": { "title": "...", "blurb": "..." } } }