Fix the calibrator's corner gate, and log what a corner walk looked like #1

Merged
lytedev merged 17 commits from log-calibration-runs into main 2026-08-20 13:51:26 -05:00
Owner

Two commits. The rationale lives in the commit messages; this is the summary.

The bug: _records quadrant gate compared a BODY measurement against the corners SCREEN fraction. The view is mirrored, so screen-left is body-right — for every corner the gate rejected the hand actually reaching it and accepted a hand reaching the opposite one. Any derived calibration came from the wrong corner, and completed normally while doing it.

The confirmation guard is unaffected (it tests screen distance), so what is deployed plays correctly today; only the calibration measurement is wrong.

Why no test caught it: the round-trip held the UI corner then stretched to the play-rect corner, so the first hold completed the corner and the second was recorded against the next slot — and the mirror makes a neighbouring corner numerically what this one wanted. Two bugs cancelled to exactly 0.8000 at every value of detect. The gate was genuinely uncovered, not under-tested: both mutations produced zero failures before the new test existed.

The logging answers the three timings (TOUCH_PX, TOUCH_SEC, CORNER_TIMEOUT_SEC) that were chosen by reasoning rather than measurement, and records demonstrated per corner so a limiting corner is distinguishable from a small room. Cancelled runs log too.

All four flake checks green.

Two commits. The rationale lives in the commit messages; this is the summary. **The bug:** `_record`s quadrant gate compared a BODY measurement against the corners SCREEN fraction. The view is mirrored, so screen-left is body-right — for every corner the gate rejected the hand actually reaching it and accepted a hand reaching the opposite one. Any derived calibration came from the wrong corner, and completed normally while doing it. The confirmation guard is unaffected (it tests screen distance), so what is deployed plays correctly today; only the calibration measurement is wrong. **Why no test caught it:** the round-trip held the UI corner then stretched to the play-rect corner, so the first hold completed the corner and the second was recorded against the *next* slot — and the mirror makes a neighbouring corner numerically what this one wanted. Two bugs cancelled to exactly 0.8000 at every value of `detect`. The gate was genuinely uncovered, not under-tested: both mutations produced zero failures before the new test existed. **The logging** answers the three timings (`TOUCH_PX`, `TOUCH_SEC`, `CORNER_TIMEOUT_SEC`) that were chosen by reasoning rather than measurement, and records `demonstrated` per corner so a limiting corner is distinguishable from a small room. Cancelled runs log too. All four flake checks green.
The quadrant gate in `_record` compared a BODY measurement against the
corner's SCREEN fraction. The view is mirrored, so screen-left is
body-right: for every corner the gate rejected the hand actually
reaching it and accepted a hand reaching the OPPOSITE corner. A
calibration would have been derived from whichever wrong corner a player
happened to pass through.

It is now taken from the target's own position run through the same
mapping as the hand, so the comparison is mirror-agnostic and stays
correct for any mapping this module is handed.

# Why no test caught it

The calibration round-trip held the UI corner and then stretched to the
play-rect corner. The first hold COMPLETED the corner, so the second was
recorded against the next corner's slot — and because the mirror makes a
neighbouring corner's body position numerically what this one wanted, it
derived exactly 0.8000 at every value of detect. Two bugs cancelling,
producing the expected number to four decimal places.

That is the `7 % 6` failure again, and the reason it surfaced now is the
instrumentation: `demonstrated[0]` asks which corner supplied the
evidence, which the old assertion on the derived value alone could not
distinguish.

Fixed both. The walk is now one hold per corner, straight to the play
rect's corner, and `_a_hand_in_the_wrong_corner_is_not_evidence_for_this_one`
exercises the gate head-on in both directions rather than hoping a ring
walk covers it — it did not.

Confirmed to fail: removing the gate gives 1 failure, restoring the
screen-space comparison gives 2. Both were confirmed to produce ZERO
failures before this test existed, so the gate was genuinely uncovered
rather than merely under-tested. An earlier attempt at the second
mutation appeared to pass and had in fact died on a GDScript type error
before running a single check — no output is not a green run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
feat: log what a corner walk actually looked like
All checks were successful
/ check (push) Successful in 44s
3931c5cd84
TOUCH_PX, TOUCH_SEC and CORNER_TIMEOUT_SEC were chosen by reasoning
about a child at the edge of detection — the worst place to reason
about, since it is where wrist localisation is poorest and where nobody
has measured anything. The first real runs are the only chance to find
out whether they are right, and an impression of "that seemed to work"
is not recoverable afterwards.

So every completed run prints one line: per corner, seconds to first
contact, seconds to completion, and how many times an in-progress hold
was abandoned; plus the reach each corner demonstrated and the value
derived from them.

Each field answers one guess. A gap between first contact and completion
with several resets means TOUCH_SEC is longer than a child can hold an
arm still at arm's length. First contact near the timeout, or -1, means
the corner was out of reach or the target was not understood. Low first
contact throughout means the screen reads — the one thing that cannot be
asserted headlessly at all.

`demonstrated` is per corner rather than only the minimum, so a reader
can see WHICH corner decided the result. That is the difference between
"this room is small" and "one child gave up on the sofa side".

Cancelled runs are logged too: an abandoned calibration is itself a
measurement of legibility, and the runs worth reading are exactly the
ones nobody would have thought to write down.

Tested by driving a hand that reaches, drifts off before the hold
completes, and returns — asserting all three fields reflect it, and that
a corner nobody approached records -1 rather than the 0 a
zero-initialised timing would show, which would read as an instant
success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: let the mouse be a mouse
All checks were successful
/ check (push) Successful in 14s
5a0561bb06
The mouse was fed through the dwell machinery as one more pointer, on
the reasoning that one code path is easier to keep correct than two.
That is true of the hit-testing and false of the interaction: resting
the cursor on a button PRESSED it after a second, a filling ring was
drawn where the cursor already is, and nothing ever showed an ordinary
hover highlight. Clicking worked, but a player reading the menu
triggered something before reaching that discovery.

Dwell exists because a hand has no button. A mouse has one, and every
other program on the machine has already taught the player what it
does.

So the mouse now hovers and clicks. Hit-testing is still shared with
the hands — that part was never the problem. Hovering a title button
highlights it exactly as keyboard focus does, since "this is what you
would press" is one question and answering it two ways would teach the
player two things. Hovering a settings row focuses it, so the wheel and
the keyboard act on what the cursor is over; that is set directly
rather than through `_focus_row_under`, whose stickiness exists to stop
a jittering wrist flickering the highlight and which reads as lag on a
cursor that does not jitter.

Hands are untouched.

# Testing

`_the_mouse_hovers_and_clicks_rather_than_dwelling` holds the cursor on
Play for three seconds — far longer than any dwell, so "not pressed"
cannot be an artefact of a short wait — then clicks. Confirmed to fail
by restoring the shared-pointer line: 2 failures, the press and the
ring.

Nothing asserted this before, which is why it shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
refactor: one button, drawn one way
All checks were successful
/ check (push) Successful in 18s
83752f1aa5
Daniel: "some buttons don't have the same hover or look as others? do we
not have a single component/node for these?" No — there were four
independent drawers (title buttons, settings tabs, settings steppers,
settings footer), each deriving "is something pointing at this" its own
way, and the palette was written out twice verbatim.

The visible consequence was the +/- steppers: their drawer matched a
dwell's target RECT against the rect being drawn, which cannot see a
cursor at all, so they were the only controls in the game with no hover
state. Two of the four grew a focus ring and two did not.

`ui.gd` is now the one drawer and the one palette, and `is_hot(action)`
the one definition of "something is pointing at this" — a hand mid-dwell,
the cursor, or keyboard focus, since every input is asking the same
question and answering it three ways is what let them drift.

These stay DRAWN rather than becoming Godot `Button` nodes, and that is
not a compromise: every target must be hit-testable by a wrist at a
position the pose daemon reports, and a Control's own input handling
only knows about mice and focus. So the shared thing is a drawing
function; layout and hit-testing stay with the screen that owns them.

This is the fast-follow `doc/menu-unification.md` was written for. Two
of its predictions were wrong and the doc now says so: the
mouse-as-a-pointer merge it wanted preserved is deleted rather than
extracted (see the previous commit), and "two copies" was four.

# Testing

The existing menu/title/reach tests pass unchanged, which is what that
doc named as the check that the extraction preserved behaviour.

Note for anyone repeating this: `ui.gd` was untracked at first, and the
Nix build only sees tracked files — so the sandbox failed to load
settings_menu.gd while everything passed locally. The symptom was
`reach: there were controls to audit (0)`, which reads like a layout
bug and is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: one menu on screen at a time
All checks were successful
/ check (push) Successful in 15s
1ab52321c8
The settings backdrop is see-through on purpose — you have to be able
to see your own hands while aiming at it — and the title screen was
left visible underneath. So its buttons and labels showed through: two
sets of controls at once, only one of which does anything while the
menu holds the pointers.

Closing restores what was RECORDED on open rather than deciding again,
which is what keeps the two exits from disagreeing: opened from the
title it goes back to the title, opened from play it returns to the
round rather than dropping the player at the front of the game.

# Testing

`_only_one_menu_is_on_screen_at_a_time` walks both entries and both
exits. Confirmed to fail by dropping the hide: 1 failure, 'settings
alone, with the title put away'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: reach is two numbers, because a body is not symmetric
Some checks failed
/ check (push) Has been cancelled
443b6b8693
Daniel: "seems cropping is too aggressive by default? can seem to
'reach' the bottom of my screen out of the box?"

`reach` was ONE number for both axes, and the derivation took
`min(|dx|, |dy|)` across them. Arms swing wide sideways and travel very
little up and down, and a 16:9 frame widens the gap again — so the
vertical shortfall was always the smaller number, and it became the
answer for BOTH axes. The horizontal play area was then far smaller
than anything the player had demonstrated, which is the cropped feeling,
and the bottom of the screen sat where only a hand at ankle height
could reach.

Now `play_reach` (side to side, 0.8) and `play_reach_y` (up and down,
0.55) are separate settings, and the corner walk derives each axis from
its own minimum. Within an axis the minimum is still right: it is what
makes an unreachable corner information rather than a failure.

# The bug this uncovered, which matters more

Presence is tested on the SHOULDER midpoint, and a standing body's
shoulders sit well above the band its hands sweep through. While reach
was one number the play area and the presence region were the same
rect and this was free. Splitting the axes broke it silently: at a
realistic `reach.y` every player's torso fell outside the play area, so
they were marked out of position, lost their gloves, and could not clap
to open the menu. A person standing normally in front of the camera was
not in the game.

`presence_crop_rect` separates the two. Horizontal reach still gates
presence — stepping sideways out of the play area is meant to drop your
gloves while keeping your player number, which is the structural answer
to identity churn. Vertically it spans the whole detected region: there
is nowhere to walk on that axis, and "your shoulders are higher than
your hands go" is not a reason to take somebody out of the game.

# Also: what to do when you cannot reach the dot

Daniel: "not sure how you're expecting the 'touch the glowing dot'
calibration system to work? what if you can't reach?"

The answer was already right in the code — an unreachable corner
records how far the hand got and shrinks the play area — and nowhere on
screen. The calibrator now says "Can't get there? Reach as far as you
can and hold." CONFIRM deliberately does not: there, falling short
means the play area you just chose is unusable and will be put back, so
inviting a player to give up would invite them to keep a setting they
cannot operate.

# Testing

`_a_standing_body_counts_as_present_however_short_the_vertical_reach`
covers the presence bug at four vertical reaches, and asserts the
horizontal half is NOT given the same treatment. Confirmed to fail by
pointing presence back at the play crop: 2 failures.

`_no_crop_and_no_padding_is_an_exact_identity` caught a precision
regression on the way: GDScript floats are doubles while Vector2
components are single-precision, so computing the margins through a
Vector2 rounded twice and moved the frame edge by ~2e-5. The margins
are computed as scalars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: the glove reaches past the wrist without a confident elbow
All checks were successful
/ check (push) Successful in 16s
79a9c2c721
Daniel: "really feels like 'hands' stop right at wrists, which is super
annoying?" They did, and the cause is not the reach setting.

`_forearm_of` returned ZERO whenever the elbow scored below MIN_SCORE,
and blade.gd responds to a zero arm vector by keeping its PREVIOUS
reach — which on a glove that has never had a confident elbow is zero.
The glove then sits exactly on the wrist and the player's reach ends at
their hand, whatever "Glove reach" is set to.

That is the ordinary case rather than an edge one. Measured on a real
camera, elbow confidence ran around 0.015 against a MIN_SCORE of 0.35:
the elbow is the worst-localised joint in the arm, being small,
frequently occluded by the torso, and blurred by exactly the fast
motion this game is made of.

The shoulder is localised far better, and with the arm extended — when
a punch happens, and when reach matters — the forearm is about half the
shoulder-to-wrist span. An approximation pointing the right way beats a
precise answer the model cannot supply. With neither joint the answer
is still zero: there is no direction to be had, and inventing one would
point the glove somewhere arbitrary.

# Testing

`_a_glove_reaches_past_the_wrist_without_a_confident_elbow` feeds a
confident shoulder and wrist with an elbow at 0.05, and asserts both
the length and that it points away from the shoulder rather than back
at it. Confirmed to fail by removing the fallback: 2 failures.

blade.gd is unchanged apart from its comment, which claimed the
previous-reach path kept the blade's shape. It does, except from a
standing start, and that exception was the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: point from the middle of the hand, not the wrist joint
Some checks failed
/ check (push) Has been cancelled
ae0aa52b21
Daniel: "Cursor is still showing up at the wrists instead of at roughly
the center of mass of the hand... we do need to at least extend the
cursor by some reasonable amount of the forearm to account for the
hand."

The model gives 17 COCO keypoints and the wrist is the LAST one on the
arm, so there is nothing past it to point with. The pointer sat on the
joint while the player aimed with their hand: a cursor trailing an inch
or two behind where you point, which cannot be corrected by aiming
differently because the offset rotates with the arm.

`_hand_positions` now extends along the forearm by a third of its
length, which is a shade past the knuckles on most people. Anatomy
rather than tracking, deliberately: real hand tracking means a second
model and a much larger frame budget to place a point this estimates
well enough.

It uses the SAME arm vector the glove is built from, so the pointer and
the fist cannot disagree about where the hand is — two answers to one
question is how a player ends up seeing their glove on a button that
will not press.

Deliberately NOT changed: `_hand_pairs`, which feeds the clap. That
gesture measures wrist SEPARATION, and its thresholds were tuned
against wrists on real footage; moving both endpoints outward would
rescale every one of them for no benefit, since a clap is judged by the
gap rather than by where the gap is.

# Testing

`_the_pointer_sits_in_the_hand_not_on_the_wrist` asserts the
displacement is along the arm rather than against a pixel count — the
fraction is a tunable, the direction is the contract — and that it does
not overshoot the fist the player is watching. Confirmed to fail with
the fraction at 0.0: 2 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
Daniel: "Default camera cropping at least on this machine is really
bad. It seems to be cutting off like 50% of the pixels, so I have to
scoot way back, and even then, I'm still not in frame enough, so my
hands are bouncing around everywhere, and it's basically impossible to
test."

Measured rather than guessed at this time: the displayed camera view IS
the play crop (play_area.gd draws the video through it), so a vertical
reach of 0.55 against a detect of 0.85 showed 47% of the frame height.
Half the pixels, exactly as reported.

`play_reach_y` therefore starts EQUAL to the horizontal default. That
is not a retreat from the per-axis split — the split is what lets
calibration answer the two axes separately, and the vertical answer
genuinely is smaller in a room. What it retreats from is my guess at
the number, which was reasoned from anatomy, shipped untested, and
turned out to cost the one thing a player needs in order to judge
anything: being able to see themselves.

A default that is merely imprecise costs some reach. A default that
hides half the frame costs the ability to test at all, which is worse,
and it is the failure mode to prefer avoiding when the honest answer is
"the room decides this".

So the room decides it, through "Fit to the room".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
feat: the full frame by default; hold still to set the edge
Some checks failed
/ check (push) Has been cancelled
5ec37f628c
Daniel: "the default should be the full frame I think - calibration
should let us 'narrow in' somehow, no?" and then, of the calibrator:
"how is the user going to indicate the desired bounds?"

Both defaults are now 1.0 — the whole detected region. The 0.8 margin
was doing real work (it is where a player stands, stays tracked, and
keeps their player number instead of returning as somebody new), but
the asymmetry decides it: too WIDE fails visibly and recoverably, since
a control near the edge is a stretch and one calibration walk fixes it.
Too NARROW fails invisibly — the player never learns there was more
frame, only that the game cannot see them. Defaults should fail the way
you can see. Calibration still derives values below 1.0, so the padding
returns for the rooms that need it, measured rather than assumed for
everybody.

Which exposed the second question, and it was a real gap: the player
could not indicate anything. A corner they could not touch ended only
by TIMING OUT, so the only way to say "this is my limit" was to stand
at it and wait out a twelve-second clock with nothing on screen
suggesting that would do anything at all. Fine while most corners were
touched; with the play area now spanning the whole detected region,
ending by not-touching is the ORDINARY path.

So holding still accepts where you are, and the on-screen line says so.
It fills the same ring the touch-hold does, because from the player's
side these are one gesture: stay put and it counts.

# The failure this invites, and what prevents it

A player standing with their arms down is perfectly still. If that
counted, every corner would accept instantly and the play area would
collapse onto whatever the resting pose happens to be — a far worse
outcome than the timeout it replaces.

The quadrant gate in `_record` is what stops it: stillness is only
accepted once a hand has been somewhere that counts as evidence for
THIS corner. Note this is the gate that was itself broken until two
commits ago, and it is now load-bearing for a second reason.

# Testing

`_holding_still_sets_the_edge_where_the_player_stopped` stops two
thirds of the way out and asserts the derived edge is where the hand
was, not where the target was — and that the corner is NOT marked
reached, since the guard asks exactly that question. It also asserts
the hold completes well before the timeout could be doing the work,
so the test cannot pass for the wrong reason.

`_a_hand_resting_in_the_middle_cannot_set_an_edge` covers the failure
above. Confirmed by removing the gate: 3 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: pin the deck-pose that publishes the detected region
Some checks failed
/ check (push) Has been cancelled
64a26ca53b
Daniel: "video still insanely cropped" — after both play-area defaults
had already been set to 1.0, which should have meant no cropping at
all.

It was not the settings. The flake pinned deck-pose at 281438b, which
predates 4642803 "publish the region a player can be detected in", so
no pose message carried a `detect` field. framing.gd then falls back to
ASSUMED_DETECT — 0.5 — and multiplies it by the play area: a saved
play_reach of 0.75 displayed 37% of the frame.

Worth noting how the fallback behaved here. It exists so an old daemon
degrades rather than crashes, and 0.5 was chosen because it is the
Deck's crop, so the game guesses the common case. That is right for
correctness and wrong for diagnosis: the game cropped hard, silently,
and looked exactly like a settings problem. Both defaults were changed
to 1.0 chasing it before the actual cause turned up. A one-line
"daemon did not report a detected region; assuming 0.5" would have
ended that in seconds, and belongs in a follow-up.

The two crops MULTIPLY, which is what made a plausible-looking 0.75
into something unusable. That composition is deliberate and documented
in play_crop_rect — the play area must be inside the detected region by
construction — but it means a wrong `detect` is not a small error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: don't stretch an uneven crop, don't drop the arm for a frame
Some checks failed
/ check (push) Has been cancelled
848fe99c77
Two reports, two causes, both introduced by per-axis reach.

# The stretch

Daniel: "the video is being stretched for some reason? Are we not
detecting the aspect ratio of the camera". It was detected correctly —
it simply stopped being the aspect of the thing being drawn. Per-axis
reach makes the crop non-square in NORMALISED terms, and a 0.75-wide,
1.0-tall crop of a 16:9 sensor is a 4:3 region of PIXELS. Fitting that
into a rect built from the sensor's 16:9 stretches everybody sideways.

The play rect is now fitted to `camera * crop`, which restores the
property `_mapping_is_uniform_on_both_axes` is named for — one scale
factor for x and y. That is not cosmetic: a stretched image and an
unstretched mapping disagree everywhere except the centre, so the glove
drifts off the hand drawing it, and the further out you reach the worse
it gets.

# The jump

Daniel: "sometimes my hand seems to jump back to the wrist position".
The pointer is the wrist plus the arm vector, and `_forearm_of`
answered a frame with no confident elbow AND no confident shoulder with
zero — which puts the pointer exactly on the joint and back out again
the next frame. Keypoint confidence flickers, hardest mid-swing, which
is when a player is watching their hand.

An arm does not teleport in 16ms, so the last vector is a better
estimate than the wrist is. It is dropped on `player_left`: a new
player inheriting the id must not start out pointing along somebody
else's arm.

# Testing

`_an_uneven_crop_is_not_drawn_stretched` covers the arithmetic and
`_the_play_rect_follows_the_crops_aspect` covers main.gd calling it.
The second exists because removing the call left every pure test green
— the invariant lived in a function nobody was obliged to use, which is
the same shape as the bug it guards.

`_a_one_frame_keypoint_dropout_does_not_snap_the_pointer_back` covers
the jump and the departure.

One existing assertion was outdated rather than broken and now says
what still holds: an arm NEVER seen yields zero; an arm seen and lost
keeps its last vector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
feat: camera fit — contain, cover or stretch
All checks were successful
/ check (push) Successful in 17s
eff5cfab0b
Daniel: "we can have a setting to allow camera to either be stretch,
contain, or cover?"

These are not a picture preference. The play rect IS the mapping —
every pose coordinate becomes a screen position through it — so each
mode trades something real:

  * CONTAIN letterboxes. Nothing the camera sees is hidden, which is
    what you want when the question is "can it see me", so it is the
    default. Costs bars and a play area smaller than the screen.
  * COVER fills the window and pushes the rest of the frame off-screen.
    Bigger picture, and reaching is EASIER because the window's corners
    correspond to positions further inside the frame — but a hand out
    at the edge of what the camera sees has nowhere on screen to be.
  * STRETCH fills it by distorting. Nothing hidden, nothing letterboxed,
    circles become ovals — and a hand's speed differs by axis, because
    the two scale factors do. The game's thresholds assume one scale, so
    a punch lands more easily in one direction than the other. Offered
    anyway: "fill my screen and show everything" is a reasonable thing
    to want, and the distortion is visible enough to judge yourself.

# The part that is not obvious

Cover makes the play rect LARGER than the window, and controls are laid
out inside the play rect — so a button in the overhanging part is not
merely awkward, it is not on the screen at all. `ui_rect` now takes the
viewport and intersects first. Reachable and visible are two conditions
and that function is where they meet.

Implemented as a float with `labels` rather than a third setting kind.
A new kind would have to be taught to step_value, clamp_value, the CLI
parser, the config writer and the menu's row drawing — five places, for
something that behaves exactly like a stepper with a short range. The
only genuinely different thing is how the value reads.

# Testing

`_each_camera_fit_does_what_its_name_says` checks each mode by the
property that distinguishes it, on windows deliberately far from 16:9
in BOTH directions — contain and cover are the same two candidate sizes
with the opposite choice, so implementing one backwards looks plausible
on a window whose aspect is near the camera's.

`_cover_never_lays_a_control_off_the_screen` covers the clipping.
Confirmed by removing the intersection: 3 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
Measured from a real run on the Deck, caught by the logging this branch
added:

  first_contact: -1 on all four corners   (nobody touched anything)
  completed: 8.8s, 4.5s, 4.0s, 7.0s       (all ended by hold-still)
  derived_reach: [0.3675, 0.3167]

Daniel: "I feel like there's some invisible wall around the edges...?"
That wall is the calibrator's own output. Positional stillness accepted
people HESITATING ON THE WAY OUT — which is simply how reaching works,
and a second of it is indistinguishable from arriving. Every corner
finished early, each minimum stacked, and the play area came out a
third of the frame.

Improvement cannot be faked by pausing. While the hand is still getting
further out the timer resets, however slowly it moves; once it stops
advancing, the corner has given up everything it is going to and there
is nothing left to wait for. STILL_SEC also goes to 1.4s.

# Also: the confirmation guard covers the whole mapping now

Daniel: "changes to camera settings should require a confirmation
dialog that requires users to reach all corners of the screen so we
establish UI works."

It compared reach and nothing else, so `camera_fit` slipped past it
entirely — and that setting moves where a hand lands as thoroughly as
reach does, since cover pushes the frame's edges off-screen and stretch
rescales the axes unequally. A player could make the controls
unreachable and the game would agree silently, which is the exact
failure the guard exists to prevent. `_mapping_now()` is now one value,
so a fourth thing that moves the mapping is added in one place and the
guard follows.

# And the text that told nobody anything

Daniel: "the 'hold still' text is busted and doesn't wrap and is cut
off". `draw_string`'s width argument CLIPS rather than wraps — the same
trap already documented in ui.gd, walked into on the one screen whose
entire job is telling the player what to do. It is `draw_multiline_
string` now, and the line below it is placed under the wrapped height
rather than at a fixed offset.

# Testing

`_hesitating_on_the_way_out_does_not_end_the_corner` pauses part way
for longer than STILL_SEC, continues, and asserts the edge lands where
the hand stopped rather than where it paused.

`_every_mapping_setting_is_confirmed_on_the_way_out` walks play_reach,
play_reach_y and camera_fit through change-then-close, and asserts
cancelling the walk restores the value. Confirmed to fail with the old
reach-only comparison: 2 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
feat: live-editable tuning, reloaded while the game runs
All checks were successful
/ check (push) Successful in 17s
1728786596
Daniel: "we need a way to live tweak the config values and hot-reload
them - iterating on these via deploys is slow - parameterize everything
and shove it in a config file we can live edit via ssh or a text
editor."

Earned, and by this branch specifically: every feel-deciding number in
it has been wrong at least once in a way only visible with a person in
front of a camera, and each correction cost a build, a deploy and a
relaunch. STILL_SEC was wrong twice.

Edit `tuning.cfg`, save, see it within half a second. The path is
printed at startup, and a commented template listing every key with its
current default is written on first run — a tuning file nobody can
discover is a tuning file nobody uses, and reading the source over ssh
to find out what is adjustable defeats the point of editing over ssh.

# Why it is not the settings screen

`settings.gd` is what a PLAYER changes: it persists, it has a menu, its
values are part of the game. This is what a DEVELOPER changes while
tuning — thresholds nobody should have to think about once they are
right. Keeping them apart stops the settings screen filling with rows
nobody wants, and stops a tuning file left on a machine becoming
somebody's saved configuration.

These are meant to GRADUATE: once a number is right it becomes the
default in code and the entry goes away. A tuning file that never
empties means the number should have been a setting.

# The safety property

Every read goes through `num(key, default)` where the default is the
constant in the code, so an absent file, a deleted key, a typo, a
half-written save or a wrong type all mean "use the built-in value".
That is not tidiness: the file is edited over ssh on a machine in
another room, while the game runs in front of people, and a file being
part-way through an edit is its ordinary state. Deleting it is a
supported way back to every default.

Polled by stat twice a second rather than watched: Godot's file
watching does not cover `user://` portably, and an editor writing
through a temp file and a rename defeats most watchers anyway.

# Testing

`tuning_test.gd` covers the reload, the deletion, the template, and
five broken files — a string where a number goes, NaN, a truncated
section header, empty, and garbage — each asserting the game keeps a
usable number.

Six values are routed through it to start (the calibrator's four
timings, its improvement margin, and the hand-centre fraction), chosen
because they are the ones this branch has actually been iterating on.
`_tunables()` is the single list, used both to write the template and
as the record of what is adjustable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
feat: the tuning file explains each value instead of listing numbers
All checks were successful
/ check (push) Successful in 16s
13994f67c8
Daniel: "put comments in the config so tuning is more interactive and
guided".

`still_sec = 1.4` says what the value is and nothing about what it
does, which way to move it, or what it costs when it is wrong — so the
file was discoverable and still useless to anyone who had not just read
the source. Each entry now carries the three things a person actually
needs while standing in front of a camera with an editor open: what it
decides, what going up buys, and what going down costs.

`more`/`less` name the CONSEQUENCE rather than the direction, because
"higher = more seconds" tells nobody anything. Where a value has a
known failure mode it says so, since most of these were found by
hitting them — still_sec records that too low reads a pause on the way
out as an edge, which is the bug that derived a play area a third of
the frame.

The prose lives in `_tunables()` next to the behaviour it describes
rather than in tuning.gd, because a description that drifts from what
the number does is worse than no description.

The header also says what to DO with a value that turns out right: tell
Claude, and it becomes the default in code. This file is for finding
numbers, not keeping them.

# Testing

The template test now asserts the guidance is actually emitted, and —
the part worth having — that the commented file still PARSES back as
its own defaults. A template only a human can read would be a quiet
regression: the game would fall back to code constants while the file
appeared to say otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
fix: a grab points from the hand, and is still judged on the wrists
All checks were successful
/ check (push) Successful in 1m30s
7759238d28
Daniel: "when 'clap-grabbing' a setting slider, we seem to be resorting
to wrist position again?" It was — `grab.gd` took its midpoint from the
wrists, so grabbing a slider snapped the marker back onto the joint
after every other pointer in the game had just agreed the hand is
further along the forearm, and the drag was then measured from
somewhere the player was not looking.

These are two questions and only one was being asked. SEPARATION
decides whether this is a clap, and every threshold in grab.gd was
tuned against wrist distance on real footage — moving both endpoints
outward along the forearms would rescale all of them for no benefit.
POSITION is where the player is pointing, which the rest of the game
answers with the middle of the hand.

So the pair carries both: wrists for the gesture, centres for the
marker and the drag origin. Absent centres fall back to the wrists, so
nothing changes for a caller that does not have them.

This is the same split I made when the pointer moved into the hand and
then only did half of — the commit then said `_hand_pairs` was left
alone deliberately because the clap is judged by the size of the gap
rather than where the gap is. True, and it silently answered the OTHER
question with the wrist too.

# Testing

`_the_grab_marker_sits_in_the_hands_but_the_clap_is_judged_on_wrists`
puts the hand centres straight DOWN from the wrists — a large offset
that leaves wrist separation untouched — so the two questions can be
told apart. It asserts the same wrists engage either way, the marker
moves to the hands, the fallback still works, and that the two
positions differ at all, without which the test would prove nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtnYWFX9eMBH7oK3gmcMMs
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lytedev/deck-slice!1
No description provided.