Every ranked map now has its own accuracy curve. Ratings come from a retrained Acc AI and an improved Analyzer, and a lighter reweight smooths out what's left. The goal: play the maps you enjoy and get a fair amount of pp for them, whatever your rank or style.
The pp scale changed. On the same set of scores, the #1 player goes from 24,140 to 21,811 pp, and the best single play from 928 to 816 pp. Stars and pp can't be compared one-to-one with PPv3.
The reweight is being applied now and takes a while. We'll announce when it's finished.
<div class="panel">
<div class="panel-head"><span class="title">Every map gets its own accuracy curve</span><div class="legend" id="lg"></div></div>
<div id="fan"></div>
</div>
<script>
const {pp, chart, fmt} = BL;
BL.$("#lg").innerHTML = chart.legend([["var(--ink)", "Standard curve"], ["var(--v4)", "Maps where the Acc AI predicts 96% to 99% (interpolated between these curves)"]]);
const pas = [0.96, 0.965, 0.97, 0.975, 0.98, 0.985, 0.99];
const series = pas.map((pa, i) => {
const curve = pp.buildAccCurve(pa, 0, 1000, 200);
return {points: chart.sample(0.94, 1, 0.0005, x => pp.curveValue(x, curve)), color: "var(--v4)", width: 1.6, opacity: 0.35 + (i / (pas.length - 1)) * 0.65};
});
series.push({points: chart.sample(0.94, 1, 0.0005, pp.baseValue), color: "var(--ink)", width: 2.6, label: "standard"});
chart.line("#fan", {height: 280, x: {min: 0.94, max: 1, ticks: [0.94, 0.96, 0.98, 1], fmt: v => fmt.pct(v)}, y: {min: 0, max: 7.5, ticks: [0, 2, 4, 6]}, series});
</script>
There are also other curve adjustments targeting other behaviour and specific elements, more about this below.
In PPv3 every map used the same curve, so 97% acc got the same share of a map's acc pp whether that 97% was easy or brutal to achieve, which changes between map types. In PPv4 the curve starts from a standard curve and then bends for each map, using the maps statistics:
Firstly, the Predicted Accuracy. Near and above the accuracy top players are expected to reach, the curve climbs more gently.
At 95.5% and below, the curve stays the same.
Between 95.5% and 96%, the steepness profile is changed to avoid increasing the reward too early, as these exceptionally difficult maps make it hard to balance a single curve where most players below the predicted acc are fairly weighted, but achievements above go up too early and become vastly overweighted.
96% to 98% see a flatter top curve, this affects mostly outlier scores which made it hard to rate 6-10 star maps fairly for most players without overweighting it for the very best.
Finally, 98% to 99.5% sees a return to exponentiality to fairly rate Acc and True Acc, while having less exponentiality than before PPv4 to reduce big gains from 0.01% acc increases at the very top end, and making it possible to reward more balanced amounts of PP outside of the 98.7%+ range without overweighting those high accuracies.
On slider-heavy maps the curve tilts: plays below the prediction earn less and plays above it earn more, up to about 15% on the most slider-heavy maps. Most maps have almost no sliders, on 90% of maps the tilt is 3% or less.
Finally, Length and note count are taken into account. Short maps and maps with very few notes get a small nerf on the whole curve (see below).
Together with the new ratings, this lets a map pay more closely to its true difficulty increase as you go up in acc. A linear speed map is easier to 97% than a tech map, and the curve can now reflect that. The standard curve itself also changed a little: below about 92% it pays slightly more than PPv3's.
Try it: pick a map, then move the prediction to see where its curve leaves the standard one. The chart shows what a whole play pays, including pass pp and tech pp. Both lines use the same ratings, so the gap comes only from the curve. Each map starts at its real prediction and slider share. Acc pp's curve also bends with the map's stars: steeper on low star maps, flatter on high star maps.
<div class="panel">
<div class="panel-head"><span class="title" id="name"></span><div class="row"><span class="small">Example map</span><div id="pick"></div></div></div>
<div class="legend" id="lg"></div>
<div id="chart"></div>
<div class="controls">
<div class="ctrl"><label for="pa">Acc AI prediction <output for="pa"></output></label><input type="range" id="pa" min="94" max="99.8" step="0.1" value="97.8"></div>
<div class="ctrl"><label for="sp">Sliders, share of swings <output for="sp"></output></label><input type="range" id="sp" min="0" max="50" step="1" value="1"></div>
</div>
<label class="check"><input type="checkbox" id="v3"> Show PPv3's curve</label>
<div class="readouts" id="out"></div>
</div>
<script>
const {pp, chart, fmt} = BL;
const MAPS = [
{label: "3★", name: "Dragostea Din Tei", diff: "Easy", acc: 4.745, pass: 0.859, tech: 1.08, pa: 0.99196, sp: 0, notes: 237, span: 122.18},
{label: "5★", name: "Believer", diff: "Expert", acc: 7.368, pass: 1.798, tech: 3.569, pa: 0.98476, sp: 0, notes: 546, span: 198.24},
{label: "8★", name: "Deja Vu (Short Ver.)", diff: "Expert+", acc: 8.821, pass: 4.352, tech: 7.44, pa: 0.9782, sp: 0.0092, notes: 446, span: 102.47},
{label: "9★", name: "Fresh!", diff: "Expert+", acc: 10.244, pass: 9.148, tech: 4.758, pa: 0.97243, sp: 0.0151, notes: 1264, span: 183.35},
{label: "13★", name: "iLLness LiLin", diff: "Expert+", acc: 13.24, pass: 11.395, tech: 4.602, pa: 0.96299, sp: 0.0178, notes: 1613, span: 116.57},
];
const state = {map: MAPS[2], pa: MAPS[2].pa, sp: MAPS[2].sp, v3: false};
function draw() {
const m = state.map;
const r = pp.ratings({accRating: m.acc, passRating: m.pass, techRating: m.tech, predictedAcc: state.pa, sliderPercentage: state.sp, notes: m.notes, noteSpan: m.span});
const standard = {...r, curve: pp.buildAccCurve(0, 0, m.notes, m.span)}, ppv3 = {...r, curve: pp.v3.table};
const map = x => pp.scorePp(x, r)[0], std = x => pp.scorePp(x, standard)[0], old = x => pp.scorePp(x, ppv3)[0];
BL.$("#name").textContent = `${m.name}, ${m.diff}: ${fmt.nf(r.stars, 2)}★`;
const series = [
{points: chart.sample(0.9, 1, 0.0005, std), color: "var(--ink-2)", width: 2, valueAt: std},
{points: chart.sample(0.9, 1, 0.0005, map), color: "var(--v4)", width: 2.6, valueAt: map},
];
if (state.v3) series.push({points: chart.sample(0.9, 1, 0.0005, old), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: old});
BL.$("#lg").innerHTML = chart.legend([["var(--ink-2)", "Same ratings, standard curve"], ["var(--v4)", "This map"]].concat(state.v3 ? [["var(--v3)", "Same ratings, PPv3's curve"]] : []));
const top = std(1) * 1.2, step = top > 2400 ? 800 : top > 1200 ? 400 : top > 600 ? 200 : 100, ticks = [];
for (let t = 0; t <= Math.ceil(top / step) * step; t += step) ticks.push(t);
chart.line("#chart", {
height: 300,
x: {min: 0.9, max: 1, ticks: [0.9, 0.92, 0.94, 0.96, 0.98, 1], fmt: v => fmt.pct(v), title: "Accuracy"},
y: {min: 0, max: ticks[ticks.length - 1], ticks, fmt: v => fmt.nf(v)},
series,
fill: {from: 0, to: 1, color: "var(--v4-soft)"},
markers: [{x: state.pa, label: "Acc AI " + fmt.pct(state.pa, 1)}],
tip: x => `<div class="t">${fmt.pct(x, 2)}</div>` +
chart.tipRow("var(--ink-2)", "Standard", fmt.nf(std(x)) + " pp") + chart.tipRow("var(--v4)", "This map", fmt.nf(map(x)) + " pp") +
(state.v3 ? chart.tipRow("var(--v3)", "PPv3", fmt.nf(old(x)) + " pp") : "") +
`<div class="r">vs standard<b>${fmt.signedPct(map(x) / std(x) - 1, 1)}</b></div>`,
});
BL.$("#out").innerHTML = [0.96, 0.98, 0.995].map(x => {
const d = map(x) / std(x) - 1;
return `<div class="readout"><span class="k">A ${fmt.pct(x, 1)} play pays</span><span class="v">${Math.abs(d) < 0.0005 ? "the same" : fmt.signedPct(d, 1)}</span><span class="k">${fmt.nf(map(x))} pp, ${fmt.nf(std(x))} on the standard curve</span></div>`;
}).join("");
}
const paInput = BL.ui.range("#pa", v => fmt.pct(v / 100, 1), v => { state.pa = v / 100; draw(); });
const spInput = BL.ui.range("#sp", v => v + "%", v => { state.sp = v / 100; draw(); });
BL.$("#v3").addEventListener("change", e => { state.v3 = e.target.checked; draw(); });
// A pick shows the map's real curve: the sliders only round what they display
function pick(label) {
const m = MAPS.find(e => e.label === label);
Object.assign(state, {map: m, pa: m.pa, sp: m.sp});
paInput.value = m.pa * 100;
spInput.value = m.sp * 100;
BL.$('output[for="pa"]').textContent = fmt.pct(m.pa, 1);
BL.$('output[for="sp"]').textContent = fmt.nf(m.sp * 100, 1) + "%";
draw();
}
BL.ui.seg("#pick", MAPS.map(m => [m.label, m.label]), "8★", pick);
pick("8★");
</script>
A short map is quicker to grind than a long one, and this length now counts towards the rating. This is made possible by the upcoming mid-map skip ban. It's measured from the first to the last note, so those long breaks count as playing time. Maps shorter than 75 seconds get a nerf on the whole curve that maxes out at 10% at 12 seconds.
Speed modifiers change the playtime, and this is calculated as well. With SF the song plays 1.5× faster, so a 60 second map counts as 40 seconds. True acc runs on short maps with SF will earn less than before. We hope this makes it possible to grind True Acc without SF.
Maps with fewer than 115 notes get a separate, smaller nerf that maxes out at 5% at 20 notes, this is much reduced from PPv3's low note nerf, made possible by the dynamic curve.
<div class="panel">
<div class="panel-head"><span class="title">Nerf on the whole curve</span><div id="mod"></div></div>
<div class="grid-2"><div id="lenChart"></div><div id="notesChart"></div></div>
<div class="controls">
<div class="ctrl"><label for="len">Map length, first to last note <output for="len"></output></label><input type="range" id="len" min="5" max="120" step="1" value="45"></div>
<div class="ctrl"><label for="notes">Notes <output for="notes"></output></label><input type="range" id="notes" min="10" max="160" step="1" value="80"></div>
</div>
<div class="readouts" id="out"></div>
</div>
<script>
const {pp, chart, fmt} = BL;
const SCALE = {none: 1, SS: 0.85, FS: 1.2, SF: 1.5};
const state = {len: 45, notes: 80, mod: "none"};
const y = {min: 0.88, max: 1.01, ticks: [0.9, 0.95, 1], fmt: v => "×" + fmt.nf(v, 2)};
function draw() {
const real = state.len / SCALE[state.mod], lf = pp.shortFactor(real), nfac = pp.lowNoteFactor(state.notes);
chart.line("#lenChart", {
height: 200, y,
x: {min: 5, max: 120, ticks: [5, 30, 60, 90, 120], fmt: v => v + "s", title: "Length in real time"},
series: [{points: chart.sample(5, 120, 0.5, pp.shortFactor), color: "var(--v4)", width: 2.2, valueAt: pp.shortFactor}],
markers: state.mod !== "none" ? [{x: state.len, label: "song length"}] : [],
dots: [[Math.min(real, 120), lf, "var(--v4)"]],
tip: x => `<div class="t">${fmt.nf(x)} s</div>` + chart.tipRow("var(--v4)", "Curve ×", fmt.nf(pp.shortFactor(x), 3)),
});
chart.line("#notesChart", {
height: 200, y,
x: {min: 10, max: 160, ticks: [10, 60, 115, 160], title: "Notes"},
series: [{points: chart.sample(10, 160, 0.5, pp.lowNoteFactor), color: "var(--v4)", width: 2.2, valueAt: pp.lowNoteFactor}],
dots: [[state.notes, nfac, "var(--v4)"]],
tip: x => `<div class="t">${fmt.nf(x)} notes</div>` + chart.tipRow("var(--v4)", "Curve ×", fmt.nf(pp.lowNoteFactor(x), 3)),
});
const total = lf * nfac;
BL.$("#out").innerHTML = [
["Length in real time", fmt.nf(real) + " s", "×" + fmt.nf(lf, 3)],
["Notes", state.notes >= 160 ? "160+" : state.notes, "×" + fmt.nf(nfac, 3)],
["Whole curve", total >= 0.9995 ? "no nerf" : fmt.signedPct(total - 1, 1), "×" + fmt.nf(total, 3)],
].map(([k, v, s]) => `<div class="readout"><span class="k">${k}</span><span class="v">${v}</span><span class="k">${s}</span></div>`).join("");
}
BL.ui.seg("#mod", [["none", "No modifier"], ["SS", "SS"], ["FS", "FS"], ["SF", "SF"]], "none", m => { state.mod = m; draw(); });
BL.ui.range("#len", v => v + " s", v => { state.len = v; draw(); });
BL.ui.range("#notes", v => (v >= 160 ? "160+" : v), v => { state.notes = v; draw(); });
</script>
To see what changes, we scored the same 4.36 million ranked plays from 144,754 players under both systems and looked at each player's 20 best plays, AKA their front page. Pick a group of players to compare:
<div class="stack">
<div id="band"></div>
<div class="tiles" id="tiles"></div>
<div class="grid-2">
<div class="panel">
<div class="panel-head"><span class="title">Share of top 20 spots by map type</span><div class="legend" id="lg"></div></div>
<div id="types"></div>
<span class="small">Dashed marks show each type's share of the ranked pool. Map types are the rating model's tags.</span>
<details class="table"><summary>Show as table</summary><div class="tbl-wrap" id="typesTable"></div></details>
</div>
<div class="panel">
<div class="panel-head"><span class="title">How many of their top 20 maps players keep</span></div>
<div id="keep"></div>
<span class="small" id="keepNote"></span>
<details class="table"><summary>Show as table</summary><div class="tbl-wrap" id="keepTable"></div></details>
</div>
</div>
<details class="table"><summary>Most common front page maps</summary><div class="tbl-wrap" id="top"></div></details>
</div>
<script>
const {chart, fmt} = BL;
const TYPES = [["tech", "Tech"], ["midspeed", "Midspeed"], ["speed", "Speed"], ["acc", "Acc"]];
const BANDS = [["all", "All players"], ["top10000", "Top 10,000"], ["top1000", "Top 1,000"], ["top100", "Top 100"]];
const label = Object.fromEntries(TYPES);
const dot = color => `<span class="vdot" style="background:${color}"></span>`;
BL.json("/data/ppv4-stats.json").then(S => {
function draw(band) {
const b = S.bands[band], v3 = b.v3, v4 = b.v4, median = b.overlap.median;
const tile = (k, from, to, sub) => `<div class="tile"><span class="k">${k}</span><div class="v"><span class="from">${dot("var(--v3)")}${from}</span><span class="arrow">→</span><span>${dot("var(--v4)")}${to}</span></div><span class="sub">${sub}</span></div>`;
BL.$("#tiles").innerHTML =
tile("Maps holding half of all top 20 spots", fmt.nf(v3.mapsForHalf), fmt.nf(v4.mapsForHalf), `${fmt.signedPct(v4.mapsForHalf / v3.mapsForHalf - 1)} more maps share the top spots`) +
tile("The most common map is on", fmt.pct(v3.top[0].share, 1), fmt.pct(v4.top[0].share, 1), "of these players' front pages") +
`<div class="tile"><span class="k">Top 20 maps a player keeps</span><div class="v"><span>${fmt.nf(median, median % 1 ? 1 : 0)}</span><span class="from">of 20</span></div><span class="sub">for the median player in this group</span></div>`;
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv3"], ["var(--v4)", "PPv4"]]);
const share = (v, t) => v.typeShare[t] || 0;
const top = Math.max(...TYPES.flatMap(([t]) => [share(v3, t), share(v4, t), S.poolTypeShare[t] || 0]));
const yMax = Math.ceil((top + 0.06) * 10) / 10, ticks = [];
for (let t = 0; t <= yMax + 1e-9; t += yMax > 0.5 ? 0.2 : 0.1) ticks.push(Math.round(t * 10) / 10);
chart.bars("#types", {
height: 250, cats: TYPES.map(([id, l]) => ({id, label: l})),
series: [{color: "var(--v3)", values: Object.fromEntries(TYPES.map(([t]) => [t, share(v3, t)]))}, {color: "var(--v4)", values: Object.fromEntries(TYPES.map(([t]) => [t, share(v4, t)]))}],
y: {max: yMax, ticks, fmt: v => fmt.pct(v)}, refs: S.poolTypeShare, valueFmt: v => fmt.pct(v),
tip: t => `<div class="t">${label[t]} maps</div>` + chart.tipRow("var(--v3)", "PPv3", fmt.pct(share(v3, t), 1)) + chart.tipRow("var(--v4)", "PPv4", fmt.pct(share(v4, t), 1)) + chart.tipRow(null, "Ranked pool", fmt.pct(S.poolTypeShare[t] || 0, 1)),
});
BL.$("#typesTable").innerHTML = `<table><thead><tr><th>Map type</th><th class="r">PPv3</th><th class="r">PPv4</th><th class="r">Ranked pool</th></tr></thead><tbody>${TYPES.map(([t, l]) => `<tr><td>${l}</td><td class="r">${fmt.pct(share(v3, t), 1)}</td><td class="r">${fmt.pct(share(v4, t), 1)}</td><td class="r">${fmt.pct(S.poolTypeShare[t] || 0, 1)}</td></tr>`).join("")}</tbody></table>`;
const hist = b.overlap.hist, total = hist.reduce((a, c) => a + c, 0), shares = hist.map(c => c / total);
const hMax = Math.ceil((Math.max(...shares) + 0.02) * 20) / 20, hTicks = [];
for (let t = 0; t <= hMax + 1e-9; t += 0.05) hTicks.push(Math.round(t * 100) / 100);
chart.hist("#keep", {
height: 250, color: "var(--v4)", bins: shares.map((v, i) => ({label: String(i), value: v})),
xLabels: [0, 5, 10, 15, 20], xTitle: "Maps kept, of 20",
y: {max: hMax, ticks: hTicks, fmt: v => fmt.pct(v)},
marker: {at: median, label: `median ${fmt.nf(median, median % 1 ? 1 : 0)}`},
tip: i => `<div class="t">Kept ${i} of 20</div>` + chart.tipRow("var(--v4)", "Players", `${fmt.nf(hist[i])} (${fmt.pct(shares[i], 1)})`),
});
BL.$("#keepNote").textContent = `Of each player's PPv3 top 20, how many maps are still in their PPv4 top 20. ${fmt.nf(v4.players)} players.`;
BL.$("#keepTable").innerHTML = `<table><thead><tr><th>Maps kept</th><th class="r">Players</th><th class="r">Share</th></tr></thead><tbody>${hist.map((c, i) => `<tr><td>${i} of 20</td><td class="r">${fmt.nf(c)}</td><td class="r">${fmt.pct(shares[i], 1)}</td></tr>`).join("")}</tbody></table>`;
const cell = t => t ? `<td>${fmt.esc(t.name)} <span class="small">${t.diff === "ExpertPlus" ? "Expert+" : fmt.esc(t.diff)}</span></td><td class="r">${fmt.pct(t.share, 1)}</td>` : "<td></td><td></td>";
BL.$("#top").innerHTML = `<table><thead><tr><th></th><th>PPv3</th><th class="r">Front pages</th><th>PPv4</th><th class="r">Front pages</th></tr></thead><tbody>${[0, 1, 2, 3, 4].map(i => `<tr><td>${i + 1}</td>${cell(v3.top[i])}${cell(v4.top[i])}</tr>`).join("")}</tbody></table>`;
}
BL.ui.seg("#band", BANDS, "all", draw);
draw("all");
});
</script>
Ratings are calculated only from the map data, without any leaderboard or plays. The Analyzer reads the notes and algorithmically assigns difficulty values, and the Acc AI predicts accuracy via a trained Machine Learning model. After a rating update (such as the PPv4 release), the reweighter runs once. It checks how much each map actually pays its players compared with other maps of its stars, using a new worth measure, and nudges the map's acc rating toward the middle.
We use it as little as possible, because it bends ratings toward how people happened to play at the time. A static rating that needs less correction is a better rating. In PPv4 the reweight is about half as strong as before, and several checks make sure it doesn't introduce systematic biases.
The biggest moves were −11.5% and +13.0% of the rating, and the median map moved by 0.9%. After the reweight, 80% of maps pay within 3% of the average for their stars.
<div class="panel">
<div class="panel-head"><span class="title">How much the reweight moved each map's acc rating</span></div>
<div id="chart"></div>
<div class="readouts" id="out"></div>
<details class="table"><summary>Show as table</summary><div class="tbl-wrap" id="table"></div></details>
</div>
<script>
const {chart, fmt} = BL;
BL.json("/data/ppv4-stats.json").then(S => {
const rw = S.reweight, last = rw.hist.length - 1;
const s = n => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0");
const bins = rw.hist.map(([b, c], i) => ({
label: i === 0 ? `≤${s(b + 1)}%` : i === last ? `≥${s(b)}%` : `${s(b)}%`,
range: i === 0 ? `Below ${s(b + 1)}%` : i === last ? `${s(b)}% or more` : `${s(b)}% to ${s(b + 1)}%`,
value: c / rw.maps, count: c,
}));
const zero = rw.hist.findIndex(([b]) => b === 0);
const yMax = Math.ceil((Math.max(...bins.map(b => b.value)) + 0.03) * 10) / 10, ticks = [];
for (let t = 0; t <= yMax + 1e-9; t += 0.1) ticks.push(Math.round(t * 10) / 10);
chart.hist("#chart", {
height: 260, color: "var(--v4)", bins, xTitle: "Change to the acc rating",
xLabels: [0, zero - 5, zero, zero + 5, last],
y: {max: yMax, ticks, fmt: v => fmt.pct(v)},
tip: i => `<div class="t">${bins[i].range}</div>` + chart.tipRow("var(--v4)", "Maps", `${fmt.nf(bins[i].count)} (${fmt.pct(bins[i].value, 1)})`),
});
BL.$("#out").innerHTML = [["Maps moved by 2% or less", fmt.pct(rw.within2)], ["Maps moved by 5% or less", fmt.pct(rw.within5)], ["Stars moved, median map", fmt.nf(rw.starsMedianMove, 2) + "★"]]
.map(([k, v]) => `<div class="readout"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("");
BL.$("#table").innerHTML = `<table><thead><tr><th>Acc rating change</th><th class="r">Maps</th><th class="r">Share</th></tr></thead><tbody>${bins.map(b => `<tr><td>${b.range}</td><td class="r">${fmt.nf(b.count)}</td><td class="r">${fmt.pct(b.value, 1)}</td></tr>`).join("")}</tbody></table>`;
});
</script>
Search the 3,960 ranked maps to see their PPv4 ratings and curve, and what a play is worth. These are the ratings being applied now. Maps ranked after the snapshot aren't listed.
<style>
.lookup { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 14px; align-items: start; }
.results { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.results button { width: 100%; display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 10px; align-items: center; text-align: left; background: transparent; border: 0; border-radius: 8px; padding: 5px; color: var(--ink); cursor: pointer; font: inherit; }
.results button:hover, .results button[aria-selected="true"] { background: var(--panel-2); }
.cover { width: 38px; height: 38px; border-radius: 6px; object-fit: cover; background: var(--panel-2); }
.nm { min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
.nm b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; }
.nm span { font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st { font: 700 13.5px var(--display); color: var(--ink-2); }
.card-top { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 12px; align-items: center; }
.card-top .cover { width: 64px; height: 64px; border-radius: 9px; }
.card-top h3 { overflow-wrap: anywhere; }
.ratings { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.ppline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 12px; }
.pp { font: 800 34px/1 var(--display); font-stretch: 110%; font-variant-numeric: tabular-nums; }
@media (max-width: 620px) { .lookup { grid-template-columns: minmax(0, 1fr); } .ratings { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
</style>
<div class="lookup">
<div>
<label for="q" class="small">Song or mapper</label>
<input id="q" type="search" placeholder="Search the ranked pool" autocomplete="off">
<ul class="results" id="results" role="listbox" aria-label="Maps"></ul>
</div>
<div class="panel" id="card"><span class="small">Loading maps…</span></div>
</div>
<script>
const {pp, chart, fmt} = BL;
const DIFFS = ["Easy", "Normal", "Hard", "Expert", "Expert+"];
const TYPES = {tech: "Tech", midspeed: "Midspeed", speed: "Speed", acc: "Acc"};
const SCALE = {none: 1, SS: 0.85, FS: 1.2, SF: 1.5};
const cover = m => `https://eu.cdn.beatsaver.com/${m.hash.toLowerCase()}.jpg`;
const state = {map: null, mod: "none", acc: 0.96, results: [], active: -1};
let MAPS = [];
function ratingsFor(m, mod) {
const [pa, sp] = m.inputs[mod];
const [accRating, passRating, techRating] = mod === "none" ? [m.acc, m.pass, m.tech] : m.modRatings[mod];
const r = pp.ratings({accRating, passRating, techRating, predictedAcc: pa, sliderPercentage: sp, notes: m.notes, noteSpan: m.span, timescale: SCALE[mod]});
return {...r, pa, shownStars: mod === "none" ? m.stars : m.modStars[mod]};
}
function renderResults() {
const q = BL.$("#q").value.trim().toLowerCase();
const words = q.split(/\s+/);
state.results = q ? MAPS.filter(m => words.every(w => m.search.includes(w)))
.sort((a, b) => (b.name.toLowerCase().startsWith(q) - a.name.toLowerCase().startsWith(q)) || b.stars - a.stars).slice(0, 8) : [];
BL.$("#results").innerHTML = !q
? `<li class="small">Type a song name or a mapper, for example "Fresh" or "Jabob".</li>`
: !state.results.length
? `<li class="small">No ranked map matches "${fmt.esc(q)}".</li>`
: state.results.map((m, i) => `<li><button type="button" role="option" data-id="${fmt.esc(m.id)}" aria-selected="${i === state.active}"><img class="cover" src="${cover(m)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'"><span class="nm"><b>${fmt.esc(m.name)}</b><span>${m.diff}${m.mode !== "Standard" ? " · " + fmt.esc(m.mode) : ""} · ${fmt.esc(m.mapper)}</span></span><span class="st">${fmt.nf(m.stars, 2)}★</span></button></li>`).join("");
}
function renderCard() {
const m = state.map, r = ratingsFor(m, state.mod);
state.ratings = r;
state.standard = {...r, curve: pp.buildAccCurve(0, 0, m.notes, m.span / SCALE[state.mod])};
const worth = m.worth == null ? "" : `<span class="small">Pays ${fmt.nf(Math.abs(m.worth) * 100, 1)}% ${m.worth >= 0 ? "more" : "less"} than the average map of its stars, for the same players.</span>`;
BL.$("#card").innerHTML = `
<div class="card-top"><img class="cover" src="${cover(m)}" alt="" onerror="this.style.visibility='hidden'">
<div><h3>${fmt.esc(m.name)}</h3><div class="small">mapped by ${fmt.esc(m.mapper)}</div>
<div class="row" style="margin-top:6px"><span class="chip">${m.diff}</span>${m.mode !== "Standard" ? `<span class="chip">${fmt.esc(m.mode)}</span>` : ""}${m.type ? `<span class="chip">${TYPES[m.type] || m.type}</span>` : ""}</div></div></div>
<div class="ratings">${[["Stars", fmt.nf(r.shownStars, 2) + "★"], ["Pass", fmt.nf(r.passRating, 2)], ["Acc", fmt.nf(r.accRating, 2)], ["Tech", fmt.nf(r.techRating, 2)], ["Acc AI", fmt.pct(r.pa, 2)]]
.map(([k, v]) => `<div class="readout"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("")}</div>
<div id="mod"></div>
<div class="ctrl"><label for="acc">Your accuracy <output for="acc"></output></label><input type="range" id="acc" min="85" max="100" step="0.01" value="${(state.acc * 100).toFixed(2)}"></div>
<div class="ppline"><span class="pp"><span id="pp"></span><span style="font-size:19px"> pp</span></span><span class="small" id="parts"></span></div>
<div class="panel-head"><span class="title">pp by accuracy</span><div class="legend">${chart.legend([["var(--v4)", "This map"], [null, "Same ratings, standard curve", "dash"]])}</div></div>
<div id="ppChart"></div>
${worth}`;
BL.ui.seg("#mod", [["none", "No modifier"], ["SS", "SS"], ["FS", "FS"], ["SF", "SF"]], state.mod, mod => { state.mod = mod; renderCard(); });
BL.ui.range("#acc", v => fmt.pct(v / 100, 2), v => { state.acc = v / 100; renderValues(); });
}
function renderValues() {
const r = state.ratings, std = state.standard, acc = state.acc;
const [total, pass, accPart, tech] = pp.scorePp(acc, r);
BL.$("#pp").textContent = fmt.nf(total, 2);
BL.$("#parts").textContent = `pass ${fmt.nf(pass, 1)} · acc ${fmt.nf(accPart, 1)} · tech ${fmt.nf(tech, 1)}`;
const at = (x, rr) => pp.scorePp(x, rr)[0];
const top = Math.max(at(1, r), at(1, std)) * 1.05, step = top > 1200 ? 400 : top > 600 ? 200 : top > 300 ? 100 : 50, ticks = [];
for (let t = 0; t <= top; t += step) ticks.push(t);
chart.line("#ppChart", {
height: 230,
x: {min: 0.85, max: 1, ticks: [0.85, 0.9, 0.95, 1], fmt: v => fmt.pct(v), title: "Accuracy"},
y: {min: 0, max: Math.max(top, step), ticks, fmt: v => fmt.nf(v)},
series: [
{points: chart.sample(0.85, 1, 0.0005, x => at(x, std)), color: "var(--muted)", width: 1.6, dash: "5 4", valueAt: x => at(x, std)},
{points: chart.sample(0.85, 1, 0.0005, x => at(x, r)), color: "var(--v4)", width: 2.4, valueAt: x => at(x, r)},
],
markers: [{x: r.pa, label: "Acc AI"}],
dots: [[acc, total, "var(--v4)"]],
tip: x => `<div class="t">${fmt.pct(x, 2)}</div>` + chart.tipRow("var(--v4)", "This map", fmt.nf(at(x, r), 1) + " pp") + chart.tipRow(null, "Standard curve", fmt.nf(at(x, std), 1) + " pp"),
});
}
function select(id) {
state.map = MAPS.find(m => m.id === id) || state.map;
state.mod = "none";
renderCard();
}
BL.json("/data/ppv4-maps.json").then(D => {
MAPS = D.maps.map(r => ({
id: r[0], name: r[1], mapper: r[2], diff: DIFFS[r[3]], mode: r[4], hash: r[5], type: r[6], worth: r[7],
stars: r[8], pass: r[9], acc: r[10], tech: r[11], notes: r[12], span: r[13],
inputs: {none: r[14], SS: r[15], FS: r[16], SF: r[17]},
modStars: {SS: r[18][0], FS: r[18][1], SF: r[18][2]},
modRatings: {SS: r[19].slice(0, 3), FS: r[19].slice(3, 6), SF: r[19].slice(6, 9)},
search: `${r[1]} ${r[2]}`.toLowerCase(),
}));
select(D.defaultMap);
renderResults();
});
BL.$("#q").addEventListener("input", () => { state.active = -1; renderResults(); });
BL.$("#q").addEventListener("keydown", e => {
const n = state.results.length;
if (!n) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
state.active = (state.active + (e.key === "ArrowDown" ? 1 : -1) + n) % n;
renderResults();
} else if (e.key === "Enter") {
e.preventDefault();
select(state.results[Math.max(0, state.active)].id);
}
});
BL.$("#results").addEventListener("click", e => { const b = e.target.closest("button[data-id]"); if (b) select(b.dataset.id); });
</script>
The whole scale moved. On the same scores, the #1 player goes from 24,140 to 21,811 pp. Your rank is the better comparison, as well as comparing between the PPv4 rating of different maps.
No. Ratings and stars were rebuilt and rescaled, so a 10★ map in PPv4 isn't the same as a 10★ map in PPv3, and 500pp in PPv4 isn't the same as 500pp in PPv3.
That's the dynamic curve. Each map's curve follows how hard accuracy is on that map, so 97% on an easy-to-acc map and 97% on a hard-to-acc map can be worth different amounts even at equal stars.
If a map paid far more than other maps of its stars, the new ratings bring it closer to the rest. Your other plays may have gained, and more maps are now worth playing for their pp.
Applying the reweight takes some time. We'll announce when it's complete. Until then some leaderboards still show PPv3 ratings.
Yes. This is the first dynamic curve, so we'll keep an eye on how it does and tune it in the next reweight, as usual.
The comparisons use snapshots of the ranked pool (3,960 maps) and 4.36 million ranked scores from October 2026. PPv3 is the live ratings at the time of the snapshot, PPv4 the ratings being applied, with every score recalculated under each. A front page is a player's 20 best plays by pp; players with at least 20 ranked plays count (29,218). Rank groups use PPv3 ranks. Curves and pp on this page use the same formula code as the website.