commit 2d1afc0f405b59e6c6f98c26d8bb778a60371e71
parent eebe94def9bbba777d0f14be5dc0675e0c2eea33
Author: Michael Camilleri <[email protected]>
Date: Fri, 19 Jun 2026 15:58:39 +0900
Derive collaborator colours per game
Collaborator colours were derived from the authorID alone, so a friend
read as the same colour in every game and across the friends list. Now
that colour carries more of the interface — the Game List colour strip
and the puzzle scoreboard — that stability worked against it: a colour
should read as 'who is in this grid', not a permanent badge for a
person.
This commit folds the game's UUID into the seed, so a collaborator's
colour depends on their authorID and the game (stableColor becomes
assignedColor(forAuthorID:inGame:reserved:)). The colours are still
threaded through a running reserved set, seeded with the local user's
own stable colour, so collaborators stay as distinct as the palette
allows within a game while deliberately differing between games. The
gameID is plumbed through ParticipantSummaries and 'GameStore' to the
roster.
Because colour is no longer a per-person identity, the friends list
drops it: a crossmate's avatar is now a neutral grey medallion carrying
their still-stable symbol.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
10 files changed, 149 insertions(+), 80 deletions(-)
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -14,6 +14,7 @@ struct GameParticipantSummary: Identifiable, Equatable {
enum ParticipantSummaries {
static func allParticipants(
+ gameID: UUID,
namesByAuthor: [String: String],
moveAuthorIDs: [String],
nicknamesByAuthor: [String: String],
@@ -24,6 +25,7 @@ enum ParticipantSummaries {
additionalAuthorIDs: [String] = []
) -> [GameParticipantSummary] {
let remotes = remoteParticipants(
+ gameID: gameID,
namesByAuthor: namesByAuthor,
moveAuthorIDs: moveAuthorIDs,
nicknamesByAuthor: nicknamesByAuthor,
@@ -45,6 +47,7 @@ enum ParticipantSummaries {
}
static func remoteParticipants(
+ gameID: UUID,
namesByAuthor: [String: String],
moveAuthorIDs: [String],
nicknamesByAuthor: [String: String],
@@ -62,19 +65,25 @@ enum ParticipantSummaries {
authorIDs.remove(localAuthorID)
}
- // Each collaborator's colour is computed independently, reserving only
- // the local user's preferred colour — exactly as the Friends list does
- // (`FriendAvatarView` passes `[preferences.color.id]`). A friend therefore
- // shows the same colour in the roster, the Game List, and the friend
- // roster. Two collaborators in one game can now land on the same colour;
- // that's the accepted trade-off for cross-surface consistency.
- let reserved: Set<String> = [localColor.id]
+ // Hand each collaborator a colour that is as distinct as the palette
+ // allows: walk them in sorted-authorID order, seed `taken` with the
+ // local user's (stable) colour, and thread each assignment back in so
+ // the next collaborator avoids it. The colour is derived from authorID
+ // *and* `gameID`, so it is distinct within the game but deliberately
+ // differs from game to game — a colour reads as "who is in this grid",
+ // never a permanent badge for a person.
+ var taken: Set<String> = [localColor.id]
var summaries: [GameParticipantSummary] = []
for authorID in authorIDs.sorted() {
let name = nicknamesByAuthor[authorID]
?? namesByAuthor[authorID]
?? "Waiting for player..."
- let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: reserved)
+ let color = PlayerColor.assignedColor(
+ forAuthorID: authorID,
+ inGame: gameID,
+ reserved: taken
+ )
+ taken.insert(color.id)
summaries.append(GameParticipantSummary(
authorID: authorID,
name: name,
diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift
@@ -11,7 +11,7 @@ struct PlayerColor: Sendable, Identifiable, Hashable {
/// The colour's *intended* position on the hue wheel, in degrees `0..<360`.
/// Hand-assigned rather than derived from `tint` so it stays a pure value
/// (resolving a SwiftUI `Color` to RGB needs a trait environment). Used
- /// only by `stableColor` to keep a participant clear of colours that are
+ /// only by `assignedColor` to keep a participant clear of colours that are
/// perceptually similar to one already taken — not just exact matches.
let hue: Double
@@ -68,7 +68,7 @@ extension PlayerColor {
]
/// Two colours whose hues are within this many degrees of each other are
- /// treated as perceptually similar by `stableColor`, so a participant is
+ /// treated as perceptually similar by `assignedColor`, so a participant is
/// kept clear of them — not only exact matches. Sized so a reserved blue
/// also rules out green (≈85° away), per the desired behaviour.
static let similarHueThreshold: Double = 90
@@ -95,24 +95,31 @@ extension PlayerColor {
return min(d, 360 - d)
}
- /// Deterministic highlight colour for a participant: hash `authorID` into
- /// the palette, then linear-probe forward for a colour that is not just
- /// absent from `reserved` but also more than `similarHueThreshold` degrees
- /// away from every reserved colour's hue — so a participant never lands on
- /// a colour that looks like one already taken (a reserved blue also rules
- /// out green, etc.). Being a pure function of its inputs, every device and
- /// every game derives the same colour for a friend with no persistence or
- /// sync — the friend stays one colour across games, and changing the local
- /// user's preferred colour (passed in `reserved`) deterministically bumps
- /// any friend that now collides or clashes with it.
+ /// Highlight colour assigned to a collaborator *within one game*: hash
+ /// `authorID` together with `gameID` into the palette, then linear-probe
+ /// forward for a colour that is not just absent from `reserved` but also
+ /// more than `similarHueThreshold` degrees away from every reserved
+ /// colour's hue — so a collaborator never lands on a colour that looks like
+ /// one already taken (a reserved blue also rules out green, etc.).
+ ///
+ /// Folding `gameID` into the seed makes the colour deliberately *unstable*
+ /// across games: the same friend reads as a different colour in each game,
+ /// so a colour is felt as "whoever is in this grid" rather than a fixed
+ /// badge for a person. Callers thread the colours already handed out in the
+ /// game through `reserved`, so collaborators within a single game stay as
+ /// distinct as the palette allows. The local user is never assigned this
+ /// way — their colour is their stable preference, passed in via `reserved`.
///
/// Degrades in two steps when the palette can't satisfy the spacing: first
- /// relax to avoiding only the exact reserved colours (the old behaviour),
- /// then, if every colour is reserved, reuse the hashed base colour. Both
- /// fallbacks stay pure, so the guarantee above still holds.
- static func stableColor(forAuthorID authorID: String, reserved: Set<String>) -> PlayerColor {
+ /// relax to avoiding only the exact reserved colours, then, if every colour
+ /// is reserved, reuse the hashed base colour.
+ static func assignedColor(
+ forAuthorID authorID: String,
+ inGame gameID: UUID,
+ reserved: Set<String>
+ ) -> PlayerColor {
let n = palette.count
- let base = stableIndex(for: authorID, count: n)
+ let base = stableIndex(for: "\(gameID.uuidString)-\(authorID)", count: n)
let reservedHues = palette.filter { reserved.contains($0.id) }.map(\.hue)
// Pass 1: clear of every reserved hue (distance 0 covers exact matches).
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -326,13 +326,13 @@ final class PlayerRoster {
}
}
- // Assign each friend a deterministic colour. Each participant's colour
- // is a pure function of (their authorID, the local user's preferred
- // colour) — see `ParticipantSummaries.remoteParticipants`. That matches
- // the Friends list exactly, so a friend keeps one colour across every
- // surface (roster, Game List, friend roster) and across games, with no
- // persisted or synced mapping.
+ // Assign each collaborator a colour for this game. Colours are derived
+ // from (authorID, gameID) and de-conflicted against each other and the
+ // local user's stable colour, so they are distinct within the game but
+ // deliberately vary from game to game — see
+ // `ParticipantSummaries.remoteParticipants`.
let remoteEntries = ParticipantSummaries.remoteParticipants(
+ gameID: gameID,
namesByAuthor: fetched.namesMap,
moveAuthorIDs: fetched.moveAuthorIDs,
nicknamesByAuthor: fetched.nicknamesByAuthor,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -145,6 +145,7 @@ struct GameSummary: Identifiable, Equatable {
self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
self.isAccessRevoked = entity.isAccessRevoked
self.allParticipants = Self.computeParticipants(
+ gameID: id,
entity: entity,
localAuthorID: localAuthorID,
localName: localName,
@@ -175,6 +176,7 @@ struct GameSummary: Identifiable, Equatable {
}
private static func computeParticipants(
+ gameID: UUID,
entity: GameEntity,
localAuthorID: String?,
localName: String,
@@ -204,6 +206,7 @@ struct GameSummary: Identifiable, Equatable {
let nicknames = friendNicknames(in: entity.managedObjectContext)
return ParticipantSummaries.allParticipants(
+ gameID: gameID,
namesByAuthor: namesByAuthor,
moveAuthorIDs: moveAuthorIDs,
nicknamesByAuthor: nicknames,
diff --git a/Crossmate/Views/Components/FriendAvatarView.swift b/Crossmate/Views/Components/FriendAvatarView.swift
@@ -14,7 +14,6 @@ struct FriendAvatarView: View {
let authorID: String
var size: CGFloat = 34
- var reservedColorIDs: Set<String> = []
var invitePhase: InvitePhase? = nil
/// Spin shared by the avatar → paper plane and paper plane → checkmark
@@ -26,7 +25,7 @@ struct FriendAvatarView: View {
@State private var ringsTask: Task<Void, Never>?
private var avatar: FriendAvatar {
- FriendAvatar.avatar(for: authorID, reservedColorIDs: reservedColorIDs)
+ FriendAvatar.avatar(for: authorID)
}
/// The glyph shown in the avatar. While sending it is a paper plane; on
@@ -59,6 +58,22 @@ struct FriendAvatarView: View {
Circle()
.fill(avatar.background)
+ // A soft radial wash from a lighter centre out to the base
+ // grey, so the medallion reads as gently domed rather than
+ // flat. Disabled for now — swap the flat `.fill` above for this
+ // to turn it back on.
+ // .fill(
+ // RadialGradient(
+ // colors: [Color(.systemGray3), avatar.background],
+ // center: .center,
+ // startRadius: 0,
+ // endRadius: size / 2
+ // )
+ // )
+
+ // A faint, tiled watermark of the friend's own symbol — just enough
+ // texture to lift the flat medallion, sitting under the main glyph.
+ SymbolPattern(symbolName: avatar.symbolName, size: size)
Image(systemName: symbolName)
.font(.system(size: size * 0.46, weight: .semibold))
@@ -128,17 +143,64 @@ private struct BroadcastRing: View {
}
}
+/// A faint, tiled wallpaper of an SF Symbol, clipped to the avatar circle, used
+/// as a subtle background texture behind the main glyph. Tiles are staggered
+/// row-to-row for a diaper pattern and drawn at low opacity in a single
+/// `Canvas`. Everything scales off `size`, so it reads the same on a list-row
+/// avatar and a larger share-grid tile.
+private struct SymbolPattern: View {
+ let symbolName: String
+ let size: CGFloat
+ var tint: Color = .white
+ var opacity: Double = 0.16
+
+ /// Tile glyph size as a fraction of the avatar size.
+ private static let tileRatio: CGFloat = 0.26
+ /// Centre-to-centre tile spacing as a fraction of the avatar size.
+ private static let spacingRatio: CGFloat = 0.30
+
+ var body: some View {
+ Canvas { context, canvasSize in
+ guard let glyph = context.resolveSymbol(id: 0) else { return }
+ context.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: canvasSize)))
+ context.opacity = opacity
+
+ let spacing = size * Self.spacingRatio
+ guard spacing > 0 else { return }
+ var row = 0
+ var y = spacing / 2
+ while y < canvasSize.height + spacing {
+ // Offset alternate rows by half a step for the staggered look.
+ let stagger = row.isMultiple(of: 2) ? 0 : spacing / 2
+ var x = spacing / 2 + stagger - spacing
+ while x < canvasSize.width + spacing {
+ context.draw(glyph, at: CGPoint(x: x, y: y))
+ x += spacing
+ }
+ y += spacing
+ row += 1
+ }
+ } symbols: {
+ Image(systemName: symbolName)
+ .font(.system(size: size * Self.tileRatio, weight: .semibold))
+ .foregroundStyle(tint)
+ .tag(0)
+ }
+ .frame(width: size, height: size)
+ }
+}
+
private struct FriendAvatar {
let symbolName: String
let background: Color
- static func avatar(for authorID: String, reservedColorIDs: Set<String>) -> FriendAvatar {
- // The symbol is seeded with a distinct string from the colour (which
- // `stableColor` hashes off the raw authorID) so a friend's symbol and
- // colour vary independently rather than always pairing up.
+ static func avatar(for authorID: String) -> FriendAvatar {
+ // Friend avatars are intentionally colourless. A player's colour now
+ // lives only inside a game and varies per game, so outside one a friend
+ // is identified by their symbol alone on a neutral grey — never a fixed
+ // colour badge. The symbol is a stable, deterministic choice per author.
let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)]
- let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: reservedColorIDs)
- return FriendAvatar(symbolName: symbol, background: color.tint)
+ return FriendAvatar(symbolName: symbol, background: Color(.darkGray))
}
private static let symbols: [String] = [
diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift
@@ -7,7 +7,6 @@ import SwiftUI
struct FriendPickerView: View {
let gameID: UUID
- @Environment(PlayerPreferences.self) private var preferences
@Environment(\.inviteFriend) private var inviteFriend
@Environment(\.dismiss) private var dismiss
@@ -72,7 +71,6 @@ struct FriendPickerView: View {
HStack {
FriendAvatarView(
authorID: authorID,
- reservedColorIDs: [preferences.color.id],
invitePhase: invitePhase(authorID: authorID, invited: invited)
)
.padding(.trailing, 8)
diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift
@@ -6,7 +6,6 @@ import SwiftUI
/// nickname via `\.renameFriend`) and blocking, which tears down the pairwise
/// channel via `\.blockFriend`.
struct FriendsView: View {
- @Environment(PlayerPreferences.self) private var preferences
@Environment(\.blockFriend) private var blockFriend
@Environment(\.renameFriend) private var renameFriend
@Environment(\.dismiss) private var dismiss
@@ -84,10 +83,7 @@ struct FriendsView: View {
@ViewBuilder
private func friendRow(for friend: FriendEntity) -> some View {
HStack {
- FriendAvatarView(
- authorID: friend.authorID ?? "",
- reservedColorIDs: [preferences.color.id]
- )
+ FriendAvatarView(authorID: friend.authorID ?? "")
.padding(.trailing, 8)
Text(friend.resolvedDisplayName)
Spacer()
diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift
@@ -6,7 +6,6 @@ struct GameShareSheet: View {
let title: String
let shareController: ShareController
- @Environment(PlayerPreferences.self) private var preferences
@Environment(\.inviteFriend) private var inviteFriend
@Environment(\.dismiss) private var dismiss
@FetchRequest(
@@ -204,7 +203,6 @@ struct GameShareSheet: View {
FriendAvatarView(
authorID: authorID,
size: 40,
- reservedColorIDs: [preferences.color.id],
invitePhase: invitePhase(authorID: authorID, invited: wasInvited)
)
Text(friend.resolvedDisplayName)
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -164,18 +164,10 @@ struct GameSummaryThumbnailTests {
#expect(summary.allParticipants.map(\.name) == ["Local Player", "Ace", "Waiting for player..."])
#expect(summary.allParticipants.map(\.isLocal) == [true, false, false])
let remoteParticipants = summary.allParticipants.filter { !$0.isLocal }
+ // Collaborators are de-conflicted within the game, so the two remotes
+ // get distinct colours and neither collides with the local 'blue'.
#expect(!remoteParticipants.map(\.color.id).contains(PlayerColor.blue.id))
- // Each collaborator's colour is computed independently, reserving only
- // the local user's colour — identical to the Friends list, so a friend
- // shows one colour across every surface. (Collaborators are no longer
- // mutually de-conflicted, so two can share a colour.)
- for participant in remoteParticipants {
- let expected = PlayerColor.stableColor(
- forAuthorID: participant.authorID,
- reserved: [PlayerColor.blue.id]
- )
- #expect(participant.color.id == expected.id)
- }
+ #expect(Set(remoteParticipants.map(\.color.id)).count == 2)
}
@Test("Shared strip includes local player and orders by score")
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -106,8 +106,8 @@ struct PlayerRosterTests {
// MARK: - Tests
- @Test("Remote colours match the Friends-list computation, none matching local")
- func threeParticipantsMatchFriendListColors() async throws {
+ @Test("Three remote participants get distinct colours, none matching local")
+ func threeParticipantsGetDistinctColors() async throws {
let (persistence, gameID) = try makePersistenceWithGame()
addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence)
@@ -122,23 +122,14 @@ struct PlayerRosterTests {
await roster.refresh()
#expect(roster.entries.count == 4) // 1 local + 3 remote
- // Each collaborator's colour is computed independently, reserving only
- // the local colour — identical to the Friends list (which passes
- // `[preferences.color.id]`). Collaborators are no longer mutually
- // de-conflicted, so two can share a colour; the contract is only that
- // each matches its cross-surface colour and none collide with local.
- for entry in roster.entries where !entry.isLocal {
- let expected = PlayerColor.stableColor(
- forAuthorID: entry.authorID,
- reserved: [prefs.color.id]
- )
- #expect(entry.color.id == expected.id)
- #expect(entry.color.id != prefs.color.id, "remote colors should not collide with local")
- }
+ let allColorIDs = roster.entries.map { $0.color.id }
+ #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours")
+ let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id }
+ #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local")
}
- @Test("Friend colour is identical across two different games (stable across games)")
- func friendColourStableAcrossGames() async throws {
+ @Test("Friend colour is derived per game (deliberately not stable across games)")
+ func friendColourIsPerGame() async throws {
let (p1, gameA) = try makePersistenceWithGame()
addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1)
let rosterA = makeRoster(gameID: gameA, persistence: p1)
@@ -149,10 +140,23 @@ struct PlayerRosterTests {
let rosterB = makeRoster(gameID: gameB, persistence: p2)
await rosterB.refresh()
- let colourInA = rosterA.entries.first { $0.authorID == "_B" }?.color.id
- let colourInB = rosterB.entries.first { $0.authorID == "_B" }?.color.id
- #expect(colourInA != nil)
- #expect(colourInA == colourInB, "the same friend should be the same colour in every game")
+ // Each game derives the friend's colour from (authorID, gameID),
+ // de-conflicted against the local user's stable colour. The colour is a
+ // function of the game, so it is allowed to differ between games rather
+ // than acting as a fixed per-person badge.
+ func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? {
+ let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id
+ let expected = PlayerColor.assignedColor(
+ forAuthorID: "_B",
+ inGame: gameID,
+ reserved: [localColorID]
+ )
+ #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected.id)
+ return roster.entries.first { $0.authorID == "_B" }?.color.id
+ }
+
+ #expect(remoteColour(in: rosterA, gameID: gameA) != nil)
+ #expect(remoteColour(in: rosterB, gameID: gameB) != nil)
}
@Test("Entry name comes from PlayerEntity when available")