playbuns
2026-09-05 · making-of

The morning was harsher than the peak

Peak Load is a falling-block puzzle with a power plant bolted on. Completed lines do not clear themselves — they sit on the board glowing gold, charged, and you decide when to fire. Measurement after the MVP turned up four faults. One of them was not a bad number: it was a premise in my own spec that turned out to be inverted.

한국어로 읽기 →

The rules, briefly

The stacking layer is deliberately untouched: 10×20, 7-bag, SRS rotation with wall kicks, one hold, five NEXT previews, ghost piece. Nothing new to learn. All the novelty lives in the power layer.

Pay attention to the width of that band, because it is the villain of this piece. Subtract the 85% floor from the 130% ceiling and you get 0.45 × demand. The band is proportional to demand, not a fixed number of MW. I wrote that line myself and still failed to read it properly for weeks.

Fault 1 — the opening was punishing the player

The first thing measurement caught was the start of a round. scripts/opening-probe.mjs pins a seed and traces the blackout gauge.

ElapsedDo nothing1 line at 20s2 lines at 20s
10s777
20s222222
40s634919
60s1009160

The diagnosis is that the 20-second row reads 22 in all three columns. Before 20 seconds nothing the player does changes anything — 22% of the failure budget is already spent in a window they cannot act in.

The arithmetic explains it exactly. Output starts at demand(0) ≈ 51 and the band floor is 43.4. With a 5.5-second half-life, output crosses below the floor after 5.5 × log₂(51 / 43.4) = 1.3 seconds. The battery starts every round at zero, so there is no cushion — and on a 10-wide board, completing your first line takes four or five pieces, roughly 15 to 25 seconds.

That is not difficulty. That is punishing the player for the ramp-up. The fix is a warm-up: from the start of the round until the first fire or 25 seconds, whichever comes first, the blackout gauge does not accumulate. Overheat still works normally. The battery also holds its charge during warm-up — burning it when there is no penalty would simply be waste. Stalling to exploit it does not work, because stable-time points do not accrue during warm-up and the round is a fixed 180 seconds; the 25-second cap nails it shut anyway.

Fault 2 — the waste penalty was a tax on firing

The original waste penalty read: "if output enters the overheat band within 3 seconds of a clear, −80." The intent was to price the decision the battery exists for — can I afford a four-line fire right now?

So I used scripts/balance-bot.mjs to measure the distribution of overheat actually wasted in the three seconds after a fire.

Overheat wastedShare
026%
0 – 113%
1 – 315%
3 – 834%
8 and above12%

Median 2.4. The trouble is that a flat −80 charges the same for wasting 0.01 as for wasting 12. And since output almost always overshoots the band briefly after a fire before settling, the condition triggered on nearly every fire: 22–27 times per run at −80 each, −1790 points per run. It cancelled the on-time bonus outright. It was called a waste penalty; it functioned as a firing tax.

The fix narrows the condition to the amount actually thrown away: proportional at ×12, with a dead zone of 1 and a cap of −80 per fire. Result: waste deductions fell from 1790 to 585 per run at a 900ms pace and 61 per run at 1400ms, and the median score rose from 1640 to 2731. Only with that definition does "the battery will absorb this" become a judgement the scoring can reward.

Fault 3 — the grade cuts were backwards

Grade cuts were pre-measurement guesses (S 5200 / A 3800 / B 2200). Running scripts/grade-calibrate.mjs over 100 runs in the casual band (1100–2400ms per piece) gave quantiles of 25% = 3519 · 50% = 4489 · 75% = 5325 · 90% = 5807.

Against those, the old cuts handed out straight C at skilled pace and straight S/A at slow pace. The grade was measuring tempo, not play. The new cuts — S 5600 / A 4500 / B 3000 — put the bot at S 17% / A 33% / B 35% / C 15%, with humans spreading further down.

Fault 4 — the entire difficulty curve was inverted

This is the one that stung.

A round is three phases: morning (0–60s, gentle), day (60–120s, rising with two spikes), peak (120–180s, violent with four spikes). Underneath that structure sat an assumption I never wrote down and never questioned — "low demand is easy" — which is why morning demand was set low, at 44±7.

scripts/morning-sweep.mjs measures gauge accumulation per phase (1400ms per piece, 20 runs).

Morning demandMorningDayPeakMorning stable %
44±7 (old)37.412.424.227%
48±733.420.925.830%
54±8 (new)18.815.525.839%

Read the first row: morning at 37.4 is harsher than peak at 24.2. The stretch I designed as the tutorial was the hardest part of the round, and no amount of playing it myself was going to tell me that.

The cause is the line I flagged at the top. The band is 0.45 × demand, so lower demand makes it narrower in absolute MW. At morning demand 44 the band runs 37 to 57 — twenty MW wide. A two-line fire is +62. It cannot fit. So both bots and humans could only fire single lines in the morning; a single line (+34) decays fast, gaps between fires stretched out, and blackout accumulated in those gaps.

The fix runs directly against intuition: I raised demand in the phase that was supposed to be easy. Morning demand became 54±8 (44–66 measured), which widens the band enough that both one- and two-line fires land inside it. Morning pressure fell from 37.4 to 18.8 and morning stable time rose from 27% to 39%.

"Gentle" is now expressed on a different axis — variation, not level. Amplitude is 8 in the morning against 13 in the day and 28 at peak, and the morning has no spikes at all. That is the sentence I actually took from this: in this system, easy is set by the width of the band, not the height of demand.

The final phase curve reads morning 18.4 → day 15.8 → peak 24.8 — the shape I meant to build in the first place.

The baseline: can 180 seconds be survived?

balance-bot.mjs runs 16 games per configuration against the real rule functions (fits, collapse, stepPower, demandAt), skipping only the DOM. Its limitation is explicit: it evaluates final resting positions without checking whether a path there exists, so it is an upper bound on placement skill.

Per pieceSurvivedMedian scoreGradesFires/runLines/fireBattery in/outWaste
900ms15/161536all C30.61.7163 / 105740
1400ms16/165684S9·A5·B232.21.4292 / 24861
2000ms16/164786A10·B628.31.2189 / 17011

One thing this settled: the battery is not a dead feature. At 1400ms it absorbs 292 and discharges 248 per run, sitting at an average charge of 25 of 60 — it genuinely cycles. Under the old balance the average sat at 47–51 of 60, effectively always full, which means it was never buffering anything. You cannot learn that by checking whether the feature exists; you have to measure whether it turns over.

What I have not fixed, stated plainly

One fault is still open, and I left it in the spec with its table attached. Placing faster makes you worse off — and this is structural, not a bot-policy artefact.

One fire injects gain × 5.5/ln2 into the grid, about 254 MW·s per line. What the grid can consume over 180 seconds is average demand 58 × 180 ≈ 10,400 MW·s. Divide: the sustainable clear budget is 41 lines, which is 1750ms per piece.

Per piecePiecesForced line clearsEnergy injectedConsumableOversupply
650ms277111 lines28.1k MW·s10.4k+169%
900ms20080 lines20.3k10.4k+95%
1400ms12951 lines13.1k10.4k+25%
1750ms10341 lines10.4k10.4k0%

A 10-wide board cannot store four cells per piece indefinitely, so beyond that pace no strategy avoids oversupply: hold your charge and you top out, fire it and you overheat. Measured, 650ms gives 81% survival, a median score of 837, and straight C. And a bot that throttles its charging did worse still — 17% survival at 650ms. "Just be more patient" is not the answer.

Fixing it properly means touching LINE_GAIN (the 34/62/88/120 figures) or the demand ceiling of 100. Scaling LINE_GAIN to 0.6× (20/37/53/72) would bring the sustainable pace down to about 1050ms. Both change how the game feels, so I left the decision open rather than slipping it in. In the target casual band (1100–2400ms) survival is 98–100% and the balance is healthy, so it does not block release — but "does not block release" and "is not a problem" are different statements, which is why the table is in the spec instead of in my head.

What I keep from it

Three of the four faults were mis-set numbers. The fourth was different: a premise I believed because I had written it down. "The morning is easy because demand is low" sounds so reasonable that nobody interrogates it, and playing by hand only ever produced a vague "the opening feels rough". It was invisible until the phases were measured separately.

So I no longer trust aggregate metrics — total score, survival rate — as the first read on balance. Slice the run into segments and measure each one, and a single table will tell you that the stretch you labelled "the easy part" is the hardest thing in the game.

Try it

Other making-of notes

Every number here comes from §9 of the repository's SPEC.md and the output of the measurement scripts themselves. If you spot something inaccurate, tell us through contact.