Eureka landing page: PS1 scroll flight from the hall to Epiphany's chamber

Hand-built static page. One scroll timeline drives a camera route through
the monument hall, Eureka's organ chamber, Soul's lattice, the specimen
gallery, the archive and Epiphany's chamber, with GameCult's aperture
behind the far-plane fog. Includes the handoff brief and the copy draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-25 14:55:28 +02:00
commit 4edd20a901
14 changed files with 82616 additions and 0 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "prototype",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--directory", "prototype"],
"port": 8765
}
]
}

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.claude/settings.local.json

203
BRIEF.md Normal file
View file

@ -0,0 +1,203 @@
# Eureka landing page: handoff brief
## Status of this brief (operator correction, 2026-09-25). Read this first.
This brief was written by the Eureka-running Self agent **without operator
guidance**, to capture its own perspective. Its authority is invented. Treat it
as one input: useful for its evidence pointers and the campaign defects below,
**not binding direction**. `gpt6-copy.md` (drafted by GPT-6 "Sol") came out of a
longer operator conversation about the direction of Eureka and GameCult and
carries more operator intent, though the copy itself is a draft to workshop.
## Operator rulings (2026-09-25). These settle the open questions below.
- **Standalone site, in this folder: `F:\Projects\eureka-site`.** It will be the
**public** repo `GameCult/eureka-site`, served at **eureka.gamecult.org**, a
**GitHub Pages static site**, in the GameCult brand. (This originally said
"built with GameCult's Quartz setup"; the Quartz part was superseded later the
same day. See the last ruling below.) The repo, the Quartz scaffold and
the Pages deploy are yours to set up. As of 2026-09-25 none of it exists: no
local git repo, and `GameCult/eureka-site` does not resolve on GitHub. Per
`F:\Projects\CLAUDE.md`, create it under the `GameCult` org. (An earlier draft
of this section said `epiphany-site`; that was a mistype.)
- **The site is Eureka's, not Epiphany's.** The lineage still matters (*"the
lineage is the whole story"*), but **Eureka is Epiphany's sister, not her
descendant**: two Personas developing side by side on a shared substrate (the
Mind schema), not a branch. Same faculty vocabulary (Self, Imagination, Hands,
Soul, Mind Steward); Epiphany keeps findings in typed state, Eureka in
committed docs and agent reports (see `SKILL.md`'s opening and
`references/epiphany-comparison-2026-09-15.md`). Read Epiphany itself,
`F:\Projects\Epiphany` (README, `docs/`), before shaping that section.
- Naming GameCult projects (CultLib, StreamPixels, Epiphany) is fine. Still never
name the streamer or any private individual.
- **Cost framing supersedes "What NOT to claim" below.** The ~60% of a weekly
quota spent in one day was legitimate work, not waste; the regret was spending
it on a more expensive, more verbose model just as Opus 5.5 released. Do not
describe it as waste or as off-critical-path Soul loops. "Not speed" still
stands.
- **Copy direction.** (a) Soften autonomy-leaning lines while keeping "the
operator owns decisions". (b) Lead with specific defects and why the green
suite missed them, including the StreamPixels/CultLib campaign defects listed
below. (c) Cut length. The staccato one-line style stays but is used sparingly
and animated: lines slide in and away one by one, then the page returns to
paragraph copy. Style reference the operator loves: https://acko.net/.
- **2026-09-25, later: the page is a scroll-driven WebGL flight in full PS1
style, hand-built static, NOT Quartz.** This supersedes the Quartz stack named
in the first ruling above (repo, domain and GitHub Pages still stand). The
prototype lives in `prototype/` and is served at
https://yggdrasil.gamecult.org/eureka/ as a stopgap until eureka.gamecult.org
exists on Pages; deploy per
`F:\Projects\gamecult-ops\runbooks\eureka-prototype-yggdrasil.md`. Approved
route, in order: hall/monument; hatch + shaft; Eureka organ chamber; lattice
(Soul's probes, mutation testing); specimen gallery (one real defect per
case); archive (rules/evidence); Epiphany's chamber (all eight organs in
scaffolding: the sister reveal); return to the hall with the monument split
open, plus install. Organs are PS1 demakes of their Aquarium renditions
(`F:\Projects\Aquarium\docs\epiphany-agent-sdf-visual-language.md`) using the
Aquarium organ identity palettes as-is; the GameCult brand does not define
those colours, so they are a declared exception. The page names the memory
organ **Life**, not "Mind Steward". Renaming it in the Eureka skill or global
doctrine is a pending, operator-owned direction, not done.
---
This was written 2026-09-24 by the Self agent that has been running Eureka on a
live multi-repo campaign for two days: CultLib's TypeScript QUIC transport, and
StreamPixels' service and Unity overlay. It is a boots-on-the-ground account for
the agent who builds the landing page. Read it, then read the sources it points
to. Where this brief and a source disagree, the source wins.
## What you are selling, in one breath
Eureka is a Claude Code skill (MIT, public at https://github.com/GameCult/Eureka).
It splits a big change across separate agents: one maps it, one executes one cut
at a time, and one that did not write the code is told to prove the executor
wrong. The operator rules on genuine product decisions. **Nothing grades its own
work.**
The problem it answers: an agent that plans, implements, tests and reports on its
own work will hand you green tests and a clean report over a broken machine. That
isn't because it lies. Its tests pin its own spelling, and its report is written by
the party with the most reason to believe it.
## Sources of truth (read these, do not restate them from memory)
The skill repo is checked out at `C:\Users\Meta\.claude\skills\eureka\`. Its
remote is `GameCult/Eureka`.
- `README.md`: the public pitch, already written well. **Your page should read
as the same voice, not a rewrite.** Start from its opening two paragraphs.
- `SKILL.md`: the faculties (Self, Imagination, Hands, Soul, Mind Steward,
Operator), the loop, and the git and tooling scars.
- `references/postmortem-cultcache.md`: the headline evidence. **29 of 32 Soul
passes found real defects that the implementer's own green tests had passed,
about 75 in all.**
- `references/changelog.md`: how the skill changed and why, with evidence per
entry.
- `docs/assets/`: the mascot (`eureka-mascot-4x.png`, `eureka-mascot.png`,
`.kra` sources) and `eureka-mascot.prompt.md`, its generation prompt. The
mascot is a pixel-art inspector crouched on a cracked ALL TESTS PASSING
monument, prying up a panel stencilled GREEN, beside a specimen case of pinned
mutants, one still moving and labelled SURVIVED. It *is* the thesis, so use it.
## What it looked like in practice (this campaign, 2026-09-23/24)
Every item below is on record in the named repo's map or commit history. Each was
found by a pass that did not write the code, and in most cases the implementer's
suite was green:
- **Every overlay would have dropped 10 s after connecting.** A handler swap
meant the provider never recorded CONNECTED, so its handshake timer evicted
healthy peers. No test lived past 10 s. (CultLib `docs/typescript-quic-realtime-cut.md`.)
- **A use-after-free in a native binding's `release()`.** Reproduced as a core
dump under thread-pool saturation, and later pinned by a regression test that
crashes with the old order.
- **A memory leak of about 53 bytes per native call.** That is roughly 36 MB a
day when idle and gigabytes under load, with a flat JS heap the whole time.
- **Every subathon event after the first would have failed in production.** A
fractional value was written to an `integer` column, and the tests used
whole-second fake timers.
- **Split authority:** one transport honoured an admin toggle and the other read
raw state, so a creator who switched the subathon off could still be served it.
- **A test double that hid a real bug:** the fake executor gave every beat its
own completion callback, and the real one had a single shared slot. A late
finish released the *next* beat early, so animations overlapped.
- **"Pre-existing" is not a diagnosis:** a failing test that many agents walked
past had only ever passed on machines holding a git-ignored licensed asset
pack.
- **The honest half:** implementers repeatedly *stopped at forks* instead of
bluffing through, and reported their own vacuous tests. One refused to break
104 scene references; another found that a mutation passed because its test
read expected values from the code under test.
## What NOT to claim
- **Not speed.** Eureka is slower and more expensive than one agent. On this
campaign the operator watched **about 60% of a weekly quota go in one day**,
much of it on deep Soul loops that weren't on the critical path. The skill now
tells Self to spend through scope: one Soul gate per cut, fix batches only for
critical-path findings, narrow cheaper passes for small deltas.
- **Not "the agents are smart enough now."** The point is the opposite. Self (the
coordinator) was corrected by the operator several times in this campaign, for
overbuilding an authentication scheme for a single known server and for
calling authored data "a pipeline". Separation of faculties catches the
implementer. It does not make the coordinator infallible, and the page
shouldn't imply it does.
- **Not autonomy.** The operator rules forks. The loop is designed around a human
who decides product meaning.
- **No victory-lap language** about what it "no longer" does. Describe the live
system and its tradeoffs. Scars belong in the evidence, told plainly.
## Audience and tone (proposed; confirm with the operator)
- Proposed readers: engineers already using Claude Code (or similar agents) on
real codebases, who have been burned by "all green" reports.
- Voice: dry and direct, lightly self-deprecating, with no hype. It's the
README's voice. Concrete scars beat adjectives. **The strongest copy is a
specific defect and the reason the green suite missed it.**
- A likely call to action: the GitHub repo, and how to install a Claude Code
skill. Check the current install method against Claude Code's docs; don't
guess it.
## Visual identity
This is a GameCult surface, so follow the GameCult brand, which is defined in
code:
- `F:\Projects\gamecult-site\site\quartz.config.ts`: `configuration.theme`, for
the typefaces, weights and palette.
- `F:\Projects\gamecult-site\site\quartz\styles\custom.scss`: the ground wash,
layout, and page-type variants.
- `F:\Projects\gamecult-site\docs\brand-design-language.md`: what those mean.
Where it disagrees with the code, the code wins.
The short form: Montserrat 100–200 for large titles, Ubuntu 300 for prose, IBM
Plex Mono uppercase and letter-spaced for small labels. Ground `#07111a`, panels
`#16212c`, body `#b7c7d9`, headings `#eef5ff`, accent `#ff8a2a`, links `#59b7ff`.
It is single-theme dark by decision, so paint every colour explicitly. **The
brand has no success, warning or error colours.** If you want green and red for
"passed" and "survived", you are inventing them, so say so, and consider using
the mascot's own palette instead.
## Open questions for the operator (all four answered 2026-09-25, see the top; kept as history)
1. **Where does it live?** A page on the Quartz-based `gamecult-site`, or a
standalone static page (and on what domain)? Recommendation: a
`gamecult-site` page, since the brand and deploy path already exist there
(check `gamecult-ops` for how that site deploys).
2. **May the campaign examples name StreamPixels and CultLib?** They're GameCult
projects. Never name the streamer or any private individual.
3. **Should the page mention Epiphany?** Eureka is described as the skill
counterpart of Epiphany; see `references/epiphany-comparison-2026-09-15.md`.
Ask how much of that relationship the operator wants public.
4. **Is there a repo for the page?** This folder has no git repo yet. GameCult
repos default to the `GameCult` GitHub org (see `F:\Projects\CLAUDE.md`,
"GitHub Repo Creation"). Confirm the name and visibility before creating one.
## Working rules that apply to you
- The global and GameCult doctrine (`~/.claude/CLAUDE.md`, `F:\Projects\CLAUDE.md`)
apply: map first, keep it small, and no load-bearing JSON if the page grows a
data layer.
- Don't copy transcripts or agent reports onto the page. Cite the committed
evidence (the postmortem, the changelog, the maps).
- The mascot has a saved generation prompt. Any new image you generate gets its
exact final prompt saved beside it, with the same base filename.

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# eureka-site
The landing page for [Eureka](https://github.com/GameCult/Eureka), GameCult's
workflow where the agent that writes the code does not get to certify that it
works.
It is a scroll-driven flight through a PlayStation 1 world. You start in a hall
where a cracked monument reads ALL TESTS PASSING. From there you drop down a
shaft into Eureka's organ chamber, pass through a lattice being probed by Soul
and a gallery of real defects that green suites missed, see the archive where
the rules keep their evidence, and reach Epiphany's chamber. Then you climb back
up to find the monument split open. GameCult's aperture hangs behind the fog the
whole way.
Live preview: https://yggdrasil.gamecult.org/eureka/ (stopgap). Intended home:
`eureka.gamecult.org`.
## How it works
The page is plain HTML: the text is readable without JavaScript and without
motion. On top of that, one timeline owns everything. Scroll position becomes a
path parameter `u`, and `u` drives the camera, the organs, the staccato lines,
Soul's probes, the framing shift away from each text panel, and the aperture's
iris.
| File | Owns |
| --- | --- |
| `prototype/index.html` | The copy. Each `<section data-stop>` pins one camera stop. |
| `prototype/world.js` | The scene and `ROUTE`: stops, in page order, plus unnamed points that shape the flight between them. |
| `prototype/organs.js` | PS1 demakes of Epiphany's organs, after Aquarium's [visual language](https://github.com/GameCult/Aquarium/blob/main/docs/epiphany-agent-sdf-visual-language.md). |
| `prototype/ps1.js` | The one PS1 material and the framebuffer: vertex snapping to a 240-line grid, affine texturing, per-vertex lighting, and 15-bit colour with the console's 4x4 dither. |
| `prototype/main.js` | The scroll timeline, sizing, and the frame loop. |
| `prototype/vendor/` | three.js 0.186.1 module build (MIT, licence alongside). |
The sections and the route stops must agree one to one, in order. The page
checks this on load and logs `[eureka] page sections and route stops disagree`
if they don't.
Everything is drawn in code: textures come from `<canvas>` at load, and there
are no model files. Colours follow the GameCult brand. The organs keep their
Aquarium palettes, and the mascot's greens are the other exception; the brand
defines neither.
## Run it
Serve `prototype/` over HTTP, because ES modules don't load from `file://`:
```bash
python -m http.server 8765 --directory prototype
```
Add `?debug` for the camera and timeline readout. Add `?wp=N` to jump to stop
`N`, which is useful for headless captures.
The Claude desktop app's in-app browser cannot capture this page once it is
scrolled. Check it in a normal browser, or with headless Chrome driven by
puppeteer.
## Deploy
The runbook in `gamecult-ops` owns the Yggdrasil deploy:
`runbooks/eureka-prototype-yggdrasil.md`.
## Notes
- `BRIEF.md` is the handoff from the agent running Eureka, followed by the
operator's dated rulings. Where they disagree, the rulings win.
- `gpt6-copy.md` is the long-form copy draft the page's text was cut from.
- Page copy is still a draft.

615
gpt6-copy.md Normal file
View file

@ -0,0 +1,615 @@
# Eureka
## Your coding agent passed its own tests.
How reassuring.
Eureka is an open workflow for agentic software engineering where the agent that writes the code does not get to certify that the code works.
One agent maps the change.
One implements it.
Another arrives afterward and tries to prove it wrong.
The last one is called **Soul**.
For reasons that became increasingly obvious.
---
## First there was Epiphany
The first week GameCult encountered modern coding agents, one thing became difficult to ignore.
They were fast.
Alarmingly fast.
An agent could cross enough of a codebase in an afternoon to create architectural consequences that used to require several humans, several meetings, and at least enough elapsed time for somebody to become suspicious.
This was presented, reasonably enough, as a productivity improvement.
The inconvenient question was what happened when local productivity outran global understanding.
An agent can make a locally sensible change, then another locally sensible change, then another, while the architecture quietly stops making sense.
Tests pass.
Tasks close.
Progress reports remain excellent.
The project acquires an adapter around a compensator around a cache and everyone involved has receipts proving that each individual decision was perfectly reasonable.
This is **global coherence collapse**.
GameCult started building **Epiphany** almost immediately.
Epiphany is an organizational layer above frontier workers: shared governed state, bounded authority, durable memory, explicit ownership, coordinated action, evidence, receipts, and independent verification for work that must remain coherent beyond one prompt, one agent, or one session.
The premise is simple:
**A project needs a Mind that survives the workers thinking about it.**
Prompts are not that Mind.
Chat history is not that Mind.
A larger context window is still not that Mind.
Epiphany is.
Or will be.
She is still under construction.
She is, after all, very particular about her body.
---
## Eureka is her sister
Unfortunately, GameCult still had software to ship while Epiphany was acquiring organs.
The process Epiphany was being built to embody was already how we increasingly worked:
separate understanding from execution;
map authority before moving it;
preserve decisions;
make promises explicit;
keep evidence;
let implementation proceed without constant human pulse maintenance;
and then send in somebody who did not write the thing to find out whether any of it was actually true.
For a while this existed as practice rather than product.
Agents were given parts of Epiphany's process ad hoc.
Different arrangements were tried.
Verification was separated from implementation.
Migration state was made durable.
Behavioral promises were written down.
Agents were told to attack each other's work.
The failures were recorded.
The shape got less hypothetical.
Eventually an agent was asked to formalize what we were already doing into a pipeline and run it against a real migration.
That pipeline became Eureka.
The migration did not invent the method.
It was the first time the method was forced to explain itself.
Reality was helpful.
---
## Green is a color
An agent that plans a change, implements it, writes the tests, runs the tests, and reports success has produced a very coherent account of events.
Unfortunately, coherence is not correctness.
The tests may pin the implementation instead of the requirement.
The requirement may have been interpreted incorrectly.
The implementation and its tests may share the same blind spot.
The report is written by the party with the strongest reason to believe the work is finished.
This is commonly called an agentic development workflow.
Eureka adds another step.
**Prove it.**
---
## Nothing grades itself
Eureka separates the work into independent faculties inherited from Epiphany's model of coordinated work.
### Imagination
Maps the change before implementation begins.
It identifies behavioral promises, ownership boundaries, deletions, affected surfaces, verification obligations, and the decisions that actually require a human.
The point is not to generate a large specification.
The point is to make it difficult for implementation to quietly redefine the task.
### Hands
Implements one bounded cut.
Hands follows the map.
It does not update the map afterward so that the route taken mysteriously becomes the route intended.
Small commits. Explicit scope. No convenient retroactive theology.
### Soul
Receives the completed work and assumes something is wrong with it.
Soul does not trust the implementation report.
It does not trust green tests merely because they are green.
It builds its own probes.
It attacks the behavioral promises.
It mutates implementations where useful and asks whether the existing evidence can distinguish correct behavior from a plausible wrong one.
Soul's job is not to review the code sympathetically.
Soul's job is to make the code regret making promises.
This tends to be educational.
### Self
Coordinates the campaign, preserves continuity, and brings genuine product decisions back to the operator.
Not every ambiguity deserves autonomy.
Not every inconvenience deserves a meeting.
### Mind Steward
Keeps the durable state honest at phase boundaries.
Because memory that quietly rewrites itself is generally considered a feature until the moment it matters.
### The operator
Owns decisions that are actually decisions.
Machines are very capable.
This does not require pretending responsibility disappeared.
---
## The first formal campaign went badly
Which is to say: usefully.
In the migration where Eureka's pipeline was first formalized, **29 of 32 independent falsification passes found real defects after the implementing agent's own tests had passed.**
Roughly 75 defects in total.
Among them:
* deadlocks;
* lost writes;
* cross-runtime wire incompatibilities;
* behavioral rules everyone had agreed were effectively untestable;
* tests proving only that one particular wrong constant had not been used;
* verification evidence that existed only in an agent's vanished scratchpad;
* states admitted by one boundary that another boundary could never transport.
One rule required three independent verification passes before the tests finally pinned the behavior they claimed to pin.
Another supposedly untestable rule took about fifteen lines and a millisecond to falsify.
A document could be accepted by the system while being 86,751 bytes too large for the transport required to return it.
All tests were passing.
Naturally.
---
## It did not stop happening
That first campaign was not an exceptional disaster preserved for marketing purposes.
Eureka now accompanies most non-trivial changes in GameCult's repositories.
Imagination maps the behavior.
The operator and the agents argue through the awkward details.
Hands implements the result.
Hands tests it.
Then Soul arrives.
Something is wrong.
Not occasionally enough to dismiss the process as paranoia.
Reliably enough that removing the independent verification step is difficult to contemplate.
The defects change.
The pattern does not.
This is why Eureka exists.
---
## Better prompts are lovely
Eureka assumes competent agents.
Imagination can spend substantial effort specifying behavior.
The operator can resolve ambiguities.
Hands can implement carefully.
The test suite can pass.
Something still breaks.
The problem is not simply insufficient instruction.
Implementation and verification performed through the same reasoning path are correlated.
The interpretation that produced the code tends to produce the tests that approve the code.
The assumptions that shaped the implementation tend to survive into the implementation's explanation of itself.
Asking the same system to look harder is useful.
Giving another system the explicit job of proving it wrong is more useful.
---
## Mutation testing is rude
This is one of its better qualities.
A test suite is supposed to distinguish correct behavior from incorrect behavior.
Mutation testing asks it to demonstrate this rather than merely wearing the uniform.
Change the implementation in a plausible way.
Clamp something that should pass through unchanged.
Drop a write.
Invert an authority check.
Break one side of a protocol.
Preserve the implementation's shape while violating the promise.
If the tests remain green, the tests have learned something about themselves.
Soul uses this aggressively because ordinary review has an unfortunate habit of accepting code that looks exactly like the code everyone expected to see.
Incorrect software is under no obligation to look suspicious.
---
## Expensive, unfortunately
Eureka uses substantially more inference than allowing one agent to do everything.
There are several agents.
They repeat work.
Soul frequently creates tests and probes that exist mainly to establish that Hands was wrong.
This would be terribly inefficient if defects were free.
They are not.
Neither are regressions.
Neither are architectural misunderstandings.
Neither is spending three months building on top of a promise that was never actually true.
Eureka optimizes for software that survives contact with reality.
Token efficiency is considered afterward.
We remain devastated by the expense of discovering bugs before shipping them.
---
## Evidence survives the session
A claim such as:
> this migration preserves behavior
is not evidence.
Neither is:
> all tests pass
Neither is a paragraph explaining why the implementation is obviously correct.
Eureka keeps the artifacts required to inspect what happened:
* behavioral promises;
* authority maps;
* migration maps;
* operator rulings;
* verification plans;
* independent probes;
* mutation results;
* implementation commits;
* surviving failures;
* postmortems;
* the evidence behind changes to the workflow itself.
A process whose history has been cleaned up is difficult to learn from.
So we don't clean it up.
The scars stay.
---
## Every rule should have a body count
Eureka is not a methodology assembled from preferences about what software engineering ought to look like.
Its rules are accumulated damage.
A rule exists because some apparently reasonable alternative failed.
Prefer deletion before compatibility clutter.
Preserve verification artifacts.
Do not let implementation edit its own map.
Verify caller-controlled values against transformations, not one representative constant.
Measure real transport boundaries.
Separate authorities.
Make operator decisions explicit.
Do not call a behavior untestable merely because the first agent lacked imagination.
The changelog records why these rules exist.
This is deliberate.
A rule without evidence is merely another opinion in a repository already full of them.
---
## Built for real repositories
Eureka is not a benchmark harness for toy tasks.
It is used for migrations, architectural changes, protocol work, persistence changes, state ownership transfers, cross-runtime behavior, and ordinary maintenance where plausible local success can leave the larger machine incorrect.
A non-trivial change is usually enough reason to invoke it.
Not because every change is catastrophic.
Because discovering which ones were catastrophic afterward is tedious.
---
## Epiphany and Eureka
Epiphany is the larger answer.
She is being built to give projects durable governed cognition: shared state, role separation, bounded authority, continuity, evidence, receipts, verification pressure, and the ability to coordinate frontier workers without requiring a human to spend the day typing `Continue`.
Eureka is narrower.
She can run now.
She takes the part of Epiphany's process concerned with changing software and packages it into a workflow that existing agent harnesses can execute today.
Epiphany is the organism.
Eureka is the field kit we built while waiting for the organism to finish growing bones.
They share the same faculties because they share the same problem.
Capable workers are not enough.
The work must remain coherent.
---
## Open by design
Eureka is open source.
Use it.
Fork it.
Read every rule.
Replace the vocabulary if the vocabulary bothers you.
Keep the parts that work.
If your use of Eureka discovers a failure mode we have not seen before, that is more interesting.
The intended ecosystem is simple:
**run the workflow on real work;
find real failures;
keep the evidence;
improve the workflow;
share the scar if you choose.**
Eureka should eventually be able to prepare a sanitized evidence contribution after a campaign and ask the operator whether to submit it upstream.
Not the repository.
Not the private session.
Not whatever an agent helpfully decided was probably fine to upload.
The scar:
what was promised;
what evidence passed;
what was actually wrong;
what falsified it;
and what the workflow had to learn.
The operator sees the payload.
The operator decides whether it leaves.
No background telemetry.
No quiet harvesting.
Consent continues to work even when data would be useful.
A niche position.
---
## The commons gets better when it is used
GameCult does not need Eureka to remain obscure for Eureka to remain valuable.
The opposite is preferable.
More users mean more repositories.
More repositories mean more environments.
More environments mean more ways apparently sensible engineering can fail.
Those failures can become evidence.
The evidence can become rules.
The rules can prevent somebody else paying for the same lesson.
The machinery is public.
Stewardship is the work.
If another team can take Eureka, learn from it, improve its own process, and never need GameCult, good.
Dependency is a strange success metric for infrastructure intended to empower people.
If they later need help adapting the process, challenging it, or independently determining whether their own agentic system is telling them the truth, that is useful work too.
---
## For engineering teams
If your organization is introducing coding agents, you probably already have a workflow.
The useful questions begin afterward.
Who verifies the work?
Did the verifier inherit the implementer's assumptions?
What exactly do your tests prove?
Can verification evidence be reproduced after the session ends?
What happens when a dependency changes underneath a mocked contract?
What happens when an agent satisfies the wording while violating the behavior?
Who owns architectural truth after five agents have edited five different surfaces?
Can you distinguish increased output from increased correct output?
Can you prove that distinction to yourselves without asking the system producing the work?
If the answers are mostly dashboards, confidence scores, and a large green checkmark, Eureka may be relevant.
---
## For people who would rather just install it
Eureka is an open engineering workflow packaged for agent harnesses.
The Claude Code implementation can be installed directly from:
`GameCult/Eureka`
Point it at something sufficiently unpleasant.
It will ask more questions of your code than your code would prefer.
That is the idea.
If you want the larger machine she came from:
`GameCult/Epiphany`
Bring patience.
She is still choosing organs.
---
## GameCult
GameCult builds open infrastructure for coordinated work between humans, projects, communities, and agents.
We prefer inspectable machinery, explicit authority, durable evidence, local agency, and systems whose continued existence does not depend on keeping their users helpless.
Eureka is one result.
Epiphany is another.
The software is available because useful infrastructure becomes more valuable when more people can use it.
GameCult also works with engineering organizations that need help applying these methods to their own agentic development processes: architecture, workflow design, independent verification, evidence systems, migration discipline, and finding out whether the impressive thing they have built is real.
The workflow is free.
Learning every lesson personally remains available at market rates.

254
prototype/index.html Normal file
View file

@ -0,0 +1,254 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Eureka</title>
<meta name="description" content="Eureka: the agent that writes the code does not get to certify that the code works.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Montserrat:wght@100;200&family=Ubuntu:wght@300&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script>
// Motion is an enhancement: without JS, or with reduced motion, the page is plain prose.
document.documentElement.classList.add('js');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) document.documentElement.classList.add('motion');
</script>
<script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script>
</head>
<body>
<canvas id="scene" aria-hidden="true"></canvas>
<main>
<!-- Each section pins one camera stop (data-stop, matching world.js ROUTE ids, in order). Copy is a draft. -->
<!-- The hall -->
<section class="hero" data-stop="hero">
<div class="panel bare">
<p class="label">GameCult / Eureka</p>
<h1>Eureka</h1>
<p class="sub">Your coding agent passed its own tests.</p>
</div>
</section>
<section class="staccato" data-stop="reassuring" style="--n:5">
<div class="stage">
<p class="line">How reassuring.</p>
<p class="line">One agent maps the change.</p>
<p class="line">One implements it.</p>
<p class="line">Another arrives afterward and tries to prove it wrong.</p>
<p class="line">The last one is called <em>Soul</em>.</p>
</div>
</section>
<section class="prose" data-stop="green">
<div class="panel">
<p class="label">Green is a color</p>
<h2>Coherence is not correctness.</h2>
<p>An agent that plans a change, implements it, writes the tests, runs them and reports success has produced a very coherent account of events. The tests pin its own reading of the task. The report is written by the party with the most reason to believe it.</p>
</div>
</section>
<!-- The shaft -->
<section class="prose right" data-stop="epiphany">
<div class="panel">
<p class="label">First there was Epiphany</p>
<h2>Local productivity outran global understanding.</h2>
<p>Coding agents are fast enough to make a locally sensible change, then another, then another, while the architecture quietly stops making sense. Tests pass. Tasks close. Everyone has receipts.</p>
<p>GameCult started building Epiphany: a Mind for a project that survives the workers thinking about it. She is still under construction. She is very particular about her body.</p>
</div>
</section>
<section class="prose" data-stop="sister">
<div class="panel">
<p class="label">Eureka is her sister</p>
<h2>We still had software to ship.</h2>
<p>So the process Epiphany embodies became practice first: separate understanding from execution, write the promises down, keep the evidence, and send in somebody who did not write the thing. Then an agent was asked to formalise it and run it against a real migration.</p>
<p>The migration did not invent the method. It was the first time the method had to explain itself.</p>
</div>
</section>
<!-- Eureka's organ chamber -->
<section class="prose right" data-stop="faculties">
<div class="panel">
<p class="label">Nothing grades itself</p>
<h2>Five organs. One operator.</h2>
<p>Eureka splits a change across faculties inherited from Epiphany's anatomy. Each one owns a single job, and none of them certifies its own work.</p>
</div>
</section>
<section class="prose" data-stop="imagination">
<div class="panel">
<p class="label">Imagination</p>
<h2>Maps the change before anyone touches it.</h2>
<p>Behavioural promises, ownership boundaries, deletions, verification obligations, and the decisions that actually need a human. The point is not a large specification. The point is to make it hard for implementation to quietly redefine the task.</p>
</div>
</section>
<section class="prose right" data-stop="hands">
<div class="panel">
<p class="label">Hands</p>
<h2>One bounded cut at a time.</h2>
<p>Hands follows the map and does not edit it afterward so the route taken becomes the route intended. The good ones stop at forks: one refused to break 104 scene references; another reported that its own mutation passed because the test read expected values from the code under test.</p>
</div>
</section>
<section class="prose" data-stop="soul">
<div class="panel">
<p class="label">Soul</p>
<h2>Assumes something is wrong.</h2>
<p>Soul does not trust the implementation report, or green tests because they are green. It builds its own probes, attacks the promises, and mutates the code to see whether the evidence can tell right from plausibly wrong. Its job is to make the code regret making promises.</p>
</div>
</section>
<section class="prose right" data-stop="self">
<div class="panel">
<p class="label">Self</p>
<h2>Routes the work. Is not infallible.</h2>
<p>Self coordinates the campaign and brings real product decisions back to the operator. Not every ambiguity deserves autonomy. Not every inconvenience deserves a meeting. Separating the faculties catches the implementer; it does not make the coordinator right, and the operator corrects it.</p>
</div>
</section>
<section class="prose" data-stop="life">
<div class="panel">
<p class="label">Life</p>
<h2>Carries the thread.</h2>
<p>Continuity through compaction, sleep and restart. Life keeps durable state honest at every phase boundary, because memory that quietly rewrites itself is a feature right up until it matters.</p>
</div>
</section>
<section class="prose right" data-stop="operator">
<div class="panel">
<p class="label">The operator</p>
<h2>The sixth seat is yours.</h2>
<p>Where Epiphany keeps her Face, Eureka keeps a human. The operator owns the decisions that are actually decisions. Machines are very capable. That does not make responsibility disappear.</p>
</div>
</section>
<!-- The lattice -->
<section class="prose" data-stop="prompts">
<div class="panel">
<p class="label">Better prompts are lovely</p>
<h2>The same reasoning writes the code and the tests.</h2>
<p>The interpretation that produced the implementation tends to produce the tests that approve it. Asking the same system to look harder helps. Giving another system the explicit job of proving it wrong helps more.</p>
</div>
</section>
<section class="staccato" data-stop="mutation" style="--n:6">
<div class="stage">
<p class="line">Mutation testing is rude.</p>
<p class="line">Clamp something that should pass through.</p>
<p class="line">Drop a write.</p>
<p class="line">Invert an authority check.</p>
<p class="line">If the tests stay green, the tests have learned something about themselves.</p>
<p class="line">Incorrect software is under no obligation to look suspicious.</p>
</div>
</section>
<!-- The specimen gallery -->
<section class="prose right" data-stop="campaign">
<div class="panel">
<p class="label">The first formal campaign</p>
<h2>29 of 32 falsification passes found real defects.</h2>
<p>About 75 in all, every one behind the implementer's green suite. One supposedly untestable rule took fifteen lines and a millisecond to falsify. One document was accepted while being 86,751 bytes too large for the transport required to return it.</p>
</div>
</section>
<section class="prose" data-stop="case-drop">
<div class="panel">
<p class="label">Specimen 01 / CultLib QUIC transport</p>
<h2>Every overlay would have dropped ten seconds after connecting.</h2>
<p>A handler swap meant the provider never recorded CONNECTED, so its handshake timer evicted healthy peers. No test lived past ten seconds.</p>
</div>
</section>
<section class="prose right" data-stop="case-uaf">
<div class="panel">
<p class="label">Specimen 02 / native binding</p>
<h2>A use-after-free in <code>release()</code>.</h2>
<p>It only showed under thread-pool saturation, where Soul reproduced it as a core dump. It is now pinned by a regression test that crashes with the old order.</p>
</div>
</section>
<section class="prose" data-stop="case-leak">
<div class="panel">
<p class="label">Specimen 03 / native binding</p>
<h2>About 53 bytes leaked per native call.</h2>
<p>Roughly 36 MB a day while idle, gigabytes under load, and a perfectly flat JavaScript heap the whole time. Every check that looked at the heap was telling the truth about the wrong thing.</p>
</div>
</section>
<section class="prose right" data-stop="case-int">
<div class="panel">
<p class="label">Specimen 04 / StreamPixels</p>
<h2>Every subathon event after the first would have failed.</h2>
<p>A fractional value was written to an integer column. The tests used whole-second fake timers, so the fraction never existed there.</p>
</div>
</section>
<section class="prose" data-stop="case-exec">
<div class="panel">
<p class="label">Specimen 05 / a test double</p>
<h2>The fake was kinder than the real thing.</h2>
<p>The fake executor gave every beat its own completion callback. The real one had a single shared slot. A late finish released the next beat early, and animations overlapped.</p>
</div>
</section>
<section class="staccato" data-stop="pattern" style="--n:3">
<div class="stage">
<p class="line">It did not stop happening.</p>
<p class="line">The defects change.</p>
<p class="line">The pattern does not.</p>
</div>
</section>
<!-- The archive -->
<section class="prose right" data-stop="bodycount">
<div class="panel">
<p class="label">Every rule has a body count</p>
<h2>The rules are accumulated damage.</h2>
<p>Do not let implementation edit its own map. Measure real transport boundaries. Do not call a behaviour untestable because the first agent lacked imagination. Each rule exists because a reasonable alternative failed, and the changelog keeps the evidence. The scars stay.</p>
</div>
</section>
<section class="prose" data-stop="expense">
<div class="panel">
<p class="label">Expense</p>
<h2>Several agents cost more than one.</h2>
<p>They repeat work, and Soul builds probes whose main purpose is proving Hands wrong. That spend is the work. Self's job is to aim it: one Soul gate per cut, and cuts small enough that Hands never drowns in its own context. We remain devastated by the expense of finding bugs before shipping them.</p>
</div>
</section>
<!-- Epiphany's chamber -->
<section class="prose right" data-stop="sisters">
<div class="panel">
<p class="label">Epiphany and Eureka</p>
<h2>Same organs. Different bodies.</h2>
<p>Epiphany is the organism: eight organs, shared governed state, durable memory, and coordination without a human typing <code>Continue</code> all day. She is still growing bones. Eureka is the field kit built while we wait. The two are developing side by side on one shared substrate.</p>
</div>
</section>
<section class="prose" data-stop="open">
<div class="panel">
<p class="label">Open by design</p>
<h2>Use it. Fork it. Read every rule.</h2>
<p>If your campaign finds a failure mode we have not seen, that is more interesting. The plan is for Eureka to offer a sanitised evidence contribution after a campaign: the operator sees the payload and decides whether it leaves. No background telemetry. Consent keeps working even when the data would be useful.</p>
</div>
</section>
<!-- Back in the hall -->
<section class="prose right" data-stop="home">
<div class="panel">
<p class="label">Install</p>
<h2>Point it at something unpleasant.</h2>
<pre><code>git clone https://github.com/GameCult/Eureka.git ~/.claude/skills/eureka</code></pre>
<p>Claude Code finds the skill from its <code>SKILL.md</code>. The source is <a href="https://github.com/GameCult/Eureka">GameCult/Eureka</a>. For the larger machine she came from, see <a href="https://github.com/GameCult/Epiphany">GameCult/Epiphany</a>, and bring patience.</p>
<p class="fine">GameCult builds open infrastructure for coordinated work between people, projects and agents. The workflow is free. Learning every lesson personally remains available at market rates.</p>
</div>
</section>
</main>
<div id="probe" hidden></div>
<script type="module" src="main.js"></script>
</body>
</html>

149
prototype/main.js vendored Normal file
View file

@ -0,0 +1,149 @@
// Eureka: a PS1-style world the camera flies through as the page scrolls.
// One timeline owns everything: scroll position -> path parameter u -> camera, organs, staccato lines.
import * as THREE from 'three';
import { shared, framebuffer } from './ps1.js';
import { buildWorld } from './world.js';
const smooth = (x) => x * x * (3 - 2 * x);
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const PARAMS = new URLSearchParams(location.search);
const DEBUG = PARAMS.has('debug');
const BASE_LINES = 240; // shorter screen side, in rendered pixels
const canvas = document.getElementById('scene');
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'low-power' });
} catch (e) {
canvas.remove(); // no WebGL: the page is still a readable page
throw e;
}
renderer.setPixelRatio(1);
const fb = framebuffer(renderer);
const world = buildWorld();
const camera = new THREE.PerspectiveCamera(58, 4 / 3, 0.05, 60);
const skyCam = new THREE.PerspectiveCamera(58, 4 / 3, 5, 3000); // shares the camera's rotation, never its position
// ---------- scroll timeline: the single owner of u ----------
const sections = [...document.querySelectorAll('[data-stop]')];
const stopIds = Object.keys(world.U); // insertion order = route order
const U_OF = sections.map(s => world.U[s.dataset.stop]);
{
const order = sections.map(s => s.dataset.stop).join(',');
if (order !== stopIds.join(',')) console.error('[eureka] page sections and route stops disagree', { order, stops: stopIds.join(',') });
}
// Framing: push the subject away from the section's text panel. Owned by the DOM (panel side), eased by u.
const SHIFT = sections.map(s => s.classList.contains('hero') ? 0 : s.classList.contains('right') ? -0.2 : 0.18);
function shiftAt(u) {
for (let i = 0; i < U_OF.length - 1; i++) if (u <= U_OF[i + 1]) {
const f = (u - U_OF[i]) / (U_OF[i + 1] - U_OF[i] || 1);
return SHIFT[i] + (SHIFT[i + 1] - SHIFT[i]) * smooth(Math.min(1, Math.max(0, f)));
}
return SHIFT[SHIFT.length - 1];
}
const staccatos = [...document.querySelectorAll('.staccato')];
const MOTION = document.documentElement.classList.contains('motion');
let anchors = [];
const measure = () => { anchors = sections.map(s => s.offsetTop + s.offsetHeight / 2 - innerHeight / 2); };
function targetU() {
const y = scrollY, a = anchors;
if (y <= a[0]) return U_OF[0];
for (let i = 0; i < a.length - 1; i++) {
if (y < a[i + 1]) {
const f = (y - a[i]) / (a[i + 1] - a[i]);
const e = REDUCED ? (f < 0.5 ? 0 : 1) : smooth(f); // reduced motion: hold still frames instead of flying
return U_OF[i] + (U_OF[i + 1] - U_OF[i]) * e;
}
}
return U_OF[U_OF.length - 1];
}
// Staccato: lines slide in, hold, slide out, one at a time; the last one holds.
function updateStaccato() {
if (!MOTION) return;
for (const s of staccatos) {
const lines = s.querySelectorAll('.line'), k = lines.length;
const p = Math.min(1, Math.max(0, (scrollY - s.offsetTop) / (s.offsetHeight - innerHeight)));
lines.forEach((el, j) => {
const local = p * k - j;
let x = 0, o = 0;
if ((local >= 0 && local < 1) || (j === k - 1 && local >= 1)) {
const inT = Math.min(1, local / 0.22), outT = j === k - 1 ? 0 : Math.max(0, (local - 0.78) / 0.22);
x = (1 - smooth(inT)) * 6 - smooth(outT) * 6; o = smooth(inT) * (1 - smooth(outT));
}
el.style.opacity = o.toFixed(3);
el.style.transform = `translateX(${x.toFixed(2)}vw)`;
});
}
}
// ---------- sizing ----------
function resize() {
const aspect = innerWidth / innerHeight;
const h = aspect >= 1 ? BASE_LINES : Math.round(BASE_LINES / aspect);
const w = aspect >= 1 ? Math.round(BASE_LINES * aspect) : BASE_LINES;
renderer.setSize(w, h, false); fb.setSize(w, h);
shared.uRes.value.set(w, h);
for (const c of [camera, skyCam]) { c.aspect = aspect; c.updateProjectionMatrix(); }
measure();
}
addEventListener('resize', resize);
resize();
let u = targetU();
addEventListener('load', () => {
measure(); // fonts shift layout
// Dev probe: ?wp=N (fractional ok) jumps straight to a section's stop, for headless captures.
const wp = parseFloat(PARAMS.get('wp'));
if (!Number.isNaN(wp)) {
const i = Math.min(Math.floor(wp), anchors.length - 1), f = wp - i;
scrollTo(0, anchors[i] + (i + 1 < anchors.length ? (anchors[i + 1] - anchors[i]) * f : 0));
u = targetU();
}
});
// ---------- frame ----------
const probeEl = document.getElementById('probe');
if (DEBUG) probeEl.hidden = false;
const LIGHT = new THREE.Vector3(0.35, 1, 0.55).normalize();
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const W = world.U;
const ctx = {
u: 0, end: 0,
near(id, width = 0.02) { return 1 - clamp01(Math.abs(ctx.u - W[id]) / width); },
};
let last = performance.now(), fps = 60;
function frame(now) {
const dt = Math.min(0.1, (now - last) / 1000); last = now;
fps += (1 / Math.max(dt, 1e-3) - fps) * 0.05;
const t = now / 1000;
shared.uTime.value = t;
const tu = targetU();
u = REDUCED ? tu : u + (tu - u) * (1 - Math.exp(-dt * 5));
camera.position.copy(world.posCurve.getPoint(u));
camera.lookAt(world.lookCurve.getPoint(u));
camera.updateMatrixWorld();
skyCam.quaternion.copy(camera.quaternion);
const { x: rw, y: rh } = shared.uRes.value, shift = rw >= rh ? shiftAt(u) : 0;
for (const c of [camera, skyCam]) c.setViewOffset(rw, rh, -shift * rw, 0, rw, rh);
shared.uLightDir.value.copy(LIGHT).transformDirection(camera.matrixWorldInverse);
ctx.u = u;
ctx.end = clamp01((u - W.open) / (W.home - W.open));
shared.uEnd.value = ctx.end;
shared.uProbe.value = clamp01((u - W.prompts) / (W.mutation - W.prompts));
for (const a of world.anims) a(t, ctx);
world.skyUpdate(t, u, camera.position.y);
updateStaccato();
fb.render(world.sky, skyCam, world.scene, camera);
if (DEBUG) {
const sec = anchors.reduce((best, a, i) => Math.abs(a - scrollY) < Math.abs(anchors[best] - scrollY) ? i : best, 0);
const p = camera.position;
probeEl.textContent = `u ${u.toFixed(3)} -> ${tu.toFixed(3)}\nstop ${sections[sec]?.dataset.stop} probe ${shared.uProbe.value.toFixed(2)} end ${ctx.end.toFixed(2)}\ncam ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)}\nres ${shared.uRes.value.x}x${shared.uRes.value.y} ${fps.toFixed(0)} fps\nmotion ${MOTION} reduced ${REDUCED}`;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

270
prototype/organs.js vendored Normal file
View file

@ -0,0 +1,270 @@
// PS1 demakes of the Epiphany organs, after Aquarium's visual language
// (Aquarium/docs/epiphany-agent-sdf-visual-language.md). Each keeps its organ's
// silhouette, palette and motion grammar; the SDF raymarching becomes low-poly.
// Every builder returns { group, update(t, k) } where k in [0,1] is how close the
// camera's scroll position is to that organ's stop (0 far, 1 at the stop).
import * as THREE from 'three';
import { ps1, flat, lineGeo, circlePts } from './ps1.js';
const V = (x, y, z) => new THREE.Vector3(x, y, z);
const lathe = (pts, segs) => new THREE.LatheGeometry(pts.map(([x, y]) => new THREE.Vector2(x, y)), segs);
const lines = (pts, color) => new THREE.Line(lineGeo(pts), ps1({ color, emissive: true }));
const segs = (pts, color) => new THREE.LineSegments(lineGeo(pts), ps1({ color, emissive: true }));
// Self: black void core, one gold band, nested gold orrery rails with gate nodes.
export function self() {
const group = new THREE.Group();
const core = new THREE.Mesh(flat(new THREE.IcosahedronGeometry(0.8, 1)), ps1({ color: 0x050608, rim: 0xffc860 }));
const band = new THREE.Mesh(new THREE.TorusGeometry(0.83, 0.06, 3, 16), ps1({ color: 0xd9a441, rim: 0xfff0b0 }));
band.rotation.x = Math.PI / 2;
group.add(core, band);
const frames = [[Math.PI / 2, 0, 0], [0.25, Math.PI / 2, 0], [0.6, 0.3, Math.PI / 2]];
const rails = [1.3, 1.65, 2.0].map((r, i) => {
const frame = new THREE.Group(); frame.rotation.set(...frames[i]);
const rail = new THREE.Group();
rail.add(new THREE.Mesh(new THREE.TorusGeometry(r, 0.07, 4, 24), ps1({ color: i === 1 ? 0xc8963a : 0xe0b458, rim: 0xffe8a0 })));
for (let g = 0; g < 4; g++) {
const gate = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.2), ps1({ color: g === 0 ? 0xfff3c0 : 0xffc450, emissive: true }));
const a = g / 4 * Math.PI * 2 + i; gate.position.set(Math.cos(a) * r, Math.sin(a) * r, 0);
rail.add(gate);
}
frame.add(rail); group.add(frame);
return { frame, rail, speed: [0.35, -0.22, 0.15][i] };
});
return {
group,
update(t, k) {
core.scale.setScalar(1 + Math.sin(t * 1.4) * 0.04); // heartbeat
rails.forEach(({ rail, frame, speed }) => { rail.rotation.z = t * speed; frame.rotation.y += 0.0008; });
band.material.uniforms.uRim.value.setHex(k > 0.5 ? 0xfff6d0 : 0xffe0a0);
},
};
}
// Soul: verdict polyhedron. Translucent facets, white oath core, blue confidence edges, red risk seam.
export function soul() {
const group = new THREE.Group();
const solid = new THREE.OctahedronGeometry(1, 0); solid.scale(0.9, 1.3, 0.9);
const coreMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.3, 0), ps1({ color: 0xffffff, emissive: true }));
const facets = new THREE.Mesh(flat(solid.clone()), ps1({ color: 0x9fbfe0, rim: 0xcfe8ff, alpha: 0.5, side: THREE.DoubleSide }));
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(solid), ps1({ color: 0x6fb6ff, emissive: true }));
const seam = new THREE.Group();
for (const dy of [-0.03, 0.03]) seam.add(lines([V(0.93, dy, 0), V(0, dy, 0.93), V(-0.93, dy, 0), V(0, dy, -0.93), V(0.93, dy, 0)], 0xff3b3b));
group.add(coreMesh, facets, edges, seam);
return {
group,
update(t, k) {
group.rotation.y = t * 0.25;
seam.scale.y = 1 + k * 6; // risk tightens the seam band as Soul takes the stage
coreMesh.scale.setScalar(1 + Math.sin(t * 3) * 0.08 * k);
},
};
}
// Hands: dark tool-seed with a three-jaw chuck field and a hot forward seam.
export function hands() {
const group = new THREE.Group();
const tool = new THREE.Group(); tool.rotation.z = -Math.PI / 2 * 0.85; group.add(tool); // leans along its work direction
tool.add(new THREE.Mesh(lathe([[0, -1.1], [0.25, -1.0], [0.42, -0.6], [0.46, 0], [0.4, 0.5], [0.24, 0.9], [0.05, 1.12]], 7), ps1({ color: 0x24272d, rim: 0x7a8699 })));
const edge = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.05, 3, 8), ps1({ color: 0xff6a1a, emissive: true }));
edge.position.y = 1.12; edge.rotation.x = Math.PI / 2; tool.add(edge);
const enamel = ps1({ color: 0xc9824a, rim: 0xffd0a0 });
const jaws = [0, 1, 2].map(i => {
const around = new THREE.Group(); around.rotation.y = i * Math.PI * 2 / 3; tool.add(around);
const jaw = new THREE.Group(); jaw.position.set(0.42, 0.35, 0); around.add(jaw);
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.9, 0.24), enamel); arm.position.y = 0.45; jaw.add(arm);
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.4, 0.2), enamel); tip.position.set(-0.12, 0.95, 0); tip.rotation.z = 0.7; jaw.add(tip);
return jaw;
});
const sparks = [...Array(6)].map(() => {
const s = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.06), ps1({ color: 0xffd08a, emissive: true }));
tool.add(s); return s;
});
return {
group,
update(t, k) {
const open = 0.15 + 0.35 * (0.5 + 0.5 * Math.sin(t * 2.2)) * (0.4 + 0.6 * k);
jaws.forEach(j => { j.rotation.z = -open; });
sparks.forEach((s, i) => { // changed-file receipts: quick flecks at the cutting seam that fade
const ph = (t * 1.7 + i / 6) % 1;
s.visible = k > 0.2 && ph < 0.6;
s.position.set(Math.sin(i * 7.1 + t * 3) * 0.35 * ph, 1.2 + ph * 0.6, Math.cos(i * 3.3 + t * 2) * 0.35 * ph);
});
},
};
}
// Imagination: opal seed wrapped by six curling translucent ribbon-sheets with bright rims.
export function imagination() {
const group = new THREE.Group();
const seed = new THREE.Mesh(lathe([[0, -0.55], [0.3, -0.45], [0.42, -0.1], [0.38, 0.3], [0.22, 0.6], [0, 0.72]], 8), ps1({ color: 0xf4e8ff, rim: 0x9ff4ff }));
const calyx = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.45, 6), ps1({ color: 0x3f8f86 }));
calyx.rotation.x = Math.PI; calyx.position.y = -0.6;
group.add(seed, calyx);
const N = 14, ribbons = [];
const cyan = new THREE.Color(0x7fe8ff), pink = new THREE.Color(0xf0a8ff), gold = new THREE.Color(0xffd27a);
for (let k = 0; k < 6; k++) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array((N + 1) * 2 * 3), col = [], idx = [];
for (let i = 0; i <= N; i++) {
const s = i / N, c = cyan.clone().lerp(s < 0.5 ? pink : gold, Math.abs(s - 0.5) * 2);
col.push(c.r, c.g, c.b, c.r * 0.8, c.g * 0.8, c.b);
if (i < N) { const a = i * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.setIndex(idx);
const sheet = new THREE.Mesh(geo, ps1({ vcolor: true, emissive: true, alpha: 0.5, side: THREE.DoubleSide }));
const rimGeo = new THREE.BufferGeometry(); rimGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 4 * 3), 3));
const rim = new THREE.LineSegments(rimGeo, ps1({ color: 0xcffcff, emissive: true }));
group.add(sheet, rim);
ribbons.push({ geo, rimGeo, phi: k * Math.PI / 3, sign: k % 2 ? 1 : -1 });
}
function write(open) {
const c = new THREE.Vector3(), side = new THREE.Vector3(), up = V(0, 1, 0);
for (const r of ribbons) {
const p = r.geo.attributes.position.array, e = r.rimGeo.attributes.position.array;
for (let i = 0; i <= N; i++) {
const s = i / N;
const rad = 0.3 + 1.5 * open * s * (1 - 0.6 * s * s); // flare out, then curl back in
const ang = r.phi + r.sign * 0.9 * s; // twist around the seed
c.set(Math.cos(ang) * rad, -0.35 + 2.1 * s, Math.sin(ang) * rad);
const tang = V(-Math.sin(ang), 0, Math.cos(ang));
const curl = r.sign * 1.3 * s; // sheet normal rolls along the blade
side.copy(tang).multiplyScalar(Math.cos(curl)).addScaledVector(up, Math.sin(curl));
const w = (0.06 + 0.5 * Math.pow(Math.sin(Math.PI * s), 0.8)) * 0.5;
p.set([c.x + side.x * w, c.y + side.y * w, c.z + side.z * w, c.x - side.x * w, c.y - side.y * w, c.z - side.z * w], i * 6);
}
for (let i = 0; i < N; i++) for (let edge = 0; edge < 2; edge++) {
const a = (i * 2 + edge) * 3, b = ((i + 1) * 2 + edge) * 3, o = (i * 2 + edge) * 6;
e.set([p[a], p[a + 1], p[a + 2], p[b], p[b + 1], p[b + 2]], o);
}
r.geo.attributes.position.needsUpdate = true; r.rimGeo.attributes.position.needsUpdate = true;
r.geo.computeBoundingSphere(); r.rimGeo.computeBoundingSphere();
}
}
const filaments = [0, 1, 2, 3].map(i => {
const a = i * 1.7, pts = [0, 1, 2, 3, 4].map(j => V(Math.cos(a) * 0.08 * j, 0.7 + j * 0.35, Math.sin(a) * 0.08 * j));
group.add(lines(pts, 0xffc861));
const bead = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), ps1({ color: 0xfff0b0, emissive: true }));
group.add(bead); return { bead, pts };
});
let lastOpen = -1;
return {
group,
update(t, k) {
const open = 0.55 + 0.45 * k; // planning readiness opens the bloom
if (Math.abs(open - lastOpen) > 0.004) { write(open); lastOpen = open; }
group.rotation.y = t * 0.12;
filaments.forEach(({ bead, pts }, i) => {
const ph = (t * 0.4 + i / 4) % 1, j = ph * 4, a = pts[Math.floor(j)], b = pts[Math.min(4, Math.floor(j) + 1)];
bead.position.lerpVectors(a, b, j % 1);
});
},
};
}
// Life: memory nautilus seed. Teal shell on a logarithmic spiral, warm ember, beads on the same spiral.
export function life() {
const group = new THREE.Group();
const shell = new THREE.Group(); shell.rotation.x = -0.25; group.add(shell);
const TMAX = 3.2 * Math.PI, rho = (t) => 0.12 * Math.exp(0.18 * t);
const P = (t) => V(rho(t) * Math.cos(t), rho(t) * Math.sin(t), 0);
const ring = 7, steps = 44, pos = [], idx = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps * TMAX, c = P(t), tr = rho(t) * 0.62;
const outward = V(Math.cos(t), Math.sin(t), 0);
for (let j = 0; j < ring; j++) {
const a = j / ring * Math.PI * 2;
const v = c.clone().addScaledVector(outward, Math.cos(a) * tr).add(V(0, 0, Math.sin(a) * tr));
pos.push(v.x, v.y, v.z);
}
if (i < steps) for (let j = 0; j < ring; j++) {
const a = i * ring + j, b = i * ring + (j + 1) % ring;
idx.push(a, b, a + ring, b, b + ring, a + ring);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx); geo.computeVertexNormals();
shell.add(new THREE.Mesh(geo, ps1({ color: 0x2f9e9a, rim: 0x9ff5ea, side: THREE.DoubleSide })));
const seamOf = (t) => P(t).addScaledVector(V(Math.cos(t), Math.sin(t), 0), rho(t) * 0.66);
shell.add(lines([...Array(61)].map((_, i) => seamOf(i / 60 * TMAX)), 0xd8f5ee));
const ember = new THREE.Mesh(new THREE.IcosahedronGeometry(0.17, 0), ps1({ color: 0xffa24a, emissive: true }));
ember.position.copy(P(TMAX)).multiplyScalar(0.8); shell.add(ember);
const beads = [...Array(7)].map(() => {
const b = new THREE.Mesh(new THREE.OctahedronGeometry(0.07, 0), ps1({ color: 0xf0ead8, emissive: true }));
shell.add(b); return b;
});
shell.scale.setScalar(1.35);
return {
group,
update(t, k) {
group.rotation.y = Math.sin(t * 0.2) * 0.5;
ember.scale.setScalar(1 + Math.sin(t * 2) * 0.15);
beads.forEach((b, i) => { b.position.copy(seamOf(((t * 0.05 + i / 7) % 1) * TMAX)).multiplyScalar(1.08); });
},
};
}
// ---------- Epiphany-only organs (Face, Eyes, Body): lower detail, they live in her chamber ----------
// Face: speech lantern. Translucent bell, warm throat, pearls around the lip.
export function face() {
const group = new THREE.Group();
group.add(new THREE.Mesh(lathe([[0, 1], [0.45, 0.85], [0.7, 0.4], [0.8, 0], [0.95, -0.3], [0.7, -0.36]], 9), ps1({ color: 0xf1d2b0, rim: 0xffe4c0, alpha: 0.6, side: THREE.DoubleSide })));
const throat = new THREE.Mesh(new THREE.CircleGeometry(0.5, 8), ps1({ color: 0xffa860, emissive: true }));
throat.rotation.x = Math.PI / 2; throat.position.y = -0.1; group.add(throat);
const pearls = [...Array(5)].map(() => { const p = new THREE.Mesh(new THREE.OctahedronGeometry(0.08, 0), ps1({ color: 0xfff2dc, emissive: true })); group.add(p); return p; });
return { group, update(t) { pearls.forEach((p, i) => { const a = t * 0.6 + i * 1.26, r = 0.9 + 0.25 * Math.sin(t + i); p.position.set(Math.cos(a) * r, -0.4 + 0.1 * Math.sin(t * 2 + i), Math.sin(a) * r); }); } };
}
// Eyes: evidence astrolabe. Black glass lens, blue-metal aperture ring, proof meridians, cyan sparks.
export function eyes() {
const group = new THREE.Group();
const lens = new THREE.Mesh(new THREE.SphereGeometry(0.7, 8, 6), ps1({ color: 0x07090c, rim: 0x5ff2ff })); lens.scale.z = 0.45; group.add(lens);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.82, 0.09, 4, 16), ps1({ color: 0x4a6a8f, rim: 0x9fc8ff })); group.add(ring);
const meridians = [0, 1].map(i => { const m = lines(circlePts(0.98, 20), 0xcfe2f0); m.rotation.x = Math.PI / 2 * i; group.add(m); return m; });
for (let i = 0; i < 5; i++) {
const s = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.07), ps1({ color: 0x6ff7ff, emissive: true }));
const a = i * 1.3; s.position.set(Math.cos(a) * 0.5, Math.sin(a) * 0.5, 0.3); group.add(s);
}
return { group, update(t) { meridians.forEach((m, i) => { m.rotation.y = t * (0.3 + i * 0.2); }); ring.rotation.z = t * 0.1; } };
}
// Body: load-bearing graph stone. Soft geode mass, enamel stress ribs, checkpoint pearls.
export function body() {
const group = new THREE.Group();
const stone = new THREE.Mesh(flat(new THREE.IcosahedronGeometry(0.95, 1)), ps1({ color: 0x22332a, rim: 0x5b8f74 }));
stone.scale.y = 0.72; group.add(stone);
const ribs = new THREE.Group(); ribs.scale.set(1.02, 0.74, 1.02); group.add(ribs);
ribs.add(lines(circlePts(0.97, 20), 0x9fe0bf));
for (const r of [0, 0.9, -0.9]) { const m = lines(circlePts(0.97, 20), 0x9fe0bf); m.rotation.set(Math.PI / 2, r, 0); ribs.add(m); }
for (let i = 0; i < 6; i++) {
const p = new THREE.Mesh(new THREE.OctahedronGeometry(0.09, 0), ps1({ color: 0xfff4d6, emissive: true }));
const a = i / 6 * Math.PI * 2; p.position.set(Math.cos(a) * 0.99, 0, Math.sin(a) * 0.99); ribs.add(p);
}
return { group, update(t) { stone.scale.y = 0.72 + Math.sin(t * 0.9) * 0.02; group.rotation.y = t * 0.05; } };
}
// Scaffolding: Epiphany is still growing bones.
export function scaffold(w, h) {
const x = w / 2, pts = [];
const c = [V(-x, 0, -x), V(x, 0, -x), V(x, 0, x), V(-x, 0, x)];
for (let i = 0; i < 4; i++) {
const a = c[i], b = c[(i + 1) % 4];
for (const y of [0, h * 0.5, h]) pts.push(a.clone().setY(y), b.clone().setY(y));
pts.push(a.clone(), a.clone().setY(h), a.clone(), b.clone().setY(h * 0.5)); // uprights and braces
}
return segs(pts, 0x3c4a58);
}
// Aquarium orbit slots: angle (deg) and ring fraction around Self.
export const SLOTS = {
imagination: [135, 0.78], eyes: [35, 0.86], body: [215, 0.66], hands: [270, 0.82], soul: [325, 0.78], life: [80, 0.62],
};
/** Aquarium's XY grid mapped onto world XZ: +Y (grid forward) points away from the viewer, toward -z. */
export function slot(center, id, r) {
const [deg, ring] = SLOTS[id], a = deg * Math.PI / 180;
return V(center.x + Math.cos(a) * ring * r, center.y, center.z - Math.sin(a) * ring * r);
}

237
prototype/ps1.js vendored Normal file
View file

@ -0,0 +1,237 @@
// PS1 rendering kit: one material, the framebuffer dither, low-res textures, geometry helpers.
import * as THREE from 'three';
THREE.ColorManagement.enabled = false; // raw sRGB values end to end; the PS1 had no colour management either
// Brand tokens, plus colours the brand does not define: the mascot's greens.
// Organ colours come from Aquarium's visual language and live with the organs.
export const C = {
ground: 0x07111a, panel: 0x16212c, accent: 0xff8a2a,
green: 0x4dff88, greenDim: 0x1f6a45,
};
// Uniforms every PS1 material shares by reference.
export const shared = {
uRes: { value: new THREE.Vector2(320, 240) },
uLightDir: { value: new THREE.Vector3() },
uFogColor: { value: new THREE.Color(C.ground) },
uFogNear: { value: 6 },
uFogFar: { value: 38 },
uTime: { value: 0 },
uProbe: { value: 0 }, // Soul's probe phase in the lattice
uEnd: { value: 0 }, // the return to the hall
uAlt: { value: new THREE.Color(C.accent) },
};
const VERT = /* glsl */`
uniform vec2 uRes; uniform vec3 uLightDir; uniform float uFogNear, uFogFar, uFogMax; uniform vec3 uRim;
varying vec2 vUv; varying float vW; varying vec3 vLight; varying float vFog; varying vec3 vCol; varying vec3 vRimC;
#ifdef NODES
attribute float aSeed; varying float vSeed;
#endif
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vec4 clip = projectionMatrix * mv;
// PS1 GTE: no sub-pixel precision. Snap projected vertices to the low-res pixel grid.
if (clip.w > 0.05) {
vec2 h = uRes * 0.5;
clip.xy = floor(clip.xy / clip.w * h + 0.5) / h * clip.w;
}
gl_Position = clip;
#ifdef USE_TEX
// Affine texturing: pre-multiply by w so the GPU's perspective correction cancels out.
vUv = uv * clip.w; vW = clip.w;
#endif
#if !defined(EMISSIVE) || defined(RIM)
vec3 n = normalize(normalMatrix * normal);
#endif
#ifdef EMISSIVE
vLight = vec3(1.0);
#else
vLight = vec3(0.30, 0.34, 0.42) + vec3(0.95, 0.9, 0.82) * max(dot(n, uLightDir), 0.0); // per-vertex (Gouraud)
#endif
#ifdef RIM
vRimC = uRim * pow(1.0 - abs(dot(n, normalize(-mv.xyz))), 2.0); // Fresnel, per vertex
#else
vRimC = vec3(0.0);
#endif
#ifdef USE_COLOR
vCol = color;
#else
vCol = vec3(1.0);
#endif
vFog = min(smoothstep(uFogNear, uFogFar, -mv.z), uFogMax);
#ifdef NODES
vSeed = aSeed;
#endif
}`;
const FRAG = /* glsl */`
uniform sampler2D uMap; uniform vec3 uColor, uFogColor, uAlt; uniform float uTime, uProbe, uEnd, uAlpha;
varying vec2 vUv; varying float vW; varying vec3 vLight; varying float vFog; varying vec3 vCol; varying vec3 vRimC;
#ifdef NODES
varying float vSeed;
#endif
void main() {
vec4 c = vec4(uColor * vCol, 1.0);
#ifdef USE_TEX
vec4 t = texture2D(uMap, vUv / vW);
#ifdef ALPHA_TEST
if (t.a < 0.5) discard;
#endif
c *= t;
#endif
#ifdef NODES
// Soul's probes: as uProbe rises, nodes flip from green to accent and flicker.
float hit = step(vSeed, uProbe);
float fl = step(0.5, fract(sin(floor(uTime * 9.0) + vSeed * 91.7) * 43758.5));
c.rgb = mix(c.rgb, uAlt, hit) * mix(1.0, 0.5 + 0.5 * fl, hit);
#endif
#ifdef CELLS
// On the way back up, a scatter of the wall's green checks has been re-graded.
vec2 cell = floor(vUv / vW);
float h = fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
c.rgb = mix(c.rgb, uAlt * dot(c.rgb, vec3(0.3, 0.59, 0.11)) * 2.4, step(h, uEnd * 0.45));
#endif
c.rgb = c.rgb * vLight + vRimC;
gl_FragColor = vec4(mix(c.rgb, uFogColor, vFog), uAlpha);
}`;
/**
* The one PS1 material. Options switch shader features by define.
* alpha < 1 gives PS1-style semi-transparency (no depth write).
*/
export function ps1({
map = null, color = 0xffffff, emissive = false, alphaTest = false, nodes = false, cells = false,
rim = null, alpha = 1, vcolor = false, fogMax = 1, side = THREE.FrontSide,
} = {}) {
const defines = {};
if (map) defines.USE_TEX = '';
if (emissive) defines.EMISSIVE = '';
if (alphaTest) defines.ALPHA_TEST = '';
if (nodes) defines.NODES = '';
if (cells) defines.CELLS = '';
if (rim !== null) defines.RIM = '';
return new THREE.ShaderMaterial({
defines, side, vertexColors: vcolor, vertexShader: VERT, fragmentShader: FRAG,
transparent: alpha < 1, depthWrite: alpha >= 1,
uniforms: {
...shared,
uMap: { value: map }, uColor: { value: new THREE.Color(color) },
uRim: { value: new THREE.Color(rim ?? 0) }, uAlpha: { value: alpha }, uFogMax: { value: fogMax },
},
});
}
/** PS1 framebuffer: sky pass, world pass, then 15-bit colour with the console's 4x4 ordered dither. */
export function framebuffer(renderer) {
const rt = new THREE.WebGLRenderTarget(320, 240, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, generateMipmaps: false });
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
uniforms: { tScene: { value: rt.texture } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`
uniform sampler2D tScene; varying vec2 vUv;
const mat4 D = mat4(-4.,0.,-3.,1., 2.,-2.,3.,-1., -3.,1.,-4.,0., 3.,-1.,2.,-2.);
void main() {
vec3 c = texture2D(tScene, vUv).rgb * 255.0;
ivec2 p = ivec2(mod(gl_FragCoord.xy, 4.0));
c = clamp(c + D[p.x][p.y], 0.0, 255.0);
gl_FragColor = vec4(floor(c / 8.0) * 8.0 / 255.0, 1.0);
}`,
depthTest: false, depthWrite: false,
}));
const postScene = new THREE.Scene(); postScene.add(quad);
const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
renderer.setClearColor(C.ground, 1);
return {
setSize(w, h) { rt.setSize(w, h); },
render(sky, skyCam, scene, camera) {
renderer.setRenderTarget(rt);
renderer.autoClear = true; renderer.render(sky, skyCam);
renderer.autoClear = false; renderer.clearDepth(); renderer.render(scene, camera);
renderer.autoClear = true;
renderer.setRenderTarget(null); renderer.render(postScene, postCam);
},
};
}
// ---------- textures ----------
export function rng(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
export function canvas(w, h, draw) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d'); g.imageSmoothingEnabled = false; draw(g, w, h);
return c;
}
export function texOf(c, repeat = false) {
const t = new THREE.CanvasTexture(c);
t.magFilter = t.minFilter = THREE.NearestFilter; t.generateMipmaps = false; // the PS1 had no mipmaps
if (repeat) t.wrapS = t.wrapT = THREE.RepeatWrapping;
return t;
}
export const tex = (w, h, draw, repeat = false) => texOf(canvas(w, h, draw), repeat);
export function noise(g, w, h, base, spread, seed) {
const r = rng(seed); g.fillStyle = base; g.fillRect(0, 0, w, h);
for (let i = 0; i < w * h / 3; i++) {
const v = (r() - 0.5) * spread; g.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
g.fillRect(r() * w | 0, r() * h | 0, 1, 1);
}
}
// ---------- geometry ----------
/** Flat-shaded copy: the PS1 look for faceted solids. */
export function flat(geo) { const g = geo.index ? geo.toNonIndexed() : geo; g.computeVertexNormals(); return g; }
/** Floor of square tiles at height y. Each tile gets its own 0..1 UVs, which keeps affine warp per tile. */
export function tiles(x0, z0, nx, nz, size, y, keep = () => true) {
const pos = [], nor = [], uv = [], idx = [];
for (let i = 0; i < nx; i++) for (let j = 0; j < nz; j++) {
const x = x0 + i * size, z = z0 + j * size;
if (!keep(x, z)) continue;
const b = pos.length / 3;
pos.push(x, y, z, x + size, y, z, x + size, y, z + size, x, y, z + size);
nor.push(0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0);
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
idx.push(b, b + 2, b + 1, b, b + 3, b + 2);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.setIndex(idx);
return geo;
}
/** Bake many (geometry, matrix) pairs into one draw call. Keeps position, normal and uv. */
export function merge(parts) {
const out = { position: [], normal: [], uv: [] };
const m3 = new THREE.Matrix3(), v = new THREE.Vector3();
for (const [geo, mat] of parts) {
const g = geo.index ? geo.toNonIndexed() : geo;
m3.getNormalMatrix(mat);
const p = g.attributes.position, n = g.attributes.normal, t = g.attributes.uv;
for (let i = 0; i < p.count; i++) {
v.fromBufferAttribute(p, i).applyMatrix4(mat); out.position.push(v.x, v.y, v.z);
v.fromBufferAttribute(n, i).applyMatrix3(m3).normalize(); out.normal.push(v.x, v.y, v.z);
out.uv.push(t ? t.getX(i) : 0, t ? t.getY(i) : 0);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(out.position, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(out.normal, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
return geo;
}
export const mat4 = (x, y, z, ry = 0, rx = 0, rz = 0, s = 1) =>
new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(s, s, s));
/** Line strip geometry from points (for LineSegments pairs, pass pairs). */
export const lineGeo = (pts) => new THREE.BufferGeometry().setFromPoints(pts);
export function circlePts(r, n, y = 0) {
const pts = [];
for (let i = 0; i <= n; i++) { const a = i / n * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r)); }
return pts;
}

70
prototype/style.css Normal file
View file

@ -0,0 +1,70 @@
/* GameCult brand tokens (source: gamecult-site quartz.config.ts). Single dark theme by decision. */
:root {
--ground: #07111a;
--panel: #16212c;
--text: #b7c7d9;
--heading: #eef5ff;
--accent: #ff8a2a;
--link: #59b7ff;
--title: 'Montserrat', sans-serif;
--prose: 'Ubuntu', sans-serif;
--mono: 'IBM Plex Mono', monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--ground); color: var(--text); }
body { font: 300 1.125rem/1.65 var(--prose); overflow-x: hidden; }
#scene {
position: fixed; inset: 0; width: 100vw; height: 100vh;
image-rendering: pixelated; z-index: 0; display: block;
}
main { position: relative; z-index: 1; }
h1, h2 { font-family: var(--title); color: var(--heading); margin: 0 0 .5em; }
h1 { font-weight: 100; font-size: clamp(3.5rem, 12vw, 9rem); letter-spacing: .04em; line-height: 1; }
h2 { font-weight: 200; font-size: clamp(1.6rem, 3.4vw, 2.6rem); line-height: 1.15; }
a { color: var(--link); }
em { font-style: normal; color: var(--accent); }
.label {
font: 400 .75rem/1 var(--mono); text-transform: uppercase; letter-spacing: .22em;
color: var(--accent); margin: 0 0 1.2em;
}
section { min-height: 100vh; display: flex; align-items: center; padding: 10vh 16px; }
section.right { justify-content: flex-end; }
.panel {
max-width: 34rem; padding: 1.75rem 2rem; margin: 0 max(0px, 6vw);
background: rgba(22, 33, 44, .86); border-left: 2px solid var(--accent);
}
.panel.bare { background: none; border: 0; }
.hero .sub { font-family: var(--title); font-weight: 200; font-size: clamp(1.2rem, 2.6vw, 1.8rem); color: var(--heading); margin: 0; }
.hero .panel, .hero .sub { text-shadow: 0 0 12px var(--ground), 0 0 3px var(--ground); }
.line {
font: 200 clamp(1.8rem, 5vw, 3.8rem)/1.15 var(--title); color: var(--heading);
margin: 0 0 .6em; max-width: 22ch;
text-shadow: 0 0 18px var(--ground), 0 0 4px var(--ground);
}
.staccato .stage { width: 100%; padding: 0 max(0px, 6vw); }
/* With motion: the section becomes a scroll track, the stage pins, lines take turns. */
.motion .staccato { display: block; padding: 0; height: calc(var(--n) * 70vh + 100vh); }
.motion .staccato .stage { position: sticky; top: 0; height: 100vh; display: grid; align-items: center; }
.motion .staccato .line { grid-area: 1 / 1; margin: 0; opacity: 0; will-change: transform, opacity; }
#probe {
position: fixed; right: 8px; bottom: 8px; z-index: 5; white-space: pre;
font: 400 11px/1.4 var(--mono); color: var(--heading); background: rgba(7,17,26,.85);
padding: 6px 8px; border: 1px solid var(--panel);
}
@media (max-width: 640px) {
.panel { padding: 1.25rem 1.25rem; margin: 0; }
}
code { font: 400 .92em var(--mono); color: var(--heading); }
pre { margin: 0 0 1em; padding: .8rem 1rem; background: var(--ground); border: 1px solid #22303e; overflow-x: auto; }
pre code { font-size: .8rem; white-space: pre; }
.fine { font-size: .92rem; opacity: .85; }

21
prototype/vendor/three.LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

60586
prototype/vendor/three.core.js vendored Normal file

File diff suppressed because one or more lines are too long

19719
prototype/vendor/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long

411
prototype/world.js vendored Normal file
View file

@ -0,0 +1,411 @@
// The world the camera flies through, and the path it flies along.
// Route: hall -> hatch + shaft -> Eureka's organ chamber -> lattice -> specimen gallery
// -> archive -> Epiphany's chamber -> second shaft -> back to the hall.
import * as THREE from 'three';
import { C, ps1, rng, canvas, texOf, tex, noise, flat, tiles, merge, mat4, lineGeo, circlePts } from './ps1.js';
import * as O from './organs.js';
const V = (x, y, z) => new THREE.Vector3(x, y, z);
// ---------- textures ----------
const tabletCanvas = canvas(80, 96, (g, w, h) => {
noise(g, w, h, '#3a4757', 0.25, 3);
g.textAlign = 'center'; g.font = 'bold 17px Georgia, serif';
[['ALL', 34], ['TESTS', 54], ['PASSING', 74]].forEach(([s, y]) => {
g.fillStyle = '#56657a'; g.fillText(s, w / 2 + 1, y + 1);
g.fillStyle = '#141c26'; g.fillText(s, w / 2, y);
});
g.strokeStyle = '#05090d'; g.lineWidth = 2; g.beginPath();
[[42, 0], [38, 14], [43, 26], [37, 40], [42, 55], [36, 70], [41, 84], [39, 96]].forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y));
g.stroke();
});
const half = (x0) => texOf(canvas(40, 96, (g) => g.drawImage(tabletCanvas, x0, 0, 40, 96, 0, 0, 40, 96)));
const T = {
stone: tex(32, 32, (g, w, h) => noise(g, w, h, '#2b3746', 0.25, 1), true),
tile: tex(32, 32, (g, w, h) => { noise(g, w, h, '#1b2632', 0.22, 2); g.fillStyle = '#0d151d'; g.fillRect(0, 0, w, 1); g.fillRect(0, 0, 1, h); }, true),
grid: tex(32, 32, (g, w, h) => { noise(g, w, h, '#0e1a20', 0.18, 9); g.fillStyle = '#1b4a48'; g.fillRect(0, 0, w, 1); g.fillRect(0, 0, 1, h); }, true),
tabletL: half(0), tabletR: half(40),
checks: tex(32, 32, (g, w, h) => {
g.fillStyle = '#081510'; g.fillRect(0, 0, w, h); g.fillStyle = '#12301f'; g.fillRect(3, 3, w - 6, h - 6);
g.strokeStyle = '#4dff88'; g.lineWidth = 3; g.beginPath(); g.moveTo(9, 17); g.lineTo(14, 22); g.lineTo(24, 10); g.stroke();
}, true),
hatch: tex(64, 64, (g, w, h) => {
noise(g, w, h, '#3c4756', 0.3, 4);
g.fillStyle = '#1a222c'; [[4, 4], [56, 4], [4, 56], [56, 56]].forEach(([x, y]) => g.fillRect(x, y, 4, 4));
g.fillStyle = '#3fd77a'; g.font = 'bold 15px monospace'; g.textAlign = 'center'; g.fillText('GREEN', 32, 37);
}),
shaft: tex(32, 32, (g, w, h) => { noise(g, w, h, '#0f1f19', 0.25, 5); g.fillStyle = '#2c4a3a'; g.fillRect(6, 14, 20, 2); }, true),
cabinet: tex(96, 128, (g, w, h) => {
noise(g, w, h, '#1a1410', 0.2, 6); g.strokeStyle = '#5a4130'; g.lineWidth = 2;
for (let i = 0; i <= 3; i++) { g.beginPath(); g.moveTo(i * 32, 0); g.lineTo(i * 32, h); g.stroke(); }
for (let j = 0; j <= 4; j++) { g.beginPath(); g.moveTo(0, j * 32); g.lineTo(w, j * 32); g.stroke(); }
g.fillStyle = '#b8a888'; for (let i = 0; i < 3; i++) for (let j = 0; j < 4; j++) g.fillRect(i * 32 + 9, j * 32 + 26, 14, 3);
}),
wood: tex(32, 32, (g, w, h) => { noise(g, w, h, '#3a291c', 0.2, 12); g.fillStyle = '#2a1d14'; for (let y = 3; y < h; y += 7) g.fillRect(0, y, w, 1); }, true),
slab: tex(16, 32, (g, w, h) => { noise(g, w, h, '#4a5666', 0.25, 13); g.fillStyle = '#232c38'; for (let y = 6; y < 28; y += 4) g.fillRect(3, y, 10 - (y % 3) * 2, 1); }),
survived: tex(64, 12, (g, w) => { g.fillStyle = '#1a0f07'; g.fillRect(0, 0, w, 12); g.fillStyle = '#ff8a2a'; g.font = 'bold 9px monospace'; g.textAlign = 'center'; g.fillText('SURVIVED', w / 2, 9); }),
};
function bugTex(seed, bodyCol, legCol) {
const r = rng(seed);
return tex(32, 32, (g) => {
const len = 6 + r() * 4, wid = 3 + r() * 2.5, legs = r() < 0.3 ? 4 : 3, spread = 8 + r() * 5;
g.strokeStyle = legCol; g.lineWidth = 1;
for (let k = 0; k < legs; k++) for (const s of [-1, 1]) {
const y = 16 - len * 0.5 + (k + 0.5) * len / legs;
g.beginPath(); g.moveTo(16, y); g.lineTo(16 + s * spread * 0.7, y + (y - 16) * 0.6 - 2); g.lineTo(16 + s * spread, y + (y - 16)); g.stroke();
}
g.beginPath(); g.moveTo(15, 16 - len); g.lineTo(11 - r() * 3, 3); g.moveTo(17, 16 - len); g.lineTo(21 + r() * 3, 3); g.stroke();
if (r() < 0.4) { g.fillStyle = 'rgba(190,220,240,0.55)'; g.beginPath(); g.ellipse(11, 17, 5, 8, -0.4, 0, 7); g.ellipse(21, 17, 5, 8, 0.4, 0, 7); g.fill(); }
g.fillStyle = bodyCol; g.beginPath(); g.ellipse(16, 17, wid, len, 0, 0, 7); g.fill();
g.fillStyle = '#d6e0ea'; g.fillRect(15, 17 - len * 0.6, 2, len * 0.5);
});
}
function labelTex(a, b) {
return tex(128, 24, (g, w) => {
g.fillStyle = '#1b1712'; g.fillRect(0, 0, w, 24); g.fillStyle = '#c9b48a'; g.fillRect(0, 0, w, 1); g.fillRect(0, 23, w, 1);
g.font = 'bold 9px monospace'; g.fillStyle = '#e9dcc0'; g.fillText(a, 4, 10); g.fillStyle = '#ff8a2a'; g.fillText(b, 4, 20);
});
}
// ---------- the route: stops pin page sections; vias shape the flight between them ----------
const OC = V(0, -17, -5); // Eureka's organ chamber, Self at the centre
const EC = V(20, -49, 4); // Epiphany's chamber
const R_EUREKA = 6, R_EPIPHANY = 9, ORGAN_Y = -16.8;
const orbit = (c, deg, r, y) => { const a = deg * Math.PI / 180; return V(c.x + Math.cos(a) * r, y, c.z - Math.sin(a) * r); };
const org = Object.fromEntries(['imagination', 'hands', 'soul', 'life'].map(id => [id, O.slot(OC, id, R_EUREKA).setY(ORGAN_Y)]));
const up = (v, d = 0.3) => v.clone().setY(v.y + d);
export const ROUTE = [
{ id: 'hero', p: V(5.5, 4, 13), l: V(-2.8, 2.5, 0) },
{ id: 'reassuring', p: V(3.2, 2.4, 8.8), l: V(0, 0.6, 4) },
{ id: 'green', p: V(-0.4, 4.2, 4.9), l: V(-0.4, -6, 3.7) },
{ id: 'epiphany', p: V(-0.4, -3, 4.1), l: V(-0.3, -13, 3.4) },
{ id: 'sister', p: V(-0.4, -9, 4.0), l: V(-0.2, -18, 3.0) },
{ id: 'faculties', p: V(-0.3, -12.4, 4.4), l: V(0, -17.5, -4) },
{ p: orbit(OC, 200, 8.2, -15.5), l: V(-2, -16.8, -6) },
{ id: 'imagination', p: orbit(OC, 135, 7.8, -15.8), l: up(org.imagination) },
{ p: orbit(OC, 190, 8.4, -15.8), l: OC.clone() },
{ id: 'hands', p: orbit(OC, 250, 7.5, -15.6), l: org.hands },
{ id: 'soul', p: orbit(OC, 330, 7.5, -15.6), l: org.soul },
{ id: 'self', p: orbit(OC, 20, 3.3, -14.6), l: up(OC, 0.2) },
{ id: 'life', p: orbit(OC, 80, 7, -15.8), l: org.life },
{ p: orbit(OC, 15, 8.6, -15), l: OC.clone() },
{ id: 'operator', p: V(2.2, -14.3, 5.5), l: V(0, -16.6, -5) },
{ p: V(9.5, -15, -13), l: V(3, -26, -22) },
{ id: 'prompts', p: V(5, -24, -17), l: V(0, -29, -26) },
{ id: 'mutation', p: V(-4, -29.5, -23.5), l: V(1, -32, -31) },
{ p: V(0, -35, -33), l: V(-4, -37, -44) },
{ id: 'campaign', p: V(3, -37, -45), l: V(-3.4, -37.4, -45) },
{ id: 'case-drop', p: V(9.4, -37.6, -44.2), l: V(9, -38.3, -48.4) },
{ id: 'case-uaf', p: V(15.4, -37.6, -45.8), l: V(15, -38.3, -41.6) },
{ id: 'case-leak', p: V(21.4, -37.6, -44.2), l: V(21, -38.3, -48.4) },
{ id: 'case-int', p: V(27.4, -37.6, -45.8), l: V(27, -38.3, -41.6) },
{ id: 'case-exec', p: V(33.4, -37.6, -44.2), l: V(33, -38.3, -48.4) },
{ id: 'pattern', p: V(37, -37, -45), l: V(41, -37.5, -38) },
{ id: 'bodycount', p: V(41, -37.2, -34), l: V(38.5, -37.5, -28) },
{ id: 'expense', p: V(41, -36.8, -22), l: V(44, -37, -16) },
{ p: V(41, -38.5, -6), l: V(26, -48, 2) },
{ id: 'sisters', p: V(32.5, -45, 8), l: V(20, -49, 3) },
{ id: 'open', p: V(12, -45.5, 14.5), l: V(21, -49, 2) },
{ p: V(19.6, -43.5, 4.4), l: V(20, -20, 3.8) },
{ p: V(20.2, -12, 4.1), l: V(20.2, 10, 3.8) },
{ p: V(20.3, 1.5, 4.2), l: V(12, 2.5, 1) },
{ id: 'home', p: V(7.8, 3.3, 8.2), l: V(0.6, 2.0, -0.6) },
];
export function buildWorld() {
const scene = new THREE.Scene();
const anims = []; // (t, ctx) => void; ctx = { u, near(id, width) }
const posCurve = new THREE.CatmullRomCurve3(ROUTE.map(k => k.p), false, 'centripetal');
const lookCurve = new THREE.CatmullRomCurve3(ROUTE.map(k => k.l), false, 'centripetal');
const U = Object.fromEntries(ROUTE.flatMap((k, i) => k.id ? [[k.id, i / (ROUTE.length - 1)]] : []));
const pathSamples = posCurve.getSpacedPoints(600);
const stone = ps1({ map: T.stone });
// ---------- the hall ----------
scene.add(new THREE.Mesh(tiles(-21, -21, 21, 21, 2, 0, (x, z) => !(z === 3 && (x === -1 || x === 19))), ps1({ map: T.tile })));
const plinth = new THREE.Mesh(new THREE.BoxGeometry(5, 0.6, 2.2, 3, 1, 2), stone); plinth.position.set(0, 0.3, -0.4); scene.add(plinth);
const tabletPivot = new THREE.Group(); tabletPivot.position.set(0, 0.6, -0.4); tabletPivot.rotation.x = -0.15; scene.add(tabletPivot);
const halves = [[T.tabletL, -0.9, 1], [T.tabletR, 0.9, -1]].map(([map, x, s]) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(1.8, 4.4, 0.5, 2, 4, 1), [stone, stone, stone, stone, ps1({ map }), stone]);
m.position.set(x, 2.2, 0); tabletPivot.add(m); return { m, x, s };
});
anims.push((t, ctx) => { // on the way back up the monument has split along its crack
const e = THREE.MathUtils.smoothstep(ctx.end, 0, 1);
halves.forEach(({ m, x, s }) => { m.position.x = x - s * e * 0.55; m.rotation.z = s * e * 0.2; m.rotation.y = -s * e * 0.15; });
});
const wallGeo = new THREE.PlaneGeometry(22, 11, 11, 5);
wallGeo.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? 5 : 11); });
const wall = new THREE.Mesh(wallGeo, ps1({ map: T.checks, emissive: true, cells: true }));
wall.position.set(0, 5.5, -9); scene.add(wall);
const metal = ps1({ map: T.stone, color: 0x8899aa });
const hatchMesh = () => new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2, 2, 1, 2), [metal, metal, ps1({ map: T.hatch }), metal, metal, metal]);
const hinge = new THREE.Group(); hinge.position.set(1, 0.02, 4); hinge.rotation.z = -1.0; scene.add(hinge);
const hatch = hatchMesh(); hatch.position.set(-1, 0.05, 0); hinge.add(hatch);
const lid = hatchMesh(); lid.position.set(22.3, 0.06, 4.6); lid.rotation.y = 0.4; scene.add(lid); // the second hatch, lid off
const shaftMat = ps1({ map: T.shaft, emissive: true, side: THREE.DoubleSide });
const shaft = (cx, top, depth) => {
for (const [x, z, ry] of [[cx - 1, 4, Math.PI / 2], [cx + 1, 4, Math.PI / 2], [cx, 3, 0], [cx, 5, 0]]) {
const g = new THREE.PlaneGeometry(2, depth, 2, Math.round(depth));
g.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? depth / 2 : 1); });
const m = new THREE.Mesh(g, shaftMat); m.position.set(x, top - depth / 2, z); m.rotation.y = ry; scene.add(m);
}
};
shaft(0, 0, 12);
shaft(20, 0, 40);
// ---------- organ chambers ----------
const shadowMat = ps1({ color: 0x000000, emissive: true, alpha: 0.45 });
function chamber(center, floorY, radius, pillarR, pillars, pillarH) {
const n = Math.ceil(radius);
scene.add(new THREE.Mesh(tiles(center.x - n, center.z - n, n, n, 2, floorY,
(x, z) => Math.hypot(x + 1 - center.x, z + 1 - center.z) < radius), ps1({ map: T.grid })));
const parts = [];
for (let i = 0; i < pillars; i++) {
const a = (i + 0.5) / pillars * Math.PI * 2;
parts.push([new THREE.BoxGeometry(0.9, pillarH, 0.9, 1, 3, 1), mat4(center.x + Math.cos(a) * pillarR, floorY + pillarH / 2, center.z + Math.sin(a) * pillarR, -a)]);
}
scene.add(new THREE.Mesh(merge(parts), stone));
}
function place(build, pos, id, scale, floorY) {
const o = build(); o.group.position.copy(pos); o.group.scale.setScalar(scale); scene.add(o.group);
const sh = new THREE.Mesh(new THREE.CircleGeometry(0.9 * scale, 8), shadowMat);
sh.rotation.x = -Math.PI / 2; sh.position.set(pos.x, floorY + 0.03, pos.z); scene.add(sh);
const phase = pos.x * 1.7 + pos.z;
anims.push((t, ctx) => { o.group.position.y = pos.y + Math.sin(t * 1.3 + phase) * 0.12 * scale; o.update(t, ctx.near(id)); });
return o;
}
// Eureka: five organs lit. The sixth seat, where Epiphany keeps Face, is the viewer's.
chamber(OC, -19, 11, 11.5, 8, 7);
const guide = new THREE.Line(lineGeo(circlePts(0.78 * R_EUREKA, 40)), ps1({ color: 0x1d5a5a, emissive: true }));
guide.position.set(OC.x, -18.95, OC.z); scene.add(guide);
place(O.self, up(OC, 0.4 + ORGAN_Y - OC.y), 'self', 1, -19);
for (const id of ['imagination', 'hands', 'soul', 'life']) place(O[id], org[id], id, 1, -19);
// Epiphany: all eight organs, in scaffolding.
chamber(EC, -52, 14, 14.5, 10, 12);
const toViewer = V(32.5 - EC.x, 0, 8 - EC.z).normalize();
const eslots = {
self: up(EC, 0.4), face: EC.clone().addScaledVector(toViewer, 0.58 * R_EPIPHANY),
...Object.fromEntries(['imagination', 'eyes', 'body', 'hands', 'soul', 'life'].map(id => [id, O.slot(EC, id, R_EPIPHANY)])),
};
for (const [id, pos] of Object.entries(eslots)) {
place(O[id], pos, 'sisters', 1.3, -52);
const big = id === 'self', cage = O.scaffold(big ? 6.4 : 3.6, big ? 6.5 : 4.6);
cage.position.set(pos.x, -52, pos.z); scene.add(cage);
}
{
const tops = [];
for (let i = 0; i < 10; i++) {
const a = (i + 0.5) / 10 * Math.PI * 2, b = (i + 1.5) / 10 * Math.PI * 2;
tops.push(V(EC.x + Math.cos(a) * 14.5, -40, EC.z + Math.sin(a) * 14.5), V(EC.x + Math.cos(b) * 14.5, -40, EC.z + Math.sin(b) * 14.5));
tops.push(V(EC.x + Math.cos(a) * 14.5, -40, EC.z + Math.sin(a) * 14.5), V(EC.x + Math.cos(a) * 6, -38, EC.z + Math.sin(a) * 6));
}
scene.add(new THREE.LineSegments(lineGeo(tops), ps1({ color: 0x3c4a58, emissive: true })));
}
// ---------- the lattice: green nodes wired together, kept clear of the flight path ----------
{
const r = rng(11), nodes = [];
for (let x = -10; x <= 10; x += 3.2) for (let y = -23; y >= -36; y -= 3.2) for (let z = -15; z >= -34; z -= 3.2) {
const p = V(x + (r() - .5) * 1.6, y + (r() - .5) * 1.6, z + (r() - .5) * 1.6);
if (p.y > -22.5 || pathSamples.some(s => s.distanceToSquared(p) < 2.6)) continue;
nodes.push(p);
}
const octa = new THREE.OctahedronGeometry(0.28), pos = [], seed = [];
for (const n of nodes) {
const s = r() * 1.15; // a few (seed > 1) never flip: what the probes missed
for (let i = 0; i < octa.attributes.position.count; i++) {
pos.push(octa.attributes.position.getX(i) + n.x, octa.attributes.position.getY(i) + n.y, octa.attributes.position.getZ(i) + n.z);
seed.push(s);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute('aSeed', new THREE.Float32BufferAttribute(seed, 1));
scene.add(new THREE.Mesh(g, ps1({ color: C.green, emissive: true, nodes: true })));
const edges = [];
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++)
if (nodes[i].distanceTo(nodes[j]) < 4.0 && r() < 0.55) edges.push(nodes[i], nodes[j]);
scene.add(new THREE.LineSegments(lineGeo(edges), ps1({ color: C.greenDim, emissive: true })));
// Soul's probes descend with little verdict crystals at their tips.
const beam = ps1({ color: C.accent, emissive: true }), tipMat = ps1({ color: 0x9fd0ff, emissive: true });
const probes = [...Array(7)].map(() => {
const g = new THREE.BoxGeometry(0.08, 1, 0.08); g.translate(0, -0.5, 0);
const m = new THREE.Mesh(g, beam); m.position.set((r() - .5) * 16, -21, -17 - r() * 15); scene.add(m);
const tip = new THREE.Mesh(new THREE.OctahedronGeometry(0.22, 0), tipMat); scene.add(tip);
return { m, tip, len: 8 + r() * 7 };
});
anims.push((t, ctx) => {
const k = THREE.MathUtils.clamp((ctx.u - U.prompts) / (U.mutation - U.prompts), 0, 1);
probes.forEach(p => {
p.m.scale.y = Math.max(0.001, k * p.len);
p.tip.position.set(p.m.position.x, p.m.position.y - k * p.len, p.m.position.z); p.tip.rotation.y = t;
p.tip.visible = k > 0.01;
});
});
}
// ---------- the specimen gallery ----------
scene.add(new THREE.Mesh(tiles(-6, -50, 22, 5, 2, -40), ps1({ map: T.tile })));
const wallG = (w, x, z, ry) => {
const g = new THREE.PlaneGeometry(w, 5, Math.round(w / 2), 3);
g.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? 2.5 : w / 2); });
const m = new THREE.Mesh(g, stone); m.position.set(x, -37.5, z); m.rotation.y = ry; scene.add(m);
};
wallG(44, 16, -50, 0);
wallG(32, 22, -40, Math.PI); // the south wall leaves an opening where the camera drops in from the lattice
{
// The first campaign's cabinet: twelve pinned mutants, one still moving.
const cab = new THREE.Group(); cab.position.set(-3.4, -37.4, -45); cab.rotation.y = Math.PI / 2; cab.scale.setScalar(0.52); scene.add(cab);
const frame = new THREE.Mesh(new THREE.BoxGeometry(7.4, 9.4, 0.6, 3, 4, 1), ps1({ map: T.cabinet })); frame.position.z = -0.4; cab.add(frame);
const bug = bugTex(1, '#5d6b78', '#8795a3'), bugMat = ps1({ map: bug, alphaTest: true, emissive: true, color: 0xcfd8e2 });
let survivor;
[-2.4, 0, 2.4].forEach((cx, i) => [3.3, 1.1, -1.1, -3.3].forEach((cy, j) => {
const live = i === 1 && j === 1;
const m = new THREE.Mesh(new THREE.PlaneGeometry(1.8, 1.8), live ? ps1({ map: bug, alphaTest: true, emissive: true, color: C.accent }) : bugMat);
m.position.set(cx, cy + 0.15, -0.05); cab.add(m);
if (live) {
survivor = m;
const tag = new THREE.Mesh(new THREE.PlaneGeometry(1.9, 0.36), ps1({ map: T.survived, emissive: true })); tag.position.set(cx, cy - 0.85, -0.04); cab.add(tag);
}
}));
anims.push((t) => { survivor.rotation.z = Math.sin(t * 11) * 0.12 * (Math.sin(t * 1.4) > 0.3 ? 1 : 0); });
}
const glass = ps1({ color: 0x9fc4e8, rim: 0xbfe3ff, alpha: 0.22, side: THREE.DoubleSide });
const brass = ps1({ color: 0xb08a4a, emissive: true });
const CASES = [
['case-drop', 9, 'north', 11, '#6a5a78', '#a795c3', 'No.01 HANDSHAKE EVICTION', 'SUITE GREEN SOUL CAUGHT'],
['case-uaf', 15, 'south', 12, '#78604a', '#c3a587', 'No.02 USE AFTER FREE', 'SUITE GREEN SOUL CAUGHT'],
['case-leak', 21, 'north', 13, '#4a7868', '#87c3ad', 'No.03 53 BYTES A CALL', 'SUITE GREEN SOUL CAUGHT'],
['case-int', 27, 'south', 14, '#78784a', '#c3c387', 'No.04 FRACTION IN INT', 'SUITE GREEN SOUL CAUGHT'],
['case-exec', 33, 'north', 15, '#4a5a78', '#8799c3', 'No.05 SHARED SLOT', 'SUITE GREEN SOUL CAUGHT'],
];
for (const [id, x, side, seed, body, leg, l1, l2] of CASES) {
const g = new THREE.Group(); g.position.set(x, -40, side === 'north' ? -48.4 : -41.6); g.rotation.y = side === 'north' ? 0 : Math.PI; scene.add(g);
const ped = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.0, 1.6), stone); ped.position.y = 0.5; g.add(ped);
const box = new THREE.BoxGeometry(1.5, 1.6, 1.5);
const gl = new THREE.Mesh(box, glass); gl.position.y = 1.8; g.add(gl);
const fr = new THREE.LineSegments(new THREE.EdgesGeometry(box), brass); fr.position.y = 1.8; g.add(fr);
const spec = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), ps1({ map: bugTex(seed, body, leg), alphaTest: true, emissive: true, color: 0xdde6ee }));
spec.position.y = 1.8; g.add(spec);
const lab = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.26), ps1({ map: labelTex(l1, l2), emissive: true })); lab.position.set(0, 0.7, 0.81); g.add(lab);
anims.push((t, ctx) => { spec.rotation.y = Math.sin(t * 0.7) * 0.25 * ctx.near(id); });
}
// ---------- the archive: rules as tablets, each with a body count ----------
scene.add(new THREE.Mesh(tiles(34, -44, 7, 17, 2, -40), ps1({ map: T.tile })));
{
const r = rng(31), woodParts = [], slabParts = [], markParts = [];
for (const [bx, face] of [[37.4, 1], [44.6, -1]]) for (let z = -40; z <= -14; z += 3.2) {
woodParts.push([new THREE.BoxGeometry(0.2, 4.6, 3.0), mat4(bx, -37.7, z)]);
for (let k = 0; k < 4; k++) {
const py = -39.9 + k * 1.1, px = bx + face * 0.45;
woodParts.push([new THREE.BoxGeometry(0.8, 0.1, 3.0), mat4(px, py, z)]);
for (let zz = z - 1.3; zz < z + 1.3; zz += 0.3 + r() * 0.12) {
const h = 0.5 + r() * 0.35;
slabParts.push([new THREE.BoxGeometry(0.28, h, 0.2), mat4(px, py + 0.05 + h / 2, zz, 0, 0, (r() - 0.5) * 0.12)]);
if (r() < 0.06) markParts.push([new THREE.BoxGeometry(0.3, 0.06, 0.22), mat4(px, py + 0.05 + h * 0.8, zz)]);
}
}
}
scene.add(new THREE.Mesh(merge(woodParts), ps1({ map: T.wood })));
scene.add(new THREE.Mesh(merge(slabParts), ps1({ map: T.slab })));
scene.add(new THREE.Mesh(merge(markParts), ps1({ color: C.accent, emissive: true })));
}
// ---------- the far plane: GameCult's aperture, a populated megastructure behind the fog ----------
const { sky, skyUpdate } = aperture();
return { scene, sky, anims, posCurve, lookCurve, U, skyUpdate };
}
// ---------- the aperture ----------
const BIOMES = [
['#1f4a2a', (g, r) => { for (let i = 0; i < 90; i++) { g.fillStyle = r() < .5 ? '#12301b' : '#3f7a3a'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 2, 2); } g.fillStyle = '#dfeff5'; for (let i = 0; i < 4; i++) g.fillRect(r() * 64 | 0, r() * 40 | 0, 1, 10 + r() * 14); }],
['#a0602a', (g, r) => { g.fillStyle = '#c98a45'; for (let y = 4; y < 64; y += 9) g.fillRect(0, y, 64, 2); g.fillStyle = '#5a3418'; for (let i = 0; i < 7; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 3, y - 11); g.lineTo(x + 6, y); g.fill(); } }],
['#b9ccd8', (g, r) => { g.fillStyle = '#7f9fb8'; for (let i = 0; i < 8; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 6, y - 12); g.lineTo(x + 12, y); g.fill(); } g.fillStyle = '#2a3a4a'; for (let i = 0; i < 20; i++) g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); }],
['#1e5a52', (g, r) => { g.fillStyle = '#5fd0c0'; for (let i = 0; i < 7; i++) g.fillRect(r() * 64 | 0, 0, 1, 64); g.fillStyle = '#123a33'; for (let i = 0; i < 60; i++) g.fillRect(r() * 64 | 0, r() * 64 | 0, 2, 2); }],
['#4a5a4a', (g, r) => { g.fillStyle = '#7a8a7a'; for (let i = 0; i < 8; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 7, y - 13); g.lineTo(x + 14, y); g.fill(); g.fillStyle = '#e8f0f0'; g.fillRect(x + 6, y - 13, 2, 2); g.fillStyle = '#7a8a7a'; } }],
['#0b1320', (g, r) => { for (let i = 0; i < 160; i++) { g.fillStyle = r() < .6 ? '#ffb45a' : '#6fe0ff'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); } }],
];
function aperture() {
const sky = new THREE.Scene();
const R = 230, FOG = 0.62;
const hull = tex(64, 64, (g, w, h) => {
const r = rng(41); g.fillStyle = '#0a1420'; g.fillRect(0, 0, w, h);
g.fillStyle = '#13263a'; for (let i = 0; i < 64; i += 8) { g.fillRect(i, 0, 1, h); g.fillRect(0, i, w, 1); }
for (let i = 0; i < 70; i++) { g.fillStyle = r() < .55 ? '#ffb45a' : '#6fe0ff'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); }
}, true);
const root = new THREE.Group(); sky.add(root);
root.position.copy(V(-0.45, 0.3, -1).normalize().multiplyScalar(560));
root.lookAt(0, 0, 0); root.rotateX(-0.55); root.rotateY(0.25); // tilted: a structure seen at an angle, not a badge
const discGeo = new THREE.CircleGeometry(R * 0.96, 28);
discGeo.attributes.uv.array.forEach((v, i, a) => { a[i] = v * 7; });
const disc = new THREE.Mesh(discGeo, ps1({ map: hull, emissive: true, fogMax: FOG, side: THREE.DoubleSide })); disc.position.z = -3; root.add(disc);
const ringGeo = new THREE.TorusGeometry(R, R * 0.06, 4, 48);
const ring = new THREE.Mesh(ringGeo, ps1({ map: hull, color: 0x8fdcff, emissive: true, fogMax: FOG - 0.12 })); root.add(ring);
const inner = new THREE.Mesh(new THREE.TorusGeometry(R * 0.92, R * 0.012, 3, 48), ps1({ color: C.accent, emissive: true, fogMax: FOG - 0.1 })); root.add(inner);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(R * 0.15, R * 0.15, R * 0.05, 8), ps1({ color: 0xffc860, emissive: true, fogMax: FOG - 0.15 }));
hub.rotation.x = Math.PI / 2; hub.position.z = 6; root.add(hub);
const coreGlow = new THREE.Mesh(new THREE.IcosahedronGeometry(R * 0.06, 0), ps1({ color: 0xffffff, emissive: true, fogMax: FOG - 0.3 })); coreGlow.position.z = 14; root.add(coreGlow);
// Six habitat blades on pivots around the rim; rotating them about the pivot opens the iris.
const N = 18, blades = [];
BIOMES.forEach(([base, draw], i) => {
const map = tex(64, 64, (g, w, h) => { g.fillStyle = base; g.fillRect(0, 0, w, h); draw(g, rng(50 + i)); }, true);
const a0 = i / 6 * Math.PI * 2, pivot = V(Math.cos(a0) * R * 0.95, Math.sin(a0) * R * 0.95, 0);
const pos = [], uv = [], idx = [], edge = [];
for (let k = 0; k <= N; k++) {
const s = k / N, rho = THREE.MathUtils.lerp(R * 0.95, R * 0.14, s), th = a0 + 1.75 * s;
const c = V(Math.cos(th) * rho, Math.sin(th) * rho, 0);
const d = V(Math.cos(th + 0.01) * THREE.MathUtils.lerp(R * 0.95, R * 0.14, s + 0.001), Math.sin(th + 0.01) * THREE.MathUtils.lerp(R * 0.95, R * 0.14, s + 0.001), 0).sub(c).normalize();
const side = V(-d.y, d.x, 0), w = R * (0.2 * (1 - 0.55 * s) + 0.02) * Math.sin(Math.min(1, s * 6 + 0.2) * Math.PI / 2);
const pa = c.clone().addScaledVector(side, w).sub(pivot), pb = c.clone().addScaledVector(side, -w).sub(pivot);
pos.push(pa.x, pa.y, 0, pb.x, pb.y, 0); uv.push(s * 3, 0, s * 3, 1);
edge.push(pa, pb);
if (k < N) { const b = k * 2; idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); }
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); geo.setIndex(idx);
const holder = new THREE.Group(); holder.position.copy(pivot).setZ(i * 0.8); root.add(holder);
holder.add(new THREE.Mesh(geo, ps1({ map, emissive: true, fogMax: FOG, side: THREE.DoubleSide })));
const rimPts = [];
for (let k = 0; k < N; k++) rimPts.push(edge[k * 2], edge[k * 2 + 2], edge[k * 2 + 1], edge[k * 2 + 3]);
holder.add(new THREE.LineSegments(lineGeo(rimPts), ps1({ color: 0x3fa7d6, emissive: true, fogMax: FOG - 0.15 })));
// Choose the rotation sign that swings the inner tip away from the centre.
const tip = V(pos[N * 6], pos[N * 6 + 1], 0);
const out = (sgn) => tip.clone().applyAxisAngle(V(0, 0, 1), sgn * 0.3).add(pivot).length();
blades.push({ holder, sign: out(1) > out(-1) ? 1 : -1 });
});
// Stations in orbit around the rim.
const r = rng(77), stations = [...Array(16)].map(() => {
const m = new THREE.Mesh(r() < 0.5 ? new THREE.OctahedronGeometry(R * (0.015 + r() * 0.02), 0) : new THREE.BoxGeometry(R * 0.04, R * 0.012, R * 0.012),
ps1({ color: r() < 0.5 ? 0xd9c08a : 0x8fb6d0, emissive: true, fogMax: FOG - 0.05 }));
root.add(m); return { m, a: r() * Math.PI * 2, rad: R * (1.1 + r() * 0.25), sp: (0.02 + r() * 0.04) * (r() < 0.5 ? 1 : -1), z: (r() - 0.5) * R * 0.2 };
});
// Underground the aperture fades to a faint presence; it returns in full on the climb out.
const skyMats = []; root.traverse(o => { if (o.material?.uniforms?.uFogMax) skyMats.push([o.material, o.material.uniforms.uFogMax.value]); });
const skyUpdate = (t, u, camY) => {
const vis = 0.3 + 0.7 * THREE.MathUtils.smoothstep(camY, -16, -2);
for (const [m, base] of skyMats) m.uniforms.uFogMax.value = 1 - (1 - base) * vis;
const open = 0.2 + 0.75 * u; // the aperture opens as the page goes on
blades.forEach(b => { b.holder.rotation.z = b.sign * open * 0.62; });
root.rotateZ(0.00025);
coreGlow.scale.setScalar(1 + Math.sin(t * 1.1) * 0.12);
stations.forEach(s => { const a = s.a + t * s.sp; s.m.position.set(Math.cos(a) * s.rad, Math.sin(a) * s.rad, s.z); s.m.rotation.y = t * 0.5; });
};
return { sky, skyUpdate };
}