PebbleGolfScore/SPEC.md
2026-05-10 21:18:21 -04:00

15 KiB
Raw Permalink Blame History

Golf Score — Application Specification

Platform: Pebble Time 2 (Emery, 200×228 px, color)
Source: src/c/golf_score.c
Build output: build/golf_score.pbw


1. Purpose

A single-file Pebble watchapp for tracking golf strokes and putts across an 18-hole round. The design prioritises one-handed glanceability and minimal button presses during play, with correction and navigation features accessible via long presses.


2. Data Model

Core types

typedef struct {
  int8_t strokes;  // total shots on this hole, range [0, 99]
  int8_t putts;    // putts on this hole,        range [0, strokes]
} HoleScore;

static HoleScore s_scores[MAX_HOLES];  // MAX_HOLES = 18
static int s_current_hole = 0;         // index into s_scores, range [0, 17]

int8_t is used for both fields because:

  • The practical maximum (99) fits in a signed byte.
  • sizeof(HoleScore) = 2 bytes, so sizeof(s_scores) = 36 bytes — small enough that the entire array is written atomically as a single persist_write_data blob (no partial-update complexity).

Invariant

putts <= strokes at all times. Every mutation point enforces this:

Operation Enforcement
DOWN (1 stroke) If putts > strokes after decrement, putts = strokes
UP long (+1 putt) If putts would exceed strokes, strokes is raised to match
DOWN long (1 putt) Putts floored at 0; strokes unchanged

The invariant was chosen to reflect real golf (putts are a subset of strokes), while keeping the enforcement logic local to each handler rather than centralised — each handler knows exactly which field it owns.

Why putts are tracked separately from strokes

Many apps conflate them. Keeping a separate putt count per hole lets the player review putting performance on the scorecard (total putts is a standard handicap metric) without requiring a separate mode. The trade-off is a slightly more complex button map (long-press for putts), which is acceptable because putts happen far less frequently than stroke taps during a hole.


3. Persistence

#define PERSIST_KEY_SCORES 0
#define PERSIST_KEY_HOLE   1
Key API Written
0 persist_write_data / persist_read_data Entire s_scores[18] blob (36 bytes)
1 persist_write_int / persist_read_int s_current_hole

Write points: after every hole advance (SELECT), after jump-to-hole, after reset, and in main() after app_event_loop() returns (app exit). Stroke/putt increments during a hole are not written immediately to avoid flash wear from rapid taps.

Read point: once, at the top of main() before the window is pushed, so the first render already has the restored state.

Design decision — no per-keypress save: Pebble's persistent storage is flash-backed and has a finite write endurance. Saving on every UP/DOWN tap would burn through write cycles quickly during an 18-hole round. Losing a single stroke on a crash is an acceptable trade-off; losing an entire hole's worth of scores after a hole advance (which does save) is not.

Design decision — blob vs. individual keys: Writing the whole array as one blob is simpler than 18×2 individual keys and means the scores are always consistent (either the whole round is there or none of it is). It also fits comfortably within the 256-byte per-value maximum (sizeof(s_scores) = 36 bytes).


4. Window Architecture

Window stack hierarchy

[main_window]                ← always at the bottom; never popped by the app
  └─ [settings_window]       ← pushed by long SELECT on main
       ├─ [scorecard_window] ← pushed by "View Scorecard"
       ├─ [hole_picker_window] ← pushed by "Jump to Hole"
       └─ [help_window]      ← pushed by "Controls"

main_window is the only window created at startup and never destroyed during normal operation. All other windows are created on demand and destroy themselves in their unload handler (see §4.2).

4.1 Window lifecycle pattern

Every secondary window follows the same pattern:

// Push (caller):
s_foo_window = window_create();
window_set_window_handlers(s_foo_window, (WindowHandlers){
  .load   = foo_window_load,
  .unload = foo_window_unload,
});
window_stack_push(s_foo_window, true);

// Unload (self-cleanup):
static void foo_window_unload(Window *window) {
  // destroy child layers first
  window_destroy(s_foo_window);
  s_foo_window = NULL;
}

Setting the pointer to NULL after window_destroy prevents dangling pointer use and makes it safe to check if (s_foo_window) elsewhere.

Why self-destroy in unload? The Pebble OS calls unload before the window is fully removed from the stack, giving the app a guaranteed cleanup point regardless of whether the window was popped by BACK, by window_stack_pop, or by window_stack_remove. Centralising cleanup in unload means no caller needs to remember to free anything.

4.2 Return-to-main after settings actions

After "Jump to Hole" or "Reset Round" complete, the app needs to pop multiple windows (hole picker + settings, or just settings) and return to main. This cannot be done synchronously inside a SimpleMenuLayer select callback because the callback fires while the layer's own event processing is still on the call stack — destroying the window mid-callback would corrupt the stack frame.

Solution: 50 ms deferred timer

static void return_to_main_cb(void *data) {
  s_return_timer = NULL;
  Window *top;
  while ((top = window_stack_get_top_window()) != NULL && top != s_main_window) {
    window_stack_pop(false);
  }
  layer_mark_dirty(s_canvas_layer);
}

static void schedule_return_to_main(void) {
  if (s_return_timer) app_timer_cancel(s_return_timer);
  s_return_timer = app_timer_register(50, return_to_main_cb, NULL);
}

The 50 ms delay ensures the select callback has returned and the event loop has fully processed before any window is destroyed. The while loop pops whatever is above main without needing to know the exact stack depth, which makes it robust to future navigation changes.

Cancelling any existing timer before registering a new one prevents double-pops if an action is somehow triggered twice.


5. Main Window — Visual Layout

Screen: 200 × 228 px (no system status bar; app owns full screen).

y   043   GColorJaegerGreen fill (header)
            "HOLE N / 18"  GOTHIC_18_BOLD white centered  (y=12, h=22)
y  4449   gap
y  5071   "STROKES"       GOTHIC_18_BOLD dark-gray centered
y  70127  stroke count    BITHAM_42_BOLD black centered
y 133       divider line   GColorLightGray  x=[20, w-20]
y 139160  "PUTTS"         GOTHIC_18_BOLD dark-gray centered
y 158215  putt count      BITHAM_42_BOLD GColorCobaltBlue centered
y 216227  hint bar        GColorLightGray fill
            hint text      GOTHIC_14 dark-gray trailing-ellipsis centered

Design decisions:

  • Custom Layer instead of TextLayers: The main screen is drawn by a single LayerUpdateProc (canvas_update_proc) rather than a collection of TextLayers. This avoids the overhead of multiple layer objects, keeps all layout constants in one place, and gives full control over drawing order (background → header fill → text, all in one pass).

  • BITHAM_42_BOLD for counts: The largest readable number font available as a system font. At 42 pt the count is legible at a glance without raising the watch, which is the primary use case (hand on club, quick look).

  • Strokes black, putts cobalt blue: Colour-coding the two counters avoids misreading at a glance. Blue was chosen for putts as it is visually subordinate to black, reflecting that putts are a subset metric.

  • Hint bar at bottom: The 12 px hint bar is small enough to not compete with the counts but visible enough to remind new users about the long-press bindings. GTextOverflowModeTrailingEllipsis ensures it never wraps.

  • No status bar: StatusBarLayer would consume 20 px and show a clock the player already has on the watch face. Reclaiming that space for larger number fonts was the right trade-off for a glance-use app.


6. Button Map

Main window

Button Type Action Vibration
UP Short strokes++ (max 99) Short pulse
DOWN Short strokes-- (floor 0); auto-clamp putts None
SELECT Short Advance to next hole; save Short pulse (double on hole 18)
UP Long 700 ms putts++; auto-raise strokes if needed None
DOWN Long 700 ms putts-- (floor 0) None
SELECT Long 700 ms Push settings window None

Why no vibration on DOWN? A correction is often a recovery from a mis-tap. Adding a vibe would feel punishing and draw attention on the course. The asymmetry (vibe on increment, silent on decrement) also gives tactile confirmation that a stroke was successfully counted without looking.

Why 700 ms long-press threshold? The Pebble default is 500 ms. 700 ms was chosen to reduce accidental long-press triggers during normal play — a player tapping UP repeatedly at pace will not accidentally trigger the putt increment.

Why long SELECT for settings rather than BACK? The BACK button's system behaviour (pop window / exit app) cannot be reliably overridden for long press. Using SELECT long-press is the standard Pebble pattern for secondary actions.

Settings window

Handled by SimpleMenuLayer, which wires UP/DOWN to scroll and SELECT to activate. BACK pops the window via system default.

Scorecard and Help windows

ScrollLayer wires UP/DOWN to scroll. BACK pops via system default.

Hole picker window

SimpleMenuLayer as above. Selecting a row calls hole_picker_select_cb, which sets s_current_hole, saves, and schedules the return-to-main timer.


7. Settings Window

Implemented as a SimpleMenuLayer with one section and four items:

Index Title Subtitle Effect
0 View Scorecard Push scorecard window
1 Jump to Hole Go back to any hole Push hole picker window
2 Reset Round Clears all scores Zero all scores, reset to hole 1, save, double vibe, return to main
3 Controls Button cheatsheet Push help window

SimpleMenuSection and SimpleMenuItem arrays are static module-level variables. SimpleMenuLayer does not deep-copy the arrays — if they were stack-allocated in settings_window_load they would be freed before the layer uses them. Static allocation is the correct pattern here.

Items are initialised in settings_window_load rather than at file scope because SimpleMenuItem.callback is a function pointer that cannot be a compile-time constant in C89/C90 (though it can in C99). Initialising in load keeps the code compatible across SDK versions.


8. Hole Picker Window

SimpleMenuLayer with one section of 18 rows, pre-scrolled to s_current_hole.

Row content is built in hole_picker_window_load:

Title:    "> Hole N"   (current hole)
          "  Hole N"   (all others)
Subtitle: "X str / Y ptt"   (if played: strokes > 0 OR putts > 0)
          "not played"       (otherwise)

Why > prefix instead of a colour or bold? SimpleMenuLayer does not expose per-row styling. A text prefix is the only reliable way to mark the current hole within the system widget. Two leading spaces on non-current rows maintain visual alignment.

Why rebuild titles/subtitles in window_load rather than keeping them live? The data only needs to be accurate when the window opens. Keeping 18×(16+24) = 720 bytes of string buffers permanently resident but only occasionally accurate is wasteful. Building on load is cheap (one pass over 18 HoleScore structs) and guarantees the picker reflects current scores even if the user edits a hole and reopens settings.

Title/subtitle buffers are static for the same reason as settings items — SimpleMenuLayer holds pointers into them.


9. Scorecard Window

A ScrollLayer containing a single TextLayer. Content is built by build_scorecard() into s_scorecard_buf[700].

Buffer sizing: 18 rows × ~18 chars + header (30 chars) + footer (20 chars) + current-hole marker (2 chars × 1 row) ≈ 370 chars. 700 bytes gives comfortable headroom.

The current hole is marked with a trailing < on its row so the player can orient themselves without counting.

The content height for the ScrollLayer is measured with graphics_text_layout_get_content_size at load time using the same font and bounding width as the TextLayer. This keeps the scroll range exactly tight to the rendered text — no arbitrary magic number for content height.

The scorecard is read-only. Reset was moved to the Settings menu to enforce a clear separation: viewing data is non-destructive and accessible, mutating data requires an explicit settings action.


10. Help Window

A ScrollLayer + TextLayer displaying HELP_TEXT, a static const char *const string literal compiled into flash. No heap allocation is needed for the string itself — text_layer_set_text holds a pointer to the literal directly.

The pattern is identical to the scorecard window but simpler: no build_* function, no dynamic buffer, no current-state dependency.


11. Static Variable Strategy

All window pointers, layer pointers, and string buffers are file-scope static. This is the idiomatic Pebble C pattern for several reasons:

  1. Pebble heap is small. Stack frames are limited; large char arrays on the stack risk overflow.
  2. Layer pointers must outlive the function that creates them. window_load builds the UI, but the layers live until window_unload. File-scope statics have the right lifetime automatically.
  3. SimpleMenuLayer does not copy its data. Section and item arrays must remain valid for the lifetime of the layer — static storage guarantees this.

The downside is that only one instance of each secondary window can exist at a time, which is enforced by the navigation structure (settings is modal; its children are modal within it).


12. Source File Organisation

Functions are ordered bottom-up by dependency so that no forward declarations are needed:

1.  Persistence (save_scores, load_scores)
2.  Main canvas draw (canvas_update_proc)
3.  Main window button handlers
4.  Scorecard window (build_scorecard, load, unload)
5.  Return-to-main timer (return_to_main_cb, schedule_return_to_main)
6.  Hole picker window (select_cb, load, unload)
7.  Settings callbacks (scorecard_cb, jump_cb, help_cb, reset_cb)
        — defined after the windows they push
8.  Settings window (load, unload, show_settings)
9.  Main window click config and lifecycle
10. main()

This ordering means that every called function is defined before its caller, eliminating the need for a header file or forward declarations.


13. Memory Footprint (Emery)

Region Size
s_scores 36 bytes
String buffers (hole_buf, stroke_buf, putt_buf) 40 bytes
s_scorecard_buf 700 bytes
s_hole_titles 288 bytes (18 × 16)
s_hole_subtitles 432 bytes (18 × 24)
HELP_TEXT ~380 bytes (flash, not RAM)
Total static RAM (approximate) ~1.5 KB
SDK-reported free heap 127 KB

The app is extremely light on memory. The largest single allocation is s_scorecard_buf at 700 bytes. All secondary window layers are allocated in their load handlers and freed in unload, so only one secondary window's layer objects are in heap at any time.