Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證A browser survival-management roguelite in English and 한국어. Keep a handful of strangers alive in an underground vault after the world went quiet. Deterministic TypeScript engine, procedurally drawn UI, no art assets.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
A browser-based survival-management roguelite. You command a handful of survivors inside
Vault Meridian, an abandoned civil-defence facility, in the months after the event they
have started calling the Quiet.
Every day you decide who works, what gets built, what gets researched, and who goes up the
stair. Every night the vault produces and consumes, people get hungry and tired, machines
wear out, and the world asks you a question you would rather not answer. A run lasts
20–60 days and ends in one of ten ways.檢視完整 README ↗A browser-based survival-management roguelite. You command a handful of survivors inside Vault Meridian, an abandoned civil-defence facility, in the months after the event they have started calling the Quiet.
Every day you decide who works, what gets built, what gets researched, and who goes up the stair. Every night the vault produces and consumes, people get hungry and tired, machines wear out, and the world asks you a question you would rather not answer. A run lasts 20–60 days and ends in one of ten ways.
A day, played: assign the crew, end the day, answer the night.
Every pixel below is generated at run time — CSS, SVG, and procedural geometry. There are no
image assets in the repository, and these shots are captured from a real build by
npm run screenshots, so they cannot drift from the game.
| Crew — skills, traits, injuries, relationships, and a work multiplier you can open up | Base — a cross-section of three decks, with power priority and collapsed sections |
| Map — a radial region, three rings, danger and knowledge per site | Research — 38 nodes across seven branches, each unlocking something mechanical |
On a phone the interface is reorganised rather than shrunk: four destinations on a bottom tab bar with the rest behind More, the status rail collapsed into a strip that expands, and the log moved into a drawer.
| Panels | 1–7 |
| Close a dismissible dialog | Esc |
| Move focus | Tab / Shift+Tab |
| Activate | Enter / Space |
Story dialogs — events and expedition beats — deliberately cannot be dismissed. They are the decision, not an interruption to it.
On phones the interface is reorganised rather than shrunk: four destinations on a bottom tab bar with the rest behind More, the status rail collapses into an expandable strip, and the log becomes a drawer.
Reduced motion, high contrast, four text scales, numeric gauge mode, and full keyboard navigation, all in Settings and all applied without a reload. No essential information is conveyed by colour alone: every gauge pairs its bar with a name and a figure, and every status marker carries a label. Sound is off by default and is entirely synthesised.
npm install
npm run dev # development server on :5173
npm run build # generates icons, type-checks, and builds to dist/
npm run preview # serve the production build
| Command | What it does |
|---|---|
npm test | Vitest: engine, save, UI components, content validation, 200 headless runs |
npm run test:watch | The same suite in watch mode |
npm run test:coverage | Coverage over src/engine |
npm run lint | oxlint over src and tests |
npm run validate | Content validation on its own, with a full report |
npm run simulate | Headless balance simulation — see below |
npm run e2e | Playwright, three viewports, against a production build |
npm run e2e:install | Install the Playwright browser once |
npm run verify | lint + test + build, the gate before a release |
npm run deploy:pages | Build with the correct base path for GitHub Pages |
npm run pack:itch | Build and zip dist/ as release/last-light-itch.zip for itch.io |
npm run screenshots | Recapture the README screenshots from a running build |
The load-bearing constraint is that the simulation runs headlessly in Node with no React,
no DOM, and no Math.random. Everything else follows from it, and a test suite enforces
it.
src/
engine/ pure, deterministic simulation — importable by Node
core/ seeded RNG, maths, Breakdown (a number plus its labelled terms)
model/ every domain type; the save shape lives here
data/ content: resources, traits, facilities, items, recipes, research,
locations, encounters, events (10 themed modules), lore, scenarios
systems/ resources, survivors, facilities, crafting, research, world,
expedition, combat, events (conditions/effects/selection), dayCycle
sim/ headless agent, run harness, balance report
store/ zustand stores; game actions are thin wrappers over engine reducers
save/ IndexedDB, versioned schema, migration chain, export/import
ui/ React: app shell, layout, panels, modals, components, audio, theme
content-validation/ referential integrity over every data table
RngState is four uint32 values inside GameState, so a save resumes
the exact same stream. rng.fork(label) derives named substreams, which is why adding
rolls to one system cannot change another system's output — or invalidate a seed.Breakdown:
a total plus labelled contributing terms, some carrying a fix hint. One popover component
renders all of them, so inspectability is structural rather than something to remember.advanceDay(state) is the only function that moves time. Event
selection produces a queue; the UI and the headless agent resolve it through the same
resolveEvent, which is why the simulator exercises the code a player actually runs.In the browser's IndexedDB, database lastlight, stores saves (slot 0 is the
autosave, slots 1–5 are manual) and meta (Legacy profile, unlocks, lore archive,
settings). Nothing leaves the machine.
A save that cannot be read is never destroyed: the slot is listed with its problem and an Export button, so the file can always be recovered. Saves carry a version and run through a migration chain on load; a file from a newer build is refused with an explanation rather than a crash.
Balance is derived from simulation output rather than guessed. The headless agent plays a competent-but-not-optimal policy through the public engine API:
npm run simulate # 200 runs, all four strategies
npm run simulate -- --runs 500 --difficulty absolute
npm run simulate -- --runs 200 --scenario cold_start --json out.json
The report gives the survival curve, ending distribution, and per-strategy win rates, and
raises warnings for the failure modes that matter: unavoidable starvation, resources
running away, facilities nobody ever builds, research nobody can afford, and dominant
strategies. Every tuning constant lives in src/engine/data/balance.ts.
All content is data. Nothing below requires touching the UI.
src/engine/data/events/*.ts{
id: 'soc.the_argument',
title: 'The Argument',
body: 'It starts about the water ration and stops being about the water ration.',
tags: ['social'],
phase: 'dusk',
weight: 20,
cooldown: 8,
requires: { kind: 'survivorCount', min: 3 },
choices: [
{
id: 'intervene',
label: 'Step between them',
check: { skill: 'negotiation', target: 6, actor: 'best' },
onSuccess: [{ kind: 'relationship', a: 'actor', b: 'random_other', amount: 12 }],
onFailure: [{ kind: 'need', target: 'all', need: 'morale', amount: -6 }],
successText: 'It holds. Barely.',
failureText: 'You become the third person in the argument.',
},
],
}
Conditions and effects are discriminated unions declared in src/engine/model/types.ts;
add a case there and in systems/events/{conditions,effects}.ts to extend the vocabulary.
Chains (chain) fire immediately, schedule fires N days later.
src/engine/data/items.tsGive it an id, category, weight, tags, salvage, and an icon name. If the icon
name has no drawing in src/ui/components/Icon.tsx, add one there — a name never renders
as text. A consumable must carry a use; a wearable must carry a slot.
src/engine/data/traits.tsDeclare effects from the TraitEffect union (and conflicts, symmetrically). The hooks
in systems/traits.ts read them; a trait with no effect fails content validation.
src/engine/data/facilities.tsExactly three levels, each with buildCost, labour, powerDraw, staffSlots, and a
one-line summary. Declare the decks it may sit on and the skill its staff use. Wire
its production into systems/resources.ts.
src/engine/data/research.tsrequires must reference nodes at the same tier or lower; the validator proves the graph
is acyclic and fully reachable. unlocks entries are checked against real recipes,
facilities, and endings.
src/engine/data/locations.tsGive it rings, maxInstances, nameForms, a loot table, and encounterTags that
intersect the tags on the encounter beats you want it to draw.
Run npm run validate after any of these. Broken references fail the build.
src/i18n/locales/<id>/English is written in the data files above and is the source of truth. A locale is an overlay of overrides, and every lookup falls back to English, so a half-finished translation is a usable one rather than a screen full of missing keys.
npm run locales # what each language has, and what is stale
npm run locales -- --missing # the keys still to fill in
npm run locales:extract -- items # the English source for one table, as JSON
To add a language:
LocaleId and LOCALES in src/i18n/types.ts — the name in its own
language, the English name, and a BCP 47 tag.src/i18n/locales/ko/ and translate. messages.ts is the interface and the prose
the engine writes; content/*.ts are overrides keyed <table>.<id>.<field>, which is
what npm run locales:extract prints.BUNDLES in src/i18n/index.ts.Nothing else changes. The language selector reads LOCALES, the setting is stored in the
save, and the engine reads the same module the interface does — so log lines, day-report
notes, and combat outcomes are translated along with the buttons.
A locale may also supply postProcess, a pass over the finished string for grammar a
lookup table cannot express. Korean uses it to pick subject and object particles from the
syllable a placeholder actually produced: the translation writes {name}이/가 and the
resolver chooses.
Everything below passes on the current tree:
npm run build — clean, zero TypeScript errors under strictnpm test — 366 tests across 21 filesnpm run e2e — 150 tests across desktop, tablet, and mobile, zero console errorsnpm run lint — zero errorsnpm run validate — every data reference resolves, and no translation key is stalenpm run simulate — 200 runs, no errors, no resource runawaynpm run deploy:pages
builds with LASTLIGHT_BASE set for a project page and writes dist/, including a
.nojekyll marker and a 404.html fallback. Publish dist/ to the gh-pages branch, or
point any static host at it. The app is entirely static and needs no server.
To host at a sub-path elsewhere, set the base yourself:
LASTLIGHT_BASE=/games/last-light/ npm run build
A plain npm run build uses a relative base (./), so the same dist/ also runs from any
folder or inside an iframe.
npm run pack:itch
writes release/last-light-itch.zip with index.html at its root, ready for an HTML upload.
itch.io runs the game in an iframe on its own domain, so saves made there live in that
domain's storage and are separate from the Pages build — move a run between them with
Saves → Export / Import. If the browser blocks storage in the iframe, the game still runs;
it just cannot save. The upload settings and the store-page text are in
docs/itch/README.md.
MIT. All visuals are generated at runtime from code — CSS, SVG, and procedural geometry — and all audio is synthesised through WebAudio. There are no third-party assets.