Skip to content

1.4.7 — placement polish + recomposition perf + iOS ad fix#7

Merged
yet300 merged 5 commits into
mainfrom
1.4.7
May 17, 2026
Merged

1.4.7 — placement polish + recomposition perf + iOS ad fix#7
yet300 merged 5 commits into
mainfrom
1.4.7

Conversation

@yet300
Copy link
Copy Markdown
Owner

@yet300 yet300 commented May 17, 2026

Summary

Polish + perf release. Five commits, all responding to specific tester
reports or Layout Inspector measurements taken during 1.4.6 testing.

User-visible fixes

  • Placement animation: "ghost cells" / jerky bounce on multi-cell pieces.
    popIn used an aggressive anisotropic squash (scaleX 1.25 / scaleY 0.65)
    plus a per-cell (x+y) * 25L wave delay. Both were tuned for a single
    isolated cell. On a 2×2 or larger piece each cell intruded ~12% past
    its grid slot into the neighbour's (gap is only 2dp ≈ 8%), and the
    wave staggered the deformation so different cells of the same piece
    had clashing scales at the same frame — testers reported it as
    jittery, with extra cells momentarily appearing. Squash pulled down
    to 1.05 / 0.95 / 1.02, eases switched to FastOutSlowInEasing, recoil
    spring critically-damped (dampingRatio 0.8 / stiffness 450), wave
    delay dropped so cells of a placed piece animate in lockstep.
    (5da72f8)

  • iOS AdMob banner never loaded. The slot reserved 0dp until the ad
    reported success, but the Google Mobile Ads SDK reads
    GADBannerView.bounds at .load() time and rejects every request
    whose container has zero size — chicken-and-egg, no ad ever loaded.
    Logs were full of "Invalid ad width or height." (Response ID: null)
    with cascading WebContent / RBS noise as WebKit spun up the ad
    container, failed, and tore down. Reserve 50dp up-front so the
    BannerView has valid bounds at load time; collapse to 0dp only after
    a real load failure (offline / no-fill), expand back when a later
    refresh succeeds. Matches Android's "no ad = no slot" UX while
    accommodating the iOS SDK's stricter bounds check.
    (9bf6f4c)

Performance — Compose recomposition cleanup

Measurements taken with Layout Inspector during a normal play session.
Each fix is keyed to specific numbers we saw.

  • ScoreChip: 24 / 0 → 0 / 29 (100% skip). ScoreChip wrapped
    AnimatedCounter in an Animatable<Float> tally-rollup whose
    .value was read in the chip's composition scope. Every frame of the
    rollup recomposed the whole chip, which cascaded into AnimatedCounter
    re-allocating 4–6 AnimatedContent slots per chip per frame — ~500
    recompositions per score event across both chips. AnimatedCounter
    already animates each digit independently; the outer rollup was
    gilding the lily. Removing it drops the per-event count to 1.
    Also fixed: AnimatedContent slots weren't keyed by digit position
    (the existing comment claimed they were, but key(...) was missing),
    so on transitions like 999 → 1,000 the ones-place '9' animated into
    the thousands-place '1' and a digit slot morphed into the ','.
    (e65b6d6)

  • TraySlot: 292 / 0 → 36 / 0 (-87% recomp). Two State<Float|Color>
    values inside TraySlot (pieceAlpha, slotBg) were read via by
    delegates in composition. Each animation tick recomposed all three
    slots. Drop the by, keep the State refs, read them in draw phase
    via Modifier.drawBehind (for the background color) and a wrapping
    Modifier.graphicsLayer { alpha = ... } around MiniPiece (for the
    dim). Same pattern the file's breath/wiggle animations already used.
    BlockPiece consequently went from 51 / 76 to 0 / 83 (100% skip)
    because TraySlot stopped dragging it into recompositions.
    (6b3e288)

Versioning

  • appVersionName 1.4.6 → 1.4.7
  • appVersionCode 10 → 11
  • iOS MARKETING_VERSION 1.4.6 → 1.4.7, CURRENT_PROJECT_VERSION 10 → 11
  • Play Store changelog: fastlane/.../changelogs/11.txt
  • Tag: v1.4.7

Known / not in scope

  • Compose stability config (would mark domain models + UI state
    holders @stable globally) was prototyped during measurement —
    it would push GridCell from 83 / 99 (54% skip) to ~95% skip and
    inverts GameGrid's 182 / 0 to similar. The remaining GameGrid: 182 / 0 looks bad in the inspector but is harmless in practice:
    its children (GridCell, BlockPiece) skip, so its body only runs a
    cheap for-loop with parameter compares. Deferred to keep this PR
    focused on user-visible wins.
  • Crashlytics ↔ AdMob signal-handler conflict on iOS (AdMob
    installs its own SIGABRT/SIGBUS/SIGSEGV handlers, Crashlytics warns
    it may not catch crashes inside the SDK). Known SDK incompatibility;
    not addressed here.
  • Missing SFX assets (block_place.mp3, line_clear_1..4.mp3)
    flagged in 1.4.6 are still missing — placement and clear sounds are
    silent on both platforms. The iOS lazy-load wired in 1.4.6 will pick
    them up automatically once the files are added.

yet3a and others added 5 commits May 17, 2026 16:37
ScoreChip layered an Animatable<Float> interpolation on top of
AnimatedCounter's per-digit AnimatedContent. The outer Animatable's
.value was read in ScoreChip's composition scope, so the rollup
ticked the entire chip ~60×/s for the duration of every score change,
which in turn re-allocated 4–6 AnimatedContent slots inside
AnimatedCounter on every frame. A single score event burned roughly
500 recompositions across both chips.

Pass the raw value straight through. AnimatedCounter's per-digit
slide already provides the motion; the outer rollup was gilding the
lily. One recomp per score change instead of ~250 per chip.

While here, fix the AnimatedContent slot keying — the previous
comment claimed positional keying but the code matched slots by
list index, so on digit-count transitions (999 → 1,000) the ones-
place '9' animated into the thousands-place '1' and a digit slot
morphed into the ',' separator. Wrap each AnimatedContent in
key(positionFromRight) so slots stay anchored to their digit place.

Also memoize formatScore() by value so a parent recomp doesn't
re-format unnecessarily.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TraySlot read `pieceAlpha` and `slotBg` via `by` delegates on
animateFloatAsState / animateColorAsState, so each frame of those
animations re-ran the whole TraySlot composition — Layout Inspector
showed 292 recomp / 0 skips over a normal play session, with three
slots affected.

Drop the `by` delegates, keep the State<Float> / State<Color>
references, and read them in draw phase instead:

- slotBg → drawBehind { drawRect(slotBgState.value) } replaces
  Modifier.background(slotBg). The color tick now invalidates only
  draw, not composition.
- pieceAlpha → applied via a wrapping Modifier.graphicsLayer
  { alpha = pieceAlphaState.value } around MiniPiece, instead of
  being baked into the Color passed as a parameter.

Same pattern the breath/wiggle animations in this file already used
(see existing comment at line 163). After this, TraySlot drops to
36 / 0 (-87%) and BlockPiece skips 100% of its calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The iOS banner slot reserved 0dp until the ad reported success, but the
Google Mobile Ads SDK reads GADBannerView.bounds at .load() time and
rejects every request whose container has zero width or height — before
the request even reaches the network. Result: chicken-and-egg, no ad
ever loaded on iOS. Logs were full of

  [AdCoordinator] banner failed to load:
    Error Domain=com.google.admob Code=0 "Invalid ad width or height."
    (Response ID: null)

with the cascading WebContent / RBS noise that comes from WebKit
spinning up an ad container, failing, and tearing down.

Fix: reserve 50dp up-front so the BannerView has valid bounds at load
time, then collapse to 0dp only after the SDK reports a real load
failure (offline / no-fill). If a later refresh succeeds the slot
expands back to 50dp. Matches the Android UX (no empty banner-sized
hole when there's no ad to show) while accommodating the iOS SDK's
stricter bounds check that Android's AdView quietly works around via
its declared AdSize.BANNER intrinsic size.

Wiring:
- IosAdBridge.makeBannerView gains a third onFailed: () -> Unit param
- AdCoordinator.makeBannerView forwards onFailed to BannerDelegate
- BannerDelegate.didFailToReceiveAdWithError invokes it
- AdBanner.ios.kt tracks isAdLoaded + hasFailed; visible iff loaded or
  not-yet-failed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
popIn used an aggressive anisotropic squash (scaleX 1.25 / scaleY 0.65,
recoil 1.18) plus a per-cell (x+y) * 25L wave delay. Both were tuned
for a single isolated cell and backfired on multi-cell pieces: at peak
squash each cell intruded ~12% past its grid bounds into the
neighbour's slot (the inter-cell gap is only 2dp ≈ 8%), and the wave
delay staggered that deformation so different cells of the same piece
were at clashing scales at the same instant. Testers reported "ghost
cells" and a jerky placement animation.

Pull the squash amplitudes way down (1.05 / 0.95 / 1.02) so the cell
stays inside its grid slot throughout the animation. Switch all eases
to FastOutSlowInEasing and raise the recoil spring's damping ratio to
0.8 with stiffness 450 so the settle is critically-damped rather than
ringing. Drop the wave delay at the call site so all cells of a placed
piece animate in lockstep. Total duration is still ~250ms; the visible
collision is gone and the motion reads as a gentle settle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@yet300 yet300 merged commit 912880c into main May 17, 2026
3 checks passed
@yet300 yet300 deleted the 1.4.7 branch May 17, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants