I am creating a small hobby project in SwiftUI where a revolver cylinder spins and lands on either a loaded chamber (player drinks) or empty chamber (player is safe). I'm having trouble ensuring the visual animation (what chamber lands at the firing pin) consistently matches the game logic outcome.
The Problem. The cylinder has 8 chambers, some with bullets, some empty. When spinning, I want to:
However, my current implementation occasionally shows a mismatch - sometimes landing visually on an empty chamber but triggering the "BANG!" outcome, or vice versa.
Her is also a full code view:
import SwiftUI
// MARK: - Chamber Model
struct RevolverChamber: Identifiable {
let id = UUID()
let position: Int // Position number (0-7)
let angleDegrees: Double // Position in degrees (0, 45, 90, etc.)
var hasBullet: Bool // Whether this chamber has a bullet
}
struct GameMode3View: View {
let config: GameConfiguration
@Binding var path: [Route]
//@Environment(\.colorScheme) var colorScheme
// Game state
@State private var chambers: [RevolverChamber] = []
@State private var currentPlayerIndex: Int = 0
@State private var rotation: Double = 0
@State private var isSpinning: Bool = false
@State private var outcome: SpinOutcome? = nil
@State private var bulletCount: Int = 3 // Start with 3 bullets
@State private var lastSpinPlayerName: String = ""
@State private var debugText: String = ""
// Constants
private let chamberAngle: Double = 45.0 // 360 / 8 chambers
enum SpinOutcome {
case safe
case hit
}
var currentPlayer: Player {
config.players[currentPlayerIndex]
}
var body: some View {
ZStack {
// Background
Color.gray
.edgesIgnoringSafeArea(.all)
VStack(spacing: 20) {
// Back button at top left
HStack {
Button(action: { path = [.gameModes] }) {
Image(systemName: "arrowshape.left.fill")
.foregroundColor(.white)
.padding(.leading)
.font(.system(size: 24))
}
Spacer()
}
// Title and player turn indicator
Text("Russian Roulette")
.font(.largeTitle.bold())
.foregroundColor(.white)
Text("\(currentPlayer.name)'s turn")
.font(.title2.bold())
.foregroundColor(.yellow)
.padding(.bottom, 10)
// Game status
Text("Bullets: \(bulletCount)/8")
.font(.headline)
.foregroundColor(bulletCount > 4 ? .red : .white)
// Cylinder view with fixed firing pin
ZStack {
// Spinning cylinder
ZStack {
// Cylinder background
Circle()
.fill(Color(hex: "#444444"))
.frame(width: 270, height: 270)
.shadow(color: .black, radius: 10)
// Chambers using our RevolverChamber model
ForEach(chambers) { chamber in
ChamberView(hasBullet: chamber.hasBullet)
.frame(width: 150, height: 150)
.offset(y: -100)
.rotationEffect(.degrees(chamber.angleDegrees))
}
// Chamber dividers
ForEach(0..<8, id: \.self) { idx in
Rectangle()
.fill(Color.black)
.frame(width: 4, height: 90)
.offset(y: -70)
.rotationEffect(.degrees(Double(idx) * chamberAngle + chamberAngle/2))
}
// Cylinder body
Circle()
.fill(Color(hex: "#333333"))
.frame(width: 120, height: 120)
}
.rotationEffect(.degrees(rotation))
// Fixed firing pin on top (outside rotation) - rotated to point down
Triangle()
.fill(Color.red)
.frame(width: 20, height: 20)
.rotationEffect(.degrees(180)) // Rotate arrow 180 degrees
.offset(y: -130) // Keep at the top
}
// Debug text (remove in production)
Text(debugText)
.font(.caption)
.foregroundColor(.white)
.opacity(0.7)
// Outcome message with player name
if let outcome = outcome {
VStack {
if outcome == .hit {
Text("BANG!")
.font(.title.bold())
.foregroundColor(.red)
Text("\(lastSpinPlayerName) must DRINK!")
.font(.title2.bold())
.foregroundColor(.red)
} else {
Text("Click.")
.font(.title.bold())
.foregroundColor(.green)
Text("\(lastSpinPlayerName) is safe this time...")
.font(.title2)
.foregroundColor(.green)
}
}
.padding()
.background(Color.black.opacity(0.6))
.cornerRadius(10)
.transition(.scale)
}
Spacer()
// Control buttons
HStack(spacing: 30) {
// Remove bullet
Button(action: removeBullet) {
Image(systemName: "minus.circle.fill")
.resizable()
.frame(width: 44, height: 44)
.foregroundColor(.white)
}
.disabled(bulletCount <= 1 || isSpinning)
// Spin button
Button(action: spin) {
Text("SPIN")
.font(.title.bold())
.foregroundColor(.white)
.frame(width: 120, height: 50)
.background(isSpinning ? Color.gray : Color.red)
.cornerRadius(10)
.shadow(radius: 5)
}
.disabled(isSpinning)
// Add bullet
Button(action: addBullet) {
Image(systemName: "plus.circle.fill")
.resizable()
.frame(width: 44, height: 44)
.foregroundColor(.white)
}
.disabled(bulletCount >= 7 || isSpinning)
}
}
.padding()
}
.onAppear {
// Initialize the game
setupGame(initialSetup: true)
}
.navigationBarBackButtonHidden(true) // Hide the default back button
}
// Game functions
private func setupGame(initialSetup: Bool = false) {
// Create chambers with explicit positions and angles
chambers = (0..<8).map { position in
let angle = Double(position) * chamberAngle
return RevolverChamber(
position: position,
angleDegrees: angle,
hasBullet: false
)
}
// Clear all bullets first
for i in 0..<chambers.count {
chambers[i].hasBullet = false
}
// Distribute bullets randomly
var bulletsToAdd = bulletCount
while bulletsToAdd > 0 {
let idx = Int.random(in: 0..<8)
if !chambers[idx].hasBullet {
chambers[idx].hasBullet = true
bulletsToAdd -= 1
}
}
// Only randomize player on first setup, not when adding/removing bullets
if initialSetup {
currentPlayerIndex = Int.random(in: 0..<config.players.count)
}
// Debug display
debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
}
private func spin() {
isSpinning = true
outcome = nil
// Store the name of the player who's spinning
lastSpinPlayerName = currentPlayer.name
// 1. Determine if it's a hit or miss based on probability
let isHit = Bool.random(probability: Double(bulletCount) / 8.0)
// 2. Find an appropriate chamber based on outcome
var targetChamber: RevolverChamber?
if isHit {
// Find a chamber with a bullet
targetChamber = chambers.filter { $0.hasBullet }.randomElement()
} else {
// Find a chamber without a bullet
targetChamber = chambers.filter { !$0.hasBullet }.randomElement()
}
// Default to first chamber if something went wrong
let chamber = targetChamber ?? chambers[0]
// 3. Calculate rotation to make the target chamber align with the firing pin
// First do a full circle (or more), then land on the target chamber
let fullRotations = Double.random(in: 3...5) * 360 // 3-5 full rotations
let targetAngle = 360 - chamber.angleDegrees // Position to align with the top
let finalRotation = rotation + fullRotations + targetAngle
// Animate the rotation
withAnimation(.easeOut(duration: 3.0)) {
rotation = finalRotation
}
// Show outcome after spinning stops
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
// Add debug info
debugText = "Landed on chamber \(chamber.position) - Has bullet: \(chamber.hasBullet)"
// Show outcome based on the predetermined hit/miss
withAnimation(.spring()) {
outcome = isHit ? .hit : .safe
}
isSpinning = false
// Move to next player
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
currentPlayerIndex = (currentPlayerIndex + 1) % config.players.count
}
}
}
private func addBullet() {
guard bulletCount < 7 else { return }
// Find empty chambers
let emptyChambers = chambers.filter { !$0.hasBullet }
if let chamberToFill = emptyChambers.randomElement(),
let index = chambers.firstIndex(where: { $0.id == chamberToFill.id }) {
chambers[index].hasBullet = true
bulletCount += 1
// Update debug text
debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
}
}
private func removeBullet() {
guard bulletCount > 1 else { return }
// Find filled chambers
let filledChambers = chambers.filter { $0.hasBullet }
if let chamberToEmpty = filledChambers.randomElement(),
let index = chambers.firstIndex(where: { $0.id == chamberToEmpty.id }) {
chambers[index].hasBullet = false
bulletCount -= 1
// Update debug text
debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
}
}
}
// MARK: - Supporting Views and Extensions
struct ChamberView: View {
let hasBullet: Bool
var body: some View {
ZStack {
// Chamber background
Circle()
.fill(Color.black)
.frame(width: 40, height: 40)
// Bullet if present
if hasBullet {
Circle()
.fill(Color(hex: "#FFD700"))
.frame(width: 30, height: 30)
}
}
}
}
private struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let tip = CGPoint(x: rect.midX, y: rect.minY)
let left = CGPoint(x: rect.minX, y: rect.maxY)
let right = CGPoint(x: rect.maxX, y: rect.maxY)
path.move(to: tip)
path.addLine(to: left)
path.addLine(to: right)
path.closeSubpath()
return path
}
}
// Extension to add probability-based random boolean
extension Bool {
static func random(probability: Double) -> Bool {
return Double.random(in: 0...1) < probability
}
}
// MARK: - Preview
#Preview {
let mockPlayers = [
Player(name: "Spiller 1"),
Player(name: "Spiller 2"),
Player(name: "Spiller 3"),
Player(name: "Spiller 4")
]
let config = GameConfiguration(players: mockPlayers, mode: .mode3)
return GameMode3View(config: config, path: .constant([]))
.environmentObject(PlayerManager())
}
// Determine hit/miss based on probability
let isHit = Double.random(in: 0...1) < Double(bulletCount) / 8.0
// Find chamber matching outcome
let targetChamber = isHit ?
chambers.filter(\.hasBullet).randomElement() :
chambers.filter { !$0.hasBullet }.randomElement()
chambers = (0..<8).map { position in
RevolverChamber(
position: position,
angleDegrees: Double(position) * 45.0,
hasBullet: false
)
}
let fullRotations = Double.random(in: 3...5) * 360
let targetAngle = 360 - chamber.angleDegrees
let finalRotation = rotation + fullRotations + targetAngle
Not sure about the updated method but in the original approach the problem was that you were not taking current rotation angle into account. A simple fix would be to add a variable to know what's previous was previous rotation and use it while calculating next rotation:
@State private var previousAngle: Double = 0
private func spin() {
...
let fullRotations = Double.random(in: 3...5) * 360 // 3-5 full rotations
let targetAngle = 360 + previousAngle - chamber.angleDegrees // Position to align with the top
let finalRotation = rotation + targetAngle + fullRotations
...
}