fix(bit-by-bit): make pixel reveal 1:1 with streak-weighted units

Drop the pixelsPerCheck calibration constant that scaled units to a
fixed "perfect month" pace regardless of image size. 1 unit now equals
1 pixel directly (Nth check of the day reveals N pixels, multiplied by
that day's streak length), so bigger images genuinely take longer and
the numbers in the UI match what's actually happening. Also removes
the now-obsolete "~N days to finish" upload estimate and corrects the
About copy, which previously implied the streak multiplier applied
directly to pixel counts.
main
Elijah Voigt 6 days ago
parent 90d05a7687
commit cf5e7460c7

@ -5,7 +5,7 @@ Everything lives in one file, `index.html`, organized into sections:
| Component | Responsibility |
|---|---|
| **State** | Single JSON document under localStorage key `bitbybit.v1`; load/save with corruption guards; versioned for future migration. |
| **Mechanics** | Habit check/uncheck logging, daily rollover, streak math, seeded (mulberry32) reveal-order permutation, `pixelsPerCheck` calibration, and log-derived reveal: each day earns `triangular(checks)` base units (the Nth check is worth N), multiplied by that day's streak length. |
| **Mechanics** | Habit check/uncheck logging, daily rollover, streak math, seeded (mulberry32) reveal-order permutation, and log-derived reveal: each day reveals `triangular(checks)` pixels (the Nth check reveals N), multiplied by that day's streak length. |
| **Artwork** | Preset pixel-art definitions (palette + row strings); upload pipeline (offscreen canvas downsample → pixel grid); gallery of finished pieces. |
| **Rendering** | Canvas renderer for the artwork (revealed pixels, checkerboard for hidden, pop-in animation for deltas); DOM renderer for checklist, stats, settings. |
| **UI wiring** | Onboarding, settings dialog (habits / artwork / data / about), theme cycling (auto→light→dark via `data-theme` + CSS custom properties), export/import/wipe, confetti celebration, and a page footer disclosing authorship. |

@ -74,27 +74,21 @@ stay revealed.
- On image start, generate a random permutation of opaque pixel indices using a
**seeded PRNG (mulberry32)** with a stored seed. This is the reveal order —
stable across sessions and re-renders.
- **Escalating daily reward:** the _Nth_ habit checked on a given day is worth
`N` base units (1 + 2 + 3 + … ), so a day of `k` checks earns `triangular(k) =
k·(k+1)/2` base units. Doing more in one day is rewarded super-linearly; the
toast and pop for a later check reveal noticeably more pixels than an earlier
one.
- **Streak multiplier:** a day's units are multiplied by that day's **streak
- **Escalating daily reward, 1 unit = 1 pixel:** the _Nth_ habit checked on a
given day reveals `N` pixels (1 + 2 + 3 + … ), so a day of `k` checks reveals
`triangular(k) = k·(k+1)/2` pixels before the streak multiplier. Doing more
in one day is rewarded super-linearly; the toast and pop for a later check
reveal noticeably more pixels than an earlier one.
- **Streak multiplier:** a day's pixels are multiplied by that day's **streak
length** — the number of consecutive days with ≥1 check ending on (and
including) that day. On a 3-day streak the 1st check of the day is worth `3`
units and the 2nd `6`; on a 5-day streak, `5` and `10`. A day's streak length
including) that day. On a 3-day streak the 1st check of the day reveals `3`
pixels and the 2nd `6`; on a 5-day streak, `5` and `10`. A day's streak length
counts *backwards* from itself, so checking or unchecking a later day never
changes an earlier day's multiplier.
- `pixelsPerCheck` is computed **once, at image start**:
`max(1, round(opaqueCount / (triangular(30) · triangular(habitCount))))`
calibrated so a "perfect month" (all habits every day for 30 days, whose
streak-weighted units total `triangular(30) · triangular(habitCount)`) just
fills the image. Locked for the life of the image even if habits change
(prevents gaming/confusion). Clamped to ≥1 so the very first check always
reveals at least one pixel; small images simply fill in sooner than a month.
- **Revealed pixels are derived from the log, not stored.** Revealed unit total =
`Σ streakLen(d) · triangular(len(log[d]))` over days `d ≥ startedAt`; revealed
pixel count = `min(opaqueCount, floor(units · pixelsPerCheck))`. Because reveal
- **Revealed pixels are derived from the log, not stored.** Revealed pixel total =
`min(opaqueCount, Σ streakLen(d) · triangular(len(log[d])))` over days
`d ≥ startedAt`. No separate per-image calibration constant — bigger images
simply take longer to fill in, smaller ones finish sooner. Because reveal
is a pure function of the log, same-day uncheck only shrinks today's
contribution — past days can never be un-revealed, and reloads/undo stay
perfectly consistent with no separate counter to keep in sync.
@ -112,8 +106,8 @@ stay revealed.
rocket, sunflower, friendly ghost, coffee mug.
2. **Upload:** file input (image/*) → draw to offscreen canvas at a chosen grid
size (16 / 24 / 32 / 48) → sample each cell's center region; alpha < 128
transparent. Show a live preview + "~N days to finish" estimate before
confirming. Reject nothing — any image works; it just gets pixelated.
transparent. Show a live preview + opaque pixel count before confirming.
Reject nothing — any image works; it just gets pixelated.
- Switching artwork mid-image warns that current progress will be archived as
unfinished (kept in gallery, marked incomplete) — or user cancels.
@ -147,7 +141,6 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
"w": 16, "h": 16,
"pixels": [null, "#e84d4d", ...], // row-major, length w*h, null = transparent
"seed": 123456789,
"pixelsPerCheck": 2,
"startedAt": "2026-07-01"
},
"log": { // immutable history of check-offs

@ -432,13 +432,12 @@
<p>Paint by Habits is a cozy pixel-art habit tracker. Check off your daily habits and watch a hidden
picture reveal itself, one tiny square at a time. Missing a day never erases progress —
pixels stay revealed forever.</p>
<p><strong>How you earn pixels:</strong> every habit you check off reveals more of the picture — so
the more you do, the more appears. Each check within a day is worth more than the last: your 1st
check paints a little, your 2nd paints more, your 3rd more still. Checking off your <em>whole</em>
list in a day is the sweet spot.</p>
<p><strong>How you earn pixels:</strong> each habit you check off today reveals pixels — your 1st
check reveals 1 pixel, your 2nd reveals 2, your 3rd reveals 3, and so on. Checking off your
<em>whole</em> list in a day is the sweet spot.</p>
<p><strong>Streaks multiply everything.</strong> Every consecutive day you show up bumps the multiplier:
on a 3-day streak your 1st check of the day is worth 3× and your 2nd is worth 6×; on a 5-day streak
they're worth 5× and 10×. Come back tomorrow and finish the day's list, and the picture floods in.</p>
on a 3-day streak your 1st check of the day reveals 3 pixels and your 2nd reveals 6; on a 5-day streak
they reveal 5 and 10. Come back tomorrow and finish the day's list, and the picture floods in.</p>
<p>Everything lives only in this browser's local storage, on this device. Nothing is ever sent
anywhere.</p>
<p>
@ -494,7 +493,6 @@
w: img.w | 0, h: img.h | 0,
pixels: img.pixels.slice(),
seed: (img.seed == null ? 1 : (img.seed >>> 0)),
pixelsPerCheck: Math.max(1, img.pixelsPerCheck | 0 || 1),
startedAt: /^\d{4}-\d{2}-\d{2}$/.test(img.startedAt) ? img.startedAt : todayStr()
};
}
@ -636,12 +634,12 @@
return order;
}
// Escalating daily reward with a streak multiplier:
// • Within a day, the Nth habit checked is worth N "units" (1 + 2 + 3 + … =
// a triangular number), so k checks in a day earn triangular(k) base units.
// • The whole day's units are then multiplied by that day's streak length
// (consecutive active days ending on it): on a 3-day streak the 1st check
// is worth 3 units, the 2nd 6, and so on.
// Escalating daily reward with a streak multiplier, 1 unit = 1 pixel:
// • Within a day, the Nth habit checked reveals N pixels (1st = 1, 2nd = 2,
// ...), so k checks in a day reveal triangular(k) = k·(k+1)/2 pixels.
// • Each of those pixels is then multiplied by that day's streak length
// (consecutive active days ending on it): on a 3-day streak the 1st
// check reveals 3 pixels, the 2nd 6; on a 5-day streak, 5 and 10.
// Revealed pixels are re-derived from the log every time rather than stored,
// so undo/redo and reloads stay perfectly consistent and past days can never
// be un-revealed by unchecking today (a day's streak length counts backwards
@ -673,23 +671,11 @@
function getRevealInfo(image){
var order = getRevealOrder(image);
var opaque = order.length;
var revealedCount = Math.min(opaque, Math.floor(revealedUnits(image) * image.pixelsPerCheck));
var revealedCount = Math.min(opaque, revealedUnits(image));
var set = new Set(order.slice(0, revealedCount));
return { order: order, opaque: opaque, revealedCount: revealedCount, set: set };
}
// Pixels revealed per unit, fixed at image start. Calibrated so a "perfect
// month" — checking all habits every day for 30 days — just fills the image.
// With the streak multiplier a perfect month earns
// Σ_{day=1..30} day × triangular(habitCount) = triangular(30) × triangular(habitCount)
// units, so pixelsPerCheck ≈ opaqueCount / (triangular(30) × triangular(habitCount)).
// Clamped to ≥1 so the very first check always reveals at least one pixel;
// small images just fill in comfortably sooner than a month.
function pixelsPerCheckFor(opaqueCount, habitCount){
var perfectMonthUnits = triangular(30) * triangular(Math.max(1, habitCount));
return Math.max(1, Math.round(opaqueCount / perfectMonthUnits));
}
function randomSeed(){
// Never return 0: normalizeImage treats a missing seed as 1, and a 0 seed
// would reshuffle after a save/load round-trip.
@ -712,13 +698,10 @@
}
function buildImageFromDef(def){
var opaque = def.pixels.filter(function(p){ return p != null; }).length;
var habitCount = Math.max(1, state.habits.length);
return {
name: def.name, source: def.source, w: def.w, h: def.h,
pixels: def.pixels.slice(),
seed: randomSeed(),
pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount),
startedAt: todayStr()
};
}
@ -1469,12 +1452,7 @@
// Selecting an upload supersedes any highlighted preset tile.
Array.prototype.forEach.call(q('obArtGrid').children, function(c){ c.classList.remove('selected'); });
obSelectedDef = { name: '', source: 'upload', w: result.w, h: result.h, pixels: result.pixels };
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount);
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('obUploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') +
' to finish (' + opaque + ' opaque pixels).';
q('obUploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
q('obStart').disabled = false;
}).catch(function(){
obUploadResult = null;
@ -1655,16 +1633,7 @@
return;
}
lastUploadResult = result;
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount);
// With the streak multiplier, D perfect days in a row reveal
// ppc × triangular(habitCount) × triangular(D) pixels
// (each day's units scaled by its streak length 1..D). Solve
// triangular(D) ≥ opaque / (ppc × triangular(habitCount)) for D.
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('uploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') + ' to finish, ' +
'checking in daily (' + opaque + ' opaque pixels).';
q('uploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
q('useUploadBtn').disabled = false;
}).catch(function(){
lastUploadResult = null;

Loading…
Cancel
Save