Use iCloud key-value storage with Godot¶
NSUbiquitousKeyValueStore shares small preferences between a user’s Apple
devices. Use it for settings that change infrequently. Keep a local copy with
ConfigFile or another local storage system. Your game must work when iCloud is
not available.
See Apple’s class reference and preference synchronization sample.
Configure the exported app¶
Include
GodotApplePluginsFoundationandGodotApplePluginsRuntimein the project.In Xcode, select the exported app target. Add the iCloud capability.
Select the Key-value storage service.
Check the app’s signed entitlements. The app needs
com.apple.developer.ubiquity-kvstore-identifier.Use the same store identifier in the iOS, macOS, and visionOS app targets that must share preferences. Sign them with provisioning profiles that include this entitlement.
For a device test, enable iCloud Drive and sign in with the same Apple account on two devices.
An entitlement file can contain this entry. Replace the example identifier with your store identifier:
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.game</string>
Set this entitlement on the app target. The plugin cannot add it at runtime. An unsigned Godot editor is not a substitute for a signed app test. See Apple’s entitlement reference.
Read and write a preference¶
default_store() returns a Godot wrapper for Apple’s shared default store.
NSUbiquitousKeyValueStore.new() uses the same native store. Each wrapper has
its own signal. Keep one wrapper in an Autoload or another long-lived object.
Connect did_change_externally before you call synchronize() at launch.
The wrapper registers the native observer at creation. It emits the signal
on the main thread only when the signal has a connection. It removes the
observer when the wrapper is released. It does not call synchronize() for you.
This example keeps the background choice in ConfigFile. It reads the local
choice first. It applies a cloud value only when the key exists. It does not
upload a default value at launch, which could replace a value from another device.
extends Node
const COLOR_KEY := "background_color"
const LOCAL_PATH := "user://preferences.cfg"
var cloud := NSUbiquitousKeyValueStore.default_store()
var preferences := ConfigFile.new()
var background_color: int = 0
func _ready() -> void:
preferences.load(LOCAL_PATH)
background_color = int(preferences.get_value("appearance", COLOR_KEY, 0))
if cloud == null:
_apply_color()
return
cloud.did_change_externally.connect(_on_cloud_changed)
if not cloud.synchronize():
push_warning("iCloud synchronization could not start. Check the app entitlements.")
_read_cloud_color()
_apply_color()
func choose_background_color(index: int) -> void:
if index < 0 or index > 3:
return
background_color = index
_save_local_color()
_apply_color()
if cloud != null:
cloud.set_long_long(index, COLOR_KEY)
func _on_cloud_changed(reason: int, changed_keys: PackedStringArray) -> void:
match reason:
NSUbiquitousKeyValueStore.QUOTA_VIOLATION_CHANGE:
push_warning("The iCloud preference store is full.")
return
NSUbiquitousKeyValueStore.ACCOUNT_CHANGE:
# Apply your game's account policy. Keep the local choice here.
# Do not upload the previous account's preferences automatically.
return
NSUbiquitousKeyValueStore.SERVER_CHANGE, NSUbiquitousKeyValueStore.INITIAL_SYNC_CHANGE:
if changed_keys.has(COLOR_KEY):
_read_cloud_color()
_apply_color()
func _read_cloud_color() -> void:
var value = cloud.object_for_key(COLOR_KEY)
if typeof(value) == TYPE_INT and value >= 0 and value <= 3:
background_color = value
_save_local_color()
elif value == null:
# This example keeps the local choice when the cloud key is absent.
# Use your game's deletion policy here.
pass
func _save_local_color() -> void:
preferences.set_value("appearance", COLOR_KEY, background_color)
var error := preferences.save(LOCAL_PATH)
if error != OK:
push_warning("Could not save local preferences: %s" % error_string(error))
func _apply_color() -> void:
# Update the game's background from background_color.
pass
func _exit_tree() -> void:
if cloud != null and cloud.did_change_externally.is_connected(_on_cloud_changed):
cloud.did_change_externally.disconnect(_on_cloud_changed)
Local writes do not emit did_change_externally. Update your local preferences
and game state when you write. A notification reports keys, not values. Read
each changed key from the store. A key can have been removed.
Call synchronize() sparingly, usually at launch or when the app returns to
the foreground. It returns Apple’s result. A true result does not confirm
that other devices received your changes. iCloud controls upload times and can
delay frequent writes. See Apple’s synchronize reference).
Handle each change reason¶
The signal arguments are reason: int and changed_keys: PackedStringArray.
The constants are members of NSUbiquitousKeyValueStore.ChangeReason.
SERVER_CHANGE | 0 | Read the changed keys and update local preferences. |INITIAL_SYNC_CHANGE | 1 | Read the downloaded keys before you upload defaults. |QUOTA_VIOLATION_CHANGE | 2 | Reduce stored data. Keep local preferences available. |ACCOUNT_CHANGE | 3 | Apply your game’s policy for the new account. |The key list is empty when Apple omits it. This can occur for account or quota notifications. Handle the reason even if the key list is empty. See Apple’s notification reference.
Supported values¶
bool | Bool | set_bool, bool_for_key |int | Int64 | set_long_long, long_long_for_key |float | Double | set_double, double_for_key |String | String | set_string, string_for_key |PackedByteArray | Data | set_data, data_for_key |Array | Property list array | set_array, array_for_key |Dictionary | Property list dictionary | set_dictionary, dictionary_for_key |Use set_object(value, key) and object_for_key(key) for a supported value of
any type. Arrays and dictionaries can contain these types, including nested
containers. All dictionary keys must be String. Containers must have at most
64 levels. Null values inside containers and other Godot types are rejected.
The entire write is rejected if one entry is invalid.
Each setter returns false for an invalid key or unsupported value. A true
result means that the binding passed the value to Apple. It does not confirm
cloud delivery or quota acceptance. The binding reports invalid input as a
Godot error.
object_for_key() returns null for a missing key. Typed getters return
false, zero, or an empty value when the key is absent. Use object_for_key()
to distinguish a missing key from a stored zero or empty value.
Apple Date values written by native code are returned as Unix seconds in a
Godot float. To preserve this meaning across devices, store dates as Unix
seconds on all clients.
cloud.set_data(PackedByteArray([1, 2, 3]), "small_blob")
cloud.set_dictionary({"music": true, "volume": 0.5}, "audio")
var audio: Dictionary = cloud.dictionary_for_key("audio")
var all_preferences: Dictionary = cloud.dictionary_representation
cloud.remove_object_for_key("small_blob")
# A top-level null also removes the key.
cloud.set_object(null, "audio")
Storage limits and device checks¶
Apple permits up to 1,024 keys and 1 MB of values in total. A single value can be at most 1 MB. Each key can contain at most 128 UTF-16 code units. The binding rejects longer keys before Apple can raise an exception. Use files or another storage system for large save data. Store sensitive data in Keychain. Apple stores key-value data on disk without encryption. See Apple’s storage limits.
Test a signed app on two devices. Change a preference on the first device.
Check that the second device receives SERVER_CHANGE and reads the new value.
Repeat with a removed key. Check the initial download on a new installation.
Also check offline use and your account-change policy.
The Windows and Linux stubs expose the class so shared scripts can load.
They do not store preferences or synchronize with iCloud. Their
default_store() method returns null. Keep local storage available on these
platforms, as the example does.