Skip to content

Repository files navigation


Heimerbuild

A League of Legends build calculator with game-accurate numbers.

heimerbuild.caio-lemos94.workers.dev




Features

  • Every champion, always on the current patch. Game data is pulled from Riot's Data Dragon and CommunityDragon and refreshed automatically when a new patch ships.
  • Stats that match the game. Level growth, attack speed and item bonuses follow the in-game formulas, and item data is checked against the game's own tooltips.
  • Build from level 1 to 18 with up to 6 items, and see base, bonus and total for every stat.
  • Know when a build isn't possible in-game. Build what you want; if it breaks a game rule (two pairs of boots, two copies of a legendary), Heimerbuild tells you which rule and why.
  • Find the right item fast. Filter the shop by role or by stats, sort it by any stat, and check price, stats and description in a tooltip.
  • Spend skill points like in the game. Pick which ability gets each level's point, with the game's rank rules and the rank-up tooltip ("Damage 80 → 125"), and see the whole order and every ability's values per rank in the Skills tab; levels you leave alone follow the game's suggested order, and ranks that grant stats show on the stats panel.
  • Share a build with a link. The champion, level, items and skill points live in the URL.
  • Champion details: roles, attack type and lore.

Why Heimerbuild

League is a game of numbers. A small change to an AD ratio can reshape the meta, and deciding between two items often comes down to math the client never shows you. Practice Tool can answer some of it, but it means starting a match, buying items and reading numbers by hand.

Heimerbuild answers those questions in seconds, with numbers you can trust. Today it covers champion and item stats. Next up:

  • Damage calculator: auto-attack and ability damage against a chosen champion, damage taken, and eventually full combos.
  • More build parameters: runes, item stacks, dragons and role quest rewards.
  • Build suggestions based on win rates, and pro builds.
  • Community builds that players can publish, explain and vote on.

Development

This project uses Bun as package manager and script runner (version pinned in package.json under packageManager). Install it with curl -fsSL https://bun.com/install | bash.

bun install           # install dependencies (creates node_modules from bun.lock)
bun run dev           # start the Vite dev server
bun run build         # typecheck and build to dist/
bun run preview:local # serve the production build locally
bun run typecheck     # type-check the app and the tests, scripts and worker
bun run test          # run tests with bun test
bun run e2e           # Playwright flows in e2e/ against a local build (BASE_URL=<url> targets a deployed site)
bun run check         # lint and format check with Biome
bun run check:write   # apply Biome formatting and safe fixes

CI (.github/workflows/ci.yml) runs biome ci, the typecheck, the tests and the build on every pull request and push to main. The E2E workflow (.github/workflows/e2e.yml) runs the Playwright flows on every pull request, inside the official Playwright container matching @playwright/test, against its Cloudflare preview once the preview's /version.json names the PR head commit, or against a local build when no such preview shows up, and uploads the Playwright trace when a flow fails.

bun install also installs a lefthook pre-commit hook (lefthook.yml) that formats staged files with biome check --write and re-stages them; errors Biome cannot fix do not block the commit (CI catches them). Skip it once with LEFTHOOK=0 git commit ....

Worktrees

scripts/wt runs several git worktrees side by side, each with its own Vite port:

scripts/wt create <name> --branch <branch> [--deps] [--purpose <text>]  # ../heimerbuild-<name> from origin/main
scripts/wt start [name]              # bun run dev --port <port> --strictPort, in the background
scripts/wt stop [name]
scripts/wt status                    # name, branch, port, URL, state for every worktree
scripts/wt destroy <name> [--force]  # stop, remove, delete the branch once merged
  • [name] defaults to the worktree you run it from; the main checkout is main.
  • Ports are stable per worktree and stored in ~/.heimerbuild-wt.json: main always uses 5173, the others get the lowest free port from 5174. start refuses a port another process is using.
  • The dev server logs to .wt/dev.log inside the worktree (gitignored).
  • destroy refuses when the worktree has uncommitted changes unless --force. It deletes the branch only when it is merged (a merged PR, including squash merges, or no commits beyond origin/main).
  • When run by Claude Code (CLAUDECODE=1), create prefixes the name with claude- and writes a .claude-worktree file (created, branch, purpose), which git ignores through .git/info/exclude.

Source layout

src/ is grouped by feature. Full rules and the "where does new code go" table: docs/frontend-architecture.md.

src/
├── main.tsx      Vite entry
├── app/          App, providers, query client, router, Sentry and PostHog setup
├── routes/       TanStack Router routes: path, search schemas, loaders, head, fallbacks
├── pages/        home and champion-build: compose features into screens
├── features/     champions, build-calculator, item-shop, runes, skills, summoners, conditions (a feature never imports another)
├── data/         game data loading: services (fetch + Zod) and hooks
├── components/   ui/ shadcn/ui primitives, common/ shared app UI
├── lib/          pure code, including the stats engine in lib/stats, the effect model in lib/effects and the analytics client in lib/analytics
├── hooks/        hooks shared by features (useFeatureFlag)
├── assets/       images imported by code
└── styles/       Tailwind entry and theme (app.css)

Files and folders are kebab-case, @/ resolves to src/, and Biome enforces the naming and import rules in CI.

Deployment

The app is served by Cloudflare Workers static assets (wrangler.jsonc), with SPA fallback for deep links and cache rules in public/_headers. A small Worker (worker/index.ts) runs first for /data/* and /assets/* so missing files there return an uncached 404 instead of index.html, for /monitoring to forward Sentry events, and for /ingest/* to proxy PostHog. Cloudflare Workers Builds deploys main to production (wrangler deploy) and creates a Worker Preview for every other branch (wrangler preview), commenting the preview URLs on the pull request. Manual equivalents: bun run deploy and bun run preview (both build first and require wrangler login).

Telemetry keys

The Sentry DSN and the PostHog project key are not in the repository: builds read them from the VITE_SENTRY_DSN and VITE_POSTHOG_KEY build variables (set in Cloudflare Workers Builds for preview and production). A build without them, such as a fork or GitHub CI, never starts Sentry or PostHog. To send from your machine, copy .env.example to .env.local, fill in the key and set VITE_SENTRY_ENABLED=true or VITE_POSTHOG_ENABLED=true; local events go straight to Sentry and PostHog tagged environment = development.

Error monitoring

Sentry (@sentry/react, set up in src/app/sentry.ts) reports errors, sampled traces and on-error session replays from preview and production deploys, sent through the Worker's /monitoring tunnel so ad-blockers do not drop them. Builds without VITE_SENTRY_DSN send nothing, local builds send nothing unless VITE_SENTRY_ENABLED=true, and the Playwright flows mark their pages (window.__HB_E2E__) so Sentry never starts during E2E runs. When the SENTRY_AUTH_TOKEN build secret is set, Workers Builds uploads hidden source maps for the commit SHA release and deletes them from dist/.

Product analytics

PostHog (posthog-js, set up in src/app/posthog.ts; cloud region in posthog-config.ts, key in VITE_POSTHOG_KEY) records pageviews, the autocapture and web vitals enabled in the project settings, and the custom events declared in src/lib/analytics/analytics-events.ts (sent with track()), plus feature flags through useFeatureFlag(). It runs in cookieless mode, so it sets no cookies and writes nothing to local or session storage until the consent banner exists. The SDK loads in its own chunk after the first render and talks to PostHog through the Worker's /ingest/* proxy, so ad-blockers do not drop events. Session replay and exception capture stay off (Sentry owns both). Every event carries environment (production, preview, development) and release (commit SHA). It follows Sentry's rules: nothing without VITE_POSTHOG_KEY, nothing locally unless VITE_POSTHOG_ENABLED=true, and never during the Playwright flows.

Game data

Champion, item, rune and summoner spell data is static JSON under public/data/, generated by bun run sync-data from Data Dragon and CommunityDragon and committed to the repo. The app reads /data/manifest.json for the current patch, then /data/<patch>/champions.json, champions/<key>.json, items.json, runes.json and summoner-spells.json. Patch files are cached for a year as immutable, so each request carries the file's content hash from the manifest (items.json?v=<hash>): a data fix within a patch changes the URL, while unchanged files keep theirs. Raw downloads are cached in .cache/ (gitignored). The sync fails if a normalized item stat differs from the stat block Data Dragon shows in the item tooltip, unless the difference is listed with a reason in scripts/sync-data/validate-item-stats.ts.

Each champion file also carries its abilities (normalize-abilities.ts): the passive and Q/W/E/R with name, plain-text description, icon and max rank from Data Dragon's spells and passive; cooldown and cost per rank from Data Dragon; and the lines of the game's rank-up tooltip (Data Dragon's leveltip, "Damage 80 → 125") with their per-rank values read from the spell's values in the CommunityDragon character bin (DataValues, the legacy mEffectAmount for {{ e1 }}, castRange, mAmmoRechargeTime, mMaxAmmo). The game files list rank 0 first, so rank 1 is their second value. A line whose value is not in the game files (a formula from another spell, for example) is left out and counted in the sync log. The bin's default Summoner's Rift RecSpellRankUpInfo becomes recommendedOrder, the game's suggested skill order. Scaling formulas and damage are not synced. Spot-checked against the League of Legends Wiki on 16.19 (Ahri, Cassiopeia, Amumu, Twisted Fate, Tryndamere, Garen, Annie, Jinx, Lux, Udyr, Jayce, Yuumi, Kennen, Teemo): every value matched.

Summoner spells (normalize-summoner-spells.ts, summoner-spells.json) are Data Dragon's summoner.json limited to the Summoner's Rift mode (CLASSIC: Barrier, Cleanse, Exhaust, Flash, Ghost, Heal, Ignite, Smite, Teleport), each with Riot's numeric key as id (the one build links use), name, icon, cooldown and short description. Their values come from the spell objects in CommunityDragon's game/shared.cdtb.bin.json (Shared/Spells/<key>): every DataValues entry, and the spell calculations the tooltip shows, evaluated for levels 1 to 18 (ByCharLevelInterpolation is linear from level 1 to 18, ByCharLevelBreakpoints adds a per-level bonus that changes at each breakpoint). The tooltip becomes longDescription with its numbers filled in ("70–475" for a value that grows with level); a placeholder the game files cannot fill, or a cooldown that differs between Data Dragon and the game files, fails the sync. Smite also gets its charges (2, recharging in 90 s); its upgrades (Unleashed and Primal Smite) are in-game progress, not separate spells, so only their damage values are kept (smiteupgradeddamage, smite2ndupgradeddamage). Mode-only spells (ARAM's Clarity and Mark, Arena's, Swarm's and URF's variants) are left out. Spot-checked against the League of Legends Wiki on 16.19: Ignite 70 / 150 / 175 / 475 at levels 1 / 5 / 6 / 18, Heal 80 + 14 per level, Barrier 100 to 460 and Ghost 24% to 48% (the wiki's ranges run to level 20 on the same formulas: 525, 346, 502.35 and 50.82%), Exhaust 40% slow and 35% damage reduction for 3 s, Cleanse 75% tenacity for 3 s, Smite 600 / 1000 / 1400 and cooldowns all matched.

The Sync game data workflow (.github/workflows/sync-data.yml) runs daily and on manual dispatch. When Data Dragon's latest version differs from currentPatch in public/data/manifest.json, it runs the sync plus the CI checks and opens or updates a draft PR on chore/sync-game-data with the new patch folder, the manifest and a per-patch summary of champion and item changes (bun scripts/sync-data/diff-patches.ts --from <old> --to <new>). Merging that PR deploys the new data.

Data overrides

When Riot's data is wrong (Titanic Hydra tagged HealthRegen without any health regen, for example), fix it with an override instead of editing public/data/ by hand. Overrides live in scripts/sync-data/overrides/: items in item-overrides.ts, champions in champion-overrides.ts (detail fields only, so champions.json and champions/<key>.json never disagree). Each one changes a single field of a single item or champion:

defineItemOverride({
	id: "titanic-hydra-no-health-regen-tag", // unique, kebab-case
	itemId: "3748",
	field: "tags",
	since: "16.19", // first patch it applies to (major.minor)
	until: undefined, // optional last patch; open-ended by default
	reason: "Riot tags it HealthRegen, a leftover from an older version",
	source: "https://wiki.leagueoflegends.com/en-us/Titanic_Hydra", // optional
	apply: (tags) => tags.filter((tag) => tag !== "HealthRegen"),
})

The sync applies the overrides for the patch it syncs after normalizing and before validating, so a wrong fix still fails the schema and <stats> checks. It logs each applied override and fails on a duplicate id or on two overrides of the same field with overlapping ranges. When an override changes nothing (Riot fixed the data) or its item or champion is gone, the sync warns and the daily sync PR lists it under "Overrides no longer needed": set its until to the last patch that needed it. If a bug skips a patch, add a second override with its own range. After adding an override, rerun bun run sync-data --version <patch> for the patches it covers and commit the regenerated files.

Stats that change with the level alone (Kayle turns ranged at level 6, Tristana's range grows to 700) are missing from Riot's data rather than wrong. They go in champion-level-states.ts with defineLevelStates, which takes the same id, since, reason and source plus a levelStates list, and the sync writes it to the champion's levelStates:

levelStates: [
	{ fromLevel: 6, attackType: "ranged", attackRange: { base: 525, perLevel: 0 } },
	{ fromLevel: 16, attackRange: { base: 625, perLevel: 0 } },
]

The stats engine applies every state the selected level has reached, in order, each one replacing only the fields it sets. A stat with growth: "linear" adds perLevel once per level instead of following the champion growth curve.

Forms the player switches between (Mini and Mega Gnar, Human and Cougar Nidalee) go in champion-forms.ts with defineForms, the same way, and the sync writes them to the champion's forms. The first form is the default: Riot's data, so it has only an id, a name and an optional gameName (Jinx's Minigun is "Pow-Pow" in game, shown in a tooltip). Every other form may set attackType, any growth stats it replaces, its own levelStates, which replace the champion's (Mega Gnar has none, so he keeps 175 range), and requires: { slot, minRank } when it needs an ability point (Shyvana's Dragon needs R):

forms: [
	{ id: "mini", name: "Mini Gnar" },
	{ id: "mega", name: "Mega Gnar", attackType: "melee", stats: { attackRange: { base: 175, perLevel: 0 } } },
]

The stats engine applies the selected form first, then the level states. The form id is what the share link carries (?form=mega); a form whose ability rank is missing falls back to the default. A form keeps only its fixed part here: a bonus that depends on an ability rank, the champion level or another stat is an effect bound to the form (docs/frontend-architecture.md, Forms).

Skill points follow the game's default rules (one point per level, a basic ability's rank n at level 2n - 1, R at 6/11/16, max ranks from Data Dragon). Champions that differ (Elise starts with an R rank, Udyr's R ranks like a basic ability, Azir's first point is W, Shen's W needs Q first, Aphelios's points raise stats) go in champion-skill-rules.ts with defineSkillRules, the same way, and the sync writes them to the champion's skillRules:

skillRules: { innateRanks: { R: 1 }, rankLevels: { R: [1, 6, 11, 16] } }

rankLevels lists the champion level each rank needs, rank 1 first, counting innate ranks. The sync fails when an ability has more ranks than levels to unlock them at.

Stats an ability grants by its rank alone (Twisted Fate's Stacked Deck: 15% to 55% attack speed) are not overrides: their values are in the game files. RANK_STAT_RULES in scripts/sync-data/rank-stats.ts names the spell value for each one (dataValue, with a scale into the stat's unit, a reason and a wiki source), and the sync writes the per-rank values to the champion's rankStats; a renamed value fails the sync. The stats engine adds them as bonus stats from the ability ranks of the skill order.

Built with

React TypeScript Vite Bun TanStack Zod Cloudflare Workers Biome Tailwind CSS shadcn/ui

Data and legal

Game data comes from Riot Games' Data Dragon and CommunityDragon.

Heimerbuild isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot Games or anyone officially involved in producing or managing Riot Games properties. Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc.

Contact

LinkedIn

About

League of Legends build calculator with game-accurate stats: pick a champion, level and items and see every stat. React 19, TanStack, Bun and Cloudflare Workers, with game data synced every patch.

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Contributors

Languages