<style>
.content:has(.game),
#content.rain-index-content {
max-width: none !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
body:has(.game),
body.rain-index-page {
margin: 0;
overflow-x: hidden;
background:
radial-gradient(circle at 8% 16%, rgba(163, 111, 44, 0.2), transparent 26rem),
radial-gradient(circle at 82% 12%, rgba(38, 56, 74, 0.22), transparent 24rem),
linear-gradient(125deg, #1e1a17, #473424 46%, #151c20);
}
body:has(.game) #postamble,
body.rain-index-page #postamble {
display: none;
}
.game {
--ink: #211b16;
--brown: #5f4631;
--paper: #e8ddc3;
--paper-2: #d7c6a7;
--paper-3: #f4ecd9;
--green: #243d35;
--blue: #26384a;
--red: #7f3330;
--gold: #a36f2c;
--glass: rgba(244, 236, 217, 0.74);
--shadow: rgba(28, 20, 13, 0.32);
--crt: rgba(143, 202, 164, 0.18);
color-scheme: light;
font-family: Georgia, "Times New Roman", serif;
position: relative;
width: 100%;
min-height: 680px;
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
isolation: isolate;
cursor: none;
color: var(--ink);
background:
radial-gradient(circle at 8% 16%, rgba(163, 111, 44, 0.2), transparent 26rem),
radial-gradient(circle at 82% 12%, rgba(38, 56, 74, 0.22), transparent 24rem),
linear-gradient(125deg, #1e1a17, #473424 46%, #151c20);
}
.game *,
.game *::before,
.game *::after {
box-sizing: border-box;
}
.game::before,
.game::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 40;
}
.game::before {
opacity: 0.32;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.04) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, rgba(25,17,11,0.06) 0 1px, transparent 1px 9px);
mix-blend-mode: soft-light;
}
.game::after {
opacity: 0.34;
background-image:
linear-gradient(90deg, transparent 0 12px, rgba(255,255,255,0.05) 13px, transparent 14px),
radial-gradient(circle at 22% 30%, rgba(255,255,255,0.09), transparent 0.15rem),
radial-gradient(circle at 76% 68%, rgba(0,0,0,0.1), transparent 0.18rem);
background-size: 17px 100%, 23px 31px, 29px 37px;
animation: rain 1.8s linear infinite;
}
.game button,
.game input {
font: inherit;
}
.game button {
color: inherit;
}
.desk {
position: absolute;
inset: clamp(10px, 2vw, 28px);
overflow: hidden;
border: 1px solid rgba(232, 221, 195, 0.34);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255,255,255,0.03), transparent 20% 72%, rgba(0,0,0,0.18)),
repeating-linear-gradient(6deg, rgba(244,236,217,0.045) 0 1px, transparent 1px 12px),
linear-gradient(145deg, rgba(232,221,195,0.16), rgba(20,17,14,0.18));
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.42), inset 0 0 90px rgba(0,0,0,0.28);
}
.zones {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
pointer-events: none;
opacity: 0.5;
transition: opacity 400ms ease;
}
.zone {
position: relative;
border: 1px dashed rgba(232, 221, 195, 0.14);
color: rgba(232, 221, 195, 0.32);
font: 700 0.68rem/1.1 "Courier New", monospace;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.zone span {
position: absolute;
left: 0.8rem;
top: 0.75rem;
}
.zone.is-lit {
background: rgba(232, 221, 195, 0.07);
color: rgba(245, 234, 201, 0.62);
}
.masthead {
position: absolute;
left: clamp(18px, 4vw, 54px);
top: clamp(16px, 4vw, 42px);
z-index: 12;
max-width: min(560px, calc(100vw - 2rem));
color: #efe4c9;
text-shadow: 0 2px 18px rgba(0,0,0,0.65);
user-select: none;
transition: transform 600ms ease, filter 600ms ease;
}
.masthead small {
display: block;
margin-bottom: 0.35rem;
color: rgba(236, 222, 186, 0.72);
font: 700 0.72rem/1.3 "Courier New", monospace;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.masthead h1 {
margin: 0;
font-size: clamp(2.4rem, 10vw, 7.4rem);
font-weight: 500;
line-height: 0.83;
letter-spacing: 0;
}
.masthead p {
width: min(39rem, 88vw);
margin: 1rem 0 0;
color: rgba(239, 228, 201, 0.76);
font-size: clamp(1rem, 1.8vw, 1.25rem);
line-height: 1.45;
}
.no-explain {
opacity: 0;
transform: translateY(5px);
transition: opacity 600ms ease, transform 600ms ease;
}
.game.has-touched .no-explain {
opacity: 1;
transform: translateY(0);
}
.machine {
position: absolute;
right: clamp(14px, 3vw, 42px);
top: clamp(14px, 3vw, 36px);
z-index: 20;
width: min(370px, calc(100vw - 2rem));
color: #d9f0cf;
font: 0.78rem/1.45 "Courier New", monospace;
letter-spacing: 0.02em;
}
.terminal {
min-height: 142px;
padding: 0.85rem;
border: 1px solid rgba(121, 205, 156, 0.28);
border-radius: 6px;
background:
radial-gradient(circle at 40% 0%, rgba(150, 255, 177, 0.12), transparent 60%),
linear-gradient(#122019, #09110f);
box-shadow: inset 0 0 34px rgba(90, 255, 136, 0.07), 0 14px 38px rgba(0,0,0,0.34);
overflow: hidden;
}
.terminal p {
margin: 0 0 0.35rem;
}
.terminal .line {
min-height: 1.25em;
white-space: pre-wrap;
}
.meter {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.45rem 0.8rem;
align-items: center;
margin-top: 0.65rem;
color: rgba(239, 228, 201, 0.82);
}
.bar {
height: 10px;
border: 1px solid rgba(239, 228, 201, 0.24);
border-radius: 999px;
overflow: hidden;
background: rgba(0,0,0,0.24);
}
.bar i {
display: block;
width: var(--w, 0%);
height: 100%;
background: linear-gradient(90deg, var(--gold), #d9d0a2, #7fc798);
transition: width 360ms ease;
}
.rooms {
position: absolute;
inset: 0;
z-index: 10;
}
.room {
position: absolute;
width: clamp(205px, 23vw, 310px);
min-height: 245px;
padding: 1rem 1rem 0.95rem;
border: 1px solid rgba(69, 52, 36, 0.36);
border-radius: 6px;
background:
linear-gradient(110deg, rgba(255,255,255,0.34), transparent 34% 60%, rgba(81,59,39,0.16)),
repeating-linear-gradient(0deg, transparent 0 16px, rgba(88,66,44,0.035) 17px),
var(--paper);
box-shadow: 0 14px 38px var(--shadow), inset 0 0 0 1px rgba(255,255,255,0.22);
transform: translate(var(--x), var(--y)) rotate(var(--r));
touch-action: none;
user-select: none;
transition: filter 300ms ease, box-shadow 300ms ease, background 300ms ease;
}
.room::before {
content: attr(data-mark);
position: absolute;
right: 0.85rem;
top: 0.7rem;
color: rgba(95, 70, 49, 0.2);
font: 900 2.2rem/1 "Courier New", monospace;
transform: rotate(7deg);
}
.room::after {
content: "";
position: absolute;
inset: 0.45rem;
pointer-events: none;
border: 1px solid rgba(95, 70, 49, 0.12);
border-radius: 3px;
}
.room.is-dragging {
z-index: 30;
filter: saturate(1.12) contrast(1.03);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46), inset 0 0 0 1px rgba(255,255,255,0.35);
}
.room.is-awake {
animation: breathe 3.8s ease-in-out infinite;
}
.room.is-buried {
filter: grayscale(0.4) brightness(0.78);
}
.room.is-buried .memory {
opacity: 0.06;
}
.room h2 {
position: relative;
z-index: 1;
margin: 0 0 0.35rem;
color: var(--green);
font-size: 1.45rem;
font-weight: 500;
line-height: 1;
}
.room .code {
margin: 0 0 0.95rem;
color: rgba(95, 70, 49, 0.72);
font: 700 0.72rem/1 "Courier New", monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.diagram {
position: relative;
height: 90px;
margin: 0.75rem 0;
border: 1px solid rgba(95, 70, 49, 0.18);
background:
radial-gradient(circle at var(--mx, 50%) var(--my, 50%), rgba(163,111,44,0.38), transparent 10%),
repeating-linear-gradient(90deg, rgba(35,29,24,0.06) 0 1px, transparent 1px 12px),
rgba(244, 236, 217, 0.72);
overflow: hidden;
}
.diagram span {
position: absolute;
display: block;
border: 1px solid rgba(95, 70, 49, 0.42);
background: rgba(255, 255, 255, 0.18);
transition: transform 500ms ease, opacity 500ms ease;
}
.window span:nth-child(1) { left: 14%; top: 22%; width: 34%; height: 42%; }
.window span:nth-child(2) { right: 13%; top: 13%; width: 18%; height: 64%; }
.window span:nth-child(3) { left: 35%; bottom: 10%; width: 42%; height: 16%; }
.books span:nth-child(1) { left: 9%; bottom: 10%; width: 9%; height: 68%; }
.books span:nth-child(2) { left: 23%; bottom: 10%; width: 13%; height: 52%; }
.books span:nth-child(3) { left: 42%; bottom: 10%; width: 11%; height: 74%; }
.books span:nth-child(4) { left: 60%; bottom: 10%; width: 18%; height: 45%; }
.tape span:nth-child(1) { left: 14%; top: 26%; width: 26%; height: 46%; border-radius: 50%; }
.tape span:nth-child(2) { right: 14%; top: 26%; width: 26%; height: 46%; border-radius: 50%; }
.tape span:nth-child(3) { left: 28%; top: 46%; width: 44%; height: 11%; }
.stairs span:nth-child(1) { left: 14%; bottom: 13%; width: 18%; height: 18%; }
.stairs span:nth-child(2) { left: 31%; bottom: 30%; width: 18%; height: 18%; }
.stairs span:nth-child(3) { left: 48%; bottom: 47%; width: 18%; height: 18%; }
.stairs span:nth-child(4) { left: 65%; bottom: 64%; width: 18%; height: 18%; }
.table span:nth-child(1) { left: 17%; top: 26%; width: 66%; height: 12%; }
.table span:nth-child(2) { left: 26%; top: 38%; width: 9%; height: 38%; }
.table span:nth-child(3) { right: 26%; top: 38%; width: 9%; height: 38%; }
.memory {
position: relative;
z-index: 1;
min-height: 4.8rem;
margin: 0.75rem 0 0;
color: #35271d;
font-size: 0.96rem;
line-height: 1.42;
transition: opacity 400ms ease;
}
.room .stamp {
position: absolute;
right: 0.75rem;
bottom: 0.65rem;
width: 74px;
height: 74px;
border: 2px solid rgba(127, 51, 48, 0.45);
border-radius: 50%;
display: grid;
place-items: center;
color: rgba(127, 51, 48, 0.58);
font: 900 0.58rem/1.1 "Courier New", monospace;
text-align: center;
letter-spacing: 0.08em;
transform: rotate(-14deg) scale(0);
transition: transform 420ms cubic-bezier(.21,1.5,.45,1);
}
.room.is-indexed .stamp {
transform: rotate(-14deg) scale(1);
}
.slips {
position: absolute;
left: clamp(16px, 3vw, 40px);
bottom: clamp(16px, 3vw, 34px);
z-index: 18;
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
max-width: min(720px, calc(100vw - 2rem));
}
.slip {
width: 120px;
min-height: 52px;
padding: 0.55rem 0.6rem;
border: 1px solid rgba(95, 70, 49, 0.44);
border-radius: 3px;
background: var(--paper-3);
color: rgba(33, 27, 22, 0.55);
box-shadow: 0 8px 18px rgba(0,0,0,0.18);
font: 700 0.62rem/1.28 "Courier New", monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
transform: rotate(var(--r));
transition: transform 300ms ease, color 300ms ease, background 300ms ease;
}
.slip.is-found {
color: var(--ink);
background: #fff4d6;
transform: rotate(var(--r)) translateY(-8px);
}
.drawer {
position: absolute;
right: clamp(18px, 4vw, 54px);
bottom: clamp(18px, 4vw, 46px);
z-index: 14;
width: min(360px, calc(100vw - 2rem));
min-height: 138px;
padding: 0.9rem;
border: 1px solid rgba(232, 221, 195, 0.26);
border-radius: 7px;
background:
linear-gradient(180deg, rgba(95,70,49,0.62), rgba(42,30,20,0.84)),
repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0 1px, transparent 1px 13px);
color: #eadfc2;
box-shadow: 0 16px 42px rgba(0,0,0,0.36);
}
.drawer h2 {
margin: 0 0 0.5rem;
font: 700 0.78rem/1.1 "Courier New", monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.drawer p {
margin: 0;
color: rgba(234, 223, 194, 0.76);
font-size: 0.92rem;
line-height: 1.35;
}
.drawer button,
.sound {
border: 1px solid rgba(232, 221, 195, 0.36);
border-radius: 4px;
background: rgba(244, 236, 217, 0.09);
color: #eadfc2;
padding: 0.45rem 0.6rem;
cursor: pointer;
}
.drawer button:hover,
.sound:hover,
.drawer button:focus-visible,
.sound:focus-visible {
background: rgba(244, 236, 217, 0.2);
outline: 1px solid rgba(244, 236, 217, 0.5);
}
.drawer-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.sound {
position: absolute;
left: clamp(18px, 4vw, 54px);
top: clamp(18px, 4vw, 44px);
z-index: 24;
transform: translateY(138px);
font: 700 0.7rem/1 "Courier New", monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.cursor {
position: fixed;
left: 0;
top: 0;
z-index: 60;
width: 32px;
height: 32px;
pointer-events: none;
transform: translate(-50%, -50%);
transition: width 200ms ease, height 200ms ease, filter 300ms ease;
}
.cursor::before {
content: "";
position: absolute;
inset: 4px 9px 4px 12px;
border: 2px solid rgba(236, 206, 126, 0.95);
border-radius: 50% 50% 38% 38%;
box-shadow: 0 0 14px rgba(236, 206, 126, 0.45);
}
.cursor::after {
content: "";
position: absolute;
left: 17px;
top: 19px;
width: 18px;
height: 5px;
border-top: 2px solid rgba(236, 206, 126, 0.95);
border-right: 2px solid rgba(236, 206, 126, 0.95);
transform: rotate(25deg);
}
.cursor.is-waiting {
width: 46px;
height: 46px;
filter: drop-shadow(0 0 12px rgba(217, 208, 162, 0.65));
}
.word-catcher {
position: absolute;
left: -999px;
top: 0;
width: 1px;
height: 1px;
opacity: 0;
}
.typed {
position: fixed;
left: 50%;
bottom: 1rem;
z-index: 70;
max-width: calc(100vw - 2rem);
padding: 0.45rem 0.65rem;
border: 1px solid rgba(232, 221, 195, 0.28);
border-radius: 4px;
background: rgba(15, 17, 15, 0.7);
color: #d9f0cf;
font: 0.78rem/1 "Courier New", monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
transform: translateX(-50%);
opacity: 0;
transition: opacity 260ms ease;
}
.typed.is-visible {
opacity: 1;
}
.ending {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
pointer-events: none;
opacity: 0;
background:
radial-gradient(circle at 50% 42%, rgba(244,236,217,0.94), rgba(42,30,20,0.78) 58%, rgba(8,9,9,0.94));
transition: opacity 1400ms ease;
}
.ending.is-open {
pointer-events: auto;
opacity: 1;
}
.ending-card {
width: min(760px, 94vw);
padding: clamp(1.2rem, 5vw, 3rem);
border: 1px solid rgba(95, 70, 49, 0.34);
border-radius: 8px;
background:
linear-gradient(110deg, rgba(255,255,255,0.34), transparent 55%),
var(--paper-3);
box-shadow: 0 30px 90px rgba(0,0,0,0.5);
color: var(--ink);
transform: rotate(-0.8deg);
}
.ending-card h2 {
margin: 0 0 0.75rem;
color: var(--green);
font-size: clamp(2rem, 6vw, 4rem);
font-weight: 500;
line-height: 0.95;
}
.ending-card p {
max-width: 58ch;
margin: 0.85rem 0;
font-size: 1.05rem;
line-height: 1.52;
}
.ending-card button {
margin-top: 1rem;
border: 1px solid rgba(95, 70, 49, 0.42);
border-radius: 4px;
background: var(--green);
color: #f8efd9;
padding: 0.65rem 0.85rem;
cursor: pointer;
}
.ghost-note {
position: absolute;
z-index: 16;
max-width: 220px;
padding: 0.6rem 0.7rem;
border: 1px solid rgba(95,70,49,0.18);
background: rgba(244,236,217,0.88);
color: rgba(33,27,22,0.8);
font-size: 0.9rem;
line-height: 1.32;
box-shadow: 0 12px 30px rgba(0,0,0,0.22);
opacity: 0;
transform: translateY(8px) rotate(var(--r));
animation: note 5.5s ease forwards;
pointer-events: none;
}
.game.phase-dream .masthead {
transform: translateX(3vw) rotate(-1deg);
filter: blur(0.2px);
}
.game.phase-dream .desk {
background:
radial-gradient(circle at 48% 50%, rgba(38,56,74,0.3), transparent 34rem),
repeating-linear-gradient(90deg, rgba(244,236,217,0.04) 0 1px, transparent 1px 11px),
linear-gradient(145deg, rgba(49,67,72,0.28), rgba(20,17,14,0.34));
}
.game.phase-house .room {
background:
linear-gradient(110deg, rgba(255,255,255,0.34), transparent 34% 60%, rgba(81,59,39,0.16)),
repeating-linear-gradient(0deg, transparent 0 18px, rgba(88,66,44,0.04) 19px),
#ead8b7;
}
.game.phase-terminal .room {
filter: sepia(0.2) hue-rotate(40deg);
}
@media (max-width: 850px) {
.game {
min-height: 720px;
overflow: auto;
cursor: auto;
}
.cursor {
display: none;
}
.desk {
position: relative;
inset: auto;
min-height: 1080px;
margin: 0;
border-radius: 0;
}
.masthead,
.machine,
.drawer,
.sound,
.slips {
position: relative;
left: auto;
right: auto;
top: auto;
bottom: auto;
width: auto;
max-width: none;
margin: 1rem;
transform: none;
}
.machine {
z-index: 25;
}
.rooms {
position: relative;
display: grid;
gap: 1rem;
padding: 1rem;
}
.room {
position: relative;
width: auto;
min-height: 235px;
transform: rotate(var(--r));
}
.zones {
display: none;
}
.slips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.slip {
width: auto;
}
}
@keyframes rain {
from { background-position: 0 0, 0 0, 0 0; }
to { background-position: 0 42px, 0 0, 0 0; }
}
@keyframes breathe {
0%, 100% { transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(1); }
50% { transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(1.014); }
}
@keyframes note {
12%, 72% { opacity: 1; transform: translateY(0) rotate(var(--r)); }
100% { opacity: 0; transform: translateY(-18px) rotate(var(--r)); }
}
@media (prefers-reduced-motion: reduce) {
.game,
.game *,
.game *::before,
.game *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}
</style>
attic weather
terminal dusk
kitchen witness
stair memory
Department of Unfinished Domestic Topographies
The Rain Index
The exhibit accepts handling. The catalogue dislikes certainty.
sound
South Window
specimen 04 / condensation script
The glass keeps a list of people who looked out instead of answering.
not yet
indexed
Borrowed Library
specimen 11 / recursive shelf
Every book is filed under the title it had in someone else's dream.
not yet
indexed
Cassette Kitchen
specimen 19 / domestic magnetic
A spoon taps the cup twice. The tape records the third tap before it happens.
not yet
indexed
Reverse Stairwell
specimen 27 / impossible municipal
Down is a rumor told by upstairs to make itself feel necessary.
not yet
indexed
Young Desk
specimen 32 / pencil pressure
The drawer contains a map of the room drawn from under the table.
not yet
indexed
I
window missing
II
shelf missing
III
voice missing
IV
stair missing
V
name missing
The House Files You Back
The rain has found its alphabetical order. A small room opens in the index where your visit has been carefully misremembered.
The page will remember that you came here. Next time, one sentence will be waiting before you touch anything.
leave the drawer slightly open
<script>
(() => {
document.body.classList.add("rain-index-page");
document.getElementById("content")?.classList.add("rain-index-content");
const game = document.getElementById("game");
const desk = document.getElementById("desk");
const roomsWrap = document.getElementById("rooms");
const rooms = Array.from(document.querySelectorAll(".room"));
const zones = Array.from(document.querySelectorAll(".zone"));
const cursor = document.getElementById("cursor");
const typed = document.getElementById("typed");
const input = document.getElementById("wordCatcher");
const lineA = document.getElementById("lineA");
const lineB = document.getElementById("lineB");
const lineC = document.getElementById("lineC");
const humidityBar = document.getElementById("humidityBar");
const humidityText = document.getElementById("humidityText");
const visitText = document.getElementById("visitText");
const drawerText = document.getElementById("drawerText");
const closeDrawer = document.getElementById("closeDrawer");
const misfile = document.getElementById("misfile");
const soundBtn = document.getElementById("soundBtn");
const ending = document.getElementById("ending");
const endingCopy = document.getElementById("endingCopy");
const again = document.getElementById("again");
const storageKey = "rainIndexState";
const phrases = {
attic: [
"attic weather has entered the catalogue.",
"a drip begins in a room with no ceiling.",
"the window alphabetizes the rain by surname."
],
terminal: [
"terminal dusk accepts the specimen.",
"green letters gather at the edge of the paper.",
"the old lab computer asks for a childhood answer."
],
kitchen: [
"kitchen witness confirms a domestic anomaly.",
"someone rinses a cup in the next decade.",
"the cassette smells faintly of warm dust."
],
stair: [
"stair memory folds the room through itself.",
"an impossible landing briefly becomes plausible.",
"the archive miscounts the floors and apologizes."
]
};
const wordSecrets = {
rain: "The window replies in water: not weather, but handwriting.",
borge: "A shelf turns inward. For one second, every title is the title of this page.",
mother: "The tape softens. A voice nearly says your name, then chooses the kettle.",
below: "The stairwell lowers the ceiling and raises the past.",
zaine: "The desk recognizes its owner without making a scene.",
library: "A librarian with no face stamps the inside of your cursor.",
sleep: "All rooms become lighter when they pretend not to hear you.",
home: "The drawer refuses the word, then keeps it."
};
const slipNames = {
window: "I\nrain indexed",
library: "II\nshelf indexed",
tape: "III\nvoice indexed",
stairs: "IV\nstair indexed",
desklet: "V\nname indexed"
};
const visits = Number(localStorage.getItem("rainIndexVisits") || "0") + 1;
let state = loadState();
let pointer = { x: innerWidth / 2, y: innerHeight / 2 };
let heldRoom = null;
let drag = null;
let zCounter = 30;
let typedBuffer = "";
let typeTimer = 0;
let stillTimer = 0;
let lastMove = Date.now();
let audio = null;
let drone = null;
let tick = null;
localStorage.setItem("rainIndexVisits", String(visits));
visitText.textContent = visits === 1 ? "first attendance" : `attendance ${visits}`;
if (visits > 1) {
lineC.textContent = "previous visit found under blotter.";
spawnNote("You have been here in a different order.", 48, 18);
}
applyState();
updateHumidity();
input.focus({ preventScroll: true });
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerdown", () => input.focus({ preventScroll: true }));
document.addEventListener("keydown", onKey);
window.addEventListener("resize", resizeReality);
closeDrawer.addEventListener("click", drawerClose);
misfile.addEventListener("click", misfileRooms);
soundBtn.addEventListener("click", toggleSound);
again.addEventListener("click", restart);
rooms.forEach((room) => {
room.addEventListener("pointerdown", startDrag);
room.addEventListener("pointerenter", () => {
heldRoom = room;
room.classList.add("is-awake");
lineB.textContent = `specimen ${room.dataset.mark} is warm at the edge.`;
});
room.addEventListener("pointerleave", () => {
if (!drag || drag.room !== room) room.classList.remove("is-awake");
if (heldRoom === room) heldRoom = null;
});
room.addEventListener("dblclick", () => {
reveal(room, "double handling produced unauthorized certainty.");
});
});
requestAnimationFrame(loop);
function loadState() {
try {
return JSON.parse(localStorage.getItem(storageKey)) || { found: [], ending: 0 };
} catch {
return { found: [], ending: 0 };
}
}
function saveState() {
localStorage.setItem(storageKey, JSON.stringify(state));
}
function applyState() {
rooms.forEach((room) => {
if (state.found.includes(room.dataset.id)) {
room.classList.add("is-indexed");
room.querySelector(".stamp").innerHTML = "indexed
in rain";
}
});
document.querySelectorAll(".slip").forEach((slip) => {
if (state.found.includes(slip.dataset.slip)) {
slip.classList.add("is-found");
slip.textContent = slipNames[slip.dataset.slip];
}
});
}
function onPointerMove(event) {
pointer = { x: event.clientX, y: event.clientY };
cursor.style.left = `${pointer.x}px`;
cursor.style.top = `${pointer.y}px`;
lastMove = Date.now();
game.classList.add("has-touched");
if (drag) moveDrag(event);
const rect = desk.getBoundingClientRect();
const mx = `${((pointer.x - rect.left) / rect.width) * 100}%`;
const my = `${((pointer.y - rect.top) / rect.height) * 100}%`;
rooms.forEach((room) => {
const r = room.getBoundingClientRect();
if (pointer.x >= r.left && pointer.x <= r.right && pointer.y >= r.top && pointer.y <= r.bottom) {
room.querySelector(".diagram").style.setProperty("--mx", mx);
room.querySelector(".diagram").style.setProperty("--my", my);
}
});
}
function startDrag(event) {
const room = event.currentTarget;
const rect = room.getBoundingClientRect();
drag = {
room,
startX: event.clientX,
startY: event.clientY,
baseX: rect.left - desk.getBoundingClientRect().left,
baseY: rect.top - desk.getBoundingClientRect().top
};
room.classList.add("is-dragging");
room.style.zIndex = String(++zCounter);
room.setPointerCapture(event.pointerId);
room.addEventListener("pointerup", endDrag, { once: true });
room.addEventListener("pointercancel", endDrag, { once: true });
lineA.textContent = "manual rearrangement detected.";
tickSound(220, 0.03);
}
function moveDrag(event) {
const x = drag.baseX + event.clientX - drag.startX;
const y = drag.baseY + event.clientY - drag.startY;
drag.room.style.setProperty("--x", `${x}px`);
drag.room.style.setProperty("--y", `${y}px`);
lightZone(event.clientX, event.clientY);
}
function endDrag(event) {
const room = event.currentTarget;
room.classList.remove("is-dragging");
const zone = getZone(pointer.x, pointer.y);
if (zone) {
const text = pick(phrases[zone.dataset.zone]);
lineA.textContent = text;
mutateRoom(room, zone.dataset.zone);
if (zone.dataset.zone === "terminal" && room.dataset.id === "library") reveal(room, "the library prefers phosphor.");
if (zone.dataset.zone === "kitchen" && room.dataset.id === "tape") reveal(room, "the kitchen heard itself on tape.");
if (zone.dataset.zone === "stair" && room.dataset.id === "stairs") reveal(room, "the stairwell agreed to be counted.");
if (zone.dataset.zone === "attic" && room.dataset.id === "window") reveal(room, "rain entered from below.");
if (zone.dataset.zone === "kitchen" && room.dataset.id === "desklet") reveal(room, "the desk remembered a table.");
}
zones.forEach((z) => z.classList.remove("is-lit"));
drag = null;
tickSound(165, 0.04);
}
function getZone(x, y) {
return zones.find((zone) => {
const r = zone.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
});
}
function lightZone(x, y) {
const active = getZone(x, y);
zones.forEach((zone) => zone.classList.toggle("is-lit", zone === active));
}
function mutateRoom(room, zone) {
const diagram = room.querySelector(".diagram");
const parts = Array.from(diagram.children);
parts.forEach((part, index) => {
const sign = zone === "stair" || zone === "terminal" ? -1 : 1;
part.style.transform = `translate(${sign * (index + 1) * 3}px, ${(index % 2 ? -1 : 1) * 4}px)`;
part.style.opacity = zone === "attic" ? "0.62" : "1";
});
game.classList.remove("phase-dream", "phase-house", "phase-terminal");
if (zone === "stair") game.classList.add("phase-dream");
if (zone === "kitchen") game.classList.add("phase-house");
if (zone === "terminal") game.classList.add("phase-terminal");
}
function onKey(event) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === "Escape") {
typedBuffer = "";
showTyped("");
return;
}
if (event.key.length !== 1) return;
typedBuffer = (typedBuffer + event.key.toLowerCase()).replace(/[^a-z]/g, "").slice(-16);
showTyped(typedBuffer);
clearTimeout(typeTimer);
typeTimer = setTimeout(() => showTyped(""), 1600);
Object.keys(wordSecrets).forEach((word) => {
if (typedBuffer.endsWith(word)) {
lineC.textContent = wordSecrets[word];
const room = rooms.find((item) => item.dataset.word === word);
if (room) reveal(room, wordSecrets[word]);
typedBuffer = "";
showTyped("");
tickSound(440, 0.06);
}
});
}
function showTyped(text) {
typed.textContent = text ? `>${text}` : "";
typed.classList.toggle("is-visible", Boolean(text));
}
function reveal(room, reason) {
const id = room.dataset.id;
if (!state.found.includes(id)) {
state.found.push(id);
saveState();
room.classList.add("is-indexed");
room.querySelector(".stamp").innerHTML = "indexed
in rain";
const slip = document.querySelector(`[data-slip="${id}"]`);
if (slip) {
slip.classList.add("is-found");
slip.textContent = slipNames[id];
}
lineA.textContent = reason;
lineB.textContent = `${state.found.length}/5 catalogue slips recovered.`;
spawnNote(pick([
"A card slips out from behind the room.",
"Somebody wrote this down before you arrived.",
"The paper is briefly warmer than the screen."
]), pointer.x, pointer.y);
updateHumidity();
if (state.found.length >= 5) finish();
} else {
lineB.textContent = "already indexed, but not exhausted.";
}
}
function updateHumidity() {
const humidity = Math.min(100, Math.round((state.found.length / 5) * 100));
humidityText.textContent = `${humidity}%`;
humidityBar.style.setProperty("--w", `${humidity}%`);
}
function drawerClose() {
drawerText.textContent = pick([
"The closed drawer is open on the inside.",
"A brass label changes from SUBJECT to VISITOR.",
"You hear index cards settling into impossible order."
]);
rooms.forEach((room, index) => {
if ((index + state.found.length) % 2 === 0) room.classList.toggle("is-buried");
});
lineC.textContent = "conventional interface expectation broken politely.";
if (state.found.length >= 3) {
const unrevealed = rooms.find((room) => !state.found.includes(room.dataset.id));
if (unrevealed) reveal(unrevealed, "the drawer closed around a missing card.");
}
tickSound(110, 0.05);
}
function misfileRooms() {
const deskRect = desk.getBoundingClientRect();
rooms.forEach((room) => {
const x = 70 + Math.random() * Math.max(160, deskRect.width - 420);
const y = 170 + Math.random() * Math.max(160, deskRect.height - 420);
room.style.setProperty("--x", `${x}px`);
room.style.setProperty("--y", `${y}px`);
room.style.setProperty("--r", `${-3 + Math.random() * 6}deg`);
room.classList.remove("is-buried");
});
lineA.textContent = "classification failure made several new truths possible.";
spawnNote("The archive prefers a useful mistake.", pointer.x, pointer.y);
}
function resizeReality() {
if (innerWidth < 851) return;
lineB.textContent = innerWidth > innerHeight ? "wide room: the archive remembers maps." : "tall room: the archive remembers wells.";
if (Math.abs(innerWidth - innerHeight) < 90) {
const room = rooms.find((item) => item.dataset.id === "desklet");
if (room) reveal(room, "the square window admitted a private name.");
}
}
function loop() {
const waiting = Date.now() - lastMove > 1700;
cursor.classList.toggle("is-waiting", waiting);
if (waiting && heldRoom && !drag) {
stillTimer += 1;
if (stillTimer > 80) {
stillTimer = 0;
heldRoom.querySelector(".memory").textContent = developMemory(heldRoom);
if (Math.random() > 0.46) reveal(heldRoom, "a patient hover developed the image.");
}
} else {
stillTimer = 0;
}
requestAnimationFrame(loop);
}
function developMemory(room) {
const pool = {
window: [
"On the other side of the glass, someone is trying to remember this room into being.",
"The rain falls upward whenever nobody uses the word weather."
],
library: [
"A catalogue card describes a catalogue card describing your hand.",
"One book contains a floorplan. The floorplan contains a smaller library."
],
tape: [
"The cassette reverses and plays the sound of a chair being forgiven.",
"A recipe is read aloud, but all ingredients are dates."
],
stairs: [
"The fourth step is only visible from the fifth.",
"A landing opens into a lecture hall where the chalk is still raining."
],
desklet: [
"A pencil line moves by itself, then pretends to be a scratch.",
"The drawer has kept every failed password as a pressed flower."
]
};
return pick(pool[room.dataset.id]);
}
function spawnNote(text, x, y) {
const note = document.createElement("div");
note.className = "ghost-note";
note.textContent = text;
note.style.left = `${Math.min(Math.max(x - 40, 16), innerWidth - 250)}px`;
note.style.top = `${Math.min(Math.max(y - 20, 70), innerHeight - 160)}px`;
note.style.setProperty("--r", `${-3 + Math.random() * 6}deg`);
desk.appendChild(note);
setTimeout(() => note.remove(), 5600);
}
function finish() {
if (ending.classList.contains("is-open")) return;
state.ending = (state.ending || 0) + 1;
saveState();
endingCopy.textContent = state.ending > 1
? "The index recognizes the shape of your return. This time, it files the rain under Almost Home."
: "The rain has found its alphabetical order. A small room opens in the index where your visit has been carefully misremembered.";
setTimeout(() => ending.classList.add("is-open"), 900);
if (audio) {
tickSound(523, 0.08);
tickSound(659, 0.08, 0.14);
}
}
function restart() {
ending.classList.remove("is-open");
state.found = [];
saveState();
rooms.forEach((room) => {
room.classList.remove("is-indexed", "is-buried");
room.querySelector(".stamp").innerHTML = "not yet
indexed";
});
document.querySelectorAll(".slip").forEach((slip) => {
slip.classList.remove("is-found");
slip.textContent = `${slip.dataset.slip === "window" ? "I" : slip.dataset.slip === "library" ? "II" : slip.dataset.slip === "tape" ? "III" : slip.dataset.slip === "stairs" ? "IV" : "V"}\nmissing`;
});
updateHumidity();
misfileRooms();
}
function toggleSound() {
if (!audio) {
audio = new (window.AudioContext || window.webkitAudioContext)();
const gain = audio.createGain();
gain.gain.value = 0.028;
drone = audio.createOscillator();
drone.type = "sine";
drone.frequency.value = 56;
drone.connect(gain).connect(audio.destination);
drone.start();
tick = audio.createGain();
tick.gain.value = 0.08;
tick.connect(audio.destination);
soundBtn.setAttribute("aria-pressed", "true");
soundBtn.textContent = "sound on";
lineC.textContent = "rain carrier enabled.";
} else {
audio.close();
audio = null;
drone = null;
tick = null;
soundBtn.setAttribute("aria-pressed", "false");
soundBtn.textContent = "sound";
}
}
function tickSound(freq, volume, delay = 0) {
if (!audio) return;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = "triangle";
osc.frequency.value = freq;
gain.gain.value = volume;
osc.connect(gain).connect(audio.destination);
const now = audio.currentTime + delay;
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.start(now);
osc.stop(now + 0.2);
}
function pick(items) {
return items[Math.floor(Math.random() * items.length)];
}
})();
</script>