Reviewed-on: #14
TehRiehlIncremental
A meta/dev-themed incremental game. You play a developer; resources are Lines of Code, Commits, Coffee, Closed Tickets, Releases, and Tech Debt. Built on a TMT-compatible (The Modding Tree) JSON content schema with a React + Redux frontend and a NestJS + Postgres backend.
Status: v0.1 — playable. Six day-1 layers, full auth + cloud saves with envelope encryption, Tailwind-themed IDE UI. See
ProjectPlan.mdfor the original design plan.
Architecture
TehRiehlIncremental/
├── docker-compose.yml Postgres for local dev
├── pnpm-workspace.yaml
├── packages/
│ ├── shared/ TS types, TMT-compatible schema, formula registry
│ ├── content/ JSON layer definitions for the day-1 content drop
│ ├── client/ Vite + React + Redux + Tailwind + ShadCN
│ └── server/ NestJS + Prisma + envelope encryption service
└── .github/workflows/ CI: lint, typecheck, test (90% coverage), build
The shared package is the contract between client and server: both import the same TS types and the same formula registry, so save validation and game logic stay aligned by construction.
Prerequisites
- Node.js 20+
- pnpm 9+ (
corepack enable && corepack prepare pnpm@9.12.0 --activate) - Docker (for local Postgres) — or a locally-running Postgres if you prefer
Setup
# 1. Install dependencies
pnpm install
# 2. Generate local-dev secrets into .env. The example file ships with
# REPLACE_ME placeholders; the snippet below picks fresh keys.
cp .env.example .env
node -e "
const c=require('node:crypto'),fs=require('node:fs'),p='.env';
let e=fs.readFileSync(p,'utf8')
.replace(/^ENCRYPTION_MASTER_KEY=.*/m,'ENCRYPTION_MASTER_KEY=\"'+c.randomBytes(32).toString('base64')+'\"')
.replace(/^EMAIL_HASH_SECRET=.*/m,'EMAIL_HASH_SECRET=\"'+c.randomBytes(32).toString('base64')+'\"')
.replace(/^JWT_SECRET=.*/m,'JWT_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"')
.replace(/^JWT_REFRESH_SECRET=.*/m,'JWT_REFRESH_SECRET=\"'+c.randomBytes(64).toString('base64')+'\"');
fs.writeFileSync(p,e);
"
# 3. Start Postgres
docker compose up -d
# 4. Generate Prisma client + apply migrations
pnpm --filter @teh-riehl/server prisma generate
pnpm --filter @teh-riehl/server prisma migrate dev
# 5. Start everything (server on :3000, client on :5173)
pnpm dev
Open http://localhost:5173. Sign up, then play. Saves sync to Postgres every 30s; localStorage holds the truth in between.
Useful scripts
| Command | What it does |
|---|---|
pnpm dev |
Run client + server + watchers in parallel |
pnpm build |
Build all packages |
pnpm test |
Run all unit suites |
pnpm test:coverage |
Unit tests + coverage gate (≥90% on shared formulas) |
pnpm --filter @teh-riehl/server test:integration |
E2E against real Postgres — requires docker compose up -d |
pnpm typecheck |
tsc --noEmit across packages |
pnpm lint |
ESLint across packages |
pnpm format / pnpm format:check |
Prettier write / verify |
pnpm --filter @teh-riehl/server prisma migrate dev |
Apply / regenerate the DB schema |
CI (.github/workflows/ci.yml) runs test, lint, secrets-scan, vuln-scan, and sast in parallel. On the main branch, it then builds + scans both Docker images (tehriehlincremental-server, tehriehlincremental-client), pushes signed (cosign) images to Harbor under harbor.tehriehldeal.com/tehriehlincremental/{server,client}, and back-pushes per-package git tags (server-vX.Y.Z, client-vX.Y.Z). Bump the version in packages/{server,client}/package.json before merging; the push job refuses to overwrite an existing Harbor tag.
Container images
# Build (from repo root — workspace deps need the full context)
docker build -f packages/server/Dockerfile -t teh-riehl-server .
docker build -f packages/client/Dockerfile -t teh-riehl-client .
Runtime config
The client is a static Vite build. Rather than baking the backend URL into the bundle at build time, the nginx entrypoint writes /config.js from env vars on container start, and index.html loads it before the React bundle. So one immutable image targets any backend:
docker run -p 8080:80 \
-e APP_API_BASE_URL="https://api.example.com/api" \
-e APP_PUBLIC_URL="https://tehriehl.example.com" \
teh-riehl-client
APP_PUBLIC_URL is the site's absolute origin (no trailing slash). The entrypoint substitutes it into the Open Graph / Twitter Card meta tags in index.html so chat clients (Slack, Discord, iMessage, Twitter, etc.) can render link previews against fully-qualified URLs. Leave it unset for local Docker spin-ups; previews will fall back to relative paths, which most modern scrapers handle but Facebook does not.
Add a new runtime knob by extending the AppConfig interface in packages/client/src/lib/runtimeConfig.ts, adding the env var to packages/client/docker/40-app-config.sh, and documenting it here.
Security model
TL;DR: passwords are hashed (Argon2id), PII and save blobs are field-level encrypted (AES-256-GCM via envelope encryption), and disk-level encryption is expected at the infra layer in production.
- Passwords: hashed with Argon2id. Never stored or logged.
- Emails: stored as ciphertext (AES-256-GCM) + a separate HMAC-SHA256 blind index for login lookup. The DB never sees the plaintext.
- Save blobs: full game-state JSON, encrypted in the server before insert.
- Envelope encryption: every encrypted record has a per-record DEK (Data Encryption Key), itself encrypted with the master KEK (Key Encryption Key) from
ENCRYPTION_MASTER_KEY. Rotating the master key only re-encrypts the DEKs, not all data. - Encryption at rest (disk-level): the local Docker Postgres volume is not encrypted at rest — this is acceptable for development because the app-layer encryption is the primary defense. Production deployments must use an encrypted block device (AWS EBS+KMS, GCP PD+CMEK, or LUKS).
Game content
Layer definitions live in packages/content/src/layers/*.json and conform to the TS schema in packages/shared/src/schema/. Adding a new layer = drop a new JSON file in, re-export it from layers.ts, and (if you reference a new formula) register it in packages/shared/src/formulas/builtins.ts. No frontend changes required for content-only additions — the React app reads the layer list at build time.
Day-1 layers (in unlock order):
| Layer | Resource | Role | Color |
|---|---|---|---|
| Code | Lines of Code | Base producer | cyan |
| Commits | Commits | Prestige (resets Code) | magenta |
| Coffee | Cups | Multiplier buyables | amber |
| Tickets | Closed Tickets | Challenge layer (debuffs+goals) | green |
| Releases | Release Tags | Hard prestige + expansion hook | violet |
| Tech Debt | Debt | Antagonist mechanic | red |
Formulas are referenced by ID (e.g. { "fn": "polynomial", "args": [10, 1.15] }) instead of being evaluated from strings, so content stays data-only and there is no eval / new Function attack surface. The full registry (10 formulas, 9 predicates) is in packages/shared/src/formulas/builtins.ts and predicates/builtins.ts.
Visual system
The design commits to one concept: the game lives inside an IDE. Type pairing is Geist (UI) + JetBrains Mono (numbers and system text). Six layer accents drawn from Tailwind's -400 family so they read as one palette. Diff gutters on cards (+ affordable, ~ owned, − locked). Hairline 1px borders, never CSS shadow blur. One signature element: the >_ blinking prompt, recurring at hero moments.
Tokens live in packages/client/tailwind.config.ts; the per-layer --accent CSS variable is set on data-layer="..." parents, so every component themes itself from one CSS var rather than needing layer-specific class variants.
License
GNU Affero General Public License v3.0 or later (SPDX: AGPL-3.0-or-later).
In plain terms: you may run, study, modify, and redistribute this code — but any fork, including a network-deployed one, must also be released under AGPL-3.0 and made available to its users. If you want to integrate this work into a project that can't comply with those terms, contact the author.