Open Worlds Atlas: A Stateless Three.js Galaxy Built on Kepler's Third Law
I run a Persian-language gaming site, and I wanted a place to show off open-world game history: not 2026-10-5 14:42:8 Author: hackernoon.com(查看原文) 阅读量:6 收藏

I run a Persian-language gaming site, and I wanted a place to show off open-world game history: not a wiki table, not a listicle, something you could fly through. I built it as a personal project, on my own time, and once it turned into something I actually liked, I launched it on my site as a page anyone could visit.

The result is Open Worlds Atlas: a Three.js galaxy where 19 open-world game franchises orbit as planets, with a 20th stop tucked in as a moon around Assassin's Creed. Click a mascot and you fly through the galaxy in first person. Land on a planet and every remaster, spin-off and sequel that franchise ever had scrolls past as a timeline.

Assassin's Creed PlanetAssassin's Creed Planet

The plan on day one was simpler: a nice page listing some games. Then I got stuck on one question. If these franchises are planets, why would they just sit there? Planets orbit. So instead of a looping CSS animation that resets every ten seconds, I built actual orbital mechanics, and spent most of the project's time on the three problems that came out of that decision: making the orbits behave like orbits, building one template that could serve 20 very different franchises, and shipping the whole thing in two languages without doubling the codebase.

The orbits are computed, not animated

The starting rule was Kepler's third law: outer orbits take longer than inner ones, and the relationship isn't linear. I picked a formula that mirrors T² ∝ r³ closely enough to feel physical:

// Simplified from the site's orbital-physics module.
const CONFIG = {
  epoch: "2026-05-28T05:52:22Z",
  yearLengthDays: 369,       // one lap for the outermost lane
  laneRadii: [8, 13, 19],    // inner → outer, world units
  keplerExponent: 1.5,       // T ∝ r^1.5, i.e. T² ∝ r³
};

function lanePeriodDays(laneIndex) {
  const outerRadius = CONFIG.laneRadii.at(-1);
  const ratio = CONFIG.laneRadii[laneIndex] / outerRadius;
  return CONFIG.yearLengthDays * ratio ** CONFIG.keplerExponent;
}
// lanePeriodDays(0) ≈ 101, lanePeriodDays(1) ≈ 209, lanePeriodDays(2) = 369

Every planet's position on any frame is computed straight from elapsed time since a fixed epoch, not advanced frame-by-frame:

function getPlanetAngle(phase0, laneIndex, nowMs, epochMs) {
  const periodMs = lanePeriodDays(laneIndex) * 86_400_000;
  const omega = (2 * Math.PI) / periodMs; // angular velocity
  return phase0 + omega * (nowMs - epochMs);
}

That one design choice removed a whole category of bugs. There's no accumulator to drift, no state to save between page loads, and no discrepancy between two visitors looking at the galaxy an hour apart. Reload the page next week, and every planet is exactly where the formula says it should be.

There was a cost, though, and it's one I initially glossed over: at real speed, a 369-day lap is invisible over the length of a normal visit. So the version people actually see fast-forwards that elapsed-time delta by 100× for rendering only:

export const PRESENTATION_TIME_MULTIPLIER = 100;

function getSimNow(epochMs, nowMs = Date.now()) {
  return epochMs + (nowMs - epochMs) * PRESENTATION_TIME_MULTIPLIER;
}

I'd originally set the multiplier much higher, and the galaxy looked less like planets and more like a fan. Dialing it down to 100× was a trial-and-error call, not a computed one — it's the speed where the eye reads it as orbiting rather than spinning. At 100×, the outer lane completes a lap in about 3.7 days of wall-clock time, and the innermost lane in roughly a single day. The multiplier touches only the visual position of planets in the galaxy scenes. Each franchise's own landing page shows a running "age" counter that ticks at real speed, using that lane's true period, with no multiplier applied. A visitor can, in principle, watch the galaxy spin fast while a planet's own page tells them, accurately, how many real days old it is.

Sizes, mass, and where a planet ends up

Two separate numbers drive the visuals, and mixing them up was an early mistake.

Size comes from a franchise's real map area in square kilometers, log-scaled so Just Cause 4's 1,024 km² doesn't erase the original Assassin's Creed's 0.13 km² off the screen:

function radiusUnits(sizeKm2, { minKm2, maxKm2, minR = 0.6, maxR = 5.0 }) {
  const t = (Math.log10(sizeKm2) - Math.log10(minKm2)) /
            (Math.log10(maxKm2) - Math.log10(minKm2));
  return minR + t * (maxR - minR);
}

Mass is a separate 1–10 score that decides which of three shared orbital lanes a franchise lives on (lighter → inner, heavier → outer) and how fast it spins on its own axis. It's a weighted blend of three things I can actually source for any franchise:

Component

Weight

Source

Critic score of the franchise's best-reviewed entry

40%

Metacritic / OpenCritic

Lifetime sales, bucketed (<1M / 1–5M / 5–20M / 20–50M / 50–100M / 100M+)

40%

Public publisher figures, Wikipedia

Number of released mainline entries

20%

Simple count

The output is a starting point I override by hand when it feels wrong for a franchise, not a number I treat as ground truth. Franchises are split into the three lanes by mass, in even thirds, with ties broken by the order they appear in the source data:

function assignLanes(franchises, laneCount = 3) {
  const sorted = [...franchises].sort((a, b) => a.mass - b.mass);
  const perLane = Math.ceil(sorted.length / laneCount);
  return sorted.map((f, i) => ({
    ...f,
    laneIndex: Math.min(laneCount - 1, Math.floor(i / perLane)),
  }));
}

With 19 franchises this comes out to 7, 7 and 5 per lane. Because JavaScript's sort is stable, a mass tie resolves by whichever franchise appears first in the source list — a detail that only surfaced when I adjusted one franchise's mass and watched it swap lanes with its neighbor instead of just re-sorting in place. It's a reasonable behavior, but it means an editorial tweak to a rating can silently change a planet's orbit, which is worth knowing before you go rebalance one figure and wonder why two planets moved.

One template, 19 franchises, 40 pages

Every franchise's landing page runs on the same code. A single planet page's entry point is three lines:

import "../src/planet-page.css";
import { mountPlanetLanding } from "../src/planet-template.js";
import franchiseData from "../data/franchises/franchise-name.json";

mountPlanetLanding({ slug: "franchise-name", franchiseData });

mountPlanetLanding builds the whole page from that JSON: header art, a scroll-driven stack of version cards, a live age counter, and the UFO/mascot overlay. Adding a new game to a franchise means adding one object to that franchise's JSON file; adding a whole new franchise means a new JSON file, a small HTML shell, and one line in the master list. The scroll mechanic, animations, and every visual behavior live once, in the template.

First-Person Traveling with the UFOFirst-Person Traveling with the UFO

That reuse is also why a size mistake is cheap to fix and a template bug is expensive. Early on, every planet's landing page divided elapsed time by the same fixed constant to show its age, regardless of which orbital lane it was actually on — so a fast inner-lane planet and a slow outer-lane one displayed the same "year," even though their real orbital periods differ by a factor of 3.7. Because the calendar logic lives in one shared module, the fix was one function change, not 20 page-by-page corrections:

// Before: every page used the same constant, no matter its lane.
const DEFAULT_YEAR_LENGTH_DAYS = 369;

// After: each page asks for *its own* lane's year length, derived from
// the exact same lanePeriodDays() the orbit math already uses — so the
// calendar and the visible motion can never disagree again.
const yearLengthDays = getYearLengthDaysForSlug(slug);

A second bug from the same era of the project taught me a CSS lesson the hard way. The galaxy overview is a fixed, non-scrolling single-page app, so its stylesheet pins html and body to height: 100% with overflow: hidden. Planet pages import that same stylesheet for its shared design tokens, but they're supposed to scroll, since the whole timeline mechanic depends on it. Inheriting height: 100% didn't break scrolling outright: body became its own independent scroll box, with a real, visible scrollbar and content that moved. What it broke was anything that assumed the page itself scrolls, window.scrollTo() and my scroll-triggered animations included, because those look at document.documentElement, which had nothing to scroll. The fix was one override, height: auto, on the planet-page stylesheet, but finding it meant realizing that "the page visibly scrolls" and "the thing the browser considers scrollable" aren't always the same element.

Two languages, two real URLs

The whole site ships in Persian and English, and I didn't want a client-side text swap sitting behind a toggle. Every planet page exists at two real addresses: /planet-x/ for Persian, /en/planet-x/ for English, each its own HTML file with the right lang/dir attributes and its own JSON.

The language choice happens once, on the intro screen, and gets carried forward as a per-visit setting:

const STORAGE_KEY = "owa:locale";

export function setLocale(locale) {
  sessionStorage.setItem(STORAGE_KEY, locale === "en" ? "en" : "fa");
}

export function getLocale() {
  return sessionStorage.getItem(STORAGE_KEY) === "en" ? "en" : "fa";
}

// Every in-galaxy link to a planet page runs through this, so once a
// visitor picks a language, every planet they land on afterward opens
// the matching version.
export function localizedHref(path) {
  return getLocale() === "en" ? `/en${path}` : path;
}

I used sessionStorage rather than localStorage on purpose: this is a per-visit choice, not a saved site-wide preference, so a returning visitor sees the language picker again instead of being locked into whatever they clicked last time.

On the build side, the two-language, one-template setup means the number of actual pages is invisible to the build config. Vite's default build only knows about the root index.html, so a small glob picks up every planet page, in both languages, automatically:

const planetEntries = [
  ...globSync("planet-*/**/index.html"),
  ...globSync("en/planet-*/**/index.html"),
];

Nothing here needs updating when a new franchise or a new language page gets added; the build finds it. Nineteen franchises plus the moon is 20 titles; times two languages, comes out to 40 pages running on that one template. The galaxy itself, the root page where you pick a language and fly the mascot around, is the 41st page, and it runs its own separate shell, since it's a single-page app rather than a scrolling document.

What I'd tell someone building this

A static list of games with pictures would have taken a tenth of the time. I built the physics anyway because "would have done the job" was never really the point, and the two decisions I'd keep if I started over are the ones that removed whole bug categories instead of just fixing symptoms: computing every position from elapsed time instead of animating it, and keeping the calendar and the visible motion reading from the exact same function. Both bugs I hit came from the opposite move: letting two things that should agree drift apart, one constant duplicated in the wrong place, one stylesheet inherited a rule it wasn't supposed to keep.

It's live at owa.noobstic.top, under a personal Persian gaming website I run called Noobstic: 19 franchises, one moon, a mascot, and a clock that keeps running whether anyone's watching or not.


文章来源: https://hackernoon.com/open-worlds-atlas-a-stateless-threejs-galaxy-built-on-keplers-third-law?source=rss
如有侵权请联系:admin#unsafe.sh