Skip to content

Sync gate & reconnect policy

Two lightweight value-type utilities handle the board's out-of-sync state machine and BLE reconnect scheduling.


BoardSyncGate

A pure-logic state machine for the "physical board diverged from the app" transition that drives the clock pause and haptic in an OTB view.

The session feeds it the current (isOutOfSync, clockIsRunning, activeColor) on every change; the gate decides whether the clock should be paused or resumed and whether the entry-only haptic should fire.

public struct BoardSyncGate: Sendable {
    public private(set) var clockWasRunningBeforeDesync: Bool
    public private(set) var pausedActiveColor: PieceColor
    public private(set) var isDesynced: Bool
    public private(set) var shouldFireHaptic: Bool

    public init()

    public mutating func update(
        isOutOfSync: Bool,
        clockIsRunning: Bool,
        currentActiveColor: PieceColor
    ) -> Action

    public mutating func acknowledgeHaptic()
}

public enum BoardSyncGate.Action: Equatable, Sendable {
    case none
    case pauseClock
    case resumeClock(color: PieceColor)
}

Transition rules

Previous New Action
in sync out of sync .pauseClock (if clock was running), fire haptic
out of sync in sync .resumeClock(color:) (if clock was paused)
same state same state .none

currentActiveColor in update is the side currently on the move in the game position — not the clock's cached color. This ensures a move that committed during the desync window (advancing the position) resumes the clock on the correct side.

acknowledgeHaptic() resets shouldFireHaptic to false after the view has consumed it. The haptic flag is a one-shot per entry transition; consuming it explicitly prevents re-firing on subsequent updates.

Example

var syncGate = BoardSyncGate()

func onBoardUpdate(isOutOfSync: Bool, clockIsRunning: Bool, activeColor: PieceColor) {
    let action = syncGate.update(
        isOutOfSync: isOutOfSync,
        clockIsRunning: clockIsRunning,
        currentActiveColor: activeColor
    )
    switch action {
    case .pauseClock:
        gameClock.pause()
    case .resumeClock(let color):
        gameClock.resume(for: color)
    case .none:
        break
    }
    if syncGate.shouldFireHaptic {
        UIImpactFeedbackGenerator(style: .medium).impactOccurred()
        syncGate.acknowledgeHaptic()
    }
}

BoardReconnectPolicy

A pure-value reconnect schedule for unexpected disconnections (a link drop, not a user-initiated one). How long to wait and when to stop are your product's patience rather than the hardware's, so both are parameters.

public struct BoardReconnectPolicy: Sendable {
    public let maxAttempts: Int
    public let delays: [TimeInterval]

    public init(maxAttempts: Int = 5, delays: [TimeInterval] = [2, 4, 8])
    public func nextDelay(attempt: Int) -> TimeInterval?
}

The transport calls nextDelay(attempt:) before each reconnect attempt. nil means "give up": the attempt is outside 1...maxAttempts.

Attempts past the end of delays repeat its last entry, so a short schedule describes a ramp that then holds steady. The default [2, 4, 8] over five attempts waits 2 s, 4 s, then 8 s three times.

Example

let policy = BoardReconnectPolicy()                      // 2, 4, 8, 8, 8

// `delays` sets the shape of the ramp; `maxAttempts` sets how many there are.
// BoardReconnectPolicy(delays: [0.5, 1])                // 0.5, 1, 1, 1, 1
// BoardReconnectPolicy(maxAttempts: 2, delays: [0.5])   // 0.5, 0.5 — then give up

for attempt in 1... {
    guard let delay = policy.nextDelay(attempt: attempt) else {
        transport.state = .disconnected
        break
    }
    transport.state = .reconnecting(attempt: attempt)
    try await Task.sleep(for: .seconds(delay))
    await transport.attemptReconnect()
}