FRC WikiRun is a local speedrun game for The Blue Alliance. A run starts on one FRC team page and ends when the player reaches the target team page using only team-page links inside the embedded TBA site. Runs are locked to one FRC season, event pages and other non-team pages are blocked, external links are blocked, and the app tracks elapsed time plus link count.
- Starts a full random run by choosing a random FRC season and two teams that both played that season.
- Starts a classic run from two manually entered teams, either in a specific year or in a random shared year.
- Embeds The Blue Alliance through a local proxy so navigation can be restricted and measured.
- Rewrites TBA links so internal navigation stays inside the local app.
- Blocks event pages, non-team pages, external links, the Teams tab, search/year controls, and Ctrl+F during active runs.
- Shows an in-run status island with goal team, current team, region, timer, selected year, and link count.
- Saves the last 20 completed runs in browser
localStorage.
- React 19 for the browser UI.
- Vite for local development, bundling, and frontend test config.
- Express 5 for API routes and the TBA HTML proxy.
- The Blue Alliance API for team, event, district, and year metadata.
- Vitest with jsdom for unit tests.
.
├── index.html
├── package.json
├── vite.config.js
├── .env.example
├── frc-wikirun-verification.png
├── src
│ ├── main.jsx
│ ├── game.js
│ └── styles.css
├── server
│ ├── server.js
│ └── tba.js
└── tests
├── game.test.js
└── tba.test.js
Install dependencies:
npm installCreate a local environment file:
cp .env.example .envThen set TBA_AUTH_KEY in .env to a The Blue Alliance API key. Random and classic live runs need this key because the server validates teams and seasons against TBA.
TBA_AUTH_KEY=your_tba_key_here
PORT=8787Start both the Express server and the Vite dev server:
npm run devThe app runs through Vite at:
http://127.0.0.1:5173
The API/proxy server runs at:
http://127.0.0.1:8787
Vite forwards /api/* and /proxy requests to the Express server, so the browser can use one local origin during development.
npm run devstarts the client and server together withconcurrently.npm run dev:clientstarts Vite on127.0.0.1:5173.npm run dev:serverstarts the Express server with Node watch mode.npm run buildcreates the production frontend bundle indist/.npm run startruns the Express server in production mode and servesdist/.npm run testruns all Vitest unit tests.
src/main.jsx renders the main app. When no run is active, it shows:
- A random run button.
- A classic run form with start team, goal team, and optional year.
- The rule list.
- Recent local run history from
localStorage.
- The user clicks Random Run.
startRandomRun()callsGET /api/random-run.- The server picks a random year with
pickRandomYear(). - The server fetches teams for that year from TBA.
pickRandomPlayedRun()repeatedly chooses two distinct teams until both have event records in that season.- District/region data is attached when a TBA auth key is available.
- The frontend creates a local run object with
createRun(). - The app preloads the starting TBA page in the iframe, shows a countdown, then starts the timer.
- The user enters a start team and goal team.
- The optional year can be left blank.
- The frontend validates team and year input using helpers from
src/game.js. startClassicRun()callsGET /api/classic-run.- The server fetches the years both teams participated.
- If the user supplied a year, the server verifies both teams played an event in that exact year.
- If the year is blank, the server randomly tries shared years until it finds one where both teams have event records.
- The returned teams and year become the local run.
Before timing begins, the app loads the starting team page through /proxy. This avoids charging the player for initial page load time. The run starts when:
- The iframe reports that the proxied page is ready and the countdown reaches zero, or
- The player clicks the start button, or
- The player presses the spacebar.
The iframe is pointed at:
/proxy?path=/team/<team>/<year>&year=<year>
The Express proxy fetches the real TBA HTML, rewrites it, and injects a small guard script. As the player clicks rewritten TBA links, the guard script posts navigation messages to the parent React app. The parent app calls advanceRun() to update:
- Current proxied path.
- Current team, when the path is a team page.
- Link count.
- Completion state.
The run finishes when the current team number equals the target team number. runResult() calculates elapsed time and creates a history item.
This is the React application entrypoint. It owns all UI state:
- Form inputs for team and year.
- Setup/countdown state.
- Active run state.
- Elapsed timer state.
- Local history state.
- Blocked-link warnings.
- Current-team region lookups.
Important pieces:
activeis true when a run exists and is not complete.visibleRunchooses either the active run or the prepared setup run so the iframe can preload.iframeSrcbuilds the local proxy URL for the current TBA path.- Timer state updates every 50 ms while a run is active.
handleFramePath()receives iframe navigation paths and advances the run.handleFrameLoad()handles iframe load events and abandons the run if the iframe leaves the local proxy.- The
messagelistener accepts navigation, page-load, blocked-link, and blocked-route messages from the injected proxy script.
This file contains pure game-state helpers:
formatElapsed(ms)formats milliseconds as seconds or minutes.normalizeTeamInput(value)accepts inputs like254orfrc254.normalizeYearInput(value)accepts blank input or a 4-digit year.teamPath(teamNumber)creates/team/<number>.teamYearPath(teamNumber, year)creates/team/<number>/<year>.proxyUrl(path, year)creates the local/proxyURL.teamFromPath(path)extracts a team number from TBA team paths.pathFromProxyLocation(href)reads the proxied TBA path from an iframe URL.createRun(...)validates teams/year and creates a new run object.advanceRun(run, nextPath)increments link count and marks the run complete when the target team is reached.runResult(run)converts a completed run into a saved history item.loadHistory()andsaveHistory()read/write recent runs from browser storage.
This stylesheet defines the full visual system:
- Home layout and top navigation.
- Brand mark and TBA speedrun identity.
- Hero, setup, rules, and history panels.
- Full-screen iframe play view.
- Countdown overlay.
- In-run status island.
- Finish dialog.
- Blocked-route toast.
- Responsive layout for smaller screens.
This is the Express app. It exposes:
GET /api/healthfor a simple health check.GET /api/random-runfor full random run generation.GET /api/classic-runfor manual-team run generation.GET /api/team-regionfor lazy current-team region display.GET /proxyfor fetching, rewriting, and serving TBA HTML.
In production, server/server.js also serves the built Vite output from dist/ and falls back to index.html for browser routes.
This file contains all TBA-specific logic.
Data helpers:
SAMPLE_TEAMSis a small fallback team set for tests and optional sample behavior.normalizeTeamNumber()parses team numbers.currentFrcYear(),isValidFrcYear(), andpickRandomYear()manage season validation.summarizeRegion()andnormalizeTeam()convert TBA team/district data into the shape used by the UI.
TBA API helpers:
fetchTbaJson()performs authenticated TBA API requests.fetchTbaTeams()loads all team pages for a selected year and caches live results.fetchTeamDistrictForYear()retrieves district metadata.fetchTeamEventsForYear()retrieves a team's events for a season.teamPlayedInYear()checks whether a team has event records in a season.fetchTeamWithRegion()loads team metadata and region display text.fetchCommonYears()finds seasons shared by two teams.
Run-selection helpers:
pickRandomRun()chooses two distinct teams from a list.requireTeamsPlayedInYear()verifies both teams have event records in a year.createClassicRun()builds a manual-team run from either a requested year or a random shared valid year.pickRandomPlayedRun()retries random team pairs until both played in the selected season.
Proxy helpers:
resolveTbaPath()accepts only paths or URLs fromhttps://www.thebluealliance.com.isTeamsTab()detects the TBA Teams listing.pathMatchesYear()checks whether a team path belongs to the active run year.normalizePathForYear()rewrites team paths to the active year and rejects event pages, non-team pages, and invalid year-scoped routes.rewriteTbaHtml()rewrites the fetched TBA HTML so assets load from TBA, internal links go through/proxy, external links are blocked, and the run guard script is injected.fetchTbaPage()fetches and rewrites a TBA page for the iframe.
The proxy is the core of the game. It prevents the embedded TBA page from acting like a normal unrestricted browser.
When rewriteTbaHtml() sees an anchor tag:
- TBA links are rewritten to
/proxy?path=.... - Team links are forced into the selected run year.
- External links are replaced with blocked-link markers.
- The Teams tab is replaced with a blocked-route marker.
- Event pages, non-team pages, and pages that do not match the run year are blocked.
target="_blank"is removed so links stay inside the iframe.
When it sees assets such as CSS, images, scripts, sources, video, audio, or iframe tags:
- Relative TBA asset URLs are converted to absolute TBA URLs.
- This lets the proxied page keep its normal visual styling while navigation remains controlled.
The injected guard script:
- Announces page loads to the parent app.
- Announces valid proxy navigation before the iframe changes pages.
- Blocks Ctrl+F.
- Blocks form submission.
- Disables search and year controls.
- Sends visible warnings for blocked links and blocked routes.
Returns:
{ "ok": true }Creates a random season/team run.
Requirements:
TBA_AUTH_KEYmust be set unless sample fallback behavior is enabled in the server environment.
Returns:
{
"year": 2025,
"start": {
"key": "frc254",
"team_number": 254,
"nickname": "The Cheesy Poofs",
"state_prov": "California",
"country": "USA",
"region": "California, USA"
},
"target": {
"key": "frc1678",
"team_number": 1678,
"nickname": "Citrus Circuits",
"state_prov": "California",
"country": "USA",
"region": "California, USA"
}
}Query parameters:
start: start team number.target: target team number.year: optional FRC season.
Example:
/api/classic-run?start=254&target=1678&year=2025
Returns the same shape as /api/random-run.
Query parameters:
team: team number.year: FRC season.
Returns normalized team metadata with a region field.
Query parameters:
path: TBA path to fetch.year: optional run year used for rule enforcement.
Example:
/proxy?path=%2Fteam%2F254%2F2025&year=2025
Returns rewritten HTML for iframe display.
Run all tests:
npm run testThe test suite covers:
- Team/year input normalization.
- Run creation and completion logic.
- Timer formatting.
- History result generation.
- TBA path parsing.
- Random season selection.
- Team list fetching.
- Missing auth errors.
- Sample-team fallback filtering.
- TBA HTML rewriting.
- External link blocking.
- Teams tab blocking.
- Event and non-team page blocking.
- Year-scoped path enforcement.
- Classic run validation.
- Random run retries.
Build the frontend:
npm run buildStart the production server:
npm run startIn production mode, Express serves the Vite bundle from dist/ and continues to provide the same /api and /proxy routes.
TBA_AUTH_KEY: The Blue Alliance API key. Required for live random and classic runs.PORT: Express server port. Defaults to8787.TBA_SAMPLE_FALLBACK: When set to1, lets/api/random-runuse the bundled sample teams if no TBA auth key is present.NODE_ENV: When set toproduction, enables static serving fromdist/.
- This is a local solo app, not an anti-cheat system.
- It depends on the current HTML structure of The Blue Alliance pages.
- The proxy rewrites ordinary links and assets, but unusual dynamic client-side behavior on TBA may need future handling.
- TBA API rate limits and availability can affect run generation.
- Run history is local to the current browser and is not synced.