Skip to content

Tanks: every two-phone shot on both phones, live opponent aim, link chip as in Battleship (#861) - #868

Merged
margani merged 3 commits into
mainfrom
issue/861-tanks-link
Nov 12, 2026
Merged

margani merged 3 commits into
mainfrom
issue/861-tanks-link

Conversation

@margani

@margani margani commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #861

What changed

  • Every shot plays on both phones. onSync no longer acts while a shot is flying or a received turn is still queued; the sync is held (G.held) and read by settle() once the shot has landed and the queue is empty. pump() is restructured to call settle() when it has nothing left to play.
  • A drifted guest is corrected. On a digest mismatch, the host now sends its sync after G.M0 holds the shot that just landed, not before.
  • A shot that comes in while the phone is in the background is replayed from its start when the page comes back, instead of landing without being drawn.
  • Link bar as in Battleship. Host/Join and the explanatory sentence show only before the match (no role yet, or still looking before the first shot). Once linked, only the small link chip stays (Connected, or Connection lost… mid-match), placed before the gear. Before a role is picked, the status line is empty and the seats read Player 1 / Player 2, instead of "Rival is aiming" on both.
  • The other phone's aim, live (owner's request on Two-phone Tanks: shots shown on one phone only, and the link bar differs #861). While the other player aims, this phone shows their barrel turning and the angle · power label, drawn by the existing aim drawing (aimNow() returns their aim on their turn). aimOut() sends {t:'aim', g, n, angle, power} from draw() only when the value changed, at most once every 66 ms (~15/s), with a trailing send so the last position always arrives. The receiver keeps it in peerAim, outside the match: it is never written into G.M, never in a digest or sync, accepted only for the current match g and shot number n on the other's turn, and dropped when the shot starts. The turn message stays the only authority on where a shot goes, so a late or lost aim message changes nothing.
  • games.json: updated for tanks set to 2026-11-12.
  • New test group tools/tests/tanks-link-shots.js.

No new strings. No change to the core makeLink.

Proof

The new group run on its own against origin/main (old page) and against this branch:

On origin/main (old page), exit 1:

== Tanks: every two-phone shot plays on both phones (#861)
ok   the page's turn code was found (9178 characters)
ok   control: a clean match ends on both phones after 20 shots (20/20)
ok   control: both phones end on the same match
ok   control: each shot flies once on each phone
ok   the list of faulty matches is not empty
ok   the faults happened (46 drops, 76 slow flights in 8 matches)
ok   every faulty match ends on the same 20-shot match on both phones (8/8)
FAIL every shot flies exactly once on both phones, in order (2/8); 2: host 0,1,2,3,4,5,6,7,10,11,12,13,14,15,16,17,18,19 | guest 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
ok   the list of aim matches is not empty
FAIL the page streams the aim to the other phone (0 aim messages in 4 matches)
FAIL each phone sees the other's aim while the other aims (host saw 0 frames, guest 0)
FAIL at most one aim message every ~66 ms from a phone (shortest gap 1000000000 ms)
FAIL late aims of earlier shots were delivered (0)
FAIL no phone ever shows an aim the other did not send for the current shot
FAIL with the aim stream, both phones fly the same 20 shots, each at the angle and power of its turn message
FAIL with the aim stream, the digest and sync still agree and every shot flies once on both phones
ok   the drift check ran to the guest's shot (6/6)
FAIL after the drifted guest's shot both phones hold the host's match again (d73aafca / 3c5c2bcd)
ok   the page has renderTop and linkText
ok   before a role is picked: the host/join choice and the sentence that explains it (1, linkPick)
ok   while looking for the other phone the choice stays, to switch roles
FAIL once linked: no host/join choice and no sentence, one small "connected" chip (1 choice, linkConnected)
FAIL mid-match, looking again after an app switch, the choice does not come back

checks 23, failures 11, 442 ms

On this branch, exit 0:

== Tanks: every two-phone shot plays on both phones (#861)
ok   the page's turn code was found (12553 characters)
ok   control: a clean match ends on both phones after 20 shots (20/20)
ok   control: both phones end on the same match
ok   control: each shot flies once on each phone
ok   the list of faulty matches is not empty
ok   the faults happened (46 drops, 79 slow flights in 8 matches)
ok   every faulty match ends on the same 20-shot match on both phones (8/8)
ok   every shot flies exactly once on both phones, in order (8/8)
ok   the list of aim matches is not empty
ok   the page streams the aim to the other phone (1103 aim messages in 4 matches)
ok   each phone sees the other's aim while the other aims (host saw 518 frames, guest 538)
ok   at most one aim message every ~66 ms from a phone (shortest gap 80 ms)
ok   late aims of earlier shots were delivered (927)
ok   no phone ever shows an aim the other did not send for the current shot
ok   with the aim stream, both phones fly the same 20 shots, each at the angle and power of its turn message
ok   with the aim stream, the digest and sync still agree and every shot flies once on both phones
ok   the drift check ran to the guest's shot (6/6)
ok   after the drifted guest's shot both phones hold the host's match again (d73aafca / d73aafca)
ok   the page has renderTop and linkText
ok   before a role is picked: the host/join choice and the sentence that explains it (1, linkPick)
ok   while looking for the other phone the choice stays, to switch roles
ok   once linked: no host/join choice and no sentence, one small "connected" chip (0 choice, linkConnected)
ok   mid-match, looking again after an app switch, the choice does not come back

checks 23, failures 0, 490 ms

Playwright, two linked contexts at 390×800, fa/en × light/dark: on every combination the guest's canvas changed 9 times while the host turned its barrel with the keys, and the host's canvas changed 7 times while the guest aimed. Against the old page the same run gave 1 distinct frame on each phone, which is the control. Screenshots are in the review comment.

Browser fuzz (two Playwright pages joined by a fake ChoganNearby plugin relayed through Node, ordered per direction with 20–400 ms delays, the link dropped and restored ~25 times and a page hidden ~17 times per match, 20 shots each): old page 2 of 2 matches lost shots (adopt killed a flight, in the first match shot 13 never drew on the host and shot 14 never drew on the guest); this branch 0 of 2.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QY4t6rHpbsoE5aS15nw6Z7

…tleship (#861)

A phone counts a shot only once it lands (G.M0), so while a shot flew or a
turn waited in the inbox, the phone looked a shot behind. A sync in that
window (every reconnect sends one) was read as "you are behind": the newer
match was adopted, the flying shot vanished and a queued one was dropped,
so the shot showed on one phone only, in either direction. Such a sync now
waits until nothing flies or waits, and is read against the landed match.

The host answered a digest mismatch with its match from before the shot,
so a drifted guest answered back with its own and stayed off; the host now
sends the match after the shot. A shot that arrives while the phone looks
away flies again on return instead of landing unseen.

The host/join choice and its sentence stayed on screen all match; as in
Battleship they show only until the phones are linked, then one small
link chip. Before a role is picked the status no longer says "Rival is
aiming" on both seats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QY4t6rHpbsoE5aS15nw6Z7
@margani

margani commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Diff review

Root cause (1), with evidence. Each phone counts a shot in G.M0 only when its flight lands (landed()). While a shot is in the air, or a received turn waits in G.inbox for the 900 ms turn pause, that phone's M0.n is one behind the other phone's. Every reconnect makes both phones send a sync (onStatus('connected') → sendSync()), and so do a few other paths. onSync compared n straight away. syncPick then said "take", and adopt() set flight = null and G.inbox = null. The flying shot disappeared and the queued one was dropped: that shot showed only on the phone that had already landed it. It goes both ways. The receiver loses the shooter's shot when the shooter lands first. The shooter loses its own shot when its flight is slower (a page that looked away, a slow phone).

  • Evidence in a real browser: an instrumented copy of the old page logged adopt calls. The fuzz harness is two Playwright pages and a fake ChoganNearby relayed through Node, in order per direction, with 20–400 ms delays, link drops and hidden pages. In both of 2 old-page matches it logged ["adopt",13,14,"killed-flight"] (and three more like it). After those, the same shot numbers had no landed flight on that phone. A clean link with no drops reproduced nothing, which fits the owner's "sometimes".
  • makeLink was ruled out. It delivered every message in order, once, in all runs; the loss happens in Tanks' own handling.

Second bug found on the way. On a digest mismatch the host called sendSync() before G.M0 = clone(M). Its sync was therefore one shot behind the guest, the guest answered with its own wrong match, and the host keeps at equal n, so a drifted guest stayed off. The new test catches this (d73aafca / 3c5c2bcd on old code).

Why this fix. A sync that arrives while something flies or waits is held in G.held. settle() reads it once the shot has landed and the inbox is empty: at the end of landed() and of pump(). It is then compared with the match after the shot, so both phones agree and nothing is adopted. A sync that really is newer (a restarted phone, a new match) is still adopted, just after the current shot. The fix is local to Tanks: Reversi and Battleship have no flight animation between receiving a move and counting it, and their link tests are untouched and green. On resume, a flight that was still in the air now replays from its start instead of landing undrawn.

(2) Link bar. No shared link-bar piece exists in the core: Reversi, Battleship and Tanks each build theirs with C.ui.segmented plus a ch-badge chip of the same style. So Tanks follows Battleship's battle-phase layout: Host/Join and the long sentence show only before the match (pickRole(): no side yet, or not linked and no shot fired yet). After that there is one small chip beside the mode chip. Before a role is picked, the status line no longer says "Rival is aiming" and the seats read Player 1 / Player 2. No new strings.

Tested

  • New group tools/tests/tanks-link-shots.js runs the page's own turn code twice over a delayed, ordered wire, with drops and slow flights. It fails on origin/main (4 failures, output in the PR body) and passes here, in ~0.34 s locally.
  • node tools/test.js: all green. tools/browser-check.sh: 74 of 74 pages, exit 0.
  • Playwright, two linked contexts at 390×800 in fa/en and light/dark: one ~25 px chip on both phones and no picker. The host's shot drew 6 distinct canvas frames out of 6 on both phones, with no page errors. Screenshots below.

Not tested

  • Real Nearby on two real phones: the Android plugin, real disconnect timing and Android killing the app.
  • The fuzz is 2 matches per side, not a statistical sample.
  • prefers-reduced-motion is not covered: there, shots land without any flight on either phone, as before.

Persian light, host and guest

English dark, host and guest

The owner asked to see the other player aim on two phones. While a player
sets the angle and power, the page sends them (at most every 66 ms, the last
position always) and the other phone draws that barrel and label with the
existing aim drawing. The aim is kept apart from the match, tied to the
match and shot number, and dropped when the shot starts; the turn message
alone decides where a shot goes, so a late or lost aim changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QY4t6rHpbsoE5aS15nw6Z7
@margani margani changed the title Tanks: every two-phone shot on both phones; link chip as in Battleship (#861) Tanks: every two-phone shot on both phones, live opponent aim, link chip as in Battleship (#861) Nov 12, 2026
@margani

margani commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Diff review: the live aim view (second commit)

What. The owner asked on #861 to see the other player aim. Tanks now streams the aim while a player sets it, and the other phone draws it with the same barrel, line and "angle° · power" label used for its own aim.

Why this shape.

  • Sending from draw(). Every way of aiming already ends in a draw(): the drag loop, keys, the dial buttons and weapon changes. aimOut() is therefore called there once, instead of at five input sites. It sends only when the rounded angle or power changed, at most once every 66 ms, plus a trailing send so the final position arrives.
  • The aim is only a picture. It lives in peerAim, outside the match. It is never written into G.M, so it never reaches a digest, a sync, a save or a shot. It is accepted only for the current g and shot number n, only on the other's turn, and only while nothing flies. theirAim() checks the same conditions again when drawing. shoot() clears it.
  • The shot is unchanged. The turn message still carries the angle and power that are fired, so a late, lost or repeated aim message cannot change a shot.
  • Weapon not sent. The weapon choice is not part of the aim view. The opponent's weapon row is hidden on the watching phone today, and nothing draws a weapon on the barrel.

Tested

  • Same new group, 8 more checks, 23 in total. Over 4 matches with drops and slow phones, about 1100 aim messages:
    • each phone saw the other's aim;
    • the shortest gap between two aim messages from one phone was 80 ms;
    • 927 late aims of earlier shots were injected, and no phone ever showed an aim that was not sent for the current shot;
    • both phones flew the same 20 shots at the turn messages' angle and power;
    • digests agreed.
  • On origin/main 11 of the 23 checks fail.
  • Mutation check: with both the n guard in onAim and the one in theirAim removed, the stale-aim check fails (1@2:38/48). With only one removed, the other still holds and the check passes.
  • Playwright, two linked contexts at 390×800, fa/en × light/dark: the guest's canvas changed 9 times while the host turned its barrel, and the host's 7 times while the guest aimed. The old page gave 1 distinct frame (the control). node tools/test.js all green, tools/browser-check.sh 74 of 74.

Not tested

  • Real Nearby on two phones.
  • How 15 small messages a second behave over a real Bluetooth link.
  • makeLink keeps every sent message in its outbox until the link closes, as before. With the aim stream that is more entries, about 100 bytes each, roughly 15 a second while someone drags.

The host aims; the guest (right) sees the barrel and 50° · 58 live

The guest aims; the host (left) sees it live, Persian dark

Host aiming, Persian light

Guest aiming, English dark

@margani
margani merged commit a046a50 into main Nov 12, 2026
4 checks passed
@margani
margani deleted the issue/861-tanks-link branch November 12, 2026 19:10
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.

Two-phone Tanks: shots shown on one phone only, and the link bar differs

1 participant