├── GameKitDemo.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── jamesthang.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── jamesthang.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── GameKitDemo ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Extension │ ├── Extension+NotificationName.swift │ ├── UIApplication+Extension.swift │ └── UIViewController+Extension.swift ├── GameKitDemo.entitlements ├── GameKitDemoApp.swift ├── Home │ ├── HomeView.swift │ └── HomeViewModel.swift ├── Manager │ └── GameKitManager.swift ├── Multiplayer │ ├── MultiplayerView.swift │ └── MultiplayerViewModel.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Ultility │ └── AuthenticationError.swift └── README.md /GameKitDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | 30E279112D01A06E000EAC7B /* GameKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GameKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | 30E279132D01A06E000EAC7B /* GameKitDemo */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = GameKitDemo; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | 30E2790E2D01A06E000EAC7B /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | 30E279082D01A06E000EAC7B = { 33 | isa = PBXGroup; 34 | children = ( 35 | 30E279132D01A06E000EAC7B /* GameKitDemo */, 36 | 30E279122D01A06E000EAC7B /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | 30E279122D01A06E000EAC7B /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | 30E279112D01A06E000EAC7B /* GameKitDemo.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | 30E279102D01A06E000EAC7B /* GameKitDemo */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = 30E2791F2D01A071000EAC7B /* Build configuration list for PBXNativeTarget "GameKitDemo" */; 54 | buildPhases = ( 55 | 30E2790D2D01A06E000EAC7B /* Sources */, 56 | 30E2790E2D01A06E000EAC7B /* Frameworks */, 57 | 30E2790F2D01A06E000EAC7B /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | 30E279132D01A06E000EAC7B /* GameKitDemo */, 65 | ); 66 | name = GameKitDemo; 67 | packageProductDependencies = ( 68 | ); 69 | productName = GameKitDemo; 70 | productReference = 30E279112D01A06E000EAC7B /* GameKitDemo.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | 30E279092D01A06E000EAC7B /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastSwiftUpdateCheck = 1610; 81 | LastUpgradeCheck = 1610; 82 | TargetAttributes = { 83 | 30E279102D01A06E000EAC7B = { 84 | CreatedOnToolsVersion = 16.1; 85 | }; 86 | }; 87 | }; 88 | buildConfigurationList = 30E2790C2D01A06E000EAC7B /* Build configuration list for PBXProject "GameKitDemo" */; 89 | developmentRegion = en; 90 | hasScannedForEncodings = 0; 91 | knownRegions = ( 92 | en, 93 | Base, 94 | ); 95 | mainGroup = 30E279082D01A06E000EAC7B; 96 | minimizedProjectReferenceProxies = 1; 97 | preferredProjectObjectVersion = 77; 98 | productRefGroup = 30E279122D01A06E000EAC7B /* Products */; 99 | projectDirPath = ""; 100 | projectRoot = ""; 101 | targets = ( 102 | 30E279102D01A06E000EAC7B /* GameKitDemo */, 103 | ); 104 | }; 105 | /* End PBXProject section */ 106 | 107 | /* Begin PBXResourcesBuildPhase section */ 108 | 30E2790F2D01A06E000EAC7B /* Resources */ = { 109 | isa = PBXResourcesBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | /* End PBXResourcesBuildPhase section */ 116 | 117 | /* Begin PBXSourcesBuildPhase section */ 118 | 30E2790D2D01A06E000EAC7B /* Sources */ = { 119 | isa = PBXSourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXSourcesBuildPhase section */ 126 | 127 | /* Begin XCBuildConfiguration section */ 128 | 30E2791D2D01A071000EAC7B /* Debug */ = { 129 | isa = XCBuildConfiguration; 130 | buildSettings = { 131 | ALWAYS_SEARCH_USER_PATHS = NO; 132 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 133 | CLANG_ANALYZER_NONNULL = YES; 134 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 135 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 136 | CLANG_ENABLE_MODULES = YES; 137 | CLANG_ENABLE_OBJC_ARC = YES; 138 | CLANG_ENABLE_OBJC_WEAK = YES; 139 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 140 | CLANG_WARN_BOOL_CONVERSION = YES; 141 | CLANG_WARN_COMMA = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 144 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 145 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 146 | CLANG_WARN_EMPTY_BODY = YES; 147 | CLANG_WARN_ENUM_CONVERSION = YES; 148 | CLANG_WARN_INFINITE_RECURSION = YES; 149 | CLANG_WARN_INT_CONVERSION = YES; 150 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 151 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 152 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 154 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 155 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 156 | CLANG_WARN_STRICT_PROTOTYPES = YES; 157 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 158 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | DEBUG_INFORMATION_FORMAT = dwarf; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 181 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = iphoneos; 186 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 187 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 188 | }; 189 | name = Debug; 190 | }; 191 | 30E2791E2D01A071000EAC7B /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | ALWAYS_SEARCH_USER_PATHS = NO; 195 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 226 | ENABLE_NS_ASSERTIONS = NO; 227 | ENABLE_STRICT_OBJC_MSGSEND = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu17; 230 | GCC_NO_COMMON_BLOCKS = YES; 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 238 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 239 | MTL_ENABLE_DEBUG_INFO = NO; 240 | MTL_FAST_MATH = YES; 241 | SDKROOT = iphoneos; 242 | SWIFT_COMPILATION_MODE = wholemodule; 243 | VALIDATE_PRODUCT = YES; 244 | }; 245 | name = Release; 246 | }; 247 | 30E279202D01A071000EAC7B /* Debug */ = { 248 | isa = XCBuildConfiguration; 249 | buildSettings = { 250 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 251 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 252 | CODE_SIGN_ENTITLEMENTS = GameKitDemo/GameKitDemo.entitlements; 253 | CODE_SIGN_STYLE = Automatic; 254 | CURRENT_PROJECT_VERSION = 1; 255 | DEVELOPMENT_ASSET_PATHS = "\"GameKitDemo/Preview Content\""; 256 | DEVELOPMENT_TEAM = 6W4YD5KNPW; 257 | ENABLE_PREVIEWS = YES; 258 | GENERATE_INFOPLIST_FILE = YES; 259 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 260 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 261 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 262 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 263 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 264 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 265 | LD_RUNPATH_SEARCH_PATHS = ( 266 | "$(inherited)", 267 | "@executable_path/Frameworks", 268 | ); 269 | MARKETING_VERSION = 1.0; 270 | PRODUCT_BUNDLE_IDENTIFIER = com.jamesthang.GameKitDemo; 271 | PRODUCT_NAME = "$(TARGET_NAME)"; 272 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 273 | SUPPORTS_MACCATALYST = NO; 274 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 275 | SWIFT_EMIT_LOC_STRINGS = YES; 276 | SWIFT_VERSION = 5.0; 277 | TARGETED_DEVICE_FAMILY = "1,2"; 278 | }; 279 | name = Debug; 280 | }; 281 | 30E279212D01A071000EAC7B /* Release */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 285 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 286 | CODE_SIGN_ENTITLEMENTS = GameKitDemo/GameKitDemo.entitlements; 287 | CODE_SIGN_STYLE = Automatic; 288 | CURRENT_PROJECT_VERSION = 1; 289 | DEVELOPMENT_ASSET_PATHS = "\"GameKitDemo/Preview Content\""; 290 | DEVELOPMENT_TEAM = 6W4YD5KNPW; 291 | ENABLE_PREVIEWS = YES; 292 | GENERATE_INFOPLIST_FILE = YES; 293 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 294 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 295 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 296 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 298 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 299 | LD_RUNPATH_SEARCH_PATHS = ( 300 | "$(inherited)", 301 | "@executable_path/Frameworks", 302 | ); 303 | MARKETING_VERSION = 1.0; 304 | PRODUCT_BUNDLE_IDENTIFIER = com.jamesthang.GameKitDemo; 305 | PRODUCT_NAME = "$(TARGET_NAME)"; 306 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 307 | SUPPORTS_MACCATALYST = NO; 308 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 309 | SWIFT_EMIT_LOC_STRINGS = YES; 310 | SWIFT_VERSION = 5.0; 311 | TARGETED_DEVICE_FAMILY = "1,2"; 312 | }; 313 | name = Release; 314 | }; 315 | /* End XCBuildConfiguration section */ 316 | 317 | /* Begin XCConfigurationList section */ 318 | 30E2790C2D01A06E000EAC7B /* Build configuration list for PBXProject "GameKitDemo" */ = { 319 | isa = XCConfigurationList; 320 | buildConfigurations = ( 321 | 30E2791D2D01A071000EAC7B /* Debug */, 322 | 30E2791E2D01A071000EAC7B /* Release */, 323 | ); 324 | defaultConfigurationIsVisible = 0; 325 | defaultConfigurationName = Release; 326 | }; 327 | 30E2791F2D01A071000EAC7B /* Build configuration list for PBXNativeTarget "GameKitDemo" */ = { 328 | isa = XCConfigurationList; 329 | buildConfigurations = ( 330 | 30E279202D01A071000EAC7B /* Debug */, 331 | 30E279212D01A071000EAC7B /* Release */, 332 | ); 333 | defaultConfigurationIsVisible = 0; 334 | defaultConfigurationName = Release; 335 | }; 336 | /* End XCConfigurationList section */ 337 | }; 338 | rootObject = 30E279092D01A06E000EAC7B /* Project object */; 339 | } 340 | -------------------------------------------------------------------------------- /GameKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /GameKitDemo.xcodeproj/project.xcworkspace/xcuserdata/jamesthang.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enryun/GameKitDemo/23e4b57db99bbd103d3e90df1e4d5a707a5eab04/GameKitDemo.xcodeproj/project.xcworkspace/xcuserdata/jamesthang.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /GameKitDemo.xcodeproj/xcuserdata/jamesthang.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | GameKitDemo.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /GameKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /GameKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /GameKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /GameKitDemo/Extension/Extension+NotificationName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension+NotificationName.swift 3 | // PirateWar 4 | // 5 | // Created by James Thang on 30/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | static let matchmakingCancelled = Notification.Name("matchmakingCancelled") 12 | } 13 | -------------------------------------------------------------------------------- /GameKitDemo/Extension/UIApplication+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication+Extension.swift 3 | // CommonSwiftUI 4 | // 5 | // Created by James Thang on 24/03/2024. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UIApplication { 12 | /// Retrieves the top-most view controller from the main application window. 13 | /// 14 | /// This class variable assesses the application's window hierarchy, starting from the root view controller of the last key window among connected scenes. It recursively traverses presented view controllers, navigation controllers, and tab bar controllers to find the top-most view controller that is currently visible to the user. 15 | /// 16 | /// - Returns: The currently visible and top-most `UIViewController`, if one exists. 17 | public static var topMostViewController : UIViewController? { 18 | let window = UIApplication 19 | .shared 20 | .connectedScenes 21 | .compactMap { ($0 as? UIWindowScene)?.keyWindow } 22 | .last 23 | return window?.rootViewController?.topMostViewController 24 | } 25 | 26 | /// Retrieves the application's root view controller. 27 | /// 28 | /// Attempts to find the root view controller of the primary window by accessing the first connected `UIWindowScene`. If no `UIWindowScene` or root view controller can be found, returns a new instance of `UIViewController`. This ensures a non-nil return value, though it may be an empty view controller if the app's window hierarchy is not properly configured. 29 | /// 30 | /// - Returns: The app's root `UIViewController`, or a new `UIViewController` instance if none is found. 31 | public static var rootViewController: UIViewController { 32 | guard let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene else { 33 | return .init() 34 | } 35 | 36 | guard let root = screen.windows.first?.rootViewController else { 37 | return .init() 38 | } 39 | 40 | return root 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /GameKitDemo/Extension/UIViewController+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Extension.swift 3 | // CommonSwiftUI 4 | // 5 | // Created by James Thang on 24/03/2024. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController { 11 | /// Finds the top-most view controller from the current view controller's hierarchy. 12 | /// 13 | /// Navigates through presented, navigation, and tab bar controller hierarchies to identify the foremost view controller that is visible and interactive to the user. Useful for accurately determining where to present new view controllers or alerts from a nested structure. 14 | /// 15 | /// - Returns: The highest view controller in the hierarchy that can be interacted with by the user. 16 | public var topMostViewController : UIViewController { 17 | if let presented = self.presentedViewController { 18 | return presented.topMostViewController 19 | } 20 | 21 | if let navigation = self as? UINavigationController { 22 | return navigation.visibleViewController?.topMostViewController ?? navigation 23 | } 24 | 25 | if let tab = self as? UITabBarController { 26 | return tab.selectedViewController?.topMostViewController ?? tab 27 | } 28 | return self 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /GameKitDemo/GameKitDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.game-center 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /GameKitDemo/GameKitDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameKitDemoApp.swift 3 | // GameKitDemo 4 | // 5 | // Created by James Thang on 5/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct GameKitDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | HomeView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /GameKitDemo/Home/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // GameKitDemo 4 | // 5 | // Created by James Thang on 5/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomeView: View { 11 | 12 | @StateObject private var viewModel = HomeViewModel() 13 | 14 | var body: some View { 15 | NavigationView { 16 | VStack { 17 | NavigationLink { 18 | MultiplayerView() 19 | } label: { 20 | Text("Multiplayer Demo") 21 | .font(.title) 22 | .fontWeight(.medium) 23 | .foregroundStyle(.white) 24 | .padding() 25 | .background(.blue, in: .rect(cornerRadius: 12)) 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | HomeView() 34 | } 35 | -------------------------------------------------------------------------------- /GameKitDemo/Home/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // GameKitDemo 4 | // 5 | // Created by James Thang on 5/12/24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | @MainActor 12 | final class HomeViewModel: ObservableObject { 13 | 14 | @Published private(set) var isAuthenticated = false 15 | @Published var showAuthenticationView = false 16 | @Published private(set) var authenticationViewController: UIViewController? 17 | @Published private(set) var authenticationError: AuthenticationError? 18 | private var cancellables = Set() 19 | 20 | init() { 21 | setupGameKitAuthenticatationObserver() 22 | } 23 | 24 | private func setupGameKitAuthenticatationObserver() { 25 | GameKitManager.shared.$isAuthenticated 26 | .receive(on: DispatchQueue.main) 27 | .assign(to: \.isAuthenticated, on: self) 28 | .store(in: &cancellables) 29 | 30 | GameKitManager.shared.$authenticationError 31 | .receive(on: DispatchQueue.main) 32 | .assign(to: \.authenticationError, on: self) 33 | .store(in: &cancellables) 34 | 35 | GameKitManager.shared.$authenticationViewController 36 | .receive(on: DispatchQueue.main) 37 | .sink { [weak self] viewController in 38 | guard let self = self else { return } 39 | if let _ = viewController { 40 | self.showAuthenticationView = true 41 | } else { 42 | self.showAuthenticationView = false 43 | } 44 | } 45 | .store(in: &cancellables) 46 | } 47 | 48 | } 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /GameKitDemo/Manager/GameKitManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameLeaderBoardManager.swift 3 | // SudokuAR 4 | // 5 | // Created by James Thang on 25/10/24. 6 | // 7 | 8 | import GameKit 9 | import Combine 10 | 11 | final class GameKitManager: NSObject, ObservableObject { 12 | 13 | // Singleton instance 14 | static let shared = GameKitManager() 15 | 16 | // Authentication Properties 17 | @Published private(set) var isAuthenticated: Bool = GKLocalPlayer.local.isAuthenticated 18 | @Published var authenticationError: AuthenticationError? 19 | @Published var authenticationViewController: UIViewController? 20 | 21 | // Multiplayer Properties 22 | @Published var currentMatch: GKMatch? 23 | @Published var matchError: Error? 24 | @Published var receivedData: (data: Data, player: GKPlayer)? 25 | @Published var playerConnectionState: (player: GKPlayer, state: GKPlayerConnectionState)? 26 | 27 | private var cancellables = Set() 28 | 29 | // Private initializer to prevent instantiation from other classes 30 | private override init() { 31 | super.init() 32 | setupAuthenticationHandler() 33 | setupAuthenticatationStatusObserver() 34 | } 35 | 36 | private func setupAuthenticationHandler() { 37 | let localPlayer = GKLocalPlayer.local 38 | localPlayer.authenticateHandler = { [weak self] viewController, error in 39 | guard let self else { return } 40 | if let error { 41 | self.authenticationError = AuthenticationError.custom(message: error.localizedDescription) 42 | } else if let viewController { 43 | // Need to present view controller 44 | self.authenticationViewController = viewController 45 | // You may need to notify the view model or set a flag here 46 | } else if localPlayer.isAuthenticated { 47 | self.isAuthenticated = true 48 | self.authenticationError = nil 49 | self.authenticationViewController = nil 50 | } else { 51 | self.isAuthenticated = false 52 | self.authenticationError = AuthenticationError.unknown 53 | } 54 | } 55 | } 56 | 57 | private func setupAuthenticatationStatusObserver() { 58 | NotificationCenter.default.publisher(for: .GKPlayerAuthenticationDidChangeNotificationName) 59 | .receive(on: DispatchQueue.main) 60 | .sink { [weak self] _ in 61 | self?.isAuthenticated = GKLocalPlayer.local.isAuthenticated 62 | } 63 | .store(in: &cancellables) 64 | } 65 | 66 | } 67 | 68 | //MARK: - Leader Board 69 | 70 | extension GameKitManager { 71 | 72 | // MARK: - Score Reporting 73 | 74 | /// Reports the player's score to Game Center. 75 | /// - Parameters: 76 | /// - score: The score to report. 77 | /// - leaderboardID: The identifier of the leaderboard. 78 | func reportScore(score: Int, leaderboardID: String) async throws { 79 | try await GKLeaderboard.submitScore( 80 | score, 81 | context: 0, 82 | player: GKLocalPlayer.local, 83 | leaderboardIDs: [leaderboardID] 84 | ) 85 | } 86 | 87 | // MARK: - Display Leaderboard 88 | 89 | /// Presents the Game Center leaderboard interface. 90 | /// - Parameters: 91 | /// - leaderboardID: The identifier of the leaderboard. 92 | /// - viewController: The view controller used to present the Game Center view controller. 93 | func presentLeaderboard(leaderboardID: String, in viewController: UIViewController) { 94 | let gcViewController = GKGameCenterViewController( 95 | leaderboardID: leaderboardID, 96 | playerScope: .global, 97 | timeScope: .allTime 98 | ) 99 | gcViewController.gameCenterDelegate = self 100 | viewController.present(gcViewController, animated: true) 101 | } 102 | 103 | } 104 | 105 | // MARK: - GKGameCenterControllerDelegate 106 | 107 | extension GameKitManager: GKGameCenterControllerDelegate { 108 | 109 | /// Dismisses the Game Center view controller when done. 110 | func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) { 111 | gameCenterViewController.dismiss(animated: true) 112 | } 113 | 114 | } 115 | 116 | // MARK: - GKMatchmakerViewControllerDelegate 117 | 118 | extension GameKitManager: GKMatchmakerViewControllerDelegate { 119 | 120 | func matchmakerViewControllerWasCancelled(_ viewController: GKMatchmakerViewController) { 121 | viewController.dismiss(animated: true) 122 | 123 | DispatchQueue.main.async { 124 | NotificationCenter.default.post(name: .matchmakingCancelled, object: nil) 125 | } 126 | } 127 | 128 | func matchmakerViewController(_ viewController: GKMatchmakerViewController, didFailWithError error: Error) { 129 | viewController.dismiss(animated: true) 130 | self.matchError = error 131 | } 132 | 133 | func matchmakerViewController(_ viewController: GKMatchmakerViewController, didFind match: GKMatch) { 134 | viewController.dismiss(animated: true) 135 | self.currentMatch = match 136 | match.delegate = self 137 | } 138 | 139 | } 140 | 141 | //MARK: - GKMatchDelegate 142 | 143 | extension GameKitManager: GKMatchDelegate { 144 | 145 | func match(_ match: GKMatch, didReceive data: Data, fromRemotePlayer player: GKPlayer) { 146 | self.receivedData = (data, player) 147 | } 148 | 149 | func match(_ match: GKMatch, player: GKPlayer, didChange state: GKPlayerConnectionState) { 150 | self.playerConnectionState = (player, state) 151 | } 152 | 153 | func match(_ match: GKMatch, didFailWithError error: Error?) { 154 | if let error { 155 | self.matchError = error 156 | } 157 | } 158 | 159 | } 160 | 161 | //MARK: - Multiplayer 162 | 163 | extension GameKitManager { 164 | 165 | func findMatch(minPlayers: Int, maxPlayers: Int, viewController: UIViewController) throws { 166 | guard isAuthenticated else { 167 | throw AuthenticationError.custom(message: "Local player is not authenticated. Please sign in to Game Center.") 168 | } 169 | 170 | let request = GKMatchRequest() 171 | request.minPlayers = minPlayers 172 | request.maxPlayers = maxPlayers 173 | 174 | let mmvc = GKMatchmakerViewController(matchRequest: request) 175 | mmvc?.matchmakerDelegate = self 176 | 177 | if let mmvc { 178 | viewController.present(mmvc, animated: true) 179 | } 180 | } 181 | 182 | func sendData(_ data: Data, mode: GKMatch.SendDataMode = .reliable) throws { 183 | guard let match = currentMatch else { 184 | throw NSError(domain: "No active match", code: 0, userInfo: nil) 185 | } 186 | 187 | try match.sendData(toAllPlayers: data, with: mode) 188 | } 189 | 190 | func disconnectMatch() { 191 | currentMatch?.disconnect() 192 | currentMatch = nil 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /GameKitDemo/Multiplayer/MultiplayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultiplayerView.swift 3 | // PirateWar 4 | // 5 | // Created by James Thang on 29/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MultiplayerView: View { 11 | 12 | @StateObject private var multiplayerViewModel = MultiplayerViewModel() 13 | 14 | var body: some View { 15 | VStack { 16 | switch multiplayerViewModel.state { 17 | case .idle: 18 | IdleView 19 | case .matchmaking: 20 | MatchMakingView 21 | case .waitingForPlayers: 22 | WaitingForPlayersView 23 | case .readyToStart: 24 | ReadyToStartView 25 | case .gameActive: 26 | GameActiveView 27 | case .error(let message): 28 | ErrorView(message: message) 29 | } 30 | } 31 | } 32 | 33 | private var IdleView: some View { 34 | VStack { 35 | Text("No active match") 36 | .font(.headline) 37 | 38 | Button("Find Match") { 39 | multiplayerViewModel.startMatchmaking(presentingViewController: UIApplication.rootViewController) 40 | } 41 | } 42 | .padding() 43 | } 44 | 45 | private var MatchMakingView: some View { 46 | VStack { 47 | Text("Finding a match...") 48 | .font(.headline) 49 | 50 | ProgressView() 51 | } 52 | } 53 | 54 | private var WaitingForPlayersView: some View { 55 | VStack { 56 | Text("Waiting for another player to connect...") 57 | .font(.headline) 58 | 59 | List { 60 | Text("You (Local Player)") 61 | ForEach(multiplayerViewModel.connectedPlayers, id: \.gamePlayerID) { player in 62 | Text(player.displayName) 63 | } 64 | } 65 | 66 | Button("Cancel") { 67 | multiplayerViewModel.disconnectMatch() 68 | } 69 | .padding() 70 | } 71 | } 72 | 73 | private var ReadyToStartView: some View { 74 | Text("Game is starting!") 75 | .font(.headline) 76 | } 77 | 78 | private var GameActiveView: some View { 79 | VStack(spacing: 20) { 80 | Text("Game in progress") 81 | .font(.headline) 82 | 83 | Text("Counter: \(multiplayerViewModel.counter)") 84 | .font(.largeTitle) 85 | .padding() 86 | 87 | Button(action: { 88 | multiplayerViewModel.incrementCounter() 89 | }) { 90 | Text("Increment Counter") 91 | .font(.title2) 92 | .padding() 93 | .background(Color.blue) 94 | .foregroundColor(.white) 95 | .cornerRadius(10) 96 | } 97 | 98 | Button("End Game") { 99 | // Implement logic to end the game 100 | multiplayerViewModel.disconnectMatch() 101 | } 102 | .padding() 103 | } 104 | .padding() 105 | } 106 | 107 | @ViewBuilder 108 | private func ErrorView(message: String) -> some View { 109 | VStack { 110 | Text("Error: \(message)") 111 | .foregroundColor(.red) 112 | .padding() 113 | 114 | Button("Back to Home") { 115 | multiplayerViewModel.disconnectMatch() 116 | } 117 | .padding() 118 | } 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /GameKitDemo/Multiplayer/MultiplayerViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultiplayerViewModel.swift 3 | // PirateWar (iOS) 4 | // 5 | // Created by James Thang on 29/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import GameKit 11 | 12 | enum MultiplayerState: Equatable { 13 | case idle 14 | case matchmaking 15 | case waitingForPlayers 16 | case readyToStart 17 | case gameActive 18 | case error(String) 19 | } 20 | 21 | @MainActor 22 | final class MultiplayerViewModel: ObservableObject { 23 | 24 | @Published var state: MultiplayerState = .idle 25 | @Published var connectedPlayers: [GKPlayer] = [] 26 | @Published var counter: Int = 0 27 | 28 | private var isHost: Bool = false 29 | private var cancellables = Set() 30 | 31 | init() { 32 | setupSubscriptions() 33 | } 34 | 35 | private func setupSubscriptions() { 36 | GameKitManager.shared.$currentMatch 37 | .receive(on: DispatchQueue.main) 38 | .sink { [weak self] match in 39 | guard let self = self else { return } 40 | if match != nil { 41 | self.handleMatchStarted() 42 | } else { 43 | self.connectedPlayers = [] 44 | self.state = .idle 45 | } 46 | } 47 | .store(in: &cancellables) 48 | 49 | GameKitManager.shared.$receivedData 50 | .compactMap { $0 } 51 | .receive(on: DispatchQueue.main) 52 | .sink { [weak self] data, player in 53 | self?.handleReceivedData(data, from: player) 54 | } 55 | .store(in: &cancellables) 56 | 57 | GameKitManager.shared.$matchError 58 | .compactMap { $0 } 59 | .receive(on: DispatchQueue.main) 60 | .sink { [weak self] error in 61 | self?.state = .error(error.localizedDescription) 62 | } 63 | .store(in: &cancellables) 64 | 65 | GameKitManager.shared.$playerConnectionState 66 | .compactMap { $0 } 67 | .receive(on: DispatchQueue.main) 68 | .sink { [weak self] player, state in 69 | self?.handlePlayerConnectionState(player: player, state: state) 70 | } 71 | .store(in: &cancellables) 72 | 73 | NotificationCenter.default.publisher(for: .matchmakingCancelled) 74 | .receive(on: DispatchQueue.main) 75 | .sink { [weak self] _ in 76 | self?.state = .idle 77 | } 78 | .store(in: &cancellables) 79 | } 80 | 81 | func startMatchmaking(presentingViewController: UIViewController) { 82 | state = .matchmaking 83 | do { 84 | try GameKitManager.shared.findMatch(minPlayers: 2, maxPlayers: 2, viewController: presentingViewController) 85 | } catch { 86 | state = .error(error.localizedDescription) 87 | } 88 | } 89 | 90 | func sendData(_ data: Data) { 91 | do { 92 | try GameKitManager.shared.sendData(data) 93 | } catch { 94 | state = .error(error.localizedDescription) 95 | } 96 | } 97 | 98 | func disconnectMatch() { 99 | GameKitManager.shared.disconnectMatch() 100 | state = .idle 101 | } 102 | 103 | private func handleMatchStarted() { 104 | guard let match = GameKitManager.shared.currentMatch else { return } 105 | 106 | connectedPlayers = match.players 107 | let allPlayersIncludingLocal = match.players + [GKLocalPlayer.local] 108 | let sortedPlayers = allPlayersIncludingLocal.sorted { $0.gamePlayerID < $1.gamePlayerID } 109 | isHost = (sortedPlayers.first == GKLocalPlayer.local) 110 | state = .waitingForPlayers 111 | checkIfReadyToStart() 112 | } 113 | 114 | private func handleReceivedData(_ data: Data, from player: GKPlayer) { 115 | if let message = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], 116 | let action = message["action"] as? String { 117 | switch action { 118 | case "startGame": 119 | state = .gameActive 120 | case "updateCounter": 121 | if let counterValue = message["counterValue"] as? Int { 122 | counter = counterValue 123 | } 124 | default: 125 | break 126 | } 127 | } 128 | } 129 | 130 | private func handlePlayerConnectionState(player: GKPlayer, state: GKPlayerConnectionState) { 131 | switch state { 132 | case .connected: 133 | if !connectedPlayers.contains(where: { $0.gamePlayerID == player.gamePlayerID }) { 134 | connectedPlayers.append(player) 135 | } 136 | case .disconnected: 137 | connectedPlayers.removeAll { $0.gamePlayerID == player.gamePlayerID } 138 | default: 139 | break 140 | } 141 | // Update readiness status 142 | checkIfReadyToStart() 143 | } 144 | 145 | private func checkIfReadyToStart() { 146 | let totalPlayers = connectedPlayers.count + 1 // +1 for local player 147 | 148 | if totalPlayers == 2 { 149 | state = .readyToStart 150 | if isHost { 151 | notifyStartGame() 152 | } 153 | } else { 154 | if case .gameActive = state { 155 | state = .error("A player has disconnected.") 156 | } else { 157 | state = .waitingForPlayers 158 | } 159 | } 160 | } 161 | 162 | func notifyStartGame() { 163 | let message = ["action": "startGame"] 164 | if let data = try? JSONSerialization.data(withJSONObject: message, options: []) { 165 | sendData(data) 166 | } 167 | 168 | state = .gameActive 169 | } 170 | 171 | func incrementCounter() { 172 | counter += 1 173 | sendCounterUpdate() 174 | } 175 | 176 | private func sendCounterUpdate() { 177 | let messageData = [ 178 | "action": "updateCounter", 179 | "counterValue": counter 180 | ] as [String : Any] 181 | 182 | do { 183 | let data = try JSONSerialization.data(withJSONObject: messageData, options: []) 184 | sendData(data) 185 | } catch { 186 | state = .error("Failed to send counter update: \(error.localizedDescription)") 187 | } 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /GameKitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /GameKitDemo/Ultility/AuthenticationError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationError.swift 3 | // GameKitDemo 4 | // 5 | // Created by James Thang on 5/12/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum AuthenticationError: LocalizedError { 11 | 12 | case requiresPresentation(UIViewController) 13 | case unknown 14 | case custom(message: String) 15 | case cancelled 16 | 17 | var errorDescription: String? { 18 | switch self { 19 | case .requiresPresentation: 20 | return "Authentication is required. Please sign in to Game Center." 21 | case .unknown: 22 | return "An unknown error occurred during authentication." 23 | case .custom(let message): 24 | return message 25 | case .cancelled: 26 | return "Authentication was cancelled." 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multiplayer Counter Demo with GameKit 2 | 3 | ![iOS 15.0+](https://img.shields.io/badge/iOS-15.0%2B-blue.svg) 4 | 5 | https://github.com/user-attachments/assets/85a9807b-00bf-47da-bfe1-4e1269c08588 6 | 7 | A simple SwiftUI multiplayer demo using GameKit, where two players can increment a counter, and the updated value is synchronized between devices in real-time. 8 | 9 | ## Table of Contents 10 | 1. [Introduction](#introduction) 11 | 2. [Features](#features) 12 | 3. [Requirements](#requirements) 13 | 4. [Installation](#installation) 14 | 5. [Usage](#usage) 15 | 6. [ProjectStructure](#projectstructure) 16 | 7. [Author](#author) 17 | 18 | ## Introduction 19 | 20 | This project demonstrates how to implement real-time multiplayer functionality in a SwiftUI app using `GameKit`. It provides a simple counter that two (or more) players can increment, and the counter value is synchronized between both players' devices. The project serves as a starting point for building more complex multiplayer games or apps. 21 | 22 | ## Features 23 | 24 | - Real-time multiplayer using GameKit. 25 | - Asynchronous programming with Swift's async/await. 26 | - State management with @Published properties and ObservableObject. 27 | - Clean and maintainable code with MVVM architecture. 28 | - Automatic matchmaking with Game Center. 29 | - Error handling and user feedback. 30 | - Supports iOS 15.0 and above. 31 | 32 | ## Requirements 33 | 34 | - Xcode 13.0 or later. 35 | - Swift 5.9 or later. 36 | - iOS 15.6 or later. 37 | - Two devices or simulators signed into Game Center with different accounts. 38 | 39 | ## Installation 40 | 41 | - Update the bundle identifier to a unique value associated with your Apple Developer account. 42 | - Go to your project's Signing & Capabilities and set up the necessary capabilities: 43 | 44 | GameKit2 45 | 46 | - Make sure to create the Project in AppstoreConnect and enable GameKit else it will not work in real devices: 47 | 48 | GameKit3 49 | 50 | ## Usage 51 | 52 | ### Starting a Match 53 | - Open the app on both devices or simulators. 54 | - Initiate Matchmaking 55 | - On each device, tap the Find Match button. 56 | - The Game Center matchmaking UI will appear. 57 | - Connect with Another Player 58 | 59 | https://github.com/user-attachments/assets/85a9807b-00bf-47da-bfe1-4e1269c08588 60 | 61 | - The matchmaking service will automatically connect the two devices. 62 | - Once connected, the game will start automatically. 63 | - On either device, tap the Increment Counter button. 64 | - The updated counter value will appear on both devices. 65 | - Repeat tapping the button on either device to continue incrementing. 66 | 67 | Tap the End Game button to disconnect and return to the main menu. 68 | 69 | ## ProjectStructure 70 | 71 | ### Models 72 | - `MultiplayerState`: Enum representing the different states of the multiplayer session. 73 | - `AuthenticationError`: Enum for handling authentication-related errors. 74 | 75 | ### ViewModels 76 | - `MultiplayerViewModel`: Manages the game logic, state transitions, and communication with GameKitManager. 77 | 78 | ```swift 79 | @MainActor 80 | final class MultiplayerViewModel: ObservableObject { 81 | 82 | @Published var state: MultiplayerState = .idle 83 | @Published var connectedPlayers: [GKPlayer] = [] 84 | @Published var counter: Int = 0 85 | private var isHost: Bool = false 86 | 87 | // Initialization and subscriptions... 88 | 89 | func incrementCounter() { 90 | counter += 1 91 | sendCounterUpdate() 92 | } 93 | 94 | private func sendCounterUpdate() { 95 | let messageData = [ 96 | "action": "updateCounter", 97 | "counterValue": counter 98 | ] as [String : Any] 99 | 100 | do { 101 | let data = try JSONSerialization.data(withJSONObject: messageData, options: []) 102 | sendData(data) 103 | } catch { 104 | state = .error("Failed to send counter update: \(error.localizedDescription)") 105 | } 106 | } 107 | 108 | // Other methods... 109 | } 110 | ``` 111 | 112 | ### Views 113 | `MultiplayerView`: The main SwiftUI view that updates based on the MultiplayerViewModel's state. 114 | 115 | ### Managers 116 | `GameKitManager`: Handles all Game Center-related functionality, including authentication, matchmaking, and data transmission. 117 | 118 | ```swift 119 | final class GameKitManager: NSObject, ObservableObject { 120 | 121 | static let shared = GameKitManager() 122 | 123 | // Authentication Properties 124 | @Published private(set) var isAuthenticated: Bool = GKLocalPlayer.local.isAuthenticated 125 | @Published var authenticationError: AuthenticationError? 126 | @Published var authenticationViewController: UIViewController? 127 | 128 | // Multiplayer Properties 129 | @Published var currentMatch: GKMatch? 130 | @Published var matchError: Error? 131 | @Published var receivedData: (data: Data, player: GKPlayer)? 132 | @Published var playerConnectionState: (player: GKPlayer, state: GKPlayerConnectionState)? 133 | 134 | func findMatch(minPlayers: Int, maxPlayers: Int, viewController: UIViewController) throws { 135 | guard isAuthenticated else { 136 | throw AuthenticationError.custom(message: "Local player is not authenticated. Please sign in to Game Center.") 137 | } 138 | 139 | let request = GKMatchRequest() 140 | request.minPlayers = minPlayers 141 | request.maxPlayers = maxPlayers 142 | 143 | let mmvc = GKMatchmakerViewController(matchRequest: request) 144 | mmvc?.matchmakerDelegate = self 145 | 146 | if let mmvc { 147 | viewController.present(mmvc, animated: true) 148 | } 149 | } 150 | 151 | func sendData(_ data: Data, mode: GKMatch.SendDataMode = .reliable) throws { 152 | guard let match = currentMatch else { 153 | throw NSError(domain: "No active match", code: 0, userInfo: nil) 154 | } 155 | 156 | try match.sendData(toAllPlayers: data, with: mode) 157 | } 158 | 159 | func disconnectMatch() { 160 | currentMatch?.disconnect() 161 | currentMatch = nil 162 | } 163 | // Other methods... 164 | } 165 | ``` 166 | 167 | ## Author 168 | 169 | James Thang, find me on [LinkedIn](https://www.linkedin.com/in/jamesthang/) 170 | 171 | Learn more about SwiftUI, check out my book :books: on [Amazon](https://www.amazon.com/Ultimate-SwiftUI-Handbook-iOS-Developers-ebook/dp/B0CKBVY7V6/ref=tmm_kin_swatch_0?_encoding=UTF8&qid=1696776124&sr=8-1) 172 | --------------------------------------------------------------------------------