This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Two instruction sets, kept in sync. This repo is worked on with both Claude Code (
CLAUDE.md, this file) and GitHub Copilot (.github/copilot-instructions.md). They carry the same project facts. If you change a project fact here (build/ship model, branching rules, file layout, version, architecture), make the matching edit in.github/copilot-instructions.mdin the same commit. A pre-commit guard blocks committing a change to one without the other. Tool-specific mechanics may differ; shared facts must not.
Rogue Racer is a 2D top-down multiplayer racing game that ships as one self-contained file, rogue-racer.html (~17.5k lines — HTML + CSS + one big <script>). It renders to a 2D canvas (no THREE.js / no 3D engine). Its only runtime dependency is PeerJS, loaded from unpkg via a <script> tag.
rogue-racer.html is now GENERATED — do not hand-edit it. The source lives in src/: a shell index.html plus NNN-*.js files (one per subsystem, plain scripts sharing one global scope — no ES modules), concatenated back into the single rogue-racer.html by bun build.ts. Edit src/, run bun run build, commit both. See src/README.md.
desktop/ is a thin Tauri (Rust) wrapper that packages the game as a native desktop app. The game itself is engine-agnostic and runs by opening the HTML file directly in a browser.
- Develop with hot reload (recommended):
bun run devstarts a local server on http://localhost:8080 that rebuilds on everysrc/change and live-reloads the browser. The reload snippet is injected only at serve time — it never touches the shipped file. (PORT=3000 bun run devfor a custom port.) - Or build manually: edit files under src/, run
bun run buildto regenerate rogue-racer.html, then open it and refresh. The build is plain concatenation viabun build.ts— no npm, no module system. - Enable the build guard once per clone:
bun run hooks(installs.githooks/pre-commit, which blocks committing a stale/hand-editedrogue-racer.html).bun run checkis the same check for CI. - There is no test suite or linter. Verify changes by playing.
- Keep the shipped artifact a single self-contained
rogue-racer.html— that's what the loaderdocument.writes. The split is source-time only; it does not change how the game ships. Full context: docs/features/rebuild-plan.md.
The game and the native app ship on separate tracks:
- Game updates (the common case):
desktop/bootstrap.htmlis a self-updating loader. At launch it pulls the latestrogue-racer.htmlfromraw.githubusercontent.com/EndaDude/Rogue-Racer/mainanddocument.writes it into the same window. So shipping a game change = pushingrogue-racer.htmltomain— no CI build, no installer, no version bump of the app. Loader falls back to a cached copy, then to the bundledgame.html, so it works offline. - Native app updates (rare): only needed when the Rust/Tauri side changes. That's the only thing the CI workflow (.github/workflows/release.yml) builds — a Tauri matrix build on
ubuntu-22.04+windows-latest.
desktop/sync-dist.ps1 assembles the Tauri dist/: bootstrap.html→index.html (entry point), rogue-racer.html→game.html (offline fallback), plus Audio/. Run it before cargo tauri build.
GAME_VERSION is a const at the top of src/010-constants-config.js, shown on the CRT terminal. Bump it on every game change you ship (then rebuild) — it's how players confirm the self-updating loader pulled the new copy. Keep it in sync with version in desktop/src-tauri/tauri.conf.json when doing a real app release, but note the game version ships independently of the app version. Current: 0.1.6.
This repo has no branch protection and contributors have historically pushed straight to main. When the whole game was one ~17.5k-line file, two people editing in parallel and pushing didn't merge cleanly — whoever pushed last silently overwrote the other's work. A batch of features was already lost this way (see the "lost in a merge" note in docs/features/troy-feature.md). The src/ split (below) removes most of that conflict surface, but the discipline still matters: the generated rogue-racer.html is one file, and main is shared, breakable state.
Branching model (lightweight, GitHub-flow):
main= what ships to players (the loader pullsrogue-racer.htmlfrom it). Keep it working.- Do all work on a feature branch:
git switch -c feature-<thing>. - Pull-rebase before starting and again right before merging:
git pull --rebase origin main. - Open a PR into
mainand squash-merge it. PRs make changes reviewable and stop blind overwrites. Delete the branch after merge. - Never force-push
main. Never commit directly tomainfor anything non-trivial. - Recommended: the repo owner enables GitHub branch protection on
main(require a PR, block direct pushes/force-push). This is what actually prevents the clobbering rather than relying on discipline.
Access / push URL: the repo lives at EndaDude/Rogue-Racer and the self-updating loader pulls from raw.githubusercontent.com/EndaDude/Rogue-Racer/main. Only pushes to that repo's main reach players. A 403 Permission denied on push means no write access — the owner must add you as a collaborator, or you fork and PR. (A fork's main will NOT self-update players; only the canonical repo does.)
If work goes missing: check git reflog, other remote branches (git branch -a), and any docs/*.html backup copies before rebuilding by hand.
The single-file layout was the root cause of the main clobbering — one file means parallel edits always conflict. That's now fixed: the game code is split into per-subsystem files under src/ and concatenated back into the single rogue-racer.html by bun build.ts (plain concatenation, one shared global scope — deliberately not ES modules, so no import/export refactor). The built file is byte-for-byte what the old inline <script> was, so the ship model is unchanged.
- Edit
src/, neverrogue-racer.html(it's generated). Sections aresrc/NNN-*.js, numbered in load order (config/state first, render/terminal last); the DOM/CSS/<head>live insrc/index.html. bun run dev(hot reload) orbun run build(one-shot) regenerate the artifact;bun run hooksinstalls the stale-artifact guard.- Two people editing different
src/files no longer conflict — this is the whole point.
Full context, file map, and phases: docs/features/rebuild-plan.md and src/README.md.
The game code is split across src/NNN-*.js (each a former // ==== banner section), concatenated in load order into the one shipped rogue-racer.html. They share one global scope, so any file can call functions/read state defined in another. Key subsystems, in load order (file → what it does):
010-constants-config.js—GAME_VERSION, car types, track/tunable constants. Every other file reads this, so it loads first.020-procedural-track-generation.js—generateTrack(seed)builds tracks from a seed via amulberry32PRNG + Catmull-Rom splines. Bridge detection, walls, forks/branching. Seed-driven so the same seed = the same track across peers.030-game-state.js— one global objectGholds all mutable state (G.isHost,G.pad, players, race phase, etc.).040-networking-peerjs.js— PeerJS-based, no dedicated server. One player is the host (G.isHost); guests connect to the host.hostConn= a guest's link to the host;guestConns= the host's links to all guests.broadcast()/sendToAll()relay through the host (star topology — host re-forwards guest messages to other guests). Messages are{type, ...}dispatched bytype. Host setup (initHostPeer→_makePeerOnce) is resilient: destroys any stale peer first, retries fresh room codes, then falls back to an anonymous broker-assigned id so any machine that can join can also host. Note: despite older changelog claims,new Peer(...)is currently constructed with no explicit STUN/ICE config (relies on PeerJS defaults) — if cross-network peers can't connect, wiring in aconfig.iceServersSTUN list is the first thing to try.050-lobby-ui.js— lobby/room DOM flow, room codes, join/host UI.070-friends.js,080-unique-usernames.js— a persistent social layer on a separate PeerJS "presence" peer, distinct from the game peer. YourFRIEND_IDis random and cached inlocalStorage; the social peer claims the deterministic idrogueracer-fr-<FRIEND_ID>so friends can reach you by it. Because it's deterministic, a reload/unclean-close leaves the broker still holding the old claim →unavailable-id.initSocialPeerhandles this by falling back to an anonymous social peer (so outbound requests/invites work immediately —socialReady) while a 15s background timer (scheduleCanonicalIdReclaim) reclaims the canonical id once the stale claim expires. Real friend requests (friend_request_v2) go to a pending list; legacyfriend_requestis auto-accepted for back-compat. Auto-join subscriptions ride on presence broadcasts.060-game-engine.js,190-render.js— canvas 2D loop, physics, camera/visual layering (for bridges/elevation). Render is the most-coupled file (reads nearly everything) and loads late.090-track-storage.js— persists maps to a real OS folder when possible (File System Access API in browser; Tauri real-file storage on desktop), falling back tolocalStorage. Thefile://double-click case can't silently touch OS folders.100-gamepad-controller-support.js,110-hold-r-self-destruct-reset.js— rebindable keyboard + controller bindings (GPbutton map), hold-Y/hold-R self-destruct reset.120-procedural-sound-effects.js,130-juice-engine.js,140-best-lap-ghost.js,150-jank-retro-tts.js,160-missile-lock-on-warning.js,180-icon-system.js— Web Audio engine sounds, equal-power crossfade music looper, particle pool, skid marks, on-canvas toasts, procedural jingles, best-lap ghost, chat TTS, missile-lock RWR, inline SVG icons.170-ai-bot-racers.js— kinematic racing-line followers for solo play (BOT_ROSTER).200-upgrade-screen.js,210-results.js— synchronized upgrade pause between races; post-race queue/podium flow.220-map-editor.js— map editor (ME) + Local/History track browser.230-crt-terminal-os.js— the whole menu system styled as a retro CRT command line (initCrtTerminal); commands registered in a table (e.g.version). Loads last since it calls into everything.
Peer-to-peer, host-authoritative-ish relay. When editing multiplayer code, mind which side runs the branch: guards like if (G.isHost) gate host-only logic (re-broadcasting, accepting player_profile / player_ready / map_vote / map_submit), while guests act on host-sent state. Track state stays consistent across peers because it's regenerated from a shared seed, not streamed.
- Rust entry:
desktop/src-tauri/src/{main,lib.rs}; config intauri.conf.json; capabilities/permissions indesktop/src-tauri/capabilities/. Uses the Tauri http and fs plugins (the loader needs them to fetch the remote game and read/write the cache in AppData). - WebRTC is enabled on Linux for cross-platform multiplayer.
- Release helper scripts are PowerShell (
desktop/release.ps1,sync-dist.ps1) — this project is developed partly on Windows.