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 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Interactive Elden Ring map, solo or in co-op teams of up to three players. Each player marks what they find, sees the progress and next destination of everyone in the team, and sets their own next destination on the map. Changes sync within a few seconds. Anyone can watch a team without marking anything.
Built with Next.js 16 (App Router), React 19, TypeScript, Leaflet (react-leaflet) and zustand. Pages and API live in a single project.
| Login | Phone: full-screen map and the actions drawer |
|---|---|
The screenshots use demo data: two players with some Limgrave pins found and a next destination each.
npm install
cp .env.example .env # set MONGODB_URI and SESSION_SECRET
npm run dev # http://localhost:3000
Open http://localhost:3000. From the lobby you can play solo (no account, nothing leaves the browser), go back to your teams with one click, create a team, or pick a team from the list to join it (with its password) or watch it.
To let other players join from their machines, run npm run dev -- -H 0.0.0.0 and share http://<your-ip>:3000.
| Script | What it does |
|---|---|
npm run dev | Development server |
npm run typecheck | TypeScript type check |
npm test | Tests for the sync format, teams and relay (teams use an in-memory MongoDB, downloaded on the first run) |
npm run build | Production build |
npm start | Serves the build (-- -p 8080 -H 0.0.0.0 for another port or the network) |
.env)| Variable | Purpose |
|---|---|
MONGODB_URI | MongoDB connection string (MongoDB Atlas works; on Vercel allow its IPs in Atlas → Network Access). Needed for teams; solo play works without it |
MONGODB_DB | Optional database name. Defaults to eldencoop |
SESSION_SECRET | Signs the session cookies, at least 16 characters. Changing it logs everyone out |
TILE_URL | Optional tile URL template ({map}, {z}, {x}, {y}). Defaults to /tiles/{map}/{z}_{x}_{y}.jpg |
Variables are only read on the server. None has the NEXT_PUBLIC_ prefix, so they never reach the browser.
On screens up to 800px wide the map takes the whole screen. Every action lives in a drawer opened with the button in the top-left corner; tap outside it, press × or start "Pick on map" to close it. Zoom controls sit at the bottom right, touch targets are at least 40px, inputs use 16px text so iOS does not zoom in, and the layout respects the notch and home indicator (viewport-fit=cover + env(safe-area-inset-*)).
overlay/ holds a Windows desktop app that shows the map over Elden Ring with a keyboard shortcut (\), without touching the game process. See overlay/README.md.
/ is the lobby, /solo the map on your own, /team/<id> the page to join or watch a team (the link the host shares) and /map the map of your team. Unknown routes return 404.The Players section shows one card per player with their progress (total and current map, with a percentage) and their next destination: you, plus every player of the team whose progress your browser has received. Pin badges, popups, category counts and map flags follow the same rule.
Progress only counts the categories checked under Categories: unchecking "Site of Grace", for example, takes them out of both the found count and the total.
A player's data is only replaced by a newer snapshot of that player: it stays when they disconnect, and when a poll lands on a server instance that does not know them. It is cleared when they leave or are removed from the team. Players who are not syncing right now (not seen within the last 20 seconds) are labeled "offline".
localStorage). It is the only original copy and only you change it. There is one per browser, used solo and in any team.teams, never cleaned up): name, hashed password and creator key, members, banned browsers and the last saved snapshot of each player (about 1 KB per team with 3 players), so spectators can see a team while nobody plays.sendBeacon). A team is read from the database once when a server instance first serves it; after that its members are re-read every 5 minutes, or when an unknown session shows up (at most every 10 seconds).On Vercel, each request may hit a different instance, so presence and relayed snapshots are best effort. The client smooths this out: received progress is kept until a newer one arrives, and a player stays online (or in the team) until no instance has reported them for 20 seconds. A player removed on one instance can keep syncing through another one until that instance re-reads the members (5 minutes at most).
| Database operation | When |
|---|---|
| Read a team | Opening /map or /team/<id>, and the first poll of a team on a server instance |
| Read members | Every 5 minutes per team and instance, or when an unknown session syncs |
| Write a snapshot | At most every 2 minutes per player whose progress changed, and when leaving the tab |
| List teams | Each page of the lobby list |
| Create / join / remove / leave | Once per action |
A single route, POST /api/sync, with binary messages (application/octet-stream). The format is defined in src/sync/ and shared by client and server:
pins.json.| Message | Measured size |
|---|---|
| Idle poll | 13 B up / 15 B down |
| A change (1 pin + destination with a note) | ~45 B |
| Every pin found | ~245 B |
No compression (gzip/Brotli) is used: for messages this small, the compression header would be larger than the content. pins.json (static, ~700 KB) is compressed by the host and cached by the browser.
Game8 serves map tiles and popup images without Cache-Control, so browsers would only cache them heuristically. The app proxies them through its own origin:
/tiles/<map>/<z>_<x>_<y>.jpg → Game8 map tiles/game8-img/<jp|co>/… → Game8 popup images (pins.json already points here)Both respond with Cache-Control: private, max-age=31536000, immutable, so each image is downloaded once and then served from the browser cache, including on later visits. Only paths with the expected format are accepted, and requests from other sites are rejected (Sec-Fetch-Site), so it cannot be used to hotlink Game8 through this app. No session is needed, so solo play works too.
To avoid depending on Game8 at all, python3 scripts/download_tiles.py downloads every tile into public/map-tiles/ (about 4000 files). Then set TILE_URL=/map-tiles/{map}/{z}_{x}_{y}.jpg.
src/
├── app/ Next.js routes
│ ├── layout.tsx base HTML and global styles
│ ├── page.tsx "/" → lobby: solo, create a team, list of teams
│ ├── solo/page.tsx the map on your own (no session)
│ ├── team/[id]/page.tsx join a team with its password, or watch it
│ ├── map/page.tsx the map of your team; requires a team session
│ ├── not-found.tsx
│ ├── api/teams/… list, create, join, watch, members, kick, owner, leave
│ ├── api/sync/route.ts binary snapshot exchange
│ ├── tiles/[map]/[tile] cached proxy for map tiles
│ └── game8-img/[host]/[...path] cached proxy for popup images
├── server/ server-only code (import 'server-only')
│ ├── config.ts reads and validates .env; session data for the client
│ ├── db.ts MongoDB client, shared by every route
│ ├── teams.ts teams in MongoDB: create, join, kick, hand over, leave, save snapshots
│ ├── session.ts signed session cookie (team, slot, membership)
│ ├── rateLimit.ts wrong passwords and new teams per IP
│ ├── errors.ts, http.ts HTTP errors, withErrors(), withViewer(), body readers
│ ├── imageProxy.ts Game8 image proxy with immutable caching
│ ├── relay.ts per team, in memory: latest snapshots, sequences, presence, members
│ └── syncCatalog.ts pin catalog used to validate snapshots
├── sync/ binary format shared by client and server
│ ├── binary.ts ByteWriter / ByteReader (varint, u16, u32…)
│ ├── catalog.ts compact pin index + hash
│ ├── snapshot.ts a player's progress ↔ bytes
│ ├── protocol.ts /api/sync messages
│ └── sync.test.ts tests (npm test)
├── features/ frontend, by feature
│ ├── lobby/ lobby, your teams, create team, team list, join page
│ ├── player/ player app shell (client only)
│ ├── coop/ progress: store, localStorage, teams of this browser, sync engine, backup, names
│ ├── map/ map, markers, popups, "pick on map" mode
│ ├── sidebar/ sidebar panels
│ ├── pins/ pin catalog and filters
│ ├── session/ current team, player (or spectator) and profiles
│ ├── ui/ UI state (map, filters, focus)
│ └── notifications/ toasts
├── api/ typed HTTP clients for the frontend
├── shared/ reusable components and utilities
├── config/constants.ts constants shared by client and server
├── types/domain.ts domain types
└── styles/global.css
public/
├── data/pins.json pin catalog
└── icons/ category icons
scripts/ data generation
Organization rules
src/app only contains routes. Logic lives in server/, sync/ or features/.server/ is never imported from the client; server-only makes the build fail if it happens.config/constants.ts does not import Leaflet because the server uses it too. Leaflet-dependent code lives in features/map/.PlayerAppLoader with ssr: false) because Leaflet needs window.Extending it
src/app/api/<name>/route.ts wrapped in withViewer(...) (team players and spectators; check viewer.kind for write access) or withErrors(...) (no session needed), and add a client in src/api/.snapshot.ts, bump PROTOCOL_VERSION in protocol.ts, and add a test to sync.test.ts.src/app/.| Method | Route | Body / response |
|---|---|---|
| GET | /api/teams?search=&page= | { teams: [{ id, name, players }] }, most recently active first, 20 per page |
| POST | /api/teams | { name, playerName, clientId } → { team, slot, password, creatorKey }; sets the session cookie |
| POST | /api/teams/<id>/join | { password, playerName, clientId } → { team, slot }; sets the session cookie |
| POST | /api/teams/<id>/watch | No body → { ok }; sets a spectator session cookie |
| GET | /api/teams/<id>/members | → { members: [{ slot, name }], owner } |
| POST | /api/teams/<id>/kick | { creatorKey?, slot } → { ok }; owner only (session or creator key); removes and bans that player |
| POST | /api/teams/<id>/owner | { creatorKey?, slot } → { ok }; owner only; hands the team over to that member |
| POST | /api/teams/<id>/leave | { creatorKey?, newOwner?, clientId? } → { ok }; frees your slot, deletes your saved progress and clears the cookie if it was for this team. Without a session in the team, clientId says who leaves |
| POST | /api/sync | Binary, see src/sync/protocol.ts and src/sync/snapshot.ts |
Errors are JSON { error }: 400 invalid input or message, 401 wrong password, not in a team or no longer in it, 403 banned, not the owner, or a spectator tried to send progress, 404 team not found, 409 team full, name taken or catalog mismatch, 413 message too large, 429 too many attempts, 503 too many spectators.
python3 scripts/build_pins.py [json] regenerates public/data/pins.json and the icons from the Game8 map.python3 scripts/download_tiles.py downloads the tiles into public/map-tiles/ (see "Image caching").Map data and images belong to Game8 and FromSoftware. This project is for personal use only.