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.
- Firing charged lines spikes output — 1/2/3/4 lines give +34 / +62 / +88 / +120 MW — and then output halves every 5.5 seconds.
- The green ribbon over the demand graph is the safe band: 85–130% of demand. Inside it you score 12 points per second.
- Leak below and the blackout gauge climbs; spill above and overheat does. Either one at 100 ends the run.
- A battery (capacity 60) absorbs excursions first — up to 18/s in, up to 22/s out.
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.
| Elapsed | Do nothing | 1 line at 20s | 2 lines at 20s |
|---|---|---|---|
| 10s | 7 | 7 | 7 |
| 20s | 22 | 22 | 22 |
| 40s | 63 | 49 | 19 |
| 60s | 100 | 91 | 60 |
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 wasted | Share |
|---|---|
| 0 | 26% |
| 0 – 1 | 13% |
| 1 – 3 | 15% |
| 3 – 8 | 34% |
| 8 and above | 12% |
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 demand | Morning | Day | Peak | Morning stable % |
|---|---|---|---|---|
| 44±7 (old) | 37.4 | 12.4 | 24.2 | 27% |
| 48±7 | 33.4 | 20.9 | 25.8 | 30% |
| 54±8 (new) | 18.8 | 15.5 | 25.8 | 39% |
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 piece | Survived | Median score | Grades | Fires/run | Lines/fire | Battery in/out | Waste |
|---|---|---|---|---|---|---|---|
| 900ms | 15/16 | 1536 | all C | 30.6 | 1.7 | 163 / 105 | 740 |
| 1400ms | 16/16 | 5684 | S9·A5·B2 | 32.2 | 1.4 | 292 / 248 | 61 |
| 2000ms | 16/16 | 4786 | A10·B6 | 28.3 | 1.2 | 189 / 170 | 11 |
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 piece | Pieces | Forced line clears | Energy injected | Consumable | Oversupply |
|---|---|---|---|---|---|
| 650ms | 277 | 111 lines | 28.1k MW·s | 10.4k | +169% |
| 900ms | 200 | 80 lines | 20.3k | 10.4k | +95% |
| 1400ms | 129 | 51 lines | 13.1k | 10.4k | +25% |
| 1750ms | 103 | 41 lines | 10.4k | 10.4k | 0% |
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
- My magnets were decoration — a bot cleared 4 of 12 stages with zero input
- The board was dying in silence — 27% of rows were already unfixable
- I shipped two games nobody could win
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.