Skip to content

Repository files navigation

Shiftwork

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.md is the complete, current, step-by-step guide — Neon, Vercel, environment variables, and a troubleshooting section for the most common first-deploy failure.


What's actually built

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

A full animated vignette for every scene, not just an icon

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.

The animated character (the actual game-style animation, not text)

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.

The text/UI animation layer

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.

Replay variety — not always the same questions

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.

Fixing the compatibility score (three times)

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.


Tech stack

  • 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) and scripts/calibrate_careers.py (Monte Carlo scoring calibration) — run both after adding or editing any career JSON

Getting started

1. Install dependencies

npm install

2. Set up Clerk (auth)

  1. Create a free account at clerk.com and a new application.
  2. Copy .env.example to .env (not .env.local — see the callout below for why that distinction matters here).
  3. 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 (see SECURITY.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 direct prisma command load environment variables via dotenv, which only auto-loads a file literally named .env in the project root — it has no idea .env.local is a Next.js convention. Putting DATABASE_URL in .env.local makes the app itself work fine while every Prisma CLI command fails with Error: 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 .env for everything here.

3. Set up the database

Local dev needs no setup — SQLite is the default:

npx prisma migrate dev --name init

This creates prisma/dev.db (already git-ignored) and generates the Prisma client.

Why decisions is a String, not Prisma's Json type: SQLite's Prisma connector doesn't support Json at all — an earlier version of this schema used it and failed schema validation immediately (P1012) the first time someone actually ran the migration. decisions is stored as a JSON-stringified string instead (JSON.stringify/JSON.parse in src/app/api/simulation/route.ts), which works identically on SQLite, Postgres, and MySQL. If you add your own Json-shaped column later, keep this pattern rather than reaching for Prisma's Json type, 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.

4. Run it

npm run dev

Visit http://localhost:3000.

5. (Optional) enable AI narration

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.


Using Auth0 instead of Clerk

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:

  1. Replace @clerk/nextjs with @auth0/nextjs-auth0 in package.json.
  2. Replace src/proxy.ts's clerkMiddleware/auth.protect() calls with Auth0's withMiddlewareAuthRequired.
  3. Replace auth() / currentUser() calls in the two API routes (src/app/api/simulation/route.ts, src/app/api/narrate/route.ts) with Auth0's getSession().
  4. 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.


Project structure

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

Adding a new career

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:

  1. Create src/data/careers/<career-id>.json with this shape (see any existing file for a full example):
    {
      "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": "..." }
      }
    }
  2. Register it in src/lib/simulationEngine.ts: add one import line and one line in the CAREER_GRAPHS object. That's the entire integration — /careers picks 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 because saveRunSchema derives its enum from CAREER_IDS.
  3. Give it a prop icon: add one line to the switch in src/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.
  4. Run the two scripts, in this order, from the project root:
    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
    Both need to pass/run clean before the career is playable end to end.

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.


Future enhancements

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": true scenes 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.tsx is 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 per SceneStage environment 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.

What got built instead of staying on this list

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).

On "every career in the world"

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).


Keeping this current

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.yml and npm run audit (see SECURITY.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.

Scripts

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 only

Verifying local storage works

Before touching Neon or Vercel at all, confirm the local SQLite setup is actually persisting data:

  1. npx prisma migrate dev --name init (if you haven't already).
  2. npm run dev, sign in, play one career through to an ending.
  3. In a second terminal: npx prisma studio. This opens a browser GUI at http://localhost:5555 for the local database directly — click SimulationRun and you should see the row you just created, with your real endingKey, stats, and a decisions column 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.


Deploying to Vercel with Neon Postgres

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.

About

This is a Next.js app called Shiftwork that simulates a 16-hour trauma surgeon shift as a branching career game. It uses Clerk for user authentication and session protection, Prisma with SQLite for persisting signed-in users and completed simulation runs, and a JSON-driven scene graph to drive the interactive choices and outcomes.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages