commit 5bbbfa443938d15600a3e7ce670fa8b773396c63
parent 51d8695b45911984b64eba1d3847bd899dac5097
Author: Michael Camilleri <[email protected]>
Date: Mon, 1 Jun 2026 14:54:48 +0900
Coalesce a bulk gesture into one live update
A whole-puzzle check on a co-solver's device fanned out one live
cellEdit per cell over the engagement channel — emitMove fires
onLocalCellEdit for every cell, and applyBulk loops it across the grid.
So one gesture sent ~150 messages stamped at the same instant; the
receiver ran ~150 applyRealtimeCellEdit saves and the marks trickled
onto the peer's grid over a ~15-17s growing-latency backlog before
catching up. The feature the other solver wanted — seeing which letters
are wrong, live — arrived as a slow crawl rather than in one frame.
The per-cell broadcast is now buffered for the duration of a bulk
gesture and flushed once. GameMutator.collectingBroadcast wraps the
applyBulk and applyRestores loops; emitMove parks its RealtimeCellEdit
in the buffer instead of broadcasting when one is active. A one-cell
result still goes out as the legacy single .cellEdit — so single-cell
co-typing is untouched and stays wire-compatible — while a multi-cell
result ships a new EngagementMessage kind, .cellEditBatch, carrying the
whole array under one sentAt. The receiver applies it via
GameStore.applyRealtimeCellEdits, which groups edits by Moves record and
decodes/encodes/saves once regardless of cell count, preserving per-cell
last-writer-wins; applyRealtimeCellEdit is now a wrapper over it.
The durable path is unchanged: emitMove still journals and enqueues each
cell to MovesUpdater, so a peer that predates .cellEditBatch drops the
undecodable live message but still receives the same cells via Moves
sync — bulk gestures degrade to 'appears on sync' rather than breaking.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 194 insertions(+), 69 deletions(-)
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -19,6 +19,13 @@ final class GameMutator {
private let movesJournal: MovesJournal?
private let authorIDProvider: (@MainActor () -> String?)?
private let onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
+ private let onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)?
+
+ /// While non-nil, `emitMove` parks its live broadcast here instead of
+ /// firing `onLocalCellEdit` per cell. A bulk gesture (check/clear/undo of a
+ /// batch) thus ships one engagement message rather than one per cell, so
+ /// the peer's grid lights up in a single frame instead of trickling.
+ private var batchBroadcastBuffer: [RealtimeCellEdit]?
/// `true` when the current user owns the CloudKit zone for this game.
let isOwned: Bool
@@ -40,6 +47,7 @@ final class GameMutator {
movesJournal: MovesJournal? = nil,
authorIDProvider: (@MainActor () -> String?)? = nil,
onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil,
+ onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil,
isOwned: Bool = true,
isShared: Bool = false,
isAccessRevoked: Bool = false
@@ -50,6 +58,7 @@ final class GameMutator {
self.movesJournal = movesJournal
self.authorIDProvider = authorIDProvider
self.onLocalCellEdit = onLocalCellEdit
+ self.onLocalCellEditBatch = onLocalCellEditBatch
self.isOwned = isOwned
self.isShared = isShared
self.isAccessRevoked = isAccessRevoked
@@ -99,13 +108,15 @@ final class GameMutator {
let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
mutate(applicable)
let batch = UUID()
- for (cell, priorState) in zip(applicable, before) {
- emitMove(
- atRow: cell.row,
- atCol: cell.col,
- journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col),
- batchID: batch
- )
+ collectingBroadcast {
+ for (cell, priorState) in zip(applicable, before) {
+ emitMove(
+ atRow: cell.row,
+ atCol: cell.col,
+ journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col),
+ batchID: batch
+ )
+ }
}
}
@@ -176,24 +187,26 @@ final class GameMutator {
private func applyRestores(_ restores: [JournalRestore], kind: JournalKind) -> Bool {
let batch = UUID()
var appliedAny = false
- for restore in restores {
- let row = restore.position.row
- let col = restore.position.col
- let square = game.squares[row][col]
- let current = JournalCellState(
- letter: square.entry,
- mark: square.mark,
- cellAuthorID: square.letterAuthorID
- )
- guard current.letterMatches(restore.expectedCurrent) else { continue }
- game.applyCellState(
- restore.restoreTo.letter,
- mark: restore.restoreTo.mark,
- authorID: restore.restoreTo.cellAuthorID,
- atRow: row, atCol: col
- )
- emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq)
- appliedAny = true
+ collectingBroadcast {
+ for restore in restores {
+ let row = restore.position.row
+ let col = restore.position.col
+ let square = game.squares[row][col]
+ let current = JournalCellState(
+ letter: square.entry,
+ mark: square.mark,
+ cellAuthorID: square.letterAuthorID
+ )
+ guard current.letterMatches(restore.expectedCurrent) else { continue }
+ game.applyCellState(
+ restore.restoreTo.letter,
+ mark: restore.restoreTo.mark,
+ authorID: restore.restoreTo.cellAuthorID,
+ atRow: row, atCol: col
+ )
+ emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq)
+ appliedAny = true
+ }
}
return appliedAny
}
@@ -216,6 +229,24 @@ final class GameMutator {
// MARK: - Helpers
+ /// Runs `body` with per-cell live broadcasts buffered, then flushes them as
+ /// a single message. A one-cell result degrades to the legacy single-cell
+ /// `onLocalCellEdit` path, so it stays wire-compatible with peers that
+ /// don't understand batches; only genuinely multi-cell gestures send a
+ /// batch. The durable Moves/journal writes in `emitMove` are unaffected —
+ /// only the live overlay is coalesced.
+ private func collectingBroadcast(_ body: () -> Void) {
+ batchBroadcastBuffer = []
+ body()
+ let edits = batchBroadcastBuffer ?? []
+ batchBroadcastBuffer = nil
+ switch edits.count {
+ case 0: break
+ case 1: onLocalCellEdit?(edits[0])
+ default: onLocalCellEditBatch?(edits)
+ }
+ }
+
private func emitMove(
atRow row: Int,
atCol col: Int,
@@ -262,7 +293,7 @@ final class GameMutator {
let enqueuedAt = Date()
game.squares[row][col].enqueuedAt = enqueuedAt
if let actingAuthorID, !actingAuthorID.isEmpty {
- onLocalCellEdit?(RealtimeCellEdit(
+ let edit = RealtimeCellEdit(
gameID: id,
authorID: actingAuthorID,
deviceID: RecordSerializer.localDeviceID,
@@ -274,7 +305,12 @@ final class GameMutator {
checkedWrong: checkedWrong,
updatedAt: enqueuedAt,
cellAuthorID: cellAuthorID
- ))
+ )
+ if batchBroadcastBuffer != nil {
+ batchBroadcastBuffer?.append(edit)
+ } else {
+ onLocalCellEdit?(edit)
+ }
}
Task {
await movesUpdater.enqueue(
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -245,6 +245,8 @@ final class GameStore {
var onUnreadOtherMovesChanged: (() -> Void)?
@ObservationIgnored
var onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
+ @ObservationIgnored
+ var onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)?
private let eventLog: EventLog?
@@ -380,56 +382,86 @@ final class GameStore {
@discardableResult
func applyRealtimeCellEdit(_ edit: RealtimeCellEdit) -> Bool {
- guard !edit.authorID.isEmpty,
- !edit.deviceID.isEmpty,
- edit.deviceID != RecordSerializer.localDeviceID,
- let entity = fetchGameEntity(id: edit.gameID)
- else { return false }
-
- let recordName = RecordSerializer.recordName(
- forMovesInGame: edit.gameID,
- authorID: edit.authorID,
- deviceID: edit.deviceID
- )
- let movesEntity = ensureMovesEntity(
- recordName: recordName,
- game: entity,
- authorID: edit.authorID,
- deviceID: edit.deviceID
- )
+ applyRealtimeCellEdits([edit]) > 0
+ }
- var cells: [GridPosition: TimestampedCell] = [:]
- if let data = movesEntity.cells, !data.isEmpty {
- cells = (try? MovesCodec.decode(data)) ?? [:]
+ /// Applies a batch of live cell edits with a single Core Data save and a
+ /// single UI refresh, regardless of cell count. Edits are grouped by their
+ /// owning Moves record (author + device), so each record is decoded and
+ /// re-encoded once even for a whole-grid gesture like "check puzzle".
+ /// Per-cell last-writer-wins is preserved. Returns the number of cells that
+ /// actually changed.
+ @discardableResult
+ func applyRealtimeCellEdits(_ edits: [RealtimeCellEdit]) -> Int {
+ let groups = Dictionary(grouping: edits) { edit in
+ RecordSerializer.recordName(
+ forMovesInGame: edit.gameID,
+ authorID: edit.authorID,
+ deviceID: edit.deviceID
+ )
}
- let position = GridPosition(row: edit.row, col: edit.col)
- let incoming = TimestampedCell(
- letter: edit.letter,
- markKind: edit.markKind,
- checkedRight: edit.checkedRight,
- checkedWrong: edit.checkedWrong,
- updatedAt: edit.updatedAt,
- authorID: edit.cellAuthorID
- )
- if let current = cells[position], current.updatedAt > incoming.updatedAt {
- return false
- }
+ var applied = 0
+ var touchedGameIDs: Set<UUID> = []
+ for (recordName, groupEdits) in groups {
+ guard let sample = groupEdits.first,
+ !sample.authorID.isEmpty,
+ !sample.deviceID.isEmpty,
+ sample.deviceID != RecordSerializer.localDeviceID,
+ let entity = fetchGameEntity(id: sample.gameID)
+ else { continue }
+
+ let movesEntity = ensureMovesEntity(
+ recordName: recordName,
+ game: entity,
+ authorID: sample.authorID,
+ deviceID: sample.deviceID
+ )
- cells[position] = incoming
- movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data()
- if (movesEntity.updatedAt ?? .distantPast) < edit.updatedAt {
- movesEntity.updatedAt = edit.updatedAt
- }
- if (entity.updatedAt ?? .distantPast) < edit.updatedAt {
- entity.updatedAt = edit.updatedAt
+ var cells: [GridPosition: TimestampedCell] = [:]
+ if let data = movesEntity.cells, !data.isEmpty {
+ cells = (try? MovesCodec.decode(data)) ?? [:]
+ }
+
+ var latest = movesEntity.updatedAt ?? .distantPast
+ var changed = false
+ for edit in groupEdits {
+ let position = GridPosition(row: edit.row, col: edit.col)
+ let incoming = TimestampedCell(
+ letter: edit.letter,
+ markKind: edit.markKind,
+ checkedRight: edit.checkedRight,
+ checkedWrong: edit.checkedWrong,
+ updatedAt: edit.updatedAt,
+ authorID: edit.cellAuthorID
+ )
+ if let current = cells[position], current.updatedAt > incoming.updatedAt {
+ continue
+ }
+ cells[position] = incoming
+ changed = true
+ applied += 1
+ if edit.updatedAt > latest { latest = edit.updatedAt }
+ }
+
+ guard changed else { continue }
+ movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data()
+ if (movesEntity.updatedAt ?? .distantPast) < latest {
+ movesEntity.updatedAt = latest
+ }
+ if (entity.updatedAt ?? .distantPast) < latest {
+ entity.updatedAt = latest
+ }
+ touchedGameIDs.insert(sample.gameID)
}
- saveContext("applyRealtimeCellEdit")
- if currentEntity?.id == edit.gameID {
+
+ guard applied > 0 else { return 0 }
+ saveContext("applyRealtimeCellEdits")
+ if let openID = currentEntity?.id, touchedGameIDs.contains(openID) {
refreshCurrentGame()
}
onUnreadOtherMovesChanged?()
- return true
+ return applied
}
/// Number of shared games with unseen other-author moves — the same
@@ -1445,6 +1477,9 @@ final class GameStore {
onLocalCellEdit: { [weak self] edit in
self?.onLocalCellEdit?(edit)
},
+ onLocalCellEditBatch: { [weak self] edits in
+ self?.onLocalCellEditBatch?(edits)
+ },
isOwned: entity.databaseScope == 0,
isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
isAccessRevoked: entity.isAccessRevoked
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -347,6 +347,11 @@ final class AppServices {
guard self.engagementStatus.isLive(gameID: edit.gameID) else { return }
Task { await self.engagementCoordinator.sendCellEdit(edit) }
}
+ self.store.onLocalCellEditBatch = { [weak self] edits in
+ guard let self, let gameID = edits.first?.gameID else { return }
+ guard self.engagementStatus.isLive(gameID: gameID) else { return }
+ Task { await self.engagementCoordinator.sendCellEdits(edits) }
+ }
}
func start(appDelegate: AppDelegate) async {
@@ -1276,6 +1281,17 @@ final class AppServices {
"latency=\(latencyMs)ms"
)
}
+ case .cellEditBatch:
+ guard let edits = envelope.cellEdits, !edits.isEmpty else {
+ syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)")
+ return
+ }
+ let applied = store.applyRealtimeCellEdits(edits)
+ syncMonitor.note(
+ "engagement: applied cellEditBatch \(engagementID.uuidString) " +
+ "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " +
+ "latency=\(latencyMs)ms"
+ )
case .selection:
guard let selection = envelope.selection else {
syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)")
diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift
@@ -73,12 +73,14 @@ struct EngagementMessage: Codable, Equatable, Sendable {
enum Kind: String, Codable, Sendable {
case debugText
case cellEdit
+ case cellEditBatch
case selection
}
var kind: Kind
var text: String
var cellEdit: RealtimeCellEdit?
+ var cellEdits: [RealtimeCellEdit]?
var selection: EngagementSelectionUpdate?
var sentAt: Date
var ver: Int
@@ -87,6 +89,7 @@ struct EngagementMessage: Codable, Equatable, Sendable {
kind: Kind = .debugText,
text: String,
cellEdit: RealtimeCellEdit? = nil,
+ cellEdits: [RealtimeCellEdit]? = nil,
selection: EngagementSelectionUpdate? = nil,
sentAt: Date = Date(),
ver: Int = 1
@@ -94,6 +97,7 @@ struct EngagementMessage: Codable, Equatable, Sendable {
self.kind = kind
self.text = text
self.cellEdit = cellEdit
+ self.cellEdits = cellEdits
self.selection = selection
self.sentAt = sentAt
self.ver = ver
@@ -103,6 +107,21 @@ struct EngagementMessage: Codable, Equatable, Sendable {
self.kind = .cellEdit
self.text = ""
self.cellEdit = cellEdit
+ self.cellEdits = nil
+ self.selection = nil
+ self.sentAt = sentAt
+ self.ver = ver
+ }
+
+ /// Carries one bulk gesture (check/clear/multi-cell undo) as a single
+ /// message. Peers that predate this kind fail to decode it and drop the
+ /// live update; the same cells still arrive durably via the Moves/CloudKit
+ /// path, so they degrade to "appears on sync" rather than breaking.
+ init(cellEdits: [RealtimeCellEdit], sentAt: Date = Date(), ver: Int = 1) {
+ self.kind = .cellEditBatch
+ self.text = ""
+ self.cellEdit = nil
+ self.cellEdits = cellEdits
self.selection = nil
self.sentAt = sentAt
self.ver = ver
@@ -112,6 +131,7 @@ struct EngagementMessage: Codable, Equatable, Sendable {
self.kind = .selection
self.text = ""
self.cellEdit = nil
+ self.cellEdits = nil
self.selection = selection
self.sentAt = sentAt
self.ver = ver
@@ -301,6 +321,24 @@ actor EngagementCoordinator {
}
}
+ /// Ships a bulk gesture as one batched message instead of one per cell, so
+ /// a whole-grid action lands on the peer in a single frame. All edits in a
+ /// batch share a game (they originate from one local gesture).
+ func sendCellEdits(_ edits: [RealtimeCellEdit]) async {
+ guard let first = edits.first else { return }
+ guard case .live(let engagementID, _) = state(for: first.gameID) else { return }
+ do {
+ let message = EngagementMessage(cellEdits: edits)
+ try await host.send(engagementID: engagementID, message: message.encodedData())
+ await log(
+ "engagement: sent cellEditBatch \(engagementID.uuidString) " +
+ "count=\(edits.count) device=\(first.deviceID.prefix(8))"
+ )
+ } catch {
+ await log("engagement: cell edit batch send failed \(engagementID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
func sendSelection(_ selection: EngagementSelectionUpdate) async {
guard case .live(let engagementID, _) = state(for: selection.gameID) else { return }
do {