- TypeScript 88.1%
- GLSL 8.7%
- CSS 2%
- JavaScript 0.9%
- HTML 0.3%
The atmosphere shader now reads five globe-wide scalars from the
weather trio's curr slice and shifts the rim tint accordingly:
- uAvgHumidity (existing) — brightens the rim 85% → 120%
- uAvgPrecip (existing) — mixes toward storm tint
- uAvgFogDensity — NEW, desaturates the rim under thick fog
- uAvgVisibilityKm — NEW, same desaturation term (whichever dominates)
- uDominantKindId — NEW, per-kind rim hue:
fog → pale ash (0.35 mix)
snow → cool bone (0.30)
storm → deeper dried-blood (0.40)
overcast → muted violet-grey (0.18)
clear/rain → no kind pull (base rim colour)
Weights tuned so kind dispatch reads as atmospheric mood, not a
paint-swap. Heavy global haze then desaturates the result by up to
25% — optical thickness eating the sky's own saturation.
The atmosphere shell is still outside the spin group (rim is
view-dependent, not geographic) and still the outermost layer (Phase
6A invariant holds).
New dev-mode overlay `WeatherReadouts.tsx` — small top-right panel
showing `dominantKind`, avgHumidity/precip/fog, avgVisibilityKm. Wired
to `useWeatherStore.subscribe` so it updates once per bake, not per
frame. Gated on `isDevMode`. Makes live-tuning the Phase 6B–E kind
palette much easier — you can see which kind is dominant without
authoring a synthetic event to force it.
Zero sim changes; 135 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .forgejo/workflows | ||
| apps/web | ||
| data | ||
| packages | ||
| scripts | ||
| .gitignore | ||
| .nvmrc | ||
| biome.json | ||
| CLAUDE.md | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| Pyora World Research.md | ||
| pyora_brief.md | ||
| README.md | ||
| tsconfig.base.json | ||
| turbo.json | ||
pyora.world
A cursed world seen through an archivist's tools.
Pyora is a deterministic planetary simulation for a fantasy world of the same name, rendered as a 3D globe in the browser. Scrub through centuries, author narrative events that ripple through the simulation, watch a magical shroud blanket the continent, watch moons drift and eclipse each other — all from a pure function that takes a timestamp and returns a world.
This repo is the growing instrument. The MVP is live; the apparatus is wired for more.
What makes it different
Most worldbuilding tools stop at pan-and-zoom 2D maps. Pyora is being built as a full physics engine for a fantasy world — calendar, orbits, seasons, moons, atmosphere, weather (soon), and a narrative event log that lets you author "the city of Avalon fell to a dragon on year -1, day 285" and see the consequences propagate across every viewer, every API consumer, every downstream tool.
The contract: every public output is a pure function of (world, events, inputs). Same inputs → same answer, every time, everywhere. The web globe, the (future) REST API, the (future) MCP server that lets Claude query Pyora, the (future) Foundry module — they all agree, because they're all thin skins over the same deterministic core.
How it works (the shape, not the code)
The system has four layers of state, deliberately separated:
- World — the fundamental constants of Pyora. Year is 360 days. Two moons — Morrow, the bone-coloured dread-signal, and Pall, a smaller ash-violet lesser companion riding a tilted 84-day orbit. Axial tilt 23.5°. All in one file (
packages/core/src/world.ts). Change this and you change what Pyora is. - Campaign — the authored history of the world, kept as a plain list of events in
data/events.json. "The Shroud descended on year 0, day 0, global, forever." "Avalon fell year -1, day 285." Events have regions (global, a point with a radius, a named POI), durations ("forever"or{ days: 90 }), optional fade-in/out transitions, and params interpreted by the event's kind. - Inputs — what you ask about at a given moment.
(timestamp, lat, lon, seed). - UI — ephemeral. Scrubber position, camera angle, dev-panel sliders. Lives in the browser; never touches the simulation.
All the sim functions take (world, events, inputs) and return derived state: what date is it on Pyora, where's the sun pointing, where's each moon, what phase is each moon in, how thick is the cloud cover at these coords (because the Shroud's global op: max contribution wins over the base: 0), what tags apply to this region (scarred, arcane-residue, poisonous, undead-magnet), and so on. Weather, magic density, undead density — all future layers that plug into the same shape.
The grimdark orrery
This isn't a sci-fi control console or a spinning Awwwards gradient sphere. The aesthetic is a grimdark orrery — a medieval-futurist atlas device of brass and parchment laid over WebGL. Near-black background, ash-violet Fresnel atmosphere, rust-and-bone surface palette, an oversized moon framed to dread, gothic serif display type, NASA-Worldview-style scrubbing timeline at the bottom. The Shroud — permanent magical cloud cover — blankets the planet in the default view.
Palette rules: no Earth blue, no neon cyan, no magenta, no generic techno sans. Bone, rust, ash, dried-blood red, tarnished gold, aged ivory. The atmosphere shader tints violet, not cyan. Sun glows warm amber, not white.
Getting started
nvm use # Node 20+
pnpm install
pnpm dev # http://localhost:5173
What you'll see: a rust-and-bone planet blanketed by the Shroud, two moons on different orbits, a starfield, and a time scrubber at the bottom. Top-right panel shows the Pyoran date, season, and current phase of each moon. Top-left (if any event is active) shows the Active Events panel. Drag the scrubber. Hit play. The planet rotates, seasons turn, moons orbit, the Shroud persists — and if you scrub back before year 0, day 0, the Shroud lifts and you see the planet's actual surface.
Scrub to see Avalon
Drag the scrubber back to year -1, day 285 or later. Two blood-red markers appear on the globe at lat 32.4°N / lon 12.1°E — the crater where Avalon fell, and the wider poison zone that followed the day after. Markers rotate with the planet as it spins. Both events have "forever" duration, so any time after their start date, they're there.
The dev panel
Open http://localhost:5173/?dev=true (or ?dev=1, or #dev) to bring up a live control panel, top-right. Collapsible folders for:
- World — axial tilt (changes seasons live)
- Sun — intensity, color, ambient
- Atmosphere — rim color, intensity, Fresnel power
- Surface — base / highlight / shadow colors (the procedural palette)
- Shroud — coverage multiplier (slide to 0 to see the surface clearly)
- Stars — count, brightness, depth, saturation
- Moon material — roughness, metalness
- Moon: Morrow and Moon: Pall — each gets orbital period, size, orbit radius, inclination, phase offset, color; moon param changes flow through the sim so orbits actually recompute
- Postprocessing — bloom intensity + threshold
What's in the repo
pyora.world/
├─ apps/
│ └─ web/ Vite + React Three Fiber globe
├─ packages/
│ ├─ core/ @pyora/core — deterministic sim engine
│ └─ tsconfig/ shared TypeScript config
├─ data/
│ └─ events.json authored campaign event log
├─ scripts/
│ └─ check-core-isolation.mjs guardrail enforcing env-agnostic core
├─ .forgejo/workflows/
│ ├─ ci.yml lint + typecheck + test + build (every push/PR)
│ └─ deploy-prod.yml build + rsync apps/web/dist/ to prod (main only)
├─ Pyora World Research.md original research dossier
├─ pyora_brief.md project brief
└─ CLAUDE.md project-local instructions for Claude sessions
The core engine (@pyora/core)
Pure TypeScript. No Node APIs, no browser APIs. Runs unchanged in the web app, a future Cloudflare Worker, a future Foundry VTT module. Exports:
- Calendar:
pyoraTime(world, t)→ year, day-of-year, month, hour, season, solar declination - Bodies:
sunDirection(world, t),moonStates(world, t) - Events:
loadEvents(world, rawJson),activeEventsAt,activeEventsIntersecting,effectiveCloudCover, plus a closed registry of sim-affecting event kinds - Zod schemas for every public output + input
Determinism is a tested contract, not a convention. A golden fixture captures outputs for a spread of sample timestamps; a canonical hash of that fixture is committed in world.ts as GOLDEN_FIXTURE_SHA256. Change sim math, test fails loudly until you regenerate + bump the version.
The web app (apps/web)
Vite + React + React Three Fiber + drei + postprocessing. Scene is composed from slot-based components — swap <SurfaceLayerProcedural/> for <SurfaceLayerTextured/> without touching <Planet>, swap <Atmosphere model="fresnel"/> for <Atmosphere model="hillaire"/> later. Reads from @pyora/core through memoized hooks so the sim fires exactly once per (world, timestamp) pair per frame.
Deployment
Static site. On every push to main, CI builds apps/web/dist/ and rsyncs it to the prod server; the reverse proxy serves it directly. No Docker, no registry. Secrets needed on the Forgejo repo: DEPLOY_PROD_SSH_KEY, DEPLOY_PROD_HOST, DEPLOY_PROD_USER, DEPLOY_PROD_PATH, optional DEPLOY_PROD_PORT.
When the REST API and MCP server land, they'll be Cloudflare Workers on separate deploy paths. The static deploy stays as is.
Status
MVP shipped: calendar, seasons, sun, two moons with distinct orbits + colors, stars, atmosphere, procedural surface, the Shroud rendering, point markers for region events, time scrubber, info panel, active-events panel, error banner for bad events, live dev panel. 53 tests passing. Forgejo CI green.
Not yet: weather, real surface textures, REST API, MCP server, Foundry module, body-parameter-change event kind, POI layer, magic density layer, tides. All architected for, none implemented — see CLAUDE.md for the evolution sequence and pyora_brief.md for the full vision.
The philosophy, briefly
Every piece is designed so the next piece plugs in without a rewrite. Slot-based scene composition. Closed sim-kind registry + open render-kind registry. World config parameterization so PYORA is a value, not a hardcoded set of constants. The dev panel already uses the exact effectiveWorld pattern that body-parameter-change events will need when a villain drags the moon closer. The grimdark aesthetic holds even at placeholder fidelity because the palette is enforced in constants, not muscle memory.
It's ambitious. That's the point.
Links
CLAUDE.md— dense project-local instructions for Claude sessionspyora_brief.md— original project briefPyora World Research.md— the research dossier that drove the architecture
License
TBD. Personal / hobbyist project for now.