A scoresheet for colour e-ink tablets — pencil-and-paper scoring for card and board games, for two to four players, with a running tally of who has won how many.
It knows nothing about any particular game. There are no rules, no scoring logic, no dealer. It replaces the back of an envelope, and nothing else.
![]() |
![]() |
| Rivalries, each with its win tally | The sheet — bars ruled under the addends |
![]() |
![]() |
| Recording a winner | Standings, in dark mode |
Screenshots from a BOOX Note Air4 C, 10.3" Kaleido 3.
A rivalry is the same game played by the same people, more than once: a name, two to four players, and whether the highest or lowest score wins. Tapping a rivalry goes straight to a scoresheet — the game in progress if there is one, otherwise a new game started on the spot. Nobody opens a scorekeeper to admire a menu.
Each round is a row, and a subtotal follows it under a ruled addition bar — the pencil-and-paper convention, where the line is not part of any number but sits between the addends and their total. A ◆ marks whoever is ahead — all of them, if it's a tie.
Round 1 gets no subtotal, for the same reason you'd never rule a line under the first figure you wrote down: a bar needs two addends above it. From round 2 on, each total becomes the next round's addend, exactly as a running total works on paper.
Entry is free-form. Tap any cell, in any order, and type. Nothing insists you go round the table in player order, because people call out their scores in whatever order they finish counting. The only thing the app needs told is when a round is done, which is what the keypad's big key is for — and the sheet shows only rounds that exist, so there is no permanently blank row at the bottom carrying a subtotal that repeats the one above it.
That key does the whole flow, and changes to match what's in front of you:
- the current round has scores in it → Finalize round, which closes it and adds the next
- the current round is empty → you are evidently done, so it offers Finish game
It morphs rather than greying out, so it is never a dead key.
Finish records the winner. It proposes the leader, and insists you choose when the field is tied, because a scorekeeper that quietly picks one of two tied players has recorded a result nobody agreed to. Finishing a game starts the next one. A finished game can be reopened from the standings and corrected, figures and winner both; the tally follows.
The player names are pinned above the scroll. A long game runs the early rounds off the top of the screen, and a column of figures with no name over it is useless.
The app is monochrome, and that is not minimalism. A Kaleido panel puts a colour filter over a monochrome screen: black renders at full resolution, colour at half, and every hue arrives desaturated and dimmed. A sheet of figures is sharper in black. The two projects beside this one needed colour to tell players apart; a scoresheet has columns for that.
What's left is greys, and they are chosen against two specific failure modes:
-
Every grey is an exact neutral — R, G and B identical, in both themes. E-ink devices commonly ship with colour enhancement on, which multiplies saturation; a conventional UI grey like
#6F757Ccarries a blue cast that the boost amplifies into a visibly blue table.#B0B0B0has nothing to amplify. This includes Material'ssurfaceContainerroles, set explicitly because the baseline is a purple-tinted neutral and it is what dropdown menus fill with.Dark mode was originally exempted from this rule, on the reasoning that dark mode is for a backlit screen. It isn't — the theme is a toggle in the app, on the same e-ink tablet, so it goes through the same enhancement. Its greys carried casts up to forty points and the panel rendered a navy page ruled in bright blue. Every value was replaced with the neutral of identical relative luminance, so nothing about the contrast changed and the colour simply left.
-
Nothing important is carried by a pale fill. The panel resolves roughly sixteen levels, so a very pale grey can snap to white and vanish. Subtotals were originally banded
#E0E0E0, about two levels below white — a bet that only just paid. The addition bar retired it: the bar isink, the strongest value in the palette, so the one distinction the sheet cannot afford to lose is now drawn in the one colour that cannot disappear.
| ratio on paper | notes | |
|---|---|---|
ink #101010 |
19.03:1 | every figure, and the addition bar |
muted #484848 |
9.15:1 | row labels |
rule #B0B0B0 |
2.17:1 | the grid |
Row types are distinguished four ways over: the bar, the bold total, the row label (Round 4 in
ink against Subtotal in grey), and a shorter row. Losing any one of them still leaves the
sheet readable.
Everything else follows from ghosting and refresh behaviour:
- No ripples, and no press indication either. Suppressing ripples is the well-known half;
Modifier.clickabletakes its feedback fromLocalIndication, whose default paints a flat 30% black wash over the whole component while held. Left in, every keypad key darkened an 85 dp rectangle on press and repainted it on release — two extra panel updates per digit, on the control tapped fastest. - Nothing animates. No smooth scrolling, no Material text-field label float, no tonal elevation. Dialogs are a 3 dp black outline on paper, because a tonal grey lifted off the background is either invisible or muddy.
- The launch window is white, because that is what paints before Compose draws its first frame and a dark one flashes grey on every cold start.
- Glyphs that aren't guaranteed are drawn instead. The leader diamond is four lines of
Canvas, not
◆— on a device with a trimmed font set a missing glyph renders as a tofu box or gets picked up by the emoji font and arrives in colour.
In-app, fourteen fixed keys, always visible. The system keyboard was the obvious alternative and
the wrong one: it is an alphabetic layout with a numeric strip, it animates in and out, and it
repaints half the screen doing so — a visible flash on every score. Negative rounds are real in
plenty of games, so ± is a first-class key.
The digits grey out when no cell is armed, rather than looking live and silently swallowing presses.
It scales on a tighter curve than the rest of the chrome. Chrome scale on a 10.3 inch panel works out at 1.74, and applied to the keypad that gave 80 dp keys in a 386 dp block — 29% of the screen, buying no legibility and costing a round of scoresheet.
Two scale factors. Chrome scales from whichever screen dimension is tighter — width alone over-scales a 4:3 tablet, and the taller rows then eat the space the sheet needs for rounds. Column widths come from the available width, with the label column clamped so it neither crowds the figures on a phone nor sprawls on a tablet.
The header row and every body row are built by one shared row function from the same two widths, so the names cannot drift out of alignment with the columns beneath them. An earlier mockup drew the header separately and it sat one padding value out — the sort of bug that looks like a rounding error and is actually two independent sources of truth.
Below 600 dp the Standings and Finish buttons collapse into an overflow menu; laid out flat they need more width than a phone has.
The column rules run the full height of the sheet, not just as far as the last round. A game three rounds into a tall screen otherwise left two thirds of the page blank under a table that stopped dead, which reads as truncated rather than as having room left. The empty rows are not ruled horizontally, though — a blank ruled row looks like something you can tap and isn't.
Written to SharedPreferences after every change, as JSON. Rounds are a ragged list of lists of
nullable integers, and hand-rolling a delimiter format for that is how you end up unable to tell
an empty cell from a zero — org.json ships with the platform, so this costs no dependency.
A blank cell is not zero. Blank means "hasn't entered yet"; zero means "scored nothing". Conflating them makes the subtotals lie about who is still to play, and makes whoever hasn't played yet the runaway leader of every low-wins game.
Subtotals are not stored. They are sums of the rounds above them, and storing them would create a second source of truth that could disagree with the first. The one thing a scoresheet must never do is show a total that doesn't match the column.
An unreadable save is preserved, not discarded. load returns an empty state, and without
care the next edit would serialise that empty state straight over the only copy of every rivalry;
the raw string is copied aside first. A save written by a newer version is treated the same way
rather than being read optimistically.
The keypad is flushed on ON_STOP, since digits typed into a cell only become a stored score
when focus moves.
Android Studio → Open, pick this folder, sync, Run. Pinned for Android Studio Hedgehog (2023.1.1): AGP 8.2.0-rc03, Kotlin 1.9.22, Gradle 8.2, compileSdk 34, Compose BOM 2024.02.00. Requires Android 8.0. No permissions, no network, no analytics.
There is no gradlew in the repo; the IDE reads the version from
gradle/wrapper/gradle-wrapper.properties. Run gradle wrapper once if you want a
command-line build.
app/src/main/java/com/moira/scorekeeper/
Model.kt Rivalry/Game value types, derived totals and tally, JSON save/load
ScoreViewModel.kt screens and back stack, keypad buffer, rivalry editing, finishing
Theme.kt light (e-ink) and dark palettes, scale, ripple and indication suppression
Sheet.kt the scoresheet, the pinned header row, the keypad
Screens.kt rivalry list with the win tally, standings and game history
Dialogs.kt new rivalry, finish game, rename, players, delete
Widgets.kt the pill button and the drawn diamond
MainActivity.kt routing and the lifecycle flush
MIT — see LICENSE.



