PPv4.1 is the first tuning pass on PPv4, built on initial player feedback and the data. The goal is still to make more maps worth playing for everyone, and less pressure to stick to your single best skill, though that will always be part of the most optimal strategy.
Stars were rescaled, so compare maps by pp. PPv4.1 stars can't be compared with PPv4's or PPv3's. A map's star number going down doesn't mean its difficulty was rated lower. Stars are a display value, they show what a 96% play is worth.
<div class="tiles" id="tiles"></div>
<script>
const {fmt} = BL;
const dot = color => `<span class="vdot" style="background:${color}"></span>`;
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.json("/data/ppv41-stats.json").then(S => {
const top = S.bands.top1000;
BL.$("#tiles").innerHTML =
tile("Top player's total", fmt.nf(S.scale.v4.topPlayer), fmt.nf(S.scale.v41.topPlayer), `The median player changed by ${fmt.signedPct(S.bands.all.totals.median - 1, 1)}`) +
tile("Amount of maps in half of the top 1,000's top 20 scores", fmt.nf(top.v4.mapsForHalf), fmt.nf(top.v41.mapsForHalf), `${fmt.signedPct(top.v41.mapsForHalf / top.v4.mapsForHalf - 1)} more maps appear in the top spots`) +
tile("Tech maps' share of those spots", fmt.pct(top.v4.typeShare.tech), fmt.pct(top.v41.typeShare.tech), "Midspeed, speed and acc maps take the rest");
});
</script>
PPv4 rescaled stars to look like PPv3's. PPv4.1 removes that rescale, so a star is now simply a fixed amount of pp: a 96% play gives about 53 pp per star, meaning a 10★ map pays about twice what a 5★ map does at that accuracy.
<div class="panel">
<div class="panel-head"><span class="title">Stars of the same map</span><div class="legend" id="lg"></div></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/ppv41-stats.json").then(S => {
const bins = S.stars.bins, xs = bins.map(b => b.x);
const at = (key, x) => {
if (x <= xs[0]) return bins[0][key];
for (let i = 1; i < bins.length; i++) if (x <= xs[i]) { const t = (x - xs[i - 1]) / (xs[i] - xs[i - 1]); return bins[i - 1][key] + t * (bins[i][key] - bins[i - 1][key]); }
return bins[bins.length - 1][key];
};
const min = xs[0], max = xs[xs.length - 1], line = key => bins.map(b => [b.x, b[key]]);
BL.$("#lg").innerHTML = chart.legend([["var(--v4)", "Median map"], ["var(--v4-soft)", "8 in 10 maps"], [null, "Same number", "dash"]]);
chart.line("#chart", {
height: 300,
x: {min, max, ticks: [2, 4, 6, 8, 10, 12, 14], fmt: v => v + "★", title: "PPv4 stars"},
y: {min: 1, max: 15, ticks: [2, 4, 6, 8, 10, 12, 14], fmt: v => v + "★"},
series: [
{points: line("p10"), color: "transparent", width: 0, hover: false},
{points: line("p90"), color: "transparent", width: 0, hover: false},
{points: [[min, min], [max, max]], color: "var(--muted)", width: 1.6, dash: "5 4", hover: false},
{points: line("p50"), color: "var(--v4)", width: 2.6, valueAt: x => at("p50", x)},
],
fill: {from: 0, to: 1, color: "var(--v4-soft)"},
tip: x => `<div class="t">${fmt.nf(x, 1)}★ in PPv4</div>` + chart.tipRow("var(--v4)", "PPv4.1, median", fmt.nf(at("p50", x), 2) + "★") + chart.tipRow(null, "8 in 10 maps", `${fmt.nf(at("p10", x), 1)} to ${fmt.nf(at("p90", x), 1)}★`),
});
BL.$("#out").innerHTML = [3, 7, 11, 14].map(s => `<div class="readout"><span class="k">A ${s}★ map in PPv4</span><span class="v">${fmt.nf(at("p50", s), 1)}★</span><span class="k">in PPv4.1, median</span></div>`).join("");
BL.$("#table").innerHTML = `<table><thead><tr><th>PPv4 stars</th><th class="r">Maps</th><th class="r">PPv4.1, median</th><th class="r">8 in 10 maps</th></tr></thead><tbody>${bins.map(b => `<tr><td>${fmt.nf(b.x - 0.25, 1)} to ${fmt.nf(b.x + 0.25, 1)}★</td><td class="r">${fmt.nf(b.n)}</td><td class="r">${fmt.nf(b.p50, 2)}★</td><td class="r">${fmt.nf(b.p10, 1)} to ${fmt.nf(b.p90, 1)}★</td></tr>`).join("")}</tbody></table>`;
});
</script>
3,496 of the 3,968 ranked maps have a higher number now. That doesn't make them harder, and it doesn't mean they pay more. This is because pp isn't calculated from stars. PP comes from the pass, acc and tech ratings and your accuracy. Overall stars are simply the PP for a 96% score scaled to be a useful average difficulty number.
The low star and mid star bonuses described over star ranges are descriptions of where to expect those bonuses to apply.
As has been the case since PPv3, a play is made of pass pp, acc pp and tech pp, and your best plays add up with the same weights. With PPv4.1, the reward given by each part has changed:
Pick a map type to see an example of each part under both versions.
<script src="/data/ppv41-formula.js"></script>
<div class="panel">
<div class="panel-head"><span class="title" id="name"></span><div id="pick"></div></div>
<div class="legend" id="lg"></div>
<div class="grid-3">
<div><span class="small">Pass pp</span><div id="pass"></div></div>
<div><span class="small">Tech pp</span><div id="tech"></div></div>
<div><span class="small">Total pp</span><div id="total"></div></div>
</div>
<div class="readouts" id="out"></div>
</div>
<script>
const {pp, pp41, chart, fmt} = BL;
const input = (m, v) => ({accRating: v.acc, passRating: v.pass, techRating: v.tech, predictedAcc: v.pa, sliderPercentage: v.sp, notes: m.notes, noteSpan: m.span});
BL.json("/data/ppv41-stats.json").then(S => {
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv4", "dash"], ["var(--v4)", "PPv4.1"]]);
function draw(id) {
const m = S.examples.find(e => e.id === id), a = pp.ratings(input(m, m.v4)), b = pp41.ratings(input(m, m.v41));
BL.$("#name").textContent = `${m.name}, ${m.diff === "ExpertPlus" ? "Expert+" : m.diff}: ${fmt.nf(m.v4.stars, 2)}★ in PPv4, ${fmt.nf(m.v41.stars, 2)}★ in PPv4.1`;
const small = (target, part, step) => {
const old = x => pp.scorePp(x, a)[part], now = x => pp41.scorePp(x, b)[part];
const top = Math.max(...[0.85, 0.9, 0.95, 1].flatMap(x => [old(x), now(x)]), step), max = Math.ceil(top * 1.08 / step) * step, ticks = [];
for (let t = 0; t <= max; t += step) ticks.push(t);
chart.line(target, {
height: 200,
x: {min: 0.85, max: 1, ticks: [0.85, 0.9, 0.95, 1], fmt: v => fmt.pct(v)},
y: {min: 0, max, ticks, fmt: v => fmt.nf(v)},
series: [
{points: chart.sample(0.85, 1, 0.001, old), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: old},
{points: chart.sample(0.85, 1, 0.001, now), color: "var(--v4)", width: 2.4, valueAt: now},
],
markers: [{x: m.v41.pa}],
tip: x => `<div class="t">${fmt.pct(x, 1)}</div>` + chart.tipRow("var(--v3)", "PPv4", fmt.nf(old(x), 1)) + chart.tipRow("var(--v4)", "PPv4.1", fmt.nf(now(x), 1)),
});
};
small("#pass", 1, 20); small("#tech", 3, 20); small("#total", 0, 200);
BL.$("#out").innerHTML = [0.92, m.v41.pa, 0.99].map((x, i) => {
const old = pp.scorePp(x, a)[0], now = pp41.scorePp(x, b)[0];
return `<div class="readout"><span class="k">${i === 1 ? "At the Acc AI's " : "At "}${fmt.pct(x, i === 1 ? 1 : 0)}</span><span class="v">${fmt.nf(old)} → ${fmt.nf(now)} pp</span><span class="k">${fmt.signedPct(now / old - 1, 1)}</span></div>`;
}).join("");
}
BL.ui.seg("#pick", S.examples.map(e => [e.id, e.label]), S.examples[0].id, draw);
draw(S.examples[0].id);
});
</script>
The mid star bonus makes maps from about 4 to 10.5★ viable for all players. It's strongest from 6.4 to 8★. It starts at scores 3% below the Acc AI's prediction and reaches its full size at the prediction. The full bonus is +5% on acc pp, which comes to about 4.5% of the whole play. This applies to 74% of ranked maps.
<script src="/data/ppv41-formula.js"></script>
<div class="panel">
<div class="panel-head"><span class="title">Mid star bonus on acc pp</span></div>
<div class="grid-2">
<div><span class="small">By the map's stars, at the prediction and 1.5% below it</span><div id="byStars"></div></div>
<div><span class="small">By your accuracy, on a 7★ map</span><div id="byAcc"></div></div>
</div>
</div>
<script>
const {pp41, chart, fmt} = BL;
const bonus = (stars, below) => pp41.midStarTopMultiplier(stars, 0.97 - below, 0.97) - 1;
const y = {min: 0, max: 0.06, ticks: [0, 0.02, 0.04, 0.06], fmt: v => "+" + fmt.pct(v)};
chart.line("#byStars", {
height: 210, y,
x: {min: 2, max: 12, ticks: [2, 4, 6, 8, 10, 12], fmt: v => v + "★"},
series: [
{points: chart.sample(2, 12, 0.05, s => bonus(s, 0)), color: "var(--v4)", width: 2.4, valueAt: s => bonus(s, 0)},
{points: chart.sample(2, 12, 0.05, s => bonus(s, 0.015)), color: "var(--v4)", width: 1.6, dash: "5 4", valueAt: s => bonus(s, 0.015)},
],
tip: s => `<div class="t">${fmt.nf(s, 1)}★</div>` + chart.tipRow("var(--v4)", "At the prediction", fmt.signedPct(bonus(s, 0), 1)) + chart.tipRow(null, "1.5% below", fmt.signedPct(bonus(s, 0.015), 1)),
});
chart.line("#byAcc", {
height: 210, y,
x: {min: -0.04, max: 0.01, ticks: [-0.04, -0.03, -0.02, -0.01, 0, 0.01], fmt: v => (v > 0 ? "+" : v < 0 ? "−" : "") + fmt.pct(Math.abs(v))},
series: [{points: chart.sample(-0.04, 0.01, 0.0005, d => bonus(7, -d)), color: "var(--v4)", width: 2.4, valueAt: d => bonus(7, -d)}],
markers: [{x: 0, label: "Prediction"}],
tip: d => `<div class="t">${fmt.signedPct(d, 1)} from the prediction</div>` + chart.tipRow("var(--v4)", "Bonus", fmt.signedPct(bonus(7, -d), 1)),
});
</script>
The low star bonus was rebalanced, and it's much bigger on the lowest maps. Together with the rest of the update, the same plays pay more on low star maps and a little less on the highest ones. This is an example of a focused change to give part of the map pool a curve that fits better with the progression of skill expression on those map types.
<div class="panel">
<div class="grid-2">
<div>
<div class="panel-head"><span class="title">Low star bonus on the same maps</span><div class="legend" id="lg"></div></div>
<div id="low"></div>
</div>
<div>
<div class="panel-head"><span class="title">Change on the same plays</span></div>
<div id="pay"></div>
</div>
</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/ppv41-stats.json").then(S => {
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv4", "dash"], ["var(--v4)", "PPv4.1"]]);
const lerp = (rows, xKey, key, x) => {
if (x <= rows[0][xKey]) return rows[0][key];
for (let i = 1; i < rows.length; i++) if (x <= rows[i][xKey]) { const t = (x - rows[i - 1][xKey]) / (rows[i][xKey] - rows[i - 1][xKey]); return rows[i - 1][key] + t * (rows[i][key] - rows[i - 1][key]); }
return rows[rows.length - 1][key];
};
const L = S.lowStar;
chart.line("#low", {
height: 220,
x: {min: L[0].stars, max: L[L.length - 1].stars, ticks: [3, 5, 7], fmt: v => v + "★", title: "PPv4.1 stars"},
y: {min: 0, max: 0.3, ticks: [0, 0.1, 0.2, 0.3], fmt: v => "+" + fmt.pct(v)},
series: [
{points: L.map(r => [r.stars, r.v4]), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: x => lerp(L, "stars", "v4", x)},
{points: L.map(r => [r.stars, r.v41]), color: "var(--v4)", width: 2.4, valueAt: x => lerp(L, "stars", "v41", x)},
],
tip: x => `<div class="t">${fmt.nf(x, 1)}★</div>` + chart.tipRow("var(--v3)", "PPv4", fmt.signedPct(lerp(L, "stars", "v4", x), 1)) + chart.tipRow("var(--v4)", "PPv4.1", fmt.signedPct(lerp(L, "stars", "v41", x), 1)),
});
const P = S.payByStars.map(r => ({x: r.stars + 0.5, y: r.median - 1, n: r.n, stars: r.stars}));
chart.line("#pay", {
height: 220,
x: {min: P[0].x, max: P[P.length - 1].x, ticks: [4, 8, 12], fmt: v => v + "★", title: "PPv4.1 stars"},
y: {min: -0.1, max: 0.2, ticks: [-0.1, 0, 0.1, 0.2], fmt: v => (v > 0 ? "+" : v < 0 ? "−" : "") + fmt.pct(Math.abs(v))},
series: [
{points: [[P[0].x, 0], [P[P.length - 1].x, 0]], color: "var(--muted)", width: 1.2, dash: "3 3", hover: false},
{points: P.map(r => [r.x, r.y]), color: "var(--v4)", width: 2.4, valueAt: x => lerp(P, "x", "y", x)},
],
tip: x => `<div class="t">${fmt.nf(x, 1)}★</div>` + chart.tipRow("var(--v4)", "Median play", fmt.signedPct(lerp(P, "x", "y", x), 1)),
});
BL.$("#table").innerHTML = `<table><thead><tr><th>PPv4.1 stars</th><th class="r">Plays</th><th class="r">Median play</th></tr></thead><tbody>${P.map(r => `<tr><td>${r.stars} to ${r.stars + 1}★</td><td class="r">${fmt.nf(r.n)}</td><td class="r">${fmt.signedPct(r.y, 1)}</td></tr>`).join("")}</tbody></table>`;
});
</script>
Every map still has its own accuracy curve, built from its predicted accuracy, among other things. That curve only sets acc pp, though. What a better score really earns depends on the whole play: the map's stars bend acc pp's curve further, pass pp and the mid star bonus grow with accuracy, and tech pp stays the same.
Compared with a 96% play on the same map, which is what stars are based on, three things changed. The numbers below are medians over the ranked maps.
Pick a map, from hardest to easiest to acc, to see how a whole play climbs with accuracy. Both versions are shown as multiples of the map's own 96% play, so they meet at 96% and only the shape is compared. Hover over or tap the chart for the pp, or look up any map below.
<script src="/data/ppv41-formula.js"></script>
<div class="panel">
<div class="panel-head"><span class="title" id="name"></span><div class="row"><span class="small">Predicted accuracy</span><div id="pick"></div></div></div>
<div class="legend" id="lg"></div>
<div class="grid-2">
<div><span class="small">pp as a multiple of this map's 96% play</span><div id="shape"></div></div>
<div><span class="small">Change in that multiple from PPv4</span><div id="change"></div></div>
</div>
<div class="readouts" id="out"></div>
</div>
<script>
const {pp, pp41, chart, fmt} = BL;
const MAPS = [
{name: "iLLness LiLin", diff: "Expert+", stars: 12.756, pa: 0.96299, sp: 0.0178, notes: 1613, span: 116.57, v4: {acc: 13.24, pass: 11.395, tech: 4.602}, v41: {acc: 14.443, pass: 11.6, tech: 4.189}},
{name: "Fresh!", diff: "Expert+", stars: 9.624, pa: 0.97243, sp: 0.0151, notes: 1264, span: 183.35, v4: {acc: 10.244, pass: 9.148, tech: 4.758}, v41: {acc: 10.816, pass: 9.53, tech: 4.336}},
{name: "Deja Vu (Short Ver.)", diff: "Expert+", stars: 8.106, pa: 0.9782, sp: 0.0092, notes: 446, span: 102.47, v4: {acc: 8.821, pass: 4.352, tech: 7.44}, v41: {acc: 9.486, pass: 4.312, tech: 6.521}},
{name: "Believer", diff: "Expert", stars: 5.962, pa: 0.98476, sp: 0, notes: 546, span: 198.24, v4: {acc: 7.368, pass: 1.798, tech: 3.569}, v41: {acc: 7.125, pass: 1.806, tech: 2.934}},
{name: "Dragostea Din Tei", diff: "Easy", stars: 3.815, pa: 0.99196, sp: 0, notes: 237, span: 122.18, v4: {acc: 4.745, pass: 0.859, tech: 1.08}, v41: {acc: 4.256, pass: 0.789, tech: 1.103}},
];
const input = (m, v) => ({accRating: v.acc, passRating: v.pass, techRating: v.tech, predictedAcc: m.pa, sliderPercentage: m.sp, notes: m.notes, noteSpan: m.span});
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv4", "dash"], ["var(--v4)", "PPv4.1"]]);
function draw(name) {
const m = MAPS.find(e => e.name === name), a = pp.ratings(input(m, m.v4)), b = pp41.ratings(input(m, m.v41));
BL.$("#name").textContent = `${m.name}, ${m.diff}: ${fmt.nf(m.stars, 2)}★`;
const oldPp = x => pp.scorePp(x, a)[0], nowPp = x => pp41.scorePp(x, b)[0];
const old = x => oldPp(x) / oldPp(0.96), now = x => nowPp(x) / nowPp(0.96), change = x => now(x) / old(x) - 1;
const 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"};
chart.line("#shape", {
height: 270, x,
y: {min: 0, max: 7, ticks: [0, 1, 2, 3, 4, 5, 6, 7], fmt: v => "×" + v},
series: [
{points: chart.sample(0.9, 1, 0.0005, old), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: old},
{points: chart.sample(0.9, 1, 0.0005, now), color: "var(--v4)", width: 2.6, valueAt: now},
],
fill: {from: 0, to: 1, color: "var(--v4-soft)"},
markers: [{x: m.pa, label: "Predicted"}],
tip: v => `<div class="t">${fmt.pct(v, 2)}</div>` + chart.tipRow("var(--v3)", "PPv4", `×${fmt.nf(old(v), 2)}, ${fmt.nf(oldPp(v))} pp`) + chart.tipRow("var(--v4)", "PPv4.1", `×${fmt.nf(now(v), 2)}, ${fmt.nf(nowPp(v))} pp`),
});
chart.line("#change", {
height: 270, x,
y: {min: -0.35, max: 0.1, ticks: [-0.3, -0.2, -0.1, 0, 0.1], fmt: v => (v > 0 ? "+" : v < 0 ? "−" : "") + fmt.pct(Math.abs(v))},
series: [
{points: [[0.9, 0], [1, 0]], color: "var(--muted)", width: 1.2, dash: "3 3", hover: false},
{points: chart.sample(0.9, 1, 0.0005, change), color: "var(--v4)", width: 2.4, valueAt: change},
],
markers: [{x: m.pa, label: "Predicted"}],
tip: v => `<div class="t">${fmt.pct(v, 2)}</div>` + chart.tipRow("var(--v4)", "Change", fmt.signedPct(change(v), 1)),
});
BL.$("#out").innerHTML = [0.92, 0.98, 0.99, 1].map(v => `<div class="readout"><span class="k">A ${fmt.pct(v)} play</span><span class="v">×${fmt.nf(old(v), 2)} → ×${fmt.nf(now(v), 2)}</span><span class="k">of a 96% play</span></div>`).join("");
}
BL.ui.seg("#pick", MAPS.map(m => [m.name, fmt.pct(m.pa, 1)]), "Deja Vu (Short Ver.)", draw);
draw("Deja Vu (Short Ver.)");
</script>
The low note nerf is stronger, so ratings on maps with a normal note count can sit closer to where they should be without overweighting maps like Quiet Water. It still starts at 115 notes, but it now reaches 19.7% at 20 notes instead of 5%. 65 ranked maps have fewer than 115 notes, with 18 of them fewer than 60.
<script src="/data/ppv41-formula.js"></script>
<div class="panel">
<div class="panel-head"><span class="title">Nerf on the whole curve, by note count</span><div class="legend" id="lg"></div></div>
<div id="chart"></div>
<div class="ctrl"><label for="notes">Notes <output for="notes"></output></label><input type="range" id="notes" min="20" max="130" step="1" value="50"></div>
</div>
<script>
const {pp, pp41, chart, fmt} = BL;
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv4", "dash"], ["var(--v4)", "PPv4.1"]]);
BL.ui.range("#notes", v => v, n => {
chart.line("#chart", {
height: 220,
x: {min: 20, max: 130, ticks: [20, 50, 80, 115], title: "Notes"},
y: {min: 0.78, max: 1.01, ticks: [0.8, 0.85, 0.9, 0.95, 1], fmt: v => "×" + fmt.nf(v, 2)},
series: [
{points: chart.sample(20, 130, 0.5, pp.lowNoteFactor), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: pp.lowNoteFactor},
{points: chart.sample(20, 130, 0.5, pp41.lowNoteFactor), color: "var(--v4)", width: 2.4, valueAt: pp41.lowNoteFactor},
],
dots: [[n, pp.lowNoteFactor(n), "var(--v3)"], [n, pp41.lowNoteFactor(n), "var(--v4)"]],
tip: x => `<div class="t">${fmt.nf(x)} notes</div>` + chart.tipRow("var(--v3)", "PPv4", "×" + fmt.nf(pp.lowNoteFactor(x), 3)) + chart.tipRow("var(--v4)", "PPv4.1", "×" + fmt.nf(pp41.lowNoteFactor(x), 3)),
});
});
</script>
FS and SF ratings now keep only part of what the speed adds over the no modifier rating, depending on the map's stars. The change is fast around 5★ and tapers off at both ends: a 3★ map keeps 27% of the gain, a 5★ map 57%, a 6★ map 87%, and from 7★ almost all of it. This does not apply to SS. This change was done to make speed modifiers more accurate throughout the star ranges.
<div class="panel">
<div class="grid-2">
<div>
<div class="panel-head"><span class="title">Share of the speed gain a map keeps</span></div>
<div id="share"></div>
</div>
<div>
<div class="panel-head"><span class="title">Extra pp over no modifier at 96%</span><div id="mod"></div></div>
<div id="gain"></div>
<div class="legend" id="lg"></div>
</div>
</div>
<details class="table"><summary>Show as table</summary><div class="tbl-wrap" id="table"></div></details>
</div>
<script>
const {chart, fmt} = BL;
const share = s => 0.15 + 0.85 / (1 + Math.exp(-(s - 5.048) / (s < 5.048 ? 1.1218 : 0.5609)));
chart.line("#share", {
height: 220,
x: {min: 0, max: 10, ticks: [0, 2, 4, 6, 8, 10], fmt: v => v + "★", title: "Stars without modifiers"},
y: {min: 0, max: 1, ticks: [0, 0.25, 0.5, 0.75, 1], fmt: v => fmt.pct(v)},
series: [{points: chart.sample(0, 10, 0.05, share), color: "var(--v4)", width: 2.4, valueAt: share}],
tip: s => `<div class="t">${fmt.nf(s, 1)}★</div>` + chart.tipRow("var(--v4)", "Keeps", fmt.pct(share(s))),
});
BL.json("/data/ppv41-stats.json").then(S => {
BL.$("#lg").innerHTML = chart.legend([["var(--v3)", "PPv4", "dash"], ["var(--v4)", "PPv4.1"]]);
const mid = r => (r.to > 10 ? 11.5 : (r.from + r.to) / 2);
const lerp = (rows, key, x) => {
if (x <= mid(rows[0])) return rows[0][key];
for (let i = 1; i < rows.length; i++) if (x <= mid(rows[i])) { const t = (x - mid(rows[i - 1])) / (mid(rows[i]) - mid(rows[i - 1])); return rows[i - 1][key] + t * (rows[i][key] - rows[i - 1][key]); }
return rows[rows.length - 1][key];
};
function draw(m) {
const R = S.modGain[m], top = Math.ceil(Math.max(...R.flatMap(r => [r.v4, r.v41])) * 12) / 10, ticks = [];
for (let t = 0; t <= top + 1e-9; t += top > 0.3 ? 0.1 : 0.05) ticks.push(Math.round(t * 100) / 100);
chart.line("#gain", {
height: 220,
x: {min: mid(R[0]), max: mid(R[R.length - 1]), ticks: [4, 6, 8, 10], fmt: v => v + "★", title: "Stars without modifiers"},
y: {min: 0, max: top, ticks, fmt: v => "+" + fmt.pct(v)},
series: [
{points: R.map(r => [mid(r), r.v4]), color: "var(--v3)", width: 1.8, dash: "5 4", valueAt: x => lerp(R, "v4", x)},
{points: R.map(r => [mid(r), r.v41]), color: "var(--v4)", width: 2.4, valueAt: x => lerp(R, "v41", x)},
],
tip: x => `<div class="t">${fmt.nf(x, 1)}★</div>` + chart.tipRow("var(--v3)", "PPv4", fmt.signedPct(lerp(R, "v4", x), 1)) + chart.tipRow("var(--v4)", "PPv4.1", fmt.signedPct(lerp(R, "v41", x), 1)),
});
BL.$("#table").innerHTML = `<table><thead><tr><th>Stars, each version's own</th><th class="r">PPv4</th><th class="r">PPv4.1</th></tr></thead><tbody>${R.map(r => `<tr><td>${r.from} to ${r.to}★</td><td class="r">${fmt.signedPct(r.v4, 1)}</td><td class="r">${fmt.signedPct(r.v41, 1)}</td></tr>`).join("")}</tbody></table>`;
}
BL.ui.seg("#mod", [["FS", "FS"], ["SF", "SF"]], "FS", draw);
draw("FS");
});
</script>
BFS and BSFS will be launched in an event this friday and ranked after analyzing the results for balance.
The parity engine has been remade using an ML model approach trained on top players' replays. It predicts the cut angle and parity of each note, from the map data alone. Parity and predicted cut angles are a lot more realistic now, which fixes ratings on maps where angle strain was wrong or patterns were wrongly counted as resets.
On 1,219 test maps it never trained on, errors on pairs of swings fell from 1,966 to 782 compared to the old predictor. The average error in cut direction, compared to what players really do in replays, fell from 10.6 to 5.1 degrees.
The pass rating also got a fix. When one hand played a fast burst while the other kept playing slower notes, the other hand's notes diluted the average of the rating's time windows, which rated the burst too low. Swings with a swing of the other hand within 0.2 seconds are now worth 10% more.
We scored the same 4.42 million ranked plays from 146,078 players under both versions 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, the same for both versions. Acc includes every map under about 4★.</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/ppv41-stats.json").then(S => {
function draw(band) {
const b = S.bands[band], v4 = b.v4, v41 = b.v41, 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(v4.mapsForHalf), fmt.nf(v41.mapsForHalf), `${fmt.signedPct(v41.mapsForHalf / v4.mapsForHalf - 1)} more maps share the top spots`) +
`<div class="tile"><span class="k">Median player's total pp</span><div class="v"><span>${fmt.signedPct(b.totals.median - 1, 1)}</span></div><span class="sub">8 in 10 players are between ${fmt.signedPct(b.totals.p10 - 1, 1)} and ${fmt.signedPct(b.totals.p90 - 1, 1)}</span></div>` +
`<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)", "PPv4"], ["var(--v4)", "PPv4.1"]]);
const share = (v, t) => v.typeShare[t] || 0;
const top = Math.max(...TYPES.flatMap(([t]) => [share(v4, t), share(v41, 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(v4, t)]))}, {color: "var(--v4)", values: Object.fromEntries(TYPES.map(([t]) => [t, share(v41, 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)", "PPv4", fmt.pct(share(v4, t), 1)) + chart.tipRow("var(--v4)", "PPv4.1", fmt.pct(share(v41, t), 1)) + chart.tipRow(null, "Ranked pool", fmt.pct(S.poolTypeShare[t] || 0, 1)) + `<div class="r">pp on these plays<b>${fmt.signedPct(b.typePay[t] - 1, 1)}</b></div>`,
});
BL.$("#typesTable").innerHTML = `<table><thead><tr><th>Map type</th><th class="r">PPv4</th><th class="r">PPv4.1</th><th class="r">Ranked pool</th><th class="r">pp on these plays</th></tr></thead><tbody>${TYPES.map(([t, l]) => `<tr><td>${l}</td><td class="r">${fmt.pct(share(v4, t), 1)}</td><td class="r">${fmt.pct(share(v41, t), 1)}</td><td class="r">${fmt.pct(S.poolTypeShare[t] || 0, 1)}</td><td class="r">${fmt.signedPct(b.typePay[t] - 1, 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 += hMax > 0.3 ? 0.1 : 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 PPv4 top 20, how many maps are still in their PPv4.1 top 20. ${fmt.nf(v41.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>PPv4</th><th class="r">Front pages</th><th>PPv4.1</th><th class="r">Front pages</th></tr></thead><tbody>${[0, 1, 2, 3, 4].map(i => `<tr><td>${i + 1}</td>${cell(v4.top[i])}${cell(v41.top[i])}</tr>`).join("")}</tbody></table>`;
}
BL.ui.seg("#band", BANDS, "all", draw);
draw("all");
});
</script>
The shift is biggest at the top. Tech maps lose ground in every group but keep about a third of the top 1,000's spots. Midspeed maps gain in every group. Speed maps gain among the top 1,000 and top 100 and slip a little below that. Acc maps pay the most extra, 6 to 9% more on the same plays, though among the top 100 it's less than 3% and their share stays about the same. Fixed tech pp shows up too: tech pp goes from 8% to 5% of what the top 100's front pages are worth.
The reweighter runs once after a rating update and nudges a map's acc rating toward the average for its stars, based on the leaderboard data. It was tuned further:
<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/ppv41-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>
Golf leaderboards run on the same formula with your accuracy flipped, which turns the new pass pp around too: the lower you score relative to the prediction, the more it pays. The multiplier is ×1 at about the best Golf score a map usually sees, which is 6% on a map predicted at 97%. Acc pp stays a straight line, with its weight down from 42 to 38, and tech pp is fixed like everywhere else.
This is a rough inversion rather than a tuned balance, so it may change. On the top plays of the top 30 Golf players, totals change by a median of −1.7% (from −4.1% to +1.5%), and the best play goes from 809 to 849 pp.
Search the 3,968 ranked maps to see their PPv4.1 ratings and curve, and what a play is worth compared with PPv4.
<script src="/data/ppv41-formula.js"></script>
<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, pp41, 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 = [], OLD = new Map();
const parse = 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(),
});
function ratingsFor(P, 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 = P.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(pp41, m, state.mod), old = OLD.get(m.id);
state.ratings = r;
state.old = old ? ratingsFor(pp, old, state.mod) : null;
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>
${state.old ? `<span class="small">In PPv4: ${fmt.nf(state.old.shownStars, 2)}★, pass ${fmt.nf(state.old.passRating, 2)}, acc ${fmt.nf(state.old.accRating, 2)}, tech ${fmt.nf(state.old.techRating, 2)}. Ratings aren't comparable one-to-one, pp is.</span>` : ""}
<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(state.old ? [["var(--v4)", "PPv4.1"], ["var(--v3)", "PPv4", "dash"]] : [["var(--v4)", "PPv4.1"]])}</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, old = state.old, acc = state.acc;
const [total, pass, accPart, tech] = pp41.scorePp(acc, r);
const before = old ? pp.scorePp(acc, old)[0] : null;
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)}${before ? ` · ${fmt.nf(before, 1)} in PPv4 (${fmt.signedPct(total / before - 1, 1)})` : ""}`;
const now = x => pp41.scorePp(x, r)[0], then = x => pp.scorePp(x, old)[0];
const top = Math.max(now(1), old ? then(1) : 0) * 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);
const series = [];
if (old) series.push({points: chart.sample(0.85, 1, 0.0005, then), color: "var(--v3)", width: 1.6, dash: "5 4", valueAt: then});
series.push({points: chart.sample(0.85, 1, 0.0005, now), color: "var(--v4)", width: 2.4, valueAt: now});
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,
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)", "PPv4.1", fmt.nf(now(x), 1) + " pp") + (old ? chart.tipRow("var(--v3)", "PPv4", fmt.nf(then(x), 1) + " pp") : ""),
});
}
function select(id) {
state.map = MAPS.find(m => m.id === id) || state.map;
state.mod = "none";
renderCard();
}
Promise.all([BL.json("/data/ppv41-maps.json"), BL.json("/data/ppv4-maps.json")]).then(([D, O]) => {
MAPS = D.maps.map(parse);
OLD = new Map(O.maps.map(r => [r[0], parse(r)]));
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>
Not necessarily. Stars were rescaled, and above about 11★ most maps' numbers went down. Look at the pp: on the same plays, the median play on a 12★ map changes by −3%. Look up a map to see the pp at your accuracy under both versions.
Not necessarily either. Most maps have a higher number now, whether they pay more or not.
Speed modifiers gain much less on maps under about 6★, so plays that relied on that gain lose it. From about 7★ they gain about the same as before.
You still get pass pp at any accuracy, down to ×0.8 of the map's value. It just rewards you more the closer you get to the prediction and beyond.
Tech pp no longer grows with accuracy, and the balance between map types moved. Tech maps are still far above their share of the ranked pool among the top players.
Ranked maps and player totals are updated. Unranked maps' ratings update in the background, so they can show PPv4 values for a while longer.
Yes. We keep working with the data to see how things progress, and we'll keep listening to feedback.
The comparisons use the 3,968 ranked maps and 4.42 million ranked scores from 146,078 players in a snapshot from November 4, 2026. PPv4 is the formula and ratings from October 2026, PPv4.1 the new formula and ratings, with every score calculated under both. A front page is a player's 20 best plays by pp; players with at least 20 ranked plays count (29,530). Rank groups use PPv4 ranks, and map types are PPv4.1's tags for both versions. Modifier gains compare a 96% play with and without the modifier, with maps grouped by each version's own stars. The Golf numbers come from 3,728 top plays of the top 30 Golf players. Curves and pp on this page use the same formula code as the website.