Skip to content

Instantly share code, notes, and snippets.

@WolfgangSenff
Last active July 3, 2025 13:23
Show Gist options
  • Save WolfgangSenff/11978dff61f5042ded3c7dff420f7dd6 to your computer and use it in GitHub Desktop.
Save WolfgangSenff/11978dff61f5042ded3c7dff420f7dd6 to your computer and use it in GitHub Desktop.
Test new Firestore document listeners
## @meta-authors Kyle Szklenski
## @meta-version 2.2
## A reference to a Firestore Document.
## Documentation TODO.
@tool
class_name FirestoreDocument
extends Node
# A FirestoreDocument objects that holds all important values for a Firestore Document,
# @doc_name = name of the Firestore Document, which is the request PATH
# @doc_fields = fields held by Firestore Document, in APIs format
# created when requested from a `collection().get()` call
var document : Dictionary # the Document itself
var doc_name : String # only .name
var create_time : String # createTime
var collection_name : String # Name of the collection to which it belongs
var _transforms : FieldTransformArray # The transforms to apply
signal changed(changes)
func _init(doc : Dictionary = {}):
_transforms = FieldTransformArray.new()
if doc.has("fields"):
document = doc.fields
if doc.has("name"):
doc_name = doc.name
if doc_name.count("/") > 2:
doc_name = (doc_name.split("/") as Array).back()
if doc.has("createTime"):
self.create_time = doc.createTime
func replace(with : FirestoreDocument, is_listener := false) -> void:
var current = document.duplicate()
document = with.document
var changes = {
"added": [], "removed": [], "updated": []
}
for key in current.keys():
if not document.has(key):
changes.removed.push_back({ "key" : key })
else:
var new_value = Utilities.from_firebase_type(document[key])
var old_value = Utilities.from_firebase_type(current[key])
if typeof(new_value) != typeof(old_value) or new_value != old_value:
if old_value == null:
changes.removed.push_back({ "key" : key }) # ??
else:
changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value })
for key in document.keys():
if not current.has(key):
changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) })
if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()):
_emit_changes(changes)
func _emit_changes(changes) -> void:
if get_child_count() == 1:
var listener = get_child(0)
listener.send_change(changes)
else:
changed.emit(changes)
func new_document(base_document: Dictionary) -> void:
var current = document.duplicate()
document = {}
for key in base_document.keys():
document[key] = Utilities.to_firebase_type(key)
var changes = {
"added": [], "removed": [], "updated": []
}
for key in current.keys():
if not document.has(key):
changes.removed.push_back({ "key" : key })
else:
var new_value = Utilities.from_firebase_type(document[key])
var old_value = Utilities.from_firebase_type(current[key])
if typeof(new_value) != typeof(old_value) or new_value != old_value:
if old_value == null:
changes.removed.push_back({ "key" : key }) # ??
else:
changes.updated.push_back({ "key" : key, "old": old_value, "new" : new_value })
for key in document.keys():
if not current.has(key):
changes.added.push_back({ "key" : key, "new" : Utilities.from_firebase_type(document[key]) })
if not (changes.added.is_empty() and changes.removed.is_empty() and changes.updated.is_empty()):
_emit_changes(changes)
func is_null_value(key) -> bool:
return document.has(key) and Utilities.from_firebase_type(document[key]) == null
# As of right now, we do not track these with track changes; instead, they'll come back when the document updates from the server.
# Until that time, it's expected if you want to track these types of changes that you commit for the transforms and then get the document yourself.
func add_field_transform(transform : FieldTransform) -> void:
_transforms.push_back(transform)
func remove_field_transform(transform : FieldTransform) -> void:
_transforms.erase(transform)
func clear_field_transforms() -> void:
_transforms.transforms.clear()
func remove_field(field_path : String) -> void:
if document.has(field_path):
document[field_path] = Utilities.to_firebase_type(null)
var changes = {
"added": [], "removed": [], "updated": []
}
changes.removed.push_back({ "key" : field_path })
_emit_changes(changes)
func _erase(field_path : String) -> void:
document.erase(field_path)
func add_or_update_field(field_path : String, value : Variant) -> void:
var changes = {
"added": [], "removed": [], "updated": []
}
var existing_value = get_value(field_path)
var has_field_path = existing_value != null and not is_null_value(field_path)
var converted_value = Utilities.to_firebase_type(value)
document[field_path] = converted_value
if has_field_path:
changes.updated.push_back({ "key" : field_path, "old" : existing_value, "new" : value })
else:
changes.added.push_back({ "key" : field_path, "new" : value })
_emit_changes(changes)
func on_snapshot(when_called : Callable, poll_time : float = 1.0) -> FirestoreListener.FirestoreListenerConnection:
if get_child_count() >= 1: # Only one listener per
assert(false, "Multiple listeners not allowed for the same document yet")
return
var listener = preload("res://addons/godot-firebase/firestore/firestore_listener.tscn").instantiate()
add_child(listener)
listener.initialize_listener(collection_name, doc_name)
listener.owner = self
listener.changed.connect(when_called, CONNECT_REFERENCE_COUNTED)
var result = listener.enable_connection()
return result
func get_value(property : StringName) -> Variant:
if property == "doc_name":
return doc_name
elif property == "collection_name":
return collection_name
elif property == "create_time":
return create_time
if document.has(property):
var result = Utilities.from_firebase_type(document[property])
return result
return null
func _get(property: StringName) -> Variant:
return get_value(property)
func _set(property: StringName, value: Variant) -> bool:
assert(value != null, "When using the dictionary setter, the value cannot be null; use erase_field instead.")
document[property] = Utilities.to_firebase_type(value)
return true
func get_unsafe_document() -> Dictionary:
var result = {}
for key in keys():
result[key] = Utilities.from_firebase_type(document[key])
return result
func keys():
return document.keys()
# Call print(document) to return directly this document formatted
func _to_string() -> String:
return ("doc_name: {doc_name}, \ndata: {data}, \ncreate_time: {create_time}\n").format(
{doc_name = self.doc_name,
data = document,
create_time = self.create_time})
class_name FirestoreListener
extends Node
signal changed(changes)
var _doc_name: String
var _collection: FirestoreCollection
var _collection_name: String
var _rtdb_ref: FirebaseDatabaseReference
var _rtdb_path: String
var _last_update_time: float
func initialize_listener(collection_name: String, doc_name: String) -> void:
_doc_name = doc_name
_collection_name = collection_name
_collection = Firebase.Firestore.collection(_collection_name)
_rtdb_path = "firestore_mirrored_listener_data/%s" % _collection_name
_rtdb_ref = Firebase.Database.get_database_reference(_rtdb_path, {})
_last_update_time = Time.get_unix_time_from_system()
_rtdb_ref.patch_data_update.connect(_on_data_updated)
func send_change(changes) -> void:
changes["update_time"] = Time.get_unix_time_from_system()
_rtdb_ref.update(_doc_name, changes)
func _on_data_updated(data: FirebaseResource) -> void:
if data.data.update_time > _last_update_time and data.key == _doc_name:
_last_update_time = data.data.update_time
var document = await _collection.get_doc(_doc_name, true)
var changes = data.data as Dictionary
#"added": [], "removed": [], "updated": []
var updates = changes.get("updated", [])
var deletes = changes.get("removed", [])
var adds = changes.get("added", [])
if deletes:
for delete in deletes:
document.remove_field(delete.key)
if adds:
for add in adds:
document[add.key] = add.new
if updates:
for update in updates:
document[update.key] = update.new
changed.emit(changes)
func enable_connection() -> FirestoreListenerConnection:
return FirestoreListenerConnection.new(self)
class FirestoreListenerConnection extends RefCounted:
var connection
func _init(connection_node):
connection = connection_node
func stop():
if connection != null and is_instance_valid(connection):
connection.free()
Copy/paste the above files over the pre-existing ones that are already in the plugin.
To test this, you have to ensure that you're actually running two different app runs. The reason for that is simple: the listeners are stored in the scene tree, and there's only one per document per app run. There's a few simple ways to achieve this, but mainly what I'd do is just have Godot itself run multiple versions of the game at the same time. To do that, I personally do the following:
1. In the Godot editor, in your opened-project, go to Game at the top. Hit the 3-dots button on the right of the top menu, then uncheck Embed game on next play.
2. Go to Debug -> Customize Run Instances, and a popup will show.
3. Click on Enable Multiple Instances and increase the count to 2 or even more; I'd like to test this if possible with more than 2 people, if y'all have a game that would work for that.
4. Now just run the game as normal, and it'll bring up the game N many times, where N is the number of instances you told it to run. You can undo the multiple run instances simply by coming back to this popup and unchecking enable multiple instances.
Keep in mind, this new version makes changes in the underlying, unsafe Json document, so if you have a reference to the actual Json of that document somewhere, you *will not* get the updates. If you have a reference to the FirestoreDocument itself, then you should be good to go. The API is exactly the same, though you no longer need a polling time (I left the parameter in for now, but I'll probably remove it, since I don't think many people were using this feature anyway).
Quick edit: BIG thing I forgot to mention: you have to enable the realtime database for these changes to work, and you have to put these rules for it to succeed:
"firestore_mirrored_listener_data": {
".read": "auth != null",
".write": "auth != null"
}
Those go into the rules for the realtime database, and it should just work! I do require authentication for the simple reason I don't think anyone should use this without auth. For the above, if you have other rules already in there, you can just add a comma after them and add this at the bottom - it allows multiple rules in it. Also make sure that, once your rtdb is initialized, you put your databaseURL value from your settings into your .env file! Without it, it won't know where to go to update stuff.
Good luck, and please either comment here or just let me know on Discord how it worked for you and if it did what you're expecting! I'll be making a multi-instance run today myself for my multiplayer with Firebase video series on YouTube, but y'all probably have stuff that's already ready to test, which is tremendously helpful to me so I can get this checked in and going!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment