Claude Fable 5
AnthropicJuly 2026
What it wrote
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Starforge</title>
<meta name="description" content="Starforge — an incremental odyssey. Forge stardust, build a cosmos, collapse the universe.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✦</text></svg>">
<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=Unbounded:wght@500;800&family=Outfit:wght@400;600&family=JetBrains+Mono:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="starfield" aria-hidden="true"></canvas>
<div id="fx-layer" aria-hidden="true"></div>
<div id="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">✦</span>
<span class="brand-name">STARFORGE</span>
<span class="tagline">an incremental odyssey</span>
</div>
<div class="topbar-right">
<div class="chip chip-dm" id="chip-dm" hidden title="Dark matter — permanent production bonus">◈ <span id="chip-dm-val">0</span></div>
<button class="btn btn-icon" id="btn-mute" title="Toggle sound" aria-label="Toggle sound">🔊</button>
<button class="btn" id="btn-save" title="Save your progress now">Save</button>
</div>
</header>
<main class="layout">
<section class="clicker-zone" aria-label="The forge">
<div class="counter">
<div class="stardust-count mono" id="stardust">0</div>
<div class="stardust-label">stardust</div>
<div class="sps mono" id="sps">0 / sec</div>
</div>
<div class="buff-bar" id="buff-bar" hidden>
<span id="buff-label">☄️ Frenzy ×7</span>
<div class="buff-track"><div class="buff-fill" id="buff-fill"></div></div>
</div>
<div class="star-wrap">
<button id="star" aria-label="Forge stardust">
<span class="star-rays" aria-hidden="true"></span>
<span class="star-halo" aria-hidden="true"></span>
<span class="star-core" aria-hidden="true"></span>
</button>
</div>
<div class="click-power mono" id="click-power">+1 per forge</div>
</section>
<section class="panel" aria-label="Control deck">
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="shop" role="tab">Cosmos</button>
<button class="tab" data-tab="upgrades" role="tab">Upgrades <span class="badge" id="upg-badge">0</span></button>
<button class="tab" data-tab="ascend" role="tab">Collapse</button>
<button class="tab" data-tab="codex" role="tab">Codex</button>
</nav>
<div class="tab-page active" id="tab-shop">
<div class="buy-modes" id="buy-modes" aria-label="Buy amount">
<span class="buy-modes-label">Buy</span>
<button class="bm active" data-mode="1">×1</button>
<button class="bm" data-mode="10">×10</button>
<button class="bm" data-mode="max">Max</button>
</div>
<div id="shop"></div>
</div>
<div class="tab-page" id="tab-upgrades">
<div id="upgrades"></div>
<h3 class="section-h">Acquired</h3>
<div id="owned-upgrades" class="owned-grid"></div>
</div>
<div class="tab-page" id="tab-ascend">
<div class="ascend-head">
<div class="dm-glyph" aria-hidden="true">◈</div>
<h2 class="ascend-title">Collapse the universe</h2>
<p class="ascend-copy">
End this cosmos and crush everything you have forged into <strong>dark matter</strong>.
Stardust, celestial bodies, and upgrades reset — but each dark matter grants a permanent
<strong>+2%</strong> to production and forge power, forever.
</p>
</div>
<div class="dm-stats">
<div class="dm-stat"><span class="dm-stat-label">Held</span><span class="mono dm-stat-val" id="dm-held">0 ◈</span></div>
<div class="dm-stat"><span class="dm-stat-label">Current bonus</span><span class="mono dm-stat-val" id="dm-bonus">+0%</span></div>
<div class="dm-stat"><span class="dm-stat-label">On collapse</span><span class="mono dm-stat-val gold" id="dm-pending">+0 ◈</span></div>
</div>
<div class="dm-progress">
<div class="dm-track"><div class="dm-fill" id="dm-fill"></div></div>
<div class="dm-next" id="dm-next">Forge more stardust to condense dark matter.</div>
</div>
<button class="btn-collapse" id="btn-collapse" disabled>Collapse</button>
<div class="collapse-count" id="collapse-count"></div>
</div>
<div class="tab-page" id="tab-codex">
<h3 class="section-h">Observatory log</h3>
<dl class="stats-list" id="stats-list"></dl>
<h3 class="section-h">Achievements <span class="ach-count mono" id="ach-count"></span></h3>
<div class="achs" id="achs"></div>
<h3 class="section-h">Archive</h3>
<div class="save-row">
<button class="btn" id="btn-export">Export save</button>
<button class="btn" id="btn-import">Import save</button>
<button class="btn btn-danger" id="btn-wipe">Wipe save</button>
</div>
<p class="footnote">Autosaves every 15 seconds. Your cosmos keeps forging while you are away (up to 12 hours).</p>
</div>
</section>
</main>
</div>
<div id="toasts" aria-live="polite"></div>
<div id="comet" role="button" aria-label="Catch the golden comet" hidden><span>☄️</span></div>
<div id="flash" aria-hidden="true"></div>
<div id="offline-modal" hidden>
<div class="modal-card">
<div class="modal-glyph" aria-hidden="true">✦</div>
<h2>Welcome back, forgemaster</h2>
<p id="offline-text"></p>
<button class="btn btn-gold" id="offline-ok">Collect</button>
</div>
</div>
<script src="game.js"></script>
</body>
</html>/* ============================================================
STARFORGE — style
Palette: a temperature ramp. White-hot gold → ember → violet void.
Type: Unbounded (display) / Outfit (body) / JetBrains Mono (numbers)
============================================================ */
:root {
--void: #050510;
--void-2: #0a0a1e;
--text: #ecebff;
--dim: #8f92b8;
--faint: #565a80;
--gold: #ffd76a;
--ember: #ff8a3d;
--violet: #a78bfa;
--green: #7dffa8;
--panel: rgba(160, 160, 255, 0.045);
--panel-2: rgba(160, 160, 255, 0.07);
--border: rgba(180, 180, 255, 0.11);
--border-hot: rgba(255, 215, 106, 0.4);
--font-display: 'Unbounded', 'Outfit', sans-serif;
--font-body: 'Outfit', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body {
background: var(--void);
color: var(--text);
font: 400 15px/1.5 var(--font-body);
overflow: hidden;
min-height: 100vh;
}
/* Nebula color fields over the void */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(900px 600px at 12% -10%, rgba(120, 60, 220, 0.16), transparent 65%),
radial-gradient(800px 700px at 105% 110%, rgba(255, 138, 61, 0.09), transparent 60%),
radial-gradient(600px 500px at 90% -20%, rgba(90, 200, 220, 0.07), transparent 60%);
}
#starfield {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
#fx-layer {
position: fixed;
inset: 0;
z-index: 70;
pointer-events: none;
overflow: hidden;
}
#app { position: relative; z-index: 1; }
button { font-family: inherit; color: inherit; touch-action: manipulation; }
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 3px;
border-radius: 6px;
}
::selection { background: rgba(255, 215, 106, 0.25); }
/* ---------- Top bar ---------- */
.topbar {
height: 68px;
max-width: 1320px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
gap: 16px;
}
.brand { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
.brand-mark {
color: var(--gold);
font-size: 18px;
text-shadow: 0 0 14px rgba(255, 215, 106, 0.9);
animation: markPulse 3.5s ease-in-out infinite;
align-self: center;
}
@keyframes markPulse {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; text-shadow: 0 0 22px rgba(255, 215, 106, 1); }
}
.brand-name {
font: 800 17px var(--font-display);
letter-spacing: 0.24em;
background: linear-gradient(90deg, var(--gold), var(--ember) 55%, var(--violet));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline {
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--faint);
white-space: nowrap;
}
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.chip {
white-space: nowrap;
font: 600 13px var(--font-mono);
padding: 6px 12px;
border-radius: 99px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--violet);
}
.btn {
font: 600 12px var(--font-body);
letter-spacing: 0.06em;
padding: 8px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
}
.btn:hover { border-color: var(--border-hot); background: var(--panel-2); }
.btn:active { transform: scale(0.96); }
.btn-icon { font-size: 14px; padding: 7px 10px; }
.btn-danger:hover { border-color: rgba(255, 90, 90, 0.55); color: #ff9a9a; }
.btn-gold {
background: linear-gradient(135deg, rgba(255, 215, 106, 0.2), rgba(255, 138, 61, 0.14));
border-color: var(--border-hot);
color: var(--gold);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 500px;
gap: 24px;
max-width: 1320px;
margin: 0 auto;
padding: 8px 24px 24px;
height: calc(100vh - 76px);
height: calc(100dvh - 76px);
}
/* ---------- Clicker zone ---------- */
.clicker-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
user-select: none;
min-height: 0;
}
.counter { text-align: center; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stardust-count {
font-size: clamp(38px, 5.2vw, 62px);
font-weight: 800;
line-height: 1.05;
color: #fff;
text-shadow: 0 0 34px rgba(255, 210, 110, 0.35);
}
.stardust-label {
font: 500 11px var(--font-display);
letter-spacing: 0.42em;
text-transform: uppercase;
color: var(--dim);
margin-top: 6px;
}
.sps { margin-top: 8px; font-size: 14px; color: var(--gold); opacity: 0.9; }
.buff-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font: 600 12px var(--font-mono);
color: var(--gold);
background: rgba(255, 215, 106, 0.08);
border: 1px solid var(--border-hot);
border-radius: 12px;
padding: 7px 16px 9px;
box-shadow: 0 0 24px rgba(255, 190, 80, 0.15);
}
.buff-track {
width: 150px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.buff-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, var(--gold), var(--ember));
border-radius: 2px;
}
/* ---------- The star (signature element) ---------- */
.star-wrap { position: relative; padding: 18px; }
#star {
position: relative;
width: min(330px, 62vw, 38vh);
aspect-ratio: 1;
border: 0;
padding: 0;
background: transparent;
border-radius: 50%;
cursor: pointer;
transition: transform 0.08s ease-out;
}
#star:active { transform: scale(0.955); }
#star:focus-visible { outline-offset: 12px; border-radius: 50%; }
.star-rays {
position: absolute;
inset: -14%;
border-radius: 50%;
background: repeating-conic-gradient(
rgba(255, 224, 158, 0.13) 0deg 5deg,
rgba(255, 224, 158, 0) 5deg 27deg
);
-webkit-mask-image: radial-gradient(circle, transparent 36%, #000 48%, transparent 76%);
mask-image: radial-gradient(circle, transparent 36%, #000 48%, transparent 76%);
animation: spin 46s linear infinite;
pointer-events: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
.star-halo {
position: absolute;
inset: 13%;
border-radius: 50%;
box-shadow:
0 0 34px 10px rgba(255, 200, 110, 0.5),
0 0 90px 34px rgba(255, 160, 80, 0.28),
0 0 200px 80px rgba(160, 90, 255, 0.22);
animation: breatheHalo 4.6s ease-in-out infinite;
pointer-events: none;
}
.star-core {
position: absolute;
inset: 9%;
border-radius: 50%;
background: radial-gradient(circle at 37% 34%,
#ffffff 0%,
#fff3c4 16%,
#ffd76a 34%,
#ff9a48 52%,
#e2609c 68%,
#8a5ce0 82%,
rgba(110, 65, 200, 0.35) 92%,
rgba(110, 65, 200, 0) 99%);
box-shadow: inset -20px -28px 64px rgba(90, 20, 120, 0.4);
animation: breatheCore 4.6s ease-in-out infinite;
pointer-events: none;
}
@keyframes breatheCore {
0%, 100% { filter: brightness(1) saturate(1); }
50% { filter: brightness(1.13) saturate(1.12); }
}
@keyframes breatheHalo {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
.star-core.pop { animation: corePop 0.28s ease-out, breatheCore 4.6s ease-in-out infinite; }
@keyframes corePop {
0% { transform: scale(1); }
35% { transform: scale(1.045); }
100% { transform: scale(1); }
}
body.frenzy .star-core { animation-duration: 1.1s; }
body.frenzy .star-core.pop { animation-duration: 0.28s, 1.1s; }
body.frenzy .star-halo { animation-duration: 1.1s; }
body.frenzy .star-rays { animation-duration: 9s; }
.click-power { font-size: 13px; color: var(--dim); }
/* Click FX (live in #fx-layer, viewport coordinates) */
.floater {
position: absolute;
transform: translate(-50%, -50%);
font: 600 17px var(--font-mono);
color: var(--gold);
text-shadow: 0 0 12px rgba(255, 200, 80, 0.9);
animation: floatUp 1.05s ease-out forwards;
white-space: nowrap;
}
.floater.big { font-size: 22px; color: #fff; }
@keyframes floatUp {
0% { opacity: 0; margin-top: 0; }
8% { opacity: 1; }
100% { opacity: 0; margin-top: -92px; }
}
.spark {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
animation: sparkFly 0.75s ease-out forwards;
}
@keyframes sparkFly {
0% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
100% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0.15); opacity: 0; }
}
.ring {
position: absolute;
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(255, 215, 106, 0.8);
transform: translate(-50%, -50%);
animation: ringOut 0.55s ease-out forwards;
}
@keyframes ringOut {
0% { transform: translate(-50%, -50%) scale(0.4); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(4.5); opacity: 0; }
}
/* ---------- Panel ---------- */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex: none;
}
.tab {
flex: 1;
padding: 14px 4px 12px;
background: none;
border: 0;
color: var(--dim);
font: 500 10.5px var(--font-display);
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
position: relative;
transition: color 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: #fff; }
.tab.active::after {
content: '';
position: absolute;
left: 22%;
right: 22%;
bottom: -1px;
height: 2px;
background: linear-gradient(90deg, var(--gold), var(--ember));
box-shadow: 0 0 12px rgba(255, 190, 80, 0.8);
border-radius: 2px;
}
.badge {
display: none;
background: var(--gold);
color: #241300;
border-radius: 99px;
font: 700 9px var(--font-mono);
padding: 1px 6px;
vertical-align: 2px;
margin-left: 2px;
}
.badge.show { display: inline-block; }
.tab-page {
display: none;
overflow-y: auto;
padding: 14px 14px 20px;
flex: 1;
scrollbar-width: thin;
scrollbar-color: rgba(180, 180, 255, 0.18) transparent;
}
.tab-page.active { display: block; }
.tab-page::-webkit-scrollbar { width: 8px; }
.tab-page::-webkit-scrollbar-thumb { background: rgba(180, 180, 255, 0.14); border-radius: 4px; }
.tab-page::-webkit-scrollbar-track { background: transparent; }
.section-h {
font: 500 11px var(--font-display);
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--dim);
margin: 22px 4px 10px;
}
.tab-page > .section-h:first-child { margin-top: 4px; }
/* ---------- Shop ---------- */
.buy-modes {
display: flex;
align-items: center;
gap: 6px;
margin: 0 2px 12px;
}
.buy-modes-label {
font: 600 11px var(--font-body);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
margin-right: 4px;
}
.bm {
font: 600 12px var(--font-mono);
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--dim);
cursor: pointer;
transition: all 0.12s;
}
.bm:hover { color: var(--text); }
.bm.active { background: rgba(255, 215, 106, 0.12); border-color: var(--border-hot); color: var(--gold); }
.gen {
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 12px 14px;
margin-bottom: 9px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--border);
border-radius: 15px;
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.15s, box-shadow 0.15s;
}
.gen.can { border-color: rgba(255, 215, 106, 0.32); background: rgba(255, 215, 106, 0.045); }
.gen.can:hover {
transform: translateY(-2px);
box-shadow: 0 6px 22px rgba(255, 180, 70, 0.12);
border-color: var(--border-hot);
}
.gen.cant { cursor: default; }
.gen.cant .g-cost { color: var(--faint); }
.gen.locked { opacity: 0.42; cursor: default; }
.g-icon {
flex: none;
width: 46px;
height: 46px;
display: grid;
place-items: center;
font-size: 24px;
border-radius: 13px;
background: radial-gradient(circle at 32% 28%, rgba(167, 139, 250, 0.28), rgba(167, 139, 250, 0.07));
border: 1px solid rgba(167, 139, 250, 0.18);
}
.g-body { min-width: 0; }
.g-name, .g-sub, .g-flavor, .u-name, .u-desc { display: block; }
.g-name { font-weight: 600; font-size: 14.5px; }
.g-sub {
font: 400 11px var(--font-mono);
color: var(--dim);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.g-flavor { font-size: 11px; color: var(--faint); font-style: italic; margin-top: 1px; }
.g-right { margin-left: auto; text-align: right; flex: none; }
.g-count {
font: 800 21px var(--font-mono);
color: rgba(255, 255, 255, 0.92);
line-height: 1.1;
}
.g-cost { font: 600 12px var(--font-mono); color: var(--dim); margin-top: 2px; transition: color 0.15s; }
.gen.can .g-cost { color: var(--green); }
/* ---------- Upgrades ---------- */
.upg {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 11px 13px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--border);
border-radius: 13px;
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
}
.upg.can { border-color: rgba(167, 139, 250, 0.4); background: rgba(167, 139, 250, 0.06); }
.upg.can:hover { transform: translateY(-2px); border-color: var(--violet); }
.upg.cant { cursor: default; opacity: 0.75; }
.u-icon { flex: none; font-size: 20px; width: 34px; text-align: center; }
.u-name { font-weight: 600; font-size: 13.5px; }
.u-desc { font-size: 11.5px; color: var(--dim); margin-top: 1px; }
.u-cost { margin-left: auto; flex: none; font: 600 12px var(--font-mono); color: var(--faint); }
.upg.can .u-cost { color: var(--green); }
.empty-note { color: var(--faint); font-size: 13px; padding: 10px 4px; font-style: italic; }
.owned-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.owned-chip {
font-size: 11px;
color: var(--dim);
border: 1px solid var(--border);
background: var(--panel);
border-radius: 99px;
padding: 4px 10px;
cursor: help;
}
/* ---------- Ascend ---------- */
.ascend-head { text-align: center; padding: 18px 10px 6px; }
.dm-glyph {
font-size: 44px;
color: var(--violet);
text-shadow: 0 0 30px rgba(167, 139, 250, 0.8);
animation: markPulse 4s ease-in-out infinite;
}
.ascend-title {
font: 800 16px var(--font-display);
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 12px 0 8px;
}
.ascend-copy { color: var(--dim); font-size: 13.5px; max-width: 400px; margin: 0 auto; }
.ascend-copy strong { color: var(--violet); font-weight: 600; }
.dm-stats { display: flex; gap: 8px; margin: 20px 4px 14px; }
.dm-stat {
flex: 1;
background: rgba(167, 139, 250, 0.05);
border: 1px solid rgba(167, 139, 250, 0.16);
border-radius: 13px;
padding: 12px 8px;
text-align: center;
}
.dm-stat-label {
display: block;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: 5px;
}
.dm-stat-val { font-size: 16px; font-weight: 600; color: var(--violet); }
.dm-stat-val.gold { color: var(--gold); }
.dm-progress { margin: 6px 4px 18px; }
.dm-track {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.dm-fill {
height: 100%;
width: 0%;
border-radius: 3px;
background: linear-gradient(90deg, var(--violet), var(--gold));
transition: width 0.4s ease-out;
}
.dm-next { font: 400 11px var(--font-mono); color: var(--faint); margin-top: 7px; text-align: center; }
.btn-collapse {
display: block;
width: calc(100% - 8px);
margin: 0 auto;
padding: 15px;
font: 800 14px var(--font-display);
letter-spacing: 0.18em;
text-transform: uppercase;
color: #1c0f33;
background: linear-gradient(135deg, var(--gold), var(--ember) 55%, var(--violet));
border: 0;
border-radius: 14px;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.2s, filter 0.2s;
box-shadow: 0 4px 30px rgba(200, 120, 255, 0.25);
}
.btn-collapse:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 8px 40px rgba(255, 180, 90, 0.35); }
.btn-collapse:disabled { filter: grayscale(0.85) brightness(0.55); cursor: default; box-shadow: none; }
.btn-collapse.arm { animation: armShake 0.4s ease-in-out infinite; }
@keyframes armShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
.collapse-count { text-align: center; font-size: 11.5px; color: var(--faint); margin-top: 12px; }
/* ---------- Codex ---------- */
.stats-list { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 0; }
.stats-list > div {
display: contents;
}
.stats-list dt {
font-size: 12.5px;
color: var(--dim);
padding: 7px 4px;
border-bottom: 1px dashed rgba(180, 180, 255, 0.09);
}
.stats-list dd {
margin: 0;
font: 600 12.5px var(--font-mono);
text-align: right;
padding: 7px 4px;
border-bottom: 1px dashed rgba(180, 180, 255, 0.09);
}
.ach-count { font-size: 11px; color: var(--gold); }
.achs { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 7px; }
.ach {
aspect-ratio: 1;
display: grid;
place-items: center;
font-size: 19px;
border-radius: 11px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
filter: grayscale(1) brightness(0.5);
cursor: help;
}
.ach.got {
filter: none;
border-color: rgba(255, 215, 106, 0.35);
background: rgba(255, 215, 106, 0.07);
box-shadow: 0 0 14px rgba(255, 200, 90, 0.12);
}
.save-row { display: flex; gap: 8px; flex-wrap: wrap; }
.footnote { font-size: 11.5px; color: var(--faint); margin-top: 14px; }
/* ---------- Toasts ---------- */
#toasts {
position: fixed;
left: 18px;
bottom: 18px;
z-index: 80;
display: flex;
flex-direction: column;
gap: 9px;
max-width: min(340px, calc(100vw - 36px));
}
.toast {
display: flex;
gap: 11px;
align-items: flex-start;
padding: 12px 15px;
border-radius: 13px;
background: rgba(14, 13, 34, 0.92);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
backdrop-filter: blur(10px);
animation: toastIn 0.3s ease-out, toastOut 0.4s ease-in 3.8s forwards;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
.toast.ach-toast { border-left-color: var(--gold); }
.toast.comet-toast { border-left-color: var(--ember); }
.toast-icon { font-size: 20px; line-height: 1.2; }
.toast-title { font-weight: 600; font-size: 13.5px; }
.toast-body { font-size: 12px; color: var(--dim); margin-top: 1px; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(-24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
to { opacity: 0; transform: translateX(-16px); }
}
/* ---------- Golden comet ---------- */
#comet {
position: fixed;
left: 0;
top: 0;
z-index: 60;
font-size: 46px;
cursor: pointer;
filter: drop-shadow(0 0 16px rgba(255, 215, 106, 0.95)) drop-shadow(0 0 40px rgba(255, 160, 60, 0.5));
will-change: transform;
user-select: none;
}
#comet span { display: inline-block; transition: transform 0.15s; }
#comet:hover span { transform: scale(1.22); }
/* ---------- Collapse flash ---------- */
#flash {
position: fixed;
inset: 0;
z-index: 90;
pointer-events: none;
opacity: 0;
background: radial-gradient(circle, #ffffff 0%, #ffe9b0 25%, rgba(167, 139, 250, 0.7) 55%, transparent 75%);
}
#flash.go { animation: implode 1.15s ease-out forwards; }
@keyframes implode {
0% { opacity: 0; transform: scale(1.7); }
18% { opacity: 1; }
100% { opacity: 0; transform: scale(0.1); }
}
/* ---------- Modal ---------- */
#offline-modal {
position: fixed;
inset: 0;
z-index: 85;
display: grid;
place-items: center;
background: rgba(4, 4, 14, 0.72);
backdrop-filter: blur(7px);
padding: 20px;
}
#offline-modal[hidden] { display: none; }
.modal-card {
background: linear-gradient(180deg, rgba(26, 24, 58, 0.96), rgba(13, 12, 32, 0.96));
border: 1px solid var(--border-hot);
border-radius: 20px;
padding: 30px 28px;
max-width: 400px;
text-align: center;
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.6), 0 0 60px rgba(255, 200, 90, 0.08);
animation: toastIn 0.35s ease-out;
}
.modal-glyph { font-size: 34px; color: var(--gold); text-shadow: 0 0 24px rgba(255, 215, 106, 0.9); }
.modal-card h2 {
font: 800 15px var(--font-display);
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 14px 0 10px;
}
.modal-card p { color: var(--dim); font-size: 13.5px; margin: 0 0 20px; }
.modal-card p strong { color: var(--gold); font-family: var(--font-mono); font-weight: 600; }
.modal-card .btn { padding: 11px 26px; font-size: 13px; border-radius: 11px; }
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
body { overflow: auto; }
.layout {
grid-template-columns: 1fr;
height: auto;
padding: 0 14px 20px;
}
.clicker-zone { min-height: 62vh; min-height: 62dvh; }
.panel { min-height: 70vh; }
.tab-page { max-height: none; }
.topbar { padding: 0 16px; }
.tagline { display: none; }
.tab { letter-spacing: 0.08em; }
}
@media (max-width: 420px) {
.dm-stats { flex-direction: column; }
.brand-name { letter-spacing: 0.16em; }
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.star-rays, .star-core, .star-halo, .brand-mark, .dm-glyph { animation: none !important; }
.floater { animation-duration: 0.6s; }
.toast { animation: toastIn 0.01s, toastOut 0.01s 4s forwards; }
}/* ============================================================
STARFORGE — an incremental odyssey
Plain JS, no dependencies. Forge stardust. Collapse universes.
============================================================ */
'use strict';
/* ---------- utils ---------- */
const SUFFIXES = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc', 'UDc', 'DDc'];
function fmt(n) {
if (!isFinite(n)) return '∞';
if (n < 0) return '-' + fmt(-n);
if (n < 1000) {
if (Number.isInteger(n)) return String(n);
if (n < 10) return String(parseFloat(n.toFixed(2)));
if (n < 100) return String(parseFloat(n.toFixed(1)));
return String(Math.floor(n));
}
const tier = Math.floor(Math.log10(n) / 3);
if (tier < SUFFIXES.length) {
const v = n / Math.pow(10, tier * 3);
const s = v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2);
return s + SUFFIXES[tier];
}
return n.toExponential(2).replace('e+', 'e');
}
function fmtTime(sec) {
sec = Math.floor(sec);
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600),
m = Math.floor(sec % 3600 / 60), s = sec % 60;
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
/* ---------- data: generators ---------- */
const COST_GROWTH = 1.15;
const GENERATORS = [
{ id: 'mote', name: 'Dust Mote', plural: 'Dust Motes', icon: '✨', baseCost: 15, baseProd: 0.1, flavor: 'Barely matter. Still matters.' },
{ id: 'ast', name: 'Asteroid', plural: 'Asteroids', icon: '🪨', baseCost: 100, baseProd: 1, flavor: 'A rock with ambition.' },
{ id: 'comet', name: 'Comet', plural: 'Comets', icon: '☄️', baseCost: 1.1e3, baseProd: 8, flavor: 'Ice, dust, and drama.' },
{ id: 'moon', name: 'Moon', plural: 'Moons', icon: '🌙', baseCost: 12e3, baseProd: 47, flavor: 'Reflects well on you.' },
{ id: 'planet', name: 'Planet', plural: 'Planets', icon: '🪐', baseCost: 130e3, baseProd: 260, flavor: 'Now with weather.' },
{ id: 'star', name: 'Star', plural: 'Stars', icon: '⭐', baseCost: 1.4e6, baseProd: 1.4e3, flavor: 'A fusion-powered franchise.' },
{ id: 'nebula', name: 'Nebula', plural: 'Nebulae', icon: '🌌', baseCost: 20e6, baseProd: 7.8e3, flavor: 'A nursery for newborn suns.' },
{ id: 'galaxy', name: 'Galaxy', plural: 'Galaxies', icon: '💫', baseCost: 330e6, baseProd: 44e3, flavor: 'A hundred billion stars, one owner.' },
{ id: 'quasar', name: 'Quasar', plural: 'Quasars', icon: '🌟', baseCost: 5.1e9, baseProd: 260e3, flavor: 'The loudest thing in the sky.' },
{ id: 'hole', name: 'Black Hole', plural: 'Black Holes', icon: '🕳️', baseCost: 75e9, baseProd: 1.6e6, flavor: 'Extremely committed to growth.' },
];
const GEN_BY_ID = Object.fromEntries(GENERATORS.map(g => [g.id, g]));
/* ---------- data: upgrades ---------- */
const UPGRADES = [];
// Per-generator tiers: own N of a body → double its output.
const TIER_ADJ = ['Polished', 'Resonant', 'Quantum', 'Transcendent'];
const TIER_THRESH = [10, 25, 50, 100];
const TIER_COST = [12, 120, 2500, 90000]; // × baseCost
GENERATORS.forEach(g => {
TIER_THRESH.forEach((th, t) => {
UPGRADES.push({
id: `g_${g.id}_${t}`,
name: `${TIER_ADJ[t]} ${g.plural}`,
desc: `${g.plural} are twice as effective.`,
icon: g.icon,
cost: g.baseCost * TIER_COST[t],
fx: { kind: 'gen', gen: g.id, mult: 2 },
unlock: () => (state.gens[g.id] || 0) >= th,
});
});
});
// Forge (click) upgrades.
[
{ id: 'c0', name: 'Calloused Fingertips', icon: '👆', cost: 100, mult: 2, need: 25, },
{ id: 'c1', name: 'Graviton Gauntlets', icon: '🧤', cost: 5e3, mult: 2, need: 200, },
{ id: 'c2', name: 'Solar Sleight', icon: '🪄', cost: 6e5, mult: 2, need: 1000, },
{ id: 'c3', name: 'Supernova Snap', icon: '💥', cost: 8e7, mult: 3, need: 4000, },
].forEach(u => UPGRADES.push({
id: u.id, name: u.name, icon: u.icon, cost: u.cost,
desc: `Forging is ${u.mult === 2 ? 'twice' : `${u.mult}×`} as effective.`,
fx: { kind: 'click', mult: u.mult },
unlock: () => state.clicks >= u.need,
}));
// Resonance: forging yields a share of production per second.
[
{ id: 'r0', name: 'Stellar Resonance', icon: '🎵', cost: 2e5, pct: 0.01, need: 50, desc: 'Each forge also yields 1% of your production per second.' },
{ id: 'r1', name: 'Harmonic Convergence', icon: '🎶', cost: 5e7, pct: 0.04, need: 5e3, desc: 'Forging yields a further 4% of production (5% total).' },
{ id: 'r2', name: 'Event Horizon Touch', icon: '🌊', cost: 5e10, pct: 0.20, need: 1e6, desc: 'Forging yields a further 20% of production (25% total).' },
].forEach(u => UPGRADES.push({
id: u.id, name: u.name, icon: u.icon, cost: u.cost, desc: u.desc,
fx: { kind: 'res', pct: u.pct },
unlock: () => cache.sps >= u.need,
}));
// Cosmology: global production multipliers.
[
{ id: 's0', name: 'Cosmic Inflation', icon: '🎇', cost: 2e6, mult: 1.5, need: 1e6, desc: 'All production ×1.5.' },
{ id: 's1', name: 'Dark Energy Tap', icon: '🌑', cost: 5e8, mult: 2, need: 1e8, desc: 'All production ×2.' },
{ id: 's2', name: 'Unified Field', icon: '🧲', cost: 1e11, mult: 2, need: 1e10, desc: 'All production ×2.' },
{ id: 's3', name: 'Axion Choir', icon: '🎼', cost: 5e13, mult: 2, need: 1e12, desc: 'All production ×2.' },
].forEach(u => UPGRADES.push({
id: u.id, name: u.name, icon: u.icon, cost: u.cost, desc: u.desc,
fx: { kind: 'global', mult: u.mult },
unlock: () => state.lifetime >= u.need,
}));
const UPG_BY_ID = Object.fromEntries(UPGRADES.map(u => [u.id, u]));
/* ---------- data: achievements ---------- */
const ACHIEVEMENTS = [];
[[1e2, 'First Light'], [1e4, 'Glimmer'], [1e6, 'Million-Mote March'], [1e8, 'Gravity Well-Off'],
[1e10, 'Baryon Baron'], [1e12, 'Trillion-Dust Tycoon'], [1e15, 'Cosmic Magnate'], [1e18, 'Universal Constant']]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `L${i}`, name, icon: '✦', desc: `Forge ${fmt(n)} stardust all-time.`,
cond: () => state.lifetime >= n,
}));
[[10, 'Tap In'], [100, 'Knuckle Down'], [1000, 'Carpal Cosmos'], [10000, 'The Forger']]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `C${i}`, name, icon: '👆', desc: `Forge the star ${fmt(n)} times by hand.`,
cond: () => state.clicks >= n,
}));
[[10, 'Humming Along'], [1e4, 'Industrial Nebula'], [1e7, 'Stellar Assembly Line']]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `S${i}`, name, icon: '⚙️', desc: `Reach ${fmt(n)} stardust per second.`,
cond: () => cache.sps >= n,
}));
[[1, 'Lucky Streak'], [10, 'Comet Chaser'], [50, 'Storm Rider']]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `G${i}`, name, icon: '☄️', desc: `Catch ${n} golden comet${n > 1 ? 's' : ''}.`,
cond: () => state.comets >= n,
}));
[[1, 'Big Crunch'], [5, 'Serial Universer']]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `B${i}`, name, icon: '◈', desc: `Collapse the universe ${n > 1 ? n + ' times' : 'once'}.`,
cond: () => state.collapses >= n,
}));
GENERATORS.forEach(g => {
[[1, `First ${g.name}`], [50, `${g.name} Fleet`], [150, `${g.name} Empire`]]
.forEach(([n, name], i) => ACHIEVEMENTS.push({
id: `g${g.id}${i}`, name, icon: g.icon, desc: `Own ${n === 1 ? `a ${g.name}` : `${n} ${g.plural}`}.`,
cond: () => (state.gens[g.id] || 0) >= n,
}));
});
/* ---------- state ---------- */
const SAVE_KEY = 'starforge.save';
const MUTE_KEY = 'starforge.muted';
function freshState() {
return {
v: 1,
dust: 0,
lifetime: 0, // all-time, survives collapse (drives dark matter)
runLifetime: 0, // this universe
clicks: 0,
comets: 0,
collapses: 0,
dm: 0,
gens: {}, // id -> count
upgrades: {}, // id -> 1
achs: {}, // id -> 1
buyMode: 1, // 1 | 10 | 'max'
buff: null, // { type:'frenzy'|'click', mult, until, total }
playtime: 0,
createdAt: Date.now(),
savedAt: Date.now(),
};
}
let state = freshState();
// Derived numbers, rebuilt by recalc() whenever anything permanent changes.
const cache = { sps: 0, clickBase: 1, resPct: 0, genMult: {}, globalProd: 1, meta: 1 };
function achCount() { return Object.keys(state.achs).length; }
function recalc() {
const gm = {};
let clickMult = 1, resPct = 0, globalProd = 1;
for (const id in state.upgrades) {
const u = UPG_BY_ID[id];
if (!u) continue;
const fx = u.fx;
if (fx.kind === 'gen') gm[fx.gen] = (gm[fx.gen] || 1) * fx.mult;
else if (fx.kind === 'click') clickMult *= fx.mult;
else if (fx.kind === 'res') resPct += fx.pct;
else if (fx.kind === 'global') globalProd *= fx.mult;
}
const meta = (1 + state.dm * 0.02) * (1 + achCount() * 0.01);
let sps = 0;
for (const g of GENERATORS) {
const c = state.gens[g.id] || 0;
if (c) sps += c * g.baseProd * (gm[g.id] || 1);
}
cache.sps = sps * globalProd * meta;
cache.clickBase = clickMult * meta;
cache.resPct = resPct;
cache.genMult = gm;
cache.globalProd = globalProd;
cache.meta = meta;
}
function buffType() {
const b = state.buff;
if (!b) return null;
if (Date.now() >= b.until) { state.buff = null; document.body.classList.remove('frenzy'); return null; }
return b.type;
}
function currentSps() { return cache.sps * (buffType() === 'frenzy' ? state.buff.mult : 1); }
function clickPower() {
let p = cache.clickBase + currentSps() * cache.resPct;
if (buffType() === 'click') p *= state.buff.mult;
return p;
}
function addDust(n) {
state.dust += n;
state.lifetime += n;
state.runLifetime += n;
}
/* ---------- economy: buying ---------- */
function bulkCost(g, n) {
const owned = state.gens[g.id] || 0;
const r = COST_GROWTH;
return Math.ceil(g.baseCost * Math.pow(r, owned) * (Math.pow(r, n) - 1) / (r - 1));
}
function maxAffordable(g) {
const owned = state.gens[g.id] || 0;
const r = COST_GROWTH;
const c0 = g.baseCost * Math.pow(r, owned);
const n = Math.floor(Math.log(state.dust * (r - 1) / c0 + 1) / Math.log(r));
return Math.max(0, n);
}
function buyQty(g) {
return state.buyMode === 'max' ? Math.max(1, maxAffordable(g)) : state.buyMode;
}
function buyGen(g) {
const qty = buyQty(g);
const cost = bulkCost(g, qty);
if (state.dust < cost) return;
state.dust -= cost;
state.gens[g.id] = (state.gens[g.id] || 0) + qty;
recalc();
sfx.buy();
refreshShop(true);
}
function buyUpgrade(u) {
if (state.upgrades[u.id] || state.dust < u.cost) return;
state.dust -= u.cost;
state.upgrades[u.id] = 1;
recalc();
sfx.upgrade();
buildUpgrades();
}
/* ---------- economy: dark matter ---------- */
const DM_BASE = 1e10;
function dmFrom(lifetime) { return Math.floor(Math.sqrt(lifetime / DM_BASE)); }
function pendingDm() { return Math.max(0, dmFrom(state.lifetime) - state.dm); }
function collapse() {
const gain = pendingDm();
if (gain < 1) return;
els.flash.classList.remove('go');
void els.flash.offsetWidth;
els.flash.classList.add('go');
sfx.collapse();
state.dm += gain;
state.collapses++;
state.dust = 0;
state.runLifetime = 0;
state.gens = {};
state.upgrades = {};
state.buff = null;
document.body.classList.remove('frenzy');
recalc();
buildShop();
buildUpgrades();
refreshChips();
toast('◈', 'The universe collapses', `+${fmt(gain)} dark matter condensed. Everything glows a little hotter now.`, 'ach-toast');
save();
}
/* ---------- audio ---------- */
let actx = null;
let muted = localStorage.getItem(MUTE_KEY) === '1';
function initAudio() {
if (!actx) {
try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { /* no audio */ }
}
if (actx && actx.state === 'suspended') actx.resume();
}
function tone(f0, f1, dur, type, vol, delay) {
if (muted || !actx) return;
const t = actx.currentTime + (delay || 0);
const o = actx.createOscillator(), g = actx.createGain();
o.type = type || 'sine';
o.frequency.setValueAtTime(f0, t);
if (f1) o.frequency.exponentialRampToValueAtTime(Math.max(f1, 1), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(actx.destination);
o.start(t);
o.stop(t + dur + 0.02);
}
const sfx = {
click() { tone(480 + Math.random() * 140, 180, 0.07, 'triangle', 0.045); },
buy() { tone(392, 0, 0.09, 'sine', 0.055); tone(587, 0, 0.12, 'sine', 0.055, 0.07); },
upgrade() { tone(523, 0, 0.09, 'sine', 0.055); tone(659, 0, 0.09, 'sine', 0.055, 0.08); tone(784, 0, 0.14, 'sine', 0.055, 0.16); },
ach() { [659, 784, 988, 1319].forEach((f, i) => tone(f, 0, 0.16, 'sine', 0.06, i * 0.09)); },
comet() { tone(700, 1900, 0.35, 'sine', 0.06); },
collapse() { tone(200, 30, 0.9, 'sawtooth', 0.12); },
};
/* ---------- fx: floaters, sparks, rings ---------- */
const SPARK_COLORS = ['#ffd76a', '#ff8a3d', '#a78bfa', '#fff3c4'];
function fxBudgetOk() { return els.fx.childElementCount < 70; }
function floatText(x, y, txt, big) {
if (!fxBudgetOk()) return;
const el = document.createElement('div');
el.className = 'floater' + (big ? ' big' : '');
el.textContent = txt;
el.style.left = x + 'px';
el.style.top = y + 'px';
els.fx.appendChild(el);
setTimeout(() => el.remove(), 1100);
}
function sparks(x, y, count) {
if (!fxBudgetOk()) return;
for (let i = 0; i < count; i++) {
const s = document.createElement('div');
s.className = 'spark';
const a = Math.random() * Math.PI * 2;
const d = 34 + Math.random() * 60;
s.style.setProperty('--dx', Math.cos(a) * d + 'px');
s.style.setProperty('--dy', Math.sin(a) * d + 'px');
s.style.left = x + 'px';
s.style.top = y + 'px';
s.style.background = SPARK_COLORS[Math.floor(Math.random() * SPARK_COLORS.length)];
els.fx.appendChild(s);
setTimeout(() => s.remove(), 800);
}
}
function ring(x, y) {
if (!fxBudgetOk()) return;
const r = document.createElement('div');
r.className = 'ring';
r.style.left = x + 'px';
r.style.top = y + 'px';
els.fx.appendChild(r);
setTimeout(() => r.remove(), 600);
}
/* ---------- fx: toasts ---------- */
function toast(icon, title, body, cls) {
const t = document.createElement('div');
t.className = 'toast' + (cls ? ' ' + cls : '');
t.innerHTML = `<div class="toast-icon"></div><div><div class="toast-title"></div><div class="toast-body"></div></div>`;
t.querySelector('.toast-icon').textContent = icon;
t.querySelector('.toast-title').textContent = title;
t.querySelector('.toast-body').textContent = body;
els.toasts.appendChild(t);
while (els.toasts.childElementCount > 5) els.toasts.firstElementChild.remove();
setTimeout(() => t.remove(), 4400);
}
/* ---------- fx: golden comet ---------- */
let cometTimer = null;
let cometAnim = null;
function scheduleComet(first) {
clearTimeout(cometTimer);
const delay = first ? 40e3 + Math.random() * 60e3 : 70e3 + Math.random() * 140e3;
cometTimer = setTimeout(spawnComet, delay);
}
function spawnComet() {
const el = els.comet;
el.hidden = false;
const fromLeft = Math.random() < 0.5;
const y0 = innerHeight * (0.12 + Math.random() * 0.5);
const y1 = innerHeight * (0.12 + Math.random() * 0.6);
const x0 = fromLeft ? -90 : innerWidth + 90;
const x1 = fromLeft ? innerWidth + 90 : -90;
cometAnim = el.animate(
[{ transform: `translate(${x0}px, ${y0}px)` }, { transform: `translate(${x1}px, ${y1}px)` }],
{ duration: 11000, easing: 'linear' }
);
sfx.comet();
cometAnim.onfinish = () => { el.hidden = true; scheduleComet(); };
}
function catchComet(e) {
if (els.comet.hidden) return;
if (cometAnim) cometAnim.cancel();
els.comet.hidden = true;
state.comets++;
const x = e.clientX || innerWidth / 2, y = e.clientY || innerHeight / 3;
sparks(x, y, 14);
ring(x, y);
const roll = Math.random();
if (roll < 0.45) {
setBuff('frenzy', 7, 30);
toast('☄️', 'Frenzy!', 'The forge runs white-hot: production ×7 for 30 seconds.', 'comet-toast');
} else if (roll < 0.55) {
setBuff('click', 15, 15);
toast('☄️', 'Forge frenzy!', 'Your hands blur: forge power ×15 for 15 seconds.', 'comet-toast');
} else {
const gain = Math.max(60, Math.min(state.dust * 0.15, currentSps() * 900)) + 13;
addDust(gain);
floatText(x, y, '+' + fmt(gain), true);
toast('☄️', 'Stardust shower!', `The comet sheds +${fmt(gain)} stardust.`, 'comet-toast');
}
sfx.ach();
scheduleComet();
}
function setBuff(type, mult, secs) {
state.buff = { type, mult, until: Date.now() + secs * 1000, total: secs };
document.body.classList.toggle('frenzy', type === 'frenzy');
}
/* ---------- fx: starfield ---------- */
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const stars = [];
const meteors = [];
let sctx = null, dpr = 1;
function initStarfield() {
const c = els.starfield;
sctx = c.getContext('2d');
dpr = Math.min(devicePixelRatio || 1, 2);
const resize = () => {
c.width = innerWidth * dpr;
c.height = innerHeight * dpr;
};
resize();
addEventListener('resize', resize);
stars.length = 0;
for (let i = 0; i < 170; i++) {
const z = Math.random();
stars.push({
x: Math.random() * innerWidth,
y: Math.random() * innerHeight,
r: 0.4 + z * 1.4,
v: 1.5 + z * 9,
tw: 0.4 + Math.random() * 2.2,
ph: Math.random() * Math.PI * 2,
hue: Math.random() < 0.78 ? '255,255,255' : (Math.random() < 0.5 ? '255,215,140' : '190,160,255'),
});
}
if (reducedMotion) drawStars(0, performance.now());
}
function drawStars(dt, now) {
if (!sctx) return;
const w = innerWidth, h = innerHeight;
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.clearRect(0, 0, w, h);
const t = now / 1000;
for (const s of stars) {
if (!reducedMotion) {
s.x -= s.v * dt;
if (s.x < -4) { s.x = w + 4; s.y = Math.random() * h; }
}
const a = reducedMotion ? 0.7 : 0.35 + 0.55 * (0.5 + 0.5 * Math.sin(t * s.tw + s.ph));
sctx.fillStyle = `rgba(${s.hue},${a.toFixed(3)})`;
sctx.beginPath();
sctx.arc(s.x, s.y, s.r, 0, 7);
sctx.fill();
}
if (!reducedMotion) {
if (Math.random() < dt * 0.07 && meteors.length < 2) {
meteors.push({ x: Math.random() * w * 0.8 + w * 0.2, y: -20, vx: -260 - Math.random() * 200, vy: 200 + Math.random() * 140, life: 1 });
}
for (let i = meteors.length - 1; i >= 0; i--) {
const m = meteors[i];
m.x += m.vx * dt; m.y += m.vy * dt; m.life -= dt * 0.55;
if (m.life <= 0) { meteors.splice(i, 1); continue; }
const grad = sctx.createLinearGradient(m.x, m.y, m.x - m.vx * 0.16, m.y - m.vy * 0.16);
grad.addColorStop(0, `rgba(255,240,200,${(m.life * 0.9).toFixed(3)})`);
grad.addColorStop(1, 'rgba(255,240,200,0)');
sctx.strokeStyle = grad;
sctx.lineWidth = 1.6;
sctx.beginPath();
sctx.moveTo(m.x, m.y);
sctx.lineTo(m.x - m.vx * 0.16, m.y - m.vy * 0.16);
sctx.stroke();
}
}
}
/* ---------- save / load ---------- */
function save() {
catchUp();
state.savedAt = Date.now();
try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ }
}
function load() {
let raw = null;
try { raw = localStorage.getItem(SAVE_KEY); } catch (e) { /* blocked */ }
if (!raw) return 0;
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.v !== 1 || typeof parsed.dust !== 'number') return 0;
state = Object.assign(freshState(), parsed);
return state.savedAt || 0;
} catch (e) {
console.warn('Starforge: corrupted save ignored.', e);
return 0;
}
}
function applyOffline(savedAt) {
if (!savedAt || cache.sps <= 0) return;
const elapsed = clamp((Date.now() - savedAt) / 1000, 0, 12 * 3600);
if (elapsed < 90) return;
const gain = cache.sps * elapsed;
if (gain <= 0) return;
addDust(gain);
els.offlineText.innerHTML = '';
const p = els.offlineText;
p.append('You were away ', strong(fmtTime(elapsed)), '. Your constellation kept forging: ', strong('+' + fmt(gain) + ' stardust'), '.');
els.offlineModal.hidden = false;
}
function strong(txt) { const s = document.createElement('strong'); s.textContent = txt; return s; }
function exportSave() {
save();
const data = btoa(unescape(encodeURIComponent(JSON.stringify(state))));
const done = () => toast('📜', 'Save exported', 'Copied to your clipboard. Keep it somewhere safe.');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data).then(done, () => prompt('Copy your save:', data));
} else {
prompt('Copy your save:', data);
}
}
function importSave() {
const data = prompt('Paste your exported save:');
if (!data) return;
try {
const parsed = JSON.parse(decodeURIComponent(escape(atob(data.trim()))));
if (!parsed || parsed.v !== 1 || typeof parsed.dust !== 'number') throw new Error('bad save');
localStorage.setItem(SAVE_KEY, JSON.stringify(parsed));
location.reload();
} catch (e) {
alert('That save could not be read. Check that you pasted the whole string.');
}
}
function wipeSave() {
if (!confirm('Wipe this cosmos and start from nothing? Dark matter is lost too.')) return;
if (!confirm('Truly? There is no undo.')) return;
try { localStorage.removeItem(SAVE_KEY); } catch (e) { /* ignore */ }
location.reload();
}
/* ---------- DOM ---------- */
const els = {};
function grabEls() {
const ids = ['starfield', 'fx-layer', 'chip-dm', 'chip-dm-val', 'btn-mute', 'btn-save', 'stardust',
'sps', 'buff-bar', 'buff-label', 'buff-fill', 'star', 'click-power', 'upg-badge', 'shop',
'upgrades', 'owned-upgrades', 'dm-held', 'dm-bonus', 'dm-pending', 'dm-fill', 'dm-next',
'btn-collapse', 'collapse-count', 'stats-list', 'achs', 'ach-count', 'btn-export', 'btn-import',
'btn-wipe', 'toasts', 'comet', 'flash', 'offline-modal', 'offline-text', 'offline-ok'];
for (const id of ids) {
els[id.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = document.getElementById(id);
}
els.fx = els.fxLayer;
els.starCore = document.querySelector('#star .star-core');
els.tabs = [...document.querySelectorAll('.tab')];
els.pages = [...document.querySelectorAll('.tab-page')];
els.buyModes = [...document.querySelectorAll('.bm')];
}
/* ---------- UI: shop ---------- */
let shopRows = []; // { g, root, elCount, elCost, elSub, locked }
let revealedKey = '';
function isRevealed(g, idx) {
return idx < 3 || (state.gens[g.id] || 0) > 0 || state.lifetime >= g.baseCost * 0.3;
}
function buildShop() {
const frag = document.createDocumentFragment();
shopRows = [];
let revealed = [];
let teaser = null;
GENERATORS.forEach((g, i) => {
if (isRevealed(g, i)) revealed.push(g);
else if (!teaser) teaser = g;
});
revealedKey = revealed.map(g => g.id).join(',');
for (const g of revealed) {
const row = document.createElement('button');
row.className = 'gen';
row.type = 'button';
row.innerHTML = `
<span class="g-icon"></span>
<span class="g-body">
<span class="g-name"></span>
<span class="g-sub mono"></span>
<span class="g-flavor"></span>
</span>
<span class="g-right">
<span class="g-count mono">0</span>
<span class="g-cost mono"></span>
</span>`;
row.querySelector('.g-icon').textContent = g.icon;
row.querySelector('.g-name').textContent = g.name;
row.querySelector('.g-flavor').textContent = g.flavor;
row.querySelector('.g-sub').style.display = 'block';
row.querySelector('.g-flavor').style.display = 'block';
row.addEventListener('click', () => buyGen(g));
frag.appendChild(row);
shopRows.push({
g, root: row,
elCount: row.querySelector('.g-count'),
elCost: row.querySelector('.g-cost'),
elSub: row.querySelector('.g-sub'),
});
}
if (teaser) {
const row = document.createElement('div');
row.className = 'gen locked';
row.innerHTML = `
<span class="g-icon">❔</span>
<span class="g-body">
<span class="g-name">???</span>
<span class="g-flavor" style="display:block">A distant signal. Forge more stardust to resolve it.</span>
</span>
<span class="g-right"><span class="g-cost mono"></span></span>`;
row.querySelector('.g-cost').textContent = fmt(teaser.baseCost);
frag.appendChild(row);
}
els.shop.replaceChildren(frag);
refreshShop(true);
}
function refreshShop(force) {
// Rebuild if a new body has been revealed.
const nowKey = GENERATORS.filter((g, i) => isRevealed(g, i)).map(g => g.id).join(',');
if (nowKey !== revealedKey) { buildShop(); return; }
for (const r of shopRows) {
const g = r.g;
const count = state.gens[g.id] || 0;
const qty = buyQty(g);
const cost = bulkCost(g, qty);
const can = state.dust >= cost;
r.root.classList.toggle('can', can);
r.root.classList.toggle('cant', !can);
r.elCount.textContent = count;
r.elCost.textContent = (qty > 1 ? `×${qty} · ` : '') + fmt(cost);
const each = g.baseProd * (cache.genMult[g.id] || 1) * cache.globalProd * cache.meta;
if (count > 0 && cache.sps > 0) {
const total = each * count;
r.elSub.textContent = `${fmt(each)}/s each · ${fmt(total)}/s (${Math.round(total / cache.sps * 100)}%)`;
} else {
r.elSub.textContent = `${fmt(each)}/s each`;
}
}
}
/* ---------- UI: upgrades ---------- */
let upgRows = []; // { u, root }
let upgKey = '';
function availableUpgrades() {
return UPGRADES.filter(u => !state.upgrades[u.id] && u.unlock()).sort((a, b) => a.cost - b.cost);
}
function buildUpgrades() {
const avail = availableUpgrades();
upgKey = avail.map(u => u.id).join(',');
upgRows = [];
const frag = document.createDocumentFragment();
if (!avail.length) {
const note = document.createElement('div');
note.className = 'empty-note';
note.textContent = 'No upgrades in reach. Keep forging — the cosmos will provide.';
frag.appendChild(note);
}
for (const u of avail) {
const row = document.createElement('button');
row.className = 'upg';
row.type = 'button';
row.innerHTML = `
<span class="u-icon"></span>
<span><span class="u-name" style="display:block"></span><span class="u-desc" style="display:block"></span></span>
<span class="u-cost mono"></span>`;
row.querySelector('.u-icon').textContent = u.icon;
row.querySelector('.u-name').textContent = u.name;
row.querySelector('.u-desc').textContent = u.desc;
row.querySelector('.u-cost').textContent = fmt(u.cost);
row.addEventListener('click', () => buyUpgrade(u));
frag.appendChild(row);
upgRows.push({ u, root: row });
}
els.upgrades.replaceChildren(frag);
// Owned chips
const ownedFrag = document.createDocumentFragment();
const owned = UPGRADES.filter(u => state.upgrades[u.id]);
if (!owned.length) {
const note = document.createElement('div');
note.className = 'empty-note';
note.textContent = 'Nothing acquired yet.';
ownedFrag.appendChild(note);
}
for (const u of owned) {
const chip = document.createElement('span');
chip.className = 'owned-chip';
chip.textContent = `${u.icon} ${u.name}`;
chip.title = u.desc;
ownedFrag.appendChild(chip);
}
els.ownedUpgrades.replaceChildren(ownedFrag);
refreshUpgrades();
}
function refreshUpgrades() {
// Rebuild if the available set changed (new unlocks or purchases elsewhere).
const nowKey = availableUpgrades().map(u => u.id).join(',');
if (nowKey !== upgKey) { buildUpgrades(); return; }
let affordable = 0;
for (const r of upgRows) {
const can = state.dust >= r.u.cost;
if (can) affordable++;
r.root.classList.toggle('can', can);
r.root.classList.toggle('cant', !can);
}
els.upgBadge.textContent = affordable;
els.upgBadge.classList.toggle('show', affordable > 0);
}
/* ---------- UI: ascend ---------- */
let collapseArmed = false;
let collapseArmTimer = null;
function refreshAscend() {
const pend = pendingDm();
els.dmHeld.textContent = `${fmt(state.dm)} ◈`;
els.dmBonus.textContent = `+${fmt(state.dm * 2)}%`;
els.dmPending.textContent = `+${fmt(pend)} ◈`;
const nextN = state.dm + pend + 1;
const prevL = Math.pow(state.dm + pend, 2) * DM_BASE;
const nextL = Math.pow(nextN, 2) * DM_BASE;
const frac = clamp((state.lifetime - prevL) / (nextL - prevL), 0, 1);
els.dmFill.style.width = (frac * 100).toFixed(1) + '%';
els.dmNext.textContent = `Next ◈ at ${fmt(nextL)} all-time stardust (${fmt(state.lifetime)} forged so far)`;
els.btnCollapse.disabled = pend < 1;
if (!collapseArmed) {
els.btnCollapse.textContent = pend < 1 ? 'Collapse — nothing to condense yet' : `Collapse for +${fmt(pend)} ◈`;
}
els.collapseCount.textContent = state.collapses
? `You have ended ${state.collapses} universe${state.collapses > 1 ? 's' : ''}. They were beautiful.`
: 'No universe has ended yet. Yours could be the first.';
}
function onCollapseClick() {
if (pendingDm() < 1) return;
if (!collapseArmed) {
collapseArmed = true;
els.btnCollapse.classList.add('arm');
els.btnCollapse.textContent = 'Really? Click again to end it all';
collapseArmTimer = setTimeout(disarmCollapse, 4000);
return;
}
disarmCollapse();
collapse();
}
function disarmCollapse() {
collapseArmed = false;
clearTimeout(collapseArmTimer);
els.btnCollapse.classList.remove('arm');
refreshAscend();
}
/* ---------- UI: codex ---------- */
let achKey = -1;
function refreshCodex() {
const rows = [
['Stardust in the void', fmt(state.dust)],
['Forged all-time', fmt(state.lifetime)],
['Forged this universe', fmt(state.runLifetime)],
['Production', fmt(currentSps()) + ' /s'],
['Forge power', fmt(clickPower()) + ' /click'],
['Hand forges', fmt(state.clicks)],
['Golden comets caught', fmt(state.comets)],
['Universes collapsed', fmt(state.collapses)],
['Dark matter', `${fmt(state.dm)} ◈ (+${fmt(state.dm * 2)}%)`],
['Time at the forge', fmtTime(state.playtime)],
];
const frag = document.createDocumentFragment();
for (const [k, v] of rows) {
const wrap = document.createElement('div');
const dt = document.createElement('dt');
const dd = document.createElement('dd');
dt.textContent = k;
dd.textContent = v;
wrap.append(dt, dd);
frag.appendChild(wrap);
}
els.statsList.replaceChildren(frag);
}
function buildAchs() {
if (achCount() === achKey) return;
achKey = achCount();
const frag = document.createDocumentFragment();
for (const a of ACHIEVEMENTS) {
const tile = document.createElement('div');
const got = !!state.achs[a.id];
tile.className = 'ach' + (got ? ' got' : '');
tile.textContent = a.icon;
tile.title = got ? `${a.name} — ${a.desc}` : `??? — ${a.desc}`;
frag.appendChild(tile);
}
els.achs.replaceChildren(frag);
els.achCount.textContent = `${achCount()} / ${ACHIEVEMENTS.length} · +${achCount()}% production`;
}
function checkAchievements() {
let newly = 0;
for (const a of ACHIEVEMENTS) {
if (!state.achs[a.id] && a.cond()) {
state.achs[a.id] = 1;
newly++;
toast(a.icon, `Achievement — ${a.name}`, a.desc + ' (+1% production)', 'ach-toast');
sfx.ach();
}
}
if (newly) {
recalc();
buildAchs();
}
}
/* ---------- UI: chrome ---------- */
function refreshChips() {
els.chipDm.hidden = state.dm < 1;
els.chipDmVal.textContent = fmt(state.dm);
els.btnMute.textContent = muted ? '🔇' : '🔊';
}
function switchTab(name) {
els.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === name));
els.pages.forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));
if (name === 'codex') { refreshCodex(); buildAchs(); }
if (name === 'ascend') refreshAscend();
disarmCollapse();
}
function setBuyMode(mode) {
state.buyMode = mode;
els.buyModes.forEach(b => b.classList.toggle('active', b.dataset.mode === String(mode)));
refreshShop(true);
}
/* ---------- the forge (click handler) ---------- */
function forge(e) {
const gain = clickPower();
state.clicks++;
addDust(gain);
sfx.click();
const rect = els.star.getBoundingClientRect();
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
const x = e.clientX || cx, y = e.clientY || cy;
floatText(x + (Math.random() * 26 - 13), y - 12, '+' + fmt(gain));
sparks(x, y, 7);
ring(cx, cy);
els.starCore.classList.remove('pop');
void els.starCore.offsetWidth;
els.starCore.classList.add('pop');
}
/* ---------- buff pill ---------- */
function refreshBuff() {
const b = state.buff;
const active = !!buffType();
els.buffBar.hidden = !active;
if (!active) return;
const left = (b.until - Date.now()) / 1000;
els.buffLabel.textContent = b.type === 'frenzy'
? `☄️ Frenzy ×${b.mult} · ${Math.ceil(left)}s`
: `⚡ Forge frenzy ×${b.mult} · ${Math.ceil(left)}s`;
els.buffFill.style.width = clamp(left / b.total * 100, 0, 100) + '%';
}
/* ---------- production ---------- */
function produce(dt) {
const b = state.buff;
if (b && b.type === 'frenzy') {
const left = (b.until - Date.now()) / 1000;
if (left <= 0) {
state.buff = null;
document.body.classList.remove('frenzy');
addDust(cache.sps * dt);
} else if (left < dt) {
addDust(cache.sps * (b.mult * left + (dt - left)));
} else {
addDust(cache.sps * b.mult * dt);
}
} else {
addDust(cache.sps * dt);
}
}
/* ---------- main loop ---------- */
let lastFrame = performance.now();
let fastAcc = 0, slowAcc = 0;
// rAF pauses in hidden tabs; settle outstanding production before saving
// so background time is never lost from the save timestamp.
function catchUp() {
const now = performance.now();
const dt = clamp((now - lastFrame) / 1000, 0, 12 * 3600);
if (dt > 0.05) {
produce(dt);
state.playtime += dt;
lastFrame = now;
}
}
function frame(now) {
let dt = (now - lastFrame) / 1000;
lastFrame = now;
dt = clamp(dt, 0, 12 * 3600);
if (dt > 0) {
produce(dt);
state.playtime += dt;
}
if (!reducedMotion) drawStars(dt, now);
// Every frame: the big counter and buff pill.
els.stardust.textContent = fmt(Math.floor(state.dust));
refreshBuff();
fastAcc += dt;
if (fastAcc >= 0.15) {
fastAcc = 0;
els.sps.textContent = fmt(currentSps()) + ' / sec';
els.clickPower.textContent = '+' + fmt(clickPower()) + ' per forge';
refreshShop();
refreshUpgrades();
if (document.getElementById('tab-ascend').classList.contains('active')) refreshAscend();
}
slowAcc += dt;
if (slowAcc >= 1) {
slowAcc = 0;
checkAchievements();
if (document.getElementById('tab-codex').classList.contains('active')) refreshCodex();
document.title = `${fmt(Math.floor(state.dust))} ✦ Starforge`;
}
requestAnimationFrame(frame);
}
/* ---------- init ---------- */
function init() {
grabEls();
const savedAt = load();
recalc();
applyOffline(savedAt);
initStarfield();
buildShop();
buildUpgrades();
buildAchs();
refreshAscend();
refreshCodex();
refreshChips();
setBuyMode(state.buyMode);
// Listeners
els.star.addEventListener('click', forge);
els.tabs.forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
els.buyModes.forEach(b => b.addEventListener('click', () => {
setBuyMode(b.dataset.mode === 'max' ? 'max' : parseInt(b.dataset.mode, 10));
}));
els.btnSave.addEventListener('click', () => { save(); toast('💾', 'Saved', 'Your cosmos is written to the archive.'); });
els.btnMute.addEventListener('click', () => {
muted = !muted;
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
refreshChips();
});
els.btnCollapse.addEventListener('click', onCollapseClick);
els.btnExport.addEventListener('click', exportSave);
els.btnImport.addEventListener('click', importSave);
els.btnWipe.addEventListener('click', wipeSave);
els.comet.addEventListener('click', catchComet);
els.offlineOk.addEventListener('click', () => { els.offlineModal.hidden = true; });
document.addEventListener('pointerdown', initAudio, { once: false });
document.addEventListener('visibilitychange', () => { if (document.hidden) save(); });
addEventListener('beforeunload', save);
if (buffType() === 'frenzy') document.body.classList.add('frenzy');
setInterval(save, 15000);
scheduleComet(true);
lastFrame = performance.now();
requestAnimationFrame(frame);
}
init();
// Console access for the curious. Yes, you can cheat. It's your universe.
window.Starforge = { state, addDust, recalc, save, fmt };