Nobody lives forever. Everyone leaves something behind.
I built a game where players never respawn — they inherit lives. You don't create a character. The world hands you a fisherman, a queen, a criminal, a baker — someone already mid-story — and you live out their remaining life. When they die, the world keeps turning, and you inherit another life.
The world is a TypeScript simulation: ~1,600 souls across a procedurally generated realm, each with a family tree, layered memories, a journal, debts, grudges, and an unfinished goal. A monthly tick drives aging, marriage, birth, death, inheritance, war, crime, trade, technology and memory. Everything persists to Postgres. Nothing is faked — the LLM layer is optional, and when it's absent, a hand-written narrator speaks for the world.
This post walks through the architecture and the decisions that make it work.
The design philosophy: simulation-first, LLM-last
The most important decision was what the AI does not do. The LLM never controls behavior — it only writes prose. Every mechanic is a real rule system:
- Economy — settlements produce, price, trade and hoard goods; scarcity causes hunger.
- Demographics — fertility, coming-of-age, marriage, pregnancy, aging, death.
- Politics — leaders, councils, tax policy, succession, war, raids.
- Crime — theft, fraud, smuggling, trials, prisons, reputation damage.
- Technology — eras advance (stone → iron → printing → steam) as settlements accumulate progress.
If an LLM key is configured, it narrates journals, letters, gossip, dialogue and the "inherit a life" vignette. If not, the local narrator — hand-written template pools — produces the prose. Either way, the world speaks, and the simulation never blocks on an API call.
The second decision: legacy instead of levels. There's no XP and no respawn. Every life writes itself into history — found a bakery in year 42 and it's famous two centuries later; betray the king and your descendants are distrusted for generations. The reward for playing is the mark you leave on a world that outlives you.
Architecture
A TypeScript Turborepo with six packages:
┌────────────────────────┐ ┌──────────────────────────────┐
│ apps/web (Next.js 15) │ HTTP │ apps/server (Express + ws) │
│ the observatory │ ─────▶ │ owns the world, ticks it │
│ map · life · history │ ◀───── │ every TICK_RATE_MS │
│ replay · legacy │ WS │ persists to Postgres │
└────────────────────────┘ └──────────────┬───────────────┘
│ imports
┌────────────────────────▼───────────────┐
│ packages/core — THE SIMULATION ENGINE │
│ pure TS, no I/O, deterministic RNG │
│ worldgen · demographics · economy │
│ politics · crime · technology │
│ memory · journal · choices · legacy │
└───────┬──────────────┬─────────────────┘
│ │
┌──────────▼─────┐ ┌─────▼──────────────┐
│ packages/db │ │ packages/ai │
│ Drizzle + pg │ │ provider router + │
│ migrations │ │ local narrator │
└────────────────┘ └────────────────────┘
-
@inheritance/core— the simulation engine. Pure TypeScript, no I/O. 15 modules, ~4,200 lines, tested with Vitest. -
@inheritance/shared— the canonical domain model + display constants, consumed by every package. -
@inheritance/ai— an OpenAI-compatible client with provider routing and a cachednarrate()that falls back to prose templates. -
@inheritance/db— Drizzle schema (13 tables) + Postgres client + SQL migrations. -
apps/server— Express REST API, WebSocket live feed, the tick loop, player claim/resolve, a seed script. -
apps/web— a Next.js 15 "observatory": painted animated map, my-life view, NPC dialogue, searchable history, generational replay, legacy.
The tick: one in-world month
The whole game advances one month per tick. A SimCtx carries the world, a deterministic RNG, an ID factory, an event recorder, and a death queue:
export interface SimCtx {
world: WorldState;
rng: Rng; // seeded mulberry32 — deterministic
id: (prefix: string) => string; // session-salted ids, unique across restarts
pushEvent: (ev: EventInput) => HistoryEvent;
pendingDeaths: string[];
legacyEntries: LegacyEntry[];
}
export function tick(ctx: SimCtx): TickResult {
const world = ctx.world;
world.month += 1; // advance time
const yearRolled = world.month > 12;
if (yearRolled) { world.month = 1; world.year += 1; }
// per settlement: economy → crime → leadership → tax policy
// → (yearly) technology / festivals / famine / plague
// then global: trade → wars → raids
// then every character: aging → hazards → coming-of-age
// → marriage → pregnancy → birth → migration
// deaths queue through: killCharacter → settleEstate
// → obituary journal entry → funeral → legacy evaluation
}
Deaths are the emotional core, so they get a full pipeline: widows and orphans are recorded, the estate is split (businesses pass to heirs, political seats fall vacant), an obituary is written into the journal, the town gossips, and — if the dead person was a player — the life is scored into Legacy.
The server paces this with TICK_RATE_MS (default 8,000 ms per in-world month), so a year passes every ~96 seconds of wall time, and a full human life takes about an hour and a half.
Determinism by default
All randomness flows through a seeded mulberry32 RNG:
const rng = new Rng(seed ?? (world.seed + world.tick * 7919) >>> 0);
// session salt keeps ids unique across server restarts (DB primary keys)
const salt = Math.floor(Math.random() * 0xffffff).toString(36);
const ctx: SimCtx = { world, rng, id: (prefix) => `${prefix}-${++counter}-${salt}-${(rng.next() * 0xfffff) | 0}`, … };
A world is fully reproducible from WORLD_SEED — which made the engine testable (the test suite pins determinism, population staying in a sane band, no negative wealth, inheritance flowing, memories compressing, legacy accumulating).
Layered memory
The spec called for memory that works like memory: working, episodic, long-term, relationship, family, historical and world layers. Every memory carries metadata — importance, emotion, certainty, date, participants, location, tags:
export function remember(ctx: SimCtx, characterId: string, kind: MemoryKind, summary: string, opts: RememberOpts): Memory {
const mem: Memory = {
id: ctx.id("mem"), character_id: characterId, kind, summary,
importance: clamp01(opts.importance),
emotion: opts.emotion ?? ctx.rng.pick(EMOTIONS),
certainty: opts.certainty ?? (kind === "longterm" ? 0.7 : 0.95),
year: world.year, month: world.month,
participants: opts.participants ?? [], location_id: opts.location_id ?? null,
tags: opts.tags ?? [], created_tick: world.tick,
};
const list = (world.memories[characterId] ??= []);
list.push(mem);
if (kind === "episodic" && list.filter((m) => m.kind === "episodic").length > EPISODIC_CAP) {
compressOldestEpisodic(ctx, characterId); // the mind prunes itself
}
return mem;
}
When a character accumulates more than 30 episodic memories, the oldest batch is compressed into long-term summaries — grouped by dominant tag or emotion, merged into prose ("Between 42 and 47, he laid two children to rest and grieved"), and the raw episodes are dropped. Trivial memories simply fade. Retrieval scores importance × recency so the AI (or the player reading the journal) sees what the character would actually remember.
The LLM layer: bring your own key
The game supports OpenAI, Anthropic, Gemini, Groq, Together, OpenRouter, Ollama, LM Studio and Featherless — all through one OpenAI-compatible client. The router resolves a provider from env vars (or from the in-game Chronicler panel, which writes a runtime config):
export const PROVIDER_DEFAULTS: Record<string, ProviderDefaults> = {
openai: { label: "OpenAI", baseURL: "https://api.openai.com/v1", model: "gpt-4o-mini" },
anthropic: { label: "Anthropic", baseURL: "https://api.anthropic.com/v1", model: "claude-3-5-haiku-latest" },
ollama: { label: "Ollama", baseURL: "http://localhost:11434/v1", model: "llama3.2" },
openrouter: { label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", model: "openai/gpt-4o-mini" },
groq: { label: "Groq", baseURL: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile" },
// gemini · together · lmstudio · featherless …
};
narrate() is the single storytelling entry point — used for journal entries, letters, gossip, choice scenes, dialogue and the claim vignette. It caches aggressively, batches, and degrades gracefully:
export async function narrate(opts: NarrateOptions, client?: OpenAICompatClient | null): Promise<string> {
const effective = client ?? resolveProvider();
if (!effective) return localNarrate(opts, hashSeed(opts)); // zero keys → local narrator
const hit = cache.get(cacheKey(opts));
if (hit) return hit; // cached
try {
const text = await effective.chat(
[{ role: "system", content: system }, { role: "user", content: prompt }],
{ maxTokens: 160, temperature: 0.9, timeoutMs: 12_000 }
);
cache.set(key, trimTo(text, opts.maxLength ?? 600));
return trimTo(text, opts.maxLength ?? 600);
} catch (err) {
console.warn(`[ai] ${effective.config.label} failed (${(err as Error).message.slice(0, 120)}); using local narrator`);
return localNarrate(opts, hashSeed(opts)); // the world never goes silent
}
}
The prompt is character-grounded — it feeds the LLM the character's name, age, occupation, wealth, personality traits, and up to four retrieved memories — so a 70-year-old fisherman narrates differently from a young queen.
Persistence: event sourcing, pragmatic edition
Postgres is the source of truth, and the in-memory WorldState is the projection the sim runs against. Every tick writes inside one transaction: current-state rows (characters, settlements, families, trade routes) are upserted, and the append-only streams (events, journal entries, memories, choices, legacy entries, yearly stats) get only their new rows inserted:
// Append-only streams: only NEW rows are inserted each tick
const newEvents = world.all_events.slice(p.eventIndex, next.eventIndex);
for (const chunk of chunkArray(newEvents, CHUNK)) {
await tx.insert(events).values(chunk.map((e) => ({ ...e }))).onConflictDoNothing();
}
A Persister tracks per-stream indexes in memory; they're advanced only after the transaction commits, so a mid-transaction failure can never desync the indexes and silently drop rows. On boot, loadWorld rebuilds the entire in-memory projection from Postgres and the world picks up exactly where it stopped.
Why this shape? CQRS-lite. There's one writer (the tick loop + player actions), and every read is served from the in-memory projection — so the observatory is instant even as the ledger grows to hundreds of thousands of events. The history you browse is the same append-only ledger the sim writes.
The observatory
The web app is less a "game UI" and more an observatory over a living world:
- The world map — a hand-rolled canvas: value-noise landmasses, rivers, mountain ranges, ruins, settlement glyphs by kind, marching trade-route dashes, pulsing war rings, seasonal light, drifting clouds. Golden markers pulse where a life is currently being lived by a player.
- My Life — the inherited character: portrait, health, reputation, skills, dreams, fears, goals, secrets, a family tree, layered memories, a journal reader, and the year's decisions.
- Talk — an NPC dialogue system in your settlement (chat, rumor, business, gift, flatter, insult, farewell), each reply with real world effects.
- History — a searchable, filterable ledger of every recorded event; Replay scrubs a whole house or settlement through the decades like a time-lapse.
- Observatory & Legacy — population/births/deaths/wealth charts, seats of power, great houses, and the legacy score that replaced XP.
The pages are thin clients over a typed REST API (@inheritance/shared types are reused server-side and client-side via transpilePackages), with a WebSocket feed for live updates.
The player loop
-
Claim —
POST /api/player/claimpicks an eligible living soul (adult, unclaimed, age 15–55) and returns a full handoff: the chronicler's vignette, journal, family, friends and enemies, business, goals, and memories. You step into a story already in progress. - Live — the world ticks around you. Each year the game offers decisions (marry, invest, run for office, commit a crime, write a book…), and unplayed decisions auto-resolve from personality.
- Die — death is meaningful: funeral, inheritance split, business succession, vacant seats, mourning friends, celebrating enemies.
- Legacy — your life is scored and written into the ledger. You inherit another life. The world never stopped.
The foundation is the core loop, end-to-end and tested. The full vision is bigger: a searchable historical encyclopedia, a political map and relationship graph, a deeper crime subsystem with trials and prisons, technology-driven social change, players intersecting naturally in one shared persistent realm, and — the far horizon — a realtime Ghibli-style 3D renderer over the same world state.
The engine was built to be extended: constants sit next to the code they drive, every mechanic is a plain function of SimCtx, and the ledger records everything, so the observability layer can keep growing without the simulation changing shape.
Code & more: https://www.dailybuild.xyz/project/211-afterlife
Top comments (0)