fit.golf

Method

A driver fit runs four steps: the window, the loft, the heads and the shaft. Each section below says what the step does and shows the code that does it, read from the source when this page was built.

The launch, spin and descent window

Club speed picks a band, and each band has a launch and a spin window. A learned shift can move a window as fitting data accumulates; with no data it is zero. Descent (landing) angle has its own window by club speed, from True Spec Golf’s published parameters; firm ground moves it 1° shallower and soft ground 1° steeper. That 1° is our own rule: no source we found publishes a size for it.

Club speedDescent windowApex range (a check)
105+ mph34–38°100–120 ft
97–10433–37°87–100 ft
84–9632–36°70–86 ft
72–8331–35°58–70 ft
under 7227–31°45–58 ft

From src/lib/fittingEngine.ts:

export function getSpeedBand(mph: number): string {
  if (mph >= 105) return '105plus';
  if (mph >= 97) return '97to104';
  if (mph >= 84) return '84to96';
  return 'under84';
}

export function getLaunchTargets(speedBand: string, shift = 0): { lo: number; hi: number } {
  const targets: Record<string, { lo: number; hi: number }> = {
    '105plus': { lo: 9,  hi: 11 },
    '97to104': { lo: 10, hi: 12 },
    '84to96':  { lo: 11, hi: 14 },
    'under84': { lo: 13, hi: 16 },
  };
  const base = targets[speedBand] ?? targets['84to96'];
  return { lo: +(base.lo + shift).toFixed(1), hi: +(base.hi + shift).toFixed(1) };
}

export function getSpinTargets(speedBand: string, shift = 0): { lo: number; hi: number } {
  const targets: Record<string, { lo: number; hi: number }> = {
    '105plus': { lo: 1600, hi: 2400 },
    '97to104': { lo: 1700, hi: 2500 },
    '84to96':  { lo: 1900, hi: 2800 },
    'under84': { lo: 2000, hi: 3200 },
  };
  const base = targets[speedBand] ?? targets['84to96'];
  // `shift` = the learned spin offset × confidence for this band (0 when the
  // model has no data). Moves the whole window — transparent + confidence-scaled.
  return { lo: Math.round(base.lo + shift), hi: Math.round(base.hi + shift) };
}

export function getDescentTargets(speedMph: number, ground?: string, shift = 0): { lo: number; hi: number } {
  const [lo, hi] = flightFor(speedMph).descent;
  const g = ground === 'firm' ? -GROUND_DESCENT_SHIFT : ground === 'soft' ? GROUND_DESCENT_SHIFT : 0;
  return { lo: +(lo + g + shift).toFixed(1), hi: +(hi + g + shift).toFixed(1) };
}

Launch and spin come first. Descent decides only when they are inside their windows, and apex is shown as a check, never scored. Sources for the descent and apex numbers are in docs/fitting/flight-windows.md.

The loft

The loft surface gives a starting loft from your club speed and attack angle. Spin outside its window moves it up to 1.5°, launch outside its window up to 1.5°, an unusual dynamic loft 0.5°, a steep or flat descent 0.5°, and playing conditions at most 0.4°, in that order of priority. The result is matched to the nearest loft each head is sold in.

Show calculateOptimalLoft()

From src/lib/fittingEngine.ts:

export function calculateOptimalLoft(
  speedBand: string,
  attackAngleDeg: number,
  backspinRpm: number,
  dynamicLoftDeg?: number,
  launchAngleDeg?: number,
  launchChar?: string,
  /** Actual swing speed — when provided, the surface interpolates on it
   *  instead of the band's representative speed. */
  speedMph?: number,
  /** OPTIONAL conditions lever (carry vs roll). Applied LAST, tightly bounded
   *  and subordinate to every primary correction — see the block before the
   *  return. 'average'/undefined → no effect. */
  conditions?: 'firm' | 'average' | 'soft',
  /** Learned per-band spin/launch/descent window shifts (offset × confidence)
   *  from the observation log; undefined/0 when the model has no data. */
  learned?: { spinShift?: number; launchShift?: number; descentShift?: number },

The heads

Every driver is scored on what your numbers call for: its design family against your tempo and spin, forgiveness for your handicap, draw or fade bias when you want your shape straightened, and the speed range it is built for. A connected Grint account and the ratings of golfers with numbers like yours add a small, capped nudge, never enough to override the fit. Points are whole numbers, so ties are common.

Show scoreHeads()

From src/lib/fittingEngine.ts:

export function scoreHeads(
  heads: HeadEntry[],

The shaft

Speed sets the weight window and a transition shifts it 5 g either way. The driver fit, the shaft tool and the flex finder all use this one table. Flex comes from speed, nudged by tempo near a band edge; tip stiffness and kick point answer your spin, launch and descent.

Club speedWeight window
under 80 mph40–55 g
80–8950–62 g
90–9955–68 g
100–10960–72 g
110+65–80 g

From src/lib/fittingEngine.ts:

export function targetWeightWindow(speedMph: number, tempo: string): { lo: number; hi: number } {
  let lo: number;
  let hi: number;
  if (speedMph < 80) [lo, hi] = [40, 55];
  else if (speedMph < 90) [lo, hi] = [50, 62];
  else if (speedMph < 100) [lo, hi] = [55, 68];
  else if (speedMph < 110) [lo, hi] = [60, 72];
  else [lo, hi] = [65, 80];
  const shift = tempo === 'aggressive' ? 5 : tempo === 'smooth' ? -5 : 0;
  return { lo: lo + shift, hi: hi + shift };
}
Show scoreShaftCandidate()

From src/lib/shaftFitEngine.ts:

export function scoreShaftCandidate(
  family: ShaftFamily,
  wc: ShaftWeightClass,
  profile: ShaftFitProfile,
  aoa: ResolvedAoA,
  head?: CurrentHead
): ScoredCandidate {
  const reasons: string[] = [];
  let score = 0;

  const speed = profile.clubSpeedMph;
  const flex = getEffectiveFlex(speed, profile.tempo);
  const flexCode = flexLetter(flex);
  const accuracyFirst = profile.priority === 'accuracy' || profile.dispersionPriority === true;
  const distanceFirst = profile.priority === 'distance';

  // ── Flex availability (gate-ish) ─────────────────────────────────
  const codes = flexCodes(wc.flexOptions);
  const firmer = FIRMER_FLEX[flexCode];
  if (codes.has(flexCode)) {
    score += 18;
  } else if (firmer && codes.has(firmer)) {
    score += 8;
    reasons.push(`No ${flex} in this weight; the nearest option is one step firmer (${firmer})`);
  } else {
    score -= 30;
    reasons.push(`No ${flex} option in this weight — would force a flex compromise`);
  }

  // ── Weight targeting ─────────────────────────────────────────────
  const win = targetWeightWindow(speed, profile.tempo);
  const winMid = (win.lo + win.hi) / 2;
  if (wc.weightG >= win.lo && wc.weightG <= win.hi) {
    score += 20;
    reasons.push(
      `${wc.weightG} g sits in your ${win.lo}–${win.hi} g window for ${speed} mph with ${profile.tempo === 'aggressive' ? 'an' : 'a'} ${profile.tempo} transition`
    );
    // Within the window, accuracy leans to the heavier half (more stable),
    // distance to the lighter half (more speed).
    if (accuracyFirst && wc.weightG >= winMid) {
      score += 4;
      reasons.push('Toward the heavier end of your window — steadier for an accuracy-first build');
    } else if (distanceFirst && wc.weightG <= winMid) {
      score += 4;
      reasons.push('Toward the lighter end of your window — favors clubhead speed');
    }
  } else {
    const dist = wc.weightG < win.lo ? win.lo - wc.weightG : wc.weightG - win.hi;
    score -= Math.min(25, dist * 2);
    if (dist > 5) {
      reasons.push(
        wc.weightG < win.lo
          ? `${wc.weightG} g runs light for your speed — easier to overwork on the way down`
          : `${wc.weightG} g runs heavy for your speed — costs clubhead speed without adding control you need`
      );
    }
  }

  // ── Speed range fit ──────────────────────────────────────────────
  if (wc.speedMinMph !== undefined && wc.speedMaxMph !== undefined) {
    if (speed >= wc.speedMinMph && speed <= wc.speedMaxMph) score += 10;
    else if (speed < wc.speedMinMph - 10 || speed > wc.speedMaxMph + 10) score -= 18;
    else score -= 8;
  }

  // ── Tempo fit ────────────────────────────────────────────────────
  if (wc.tempoFit?.includes(profile.tempo)) {
    score += 12;
    reasons.push(`Profile is built for a ${profile.tempo} transition`);
  } else if (wc.tempoFit && wc.tempoFit.length > 0) {
    score -= 6;
  }

  // ── Torque vs transition ─────────────────────────────────────────
  const tq = wc.torque;
  if (tq !== undefined) {
    if (profile.tempo === 'aggressive') {
      if (tq <= 3.5) {
        score += 10;
        reasons.push(`Low torque (${tq}°) keeps the face stable through a hard transition`);
      } else if (tq >= 4.5) {
        score -= 10;
        reasons.push(`${tq}° torque is a lot of twist for an aggressive move`);
      }
    } else if (profile.tempo === 'smooth' && tq >= 4) {
      score += 6;
      reasons.push(`Higher torque (${tq}°) adds feel a smooth tempo can use`);
    }
    if (accuracyFirst && tq <= 3.2) {
      score += 6;
      reasons.push('Low-torque, stability-first build for your accuracy priority');
    } else if (distanceFirst && tq >= 4 && tq <= 5 && profile.tempo !== 'aggressive') {
      score += 4;
      reasons.push('A livelier, higher-torque profile can add a touch of speed for a distance priority');
    }
  }

  // ── Bend profile vs spin + launch needs (conditions shift the target) ──
  const tip = tipTier(wc);
  const descent = descentNeed(profile);
  const wantsLowerFlight =
    descent?.need === 'lower' ||
    profile.spinCategory === 'high' ||
    profile.spinGoal === 'lower' ||
    profile.launchGoal === 'lower' ||
    profile.conditions === 'windy';
  const wantsHigherFlight =
    descent?.need === 'higher' ||
    profile.spinCategory === 'low' ||
    profile.launchGoal === 'higher' ||
    profile.spinGoal === 'higher' ||
    profile.conditions === 'soft-cold' ||
    profile.conditions === 'altitude';

  if (wantsLowerFlight) {
    if (tip !== undefined && tip >= 2) {
      score += 12;
      reasons.push(
        descent?.need === 'lower'
          ? `Firm tip flattens your ${descent.deg}° descent toward the ${descent.lo}–${descent.hi}° window`
          : profile.spinCategory === 'high'
            ? 'Firm tip section works against your spin'
            : profile.launchGoal === 'lower'
              ? 'Firm tip section brings your launch down'
              : profile.conditions === 'windy'
                ? 'Firm tip section keeps the flight down in wind'
                : 'Firm tip section lowers spin, as you asked'
      );
    } else if (tip !== undefined && tip === 0) {
      score -= 10;
      reasons.push('Soft tip adds launch and spin — the opposite of your goal');
    }
    if (wc.kickPoint === 'high') score += 6;
    if (wc.spinChar === 'low' || wc.spinChar === 'low-mid') score += 6;
  }
  if (wantsHigherFlight) {
    if (tip !== undefined && tip <= 1) {
      score += 12;
      reasons.push(
        descent?.need === 'higher'
          ? `Softer tip steepens your ${descent.deg}° descent toward the ${descent.lo}–${descent.hi}° window`
          : profile.spinCategory === 'low' && profile.launchGoal !== 'higher'
            ? 'Softer tip adds spin — yours is low for your speed'
            : 'Softer tip helps the ball up — matches your launch goal'
      );
    } else if (tip !== undefined && tip === 3) {
      score -= 8;
    }
    if (wc.kickPoint === 'low') score += 6;
    if (wc.spinChar === 'mid-high' || wc.spinChar === 'high') score += 4;
  }

  // ── Head tempering (CONTEXT, not a system fit) ───────────────────
  // The head's own launch/spin character partly creates the flight the
  // player sees. A low-launch/low-spin head means the shaft is the main
  // remaining lever for a lower-flight need; a high-launch/high-spin head is
  // already doing a lot of the work, so we don't over-chase a firm tip on
  // the shaft alone (the loft/head is the better lever — flagged separately).
  if (head && tip !== undefined) {
    const headLowFlight =
      head.launchChar === 'low' || head.launchChar === 'low-mid' ||
      head.spinChar === 'low' || head.spinChar === 'low-mid';
    const headHighFlight =
      head.launchChar === 'high' || head.launchChar === 'mid-high' ||
      head.spinChar === 'high' || head.spinChar === 'mid-high';
    if (wantsLowerFlight && tip >= 2) {
      if (headHighFlight) {
        score += 4;
        reasons.push(`Your ${head.model} is a higher-flight head — a firm tip is the right shaft-side lever to bring it down`);
      } else if (headLowFlight) {
        score -= 2; // head already low; don't double-down on tip stiffness
      }
    }
    if (wantsHigherFlight && tip <= 1) {
      if (headLowFlight) {
        score += 4;
        reasons.push(`Your ${head.model} is a lower-flight head — a softer tip helps the shaft add the height you want`);
      } else if (headHighFlight) {
        score -= 2;
      }
    }
  }

  // ── Resolved AoA — steep-down adds dynamic loft + spin ───────────
  if (aoa.deg <= -2 && tip !== undefined) {
    const steepness = Math.min(3, -aoa.deg - 1); // 1..3
    if (tip >= 2) {
      score += steepness * 3;
      reasons.push(
        `Hitting down ${Math.abs(aoa.deg)}° adds spin at impact — the firm tip claws it back`
      );
    } else if (tip === 0) {
      score -= steepness * 2;
    }
  }

  // ── Feel / counterbalance ────────────────────────────────────────
  if (profile.feelPreference === 'stout' && wc.counterBalanced) {
    score += 5;
    reasons.push('Counterbalanced build gives the stout, handle-heavy feel you asked for');
  }
  if (profile.feelPreference === 'smooth' && wc.buttStiffness === 'soft') {
    score += 4;
  }

  return { family, weightClass: wc, flex, score, reasons };
}

Ties

Picks that score the same share a place (3=). Within a tie the order is a shuffle seeded from your numbers, so the same golfer always gets the same order and no brand gains from where it sits in a file.

From src/lib/ties.ts:

export function sortByScoreNeutral<T extends { score: number }>(

export function competitionRanks(scores: number[]): string[] {
  return scores.map((s, i) => {
    const first = scores.indexOf(s);
    const tied = scores.filter((x) => x === s).length > 1;
    return `${first + 1}${tied ? '=' : ''}`;
  });
}

What’s open

  • Code. The engine and this site are licensed AGPL-3.0: read it, check that no brand pays for placement, run your own copy. The repository is being prepared for its public release.
  • Data. The equipment catalogs (driver heads, shafts, stock shafts, fairway woods and hybrids, balls) are licensed CC BY-SA 4.0. Use them, even commercially: credit “fit.golf equipment data, CC BY-SA 4.0” and share alike. Product photos belong to their makers and are not included.
  • Learning. Launch-monitor shootouts you import and agree to share are stored without your name, email, account or IP address. The best session in each shootout shifts the windows a little, weighted by how many fittings back the shift. The pooled records stay private; that is the trade that keeps the tool free. Details are in the Privacy Policy.