Godot Object Serializer
by Cretezy
Godot Object Serializer
Safely serialize/deserialize objects (and built-in Godot types) to JSON or binary in Godot. Enables registration of scripts/classes and conversion of values to/from JSON or bytes, without any risk of code execution. Perfect for save state systems or networking.
Godot's built-in serialization (such as var_to_bytes
/FileAccess.store_var
/JSON.from_native
/JSON.to_native
) cannot safely serialize objects (without using full_objects
/var_to_bytes_with_objects
, which allows code execution), but this library can!
Features:
- Safety: No remote code execution, can be used for untrusted data (e.g. save state system or networking).
- Dictionary/binary mode: Dictionary mode can be used for JSON serialization (
JSON.stringify
/JSON.parse_string
), while binary mode can be used with binary serialization (var_to_bytes
/bytes_to_var
). Provides helpers to serialize directly to JSON/binary. - Objects: Objects can be serialized, including enums, inner classes, and nested values. Supports class constructors and custom serializer/deserializer.
- Built-in types: Supports all built-in value types (Vector2/3/4/i, Rect2/i, Transform2D/3D, Quaternion, Color, Plane, Basis, AABB, Projection, Packed*Array, etc).
- Efficient JSON bytes: When serializing to JSON,
PackedByteArray
s are efficiently serialized as base64, reducing the serialized byte count by ~40% - Non-string JSON dictionary keys: Supports deserializing
int
/float
/bool
keys from JSON
Quick Start
Start by installing the plugin. You can install it from the Asset Library (search "Godot Object Serializer" in the "AssetLib" tab of the editor), or by manually placing the addons/godot_object_serializer
directory in your project.
class Data:
var name: String
var position: Vector2
func _init() -> void:
# Required: Register possible object scripts
ObjectSerializer.register_script("Data", Data)
# Setup data
var data := Data.new()
data.name = "hello world"
data.position = Vector2(1, 2)
var json = DictionarySerializer.serialize_json(data)
""" Output:
{
"._type": "Object_Data",
"name": "hello world",
"position": {
"._type": "Vector2",
"._": [1.0, 2.0]
}
}
"""
data = DictionarySerializer.deserialize_json(json)
Full example:
# Example data class. Can extend any type, include Resource
class Data:
# Supports all primitive types (String, int, float, bool, null), including @export variables
@export var string: String
# Supports all extended built-in types (Vector2/3/4/i, Rect2/i, Transform2D/3D, Color, Packed*Array, etc)
var vector: Vector3
# Supports enum
var enum_state: State
# Supports arrays, including Array[Variant]
var array: Array[int]
# Supports dictionaries, including Dictionary[Variant, Variant]
var dictionary: Dictionary[String, Vector2]
# Supports efficient byte array serialization to base64
var packed_byte_array: PackedByteArray
# Supports nested data, either as a field or in array/dictionary
var nested: DataResource
class DataResource:
extends Resource
var name: String
enum State { OPENED, CLOSED }
# _static_init is used to register scripts without having to instanciate the script.
# It's recommended to either place all registrations in a single script, or have each script register itself.
static func _static_init() -> void:
# Required: Register possible object scripts
ObjectSerializer.register_script("Data", Data)
ObjectSerializer.register_script("DataResource", DataResource)
# Setup testing data
var data := Data.new()
func _init() -> void:
data.string = "Lorem ipsum"
data.vector = Vector3(1, 2, 3)
data.enum_state = State.CLOSED
data.array = [1, 2]
data.dictionary = {"position": Vector2(1, 2)}
data.packed_byte_array = PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8])
var data_resource := DataResource.new()
data_resource.name = "dolor sit amet"
data.nested = data_resource
json_serialization()
binary_serialization()
func json_serialization() -> void:
# Serialize to JSON
# Alternative: DictionarySerializer.serialize_json(data)
var serialized: Variant = DictionarySerializer.serialize_var(data)
var json := JSON.stringify(serialized, "\t")
print(json)
""" Output:
{
"._type": "Object_Data",
"string": "Lorem ipsum",
"vector": {
"._type": "Vector3",
"._": [1.0, 2.0, 3.0]
},
"enum_state": 1,
"array": [1, 2],
"dictionary": {
"position": {
"._type": "Vector2",
"._": [1.0, 2.0]
}
},
"packed_byte_array": {
"._type": "PackedByteArray_Base64",
"._": "AQIDBAUGBwg="
},
"nested": {
"._type": "Object_DataResource",
"name": "dolor sit amet"
}
}
"""
# Verify after JSON deserialization
# Alternative: DictionarySerializer.deserialize_json(json)
var parsed_json = JSON.parse_string(json)
var deserialized: Data = DictionarySerializer.deserialize_var(parsed_json)
_assert_data(deserialized)
func binary_serialization() -> void:
# Serialize to bytes
# Alternative: BinarySerializer.serialize_bytes(data)
var serialized: Variant = BinarySerializer.serialize_var(data)
var bytes := var_to_bytes(serialized)
print(bytes)
# Output: List of bytes
# Verify after bytes deserialization.
# Alternative: BinarySerializer.deserialize_bytes(bytes)
var parsed_bytes = bytes_to_var(bytes)
var deserialized: Data = BinarySerializer.deserialize_var(parsed_bytes)
_assert_data(deserialized)
func _assert_data(deserialized: Data) -> void:
assert(data.string == deserialized.string, "string is different")
assert(data.vector == deserialized.vector, "vector is different")
assert(data.enum_state == deserialized.enum_state, "enum_state is different")
assert(data.array == deserialized.array, "array is different")
assert(data.dictionary == deserialized.dictionary, "dictionary is different")
assert(
data.packed_byte_array == deserialized.packed_byte_array, "packed_byte_array is different"
)
assert(data.nested.name == deserialized.nested.name, "nested.name is different")
Dictionary vs Binary Mode
This library provides 2 modes to make values serializable: dictionary/JSON and binary/bytes mode.
Both modes handle primitives (String, bool, int, float, null) the same, as well as objects (serializing into a dictionary containing a ._type
field).
The difference between the 2 modes is how it handles extended built-in types (Vector2, Vector3, Transform3D, Color, etc):
- In dictionary mode (which is targeted to be used with
JSON.stringify
), these are serialized as dictionaries containing a._type
field and converted usingJSON.from_native
/JSON.to_native
. - In binary mode (which is targeted to be used with
var_to_bytes
), these are left as-is, asvar_to_bytes
natively supports these types.
This allows you to choose your preferred output format while ensuring efficient serialization. This library also provides helper functions to serialize directly to JSON or bytes.
Why Not Built-In Alternatives?
There's multiple ways to serialize data in Godot, with only some of them safe (safe meaning no code execution from the deserialization).
JSON.stringify
JSON serialization works great for data that can be represented as JSON (string, numbers, arrays, dictionary, booleans, null). This means that objects or other built-in types (e.g. Vector2) cannot be represented.
To use JSON.stringify
, the traditional way is to manually convert data to a JSON-compatible format before serialization (see example).
Example
class JsonData:
var string: String
var vector2: Vector2
func to_dict() -> Dictionary:
return {
"string": string,
"vector2": [vector2.x, vector2.y]
}
static func from_dict(data: Dictionary) -> JsonData:
var result := JsonData.new()
result.string = data["string"]
result.vector2 = Vector2(data["vector2"][0], data["vector2"][1])
return result
func _init():
var data = JsonData.new()
data.string = "hello world"
data.vector2 = Vector2(1, 2)
print(JSON.stringify(data.to_dict(), "\t"))
""" Output:
{
"string": "hello world",
"vector2": [1.0, 2.0]
}
"""
This is flexible but very tedious as every object type must be manually serialized and deserialized.
JSON.from_native
Godot has a built-in way to serialize more types such as Vector2, Vector3, Transform3D, etc, to a JSON representation.
By default, this does not support serializing objects, unless full_objects
is true (second argument in JSON.from_native(object, true)
). This is unsafe (can cause remote code execution) and full_objects
should never be used with untrusted data.
Additionally, using JSON.from_native
combined with a to_dict
produces inefficient packing (see example).
Example
class JsonNativeData:
var string: String
var vector2: Vector2
func to_dict() -> Dictionary:
return {
"string": string,
"vector2": vector2
}
static func from_dict(data: Dictionary) -> JsonNativeData:
var result := JsonNativeData.new()
result.string = data["string"]
result.vector2 = data["vector2"]
return result
func _init():
var data = JsonNativeData.new()
data.string = "hello world"
data.vector2 = Vector2(1, 2)
print(JSON.stringify(JSON.from_native(data.to_dict()), "\t"))
""" Output:
{
"type": "Dictionary",
"args": [
"s:string",
"s:hello world",
"s:vector2",
{
"type": "Vector2",
"args": [1.0, 2.0],
}
]
}
"""
var_to_bytes
/ bytes_to_var
Godot has a built-in way to serialize more types such as Vector2, Vector3, Transform3D, etc, to bytes.
By default, this does not support serializing objects, unless var_to_bytes_with_objects
/bytes_to_var_with_objects
is used. This is unsafe (can cause remote code execution) and *_with_objects
should never be used with untrusted data.
Additionally, using var_to_bytes
combined with a to_dict
produces inefficient packing (similar to the JSON.from_native
example above).
Registering Scripts
Registering scripts is required for the library to know how to serialize and deserialize your objects. You can do so in 2 ways:
ObjectSerializer.register_script("Data", Data)
# Or, if you have multiple
ObjectSerializer.register_scripts({
"Data": Data,
"DataResource": DataResource,
})
Registration is required to have a stable mapping of class name to script. This enables changing the name of the class without breaking previously serialized data. Additionally, this serves as a security measure as only known classes will be deserialized.
Object Serialization
During serialization, all fields are serialized. This can be overridden by implementing _get_property_list()
(only properties with PROPERTY_USAGE_SCRIPT_VARIABLE
are serialized) or _get_excluded_properties()
(see below).
During deserialization, all fields are set back on the object.
Constructors
If your class has a constructor, you must implement _get_constructor_args(): Array
to return the arguments for your constructor:
class Data:
var name: String
func _init(init_name: String) -> void:
name = init_name
func _get_constructor_args() -> Array:
return [name]
Properties in the constructor will also be included as fields in the serialized data. You can exclude these (see below) to avoid duplication.
Excluded Properties
You can exclude properties from serialization by implementing _get_excluded_properties(): Array[String]
:
class Data:
var name: String
var position: Vector2
func _get_excluded_properties() -> Array[String]:
# name won't be serialized/deserialized, but position will
return ["name"]
Custom Object Serializer
Classes can implement _serializer(serialize: Callable) -> Dictionary
and static _deserialize(data: Dictionary, deserialize: Callable) -> Variant
to customize the serialization.
Note that the type field (by default ._type
) will automatically be added after your custom serializer, and the field will be present in the deserializer's data
. Having a custom serializer/deserializer skips constructor handling.
When _serialize
/_deserialize
are implemented, other functions such as _get_excluded_properties
, _get_constructor_args
, and _serialize_partial
/_deserialize_partial
are not ran.
Use the provided serialize
/deserialize
Callables for nested data. This is necessary only for non-primitive types.
class Data:
# No need to call `serialize`/`deserialize` for primitive
var name: String
# Must call `serialize`/`deserialize` for non-primitive
var position: Vector2
func _serialize(serialize: Callable) -> Dictionary:
return {
"key": name,
"pos": serialize.call(position)
}
static func _deserialize(data: Dictionary, deserialize: Callable) -> Data:
var instance = Data.new()
instance.name = data["key"]
instance.position = deserialize.call(data["pos"])
return instance
Example output
{
"._type": "Object_Data",
"key": "hello world",
"pos": {
"._type": "Vector2",
"._": [1.0, 2.0]
}
}
Partial Custom Object Serializer
In some cases, only some fields of the class requires custom a serializer, while the rest of the fields can use the normal serialization logic. This is the case when trying to serialize one of the built-in non-value types (such as Texture
, BitMap
, etc).
In those cases, classes can implement _serializer_partial(serialize: Callable) -> Dictionary
and _deserialize_partial(data: Dictionary, deserialize: Callable) -> Dictionary
to customize the serialization. The fields returned by this will be added to the serialized/deserialized result, and will be excluded normal serialization (all other fields will be included).
It's generally recommended to prefer _serialize_partial
/_deserialize_partial
over _serialize
/_deserialize
if you only need some fields have custom serialization logic.
Use the provided serialize
/deserialize
Callables for nested data. This is necessary only for non-primitive types (same as _serialize
/_deserialize
).
class Data:
# Will be serialized normally
var name: String
# BitMap is not a built-in value type, needs to be handled with custom serializer
var bitmap: BitMap
func _serialize_partial(serialize: Callable) -> Dictionary:
return {"bitmap": _serialize_bitmap(bitmap, serialize)}
func _deserialize_partial(data: Dictionary, deserialize: Callable) -> Dictionary:
return {"bitmap": _deserialize_bitmap(data["bitmap"], deserialize)}
Example BitMap serialization functions
func _serialize_bitmap(bitmap: BitMap, _serialize: Callable) -> Array[Array]:
var size := bitmap.get_size()
var rows: Array[Array] = []
for x in range(size.x):
var column = []
for y in range(size.y):
column.append(bitmap.get_bit(x, y))
rows.append(column)
return rows
func _deserialize_bitmap(data: Variant, _serialize: Callable) -> BitMap:
var bitmap := BitMap.new()
bitmap.create(Vector2i(data.size(), data[0].size()))
for row in range(data.size()):
for column in range(data[row].size()):
bitmap.set_bit(row, column, data[row][column])
return bitmap
Example output
{
"._type": "Object_Data",
"bitmap": [
[true, false, true],
[true, false, false]
],
"name": "hello world"
}
Glossary
- "Objects" are instances of objects.
- "Scripts" refer to scripts, which can be GDScript or C#.
- "Classes" refer to the classes inside of scripts. Every GDScript is a class, and can include inner class
# This file is the "Data" class. Extends Object by default
class_name Data
# This is an inner class which extends Resource
class DataResource:
extends Resource
# This is an object
var data := Data.new()
API
ObjectSerializer.register_script(name: StringName, script: Script) -> void
Registers a script (an object type) to be serialized/deserialized. All custom types (including nested types) must be registered before using this library.
Name can be empty if script uses class_name
(e.g ObjectSerializer.register_script("", Data)
), but it's generally better to set the name.
ObjectSerializer.register_scripts(scripts: Dictionary[String, Script]) -> void
Registers multiple scripts (object types) to be serialized/deserialized from a dictionary.
See ObjectSerializer.register_script
.
DictionarySerializer.serialize_var(data: Variant) -> Variant
Serialize data
into value which can be passed to JSON.stringify
.
DictionarySerializer.serialize_json(value: Variant, indent := "", sort_keys := true, full_precision := false) -> Variant
Serialize data
into JSON string with DictionarySerializer.serialize_var
and JSON.stringify
. Supports same arguments as JSON.stringify
DictionarySerializer.deserialize_var(data: Variant) -> Variant
Deserialize data
from JSON.parse_string
into value.
DictionarySerializer.deserialize_json(data: String) -> Variant
Deserialize JSON string data
to value with JSON.parse_string
and DictionarySerializer.deserialize_var
.
BinarySerializer.serialize_var(data: Variant) -> Variant
Serialize data
to value which can be passed to var_to_bytes
.
BinarySerializer.serialize_bytes(data: Variant) -> PackedByteArray
Serialize data
into bytes with BinarySerializer.serialize_var
and var_to_bytes
.
BinarySerializer.deserialize_var(data: Variant) -> Variant
Deserialize data
from bytes_to_var
to value.
BinarySerializer.deserialize_bytes(data: PackedByteArray) -> Variant
Deserialize bytes data
to value with bytes_to_var
and BinarySerializer.deserialize_var
.
Settings
It's not recommended to change these options, but they are available. Any changes to options must be done before serialization/deserialization.
ObjectSerializer.require_export_storage: bool
(default: false
)
By default, variables with PROPERTY_USAGE_SCRIPT_VARIABLE
are serialized (all variables have this by default).
When require_export_storage
is true, variables will also require PROPERTY_USAGE_STORAGE
to be serialized.
This can be set on variables using @export_storage
. Example: @export_storage var name: String
ObjectSerializer.type_field: String
(default: ._type
)
The field containing the type in serialized object values. Not recommended to change.
This should be set to something unlikely to clash with keys in objects/dictionaries.
This can be changed, but must be configured before any serialization or deserialization.
ObjectSerializer.args_field: String
(default: ._
)
The field containing the constructor arguments in serialized object values. Not recommended to change.
This should be set to something unlikely to clash with keys in objects.
This can be changed, but must be configured before any serialization or deserialization.
ObjectSerializer.object_type_prefix: String
(default: Object_
)
The prefix for object types stored in ObjectSerializer.type_field
. Not recommended to change.
This should be set to something unlikely to clash with built-in type names.
This can be changed, but must be configured before any serialization or deserialization.
DictionarySerializer.bytes_as_base64: bool
(default: true
)
Controls if PackedByteArray should be serialized as base64 (instead of array of bytes as uint8)
It's highly recommended to leave this enabled as it will result to smaller serialized payloads and should be faster.
This can be changed, but must be configured before any serialization or deserialization.
DictionarySerializer.bytes_to_base64_type: String
(default: PackedByteArray_Base64
)
The type of the object for PackedByteArray when DictionarySerializer.bytes_as_base64
is enabled.
This should be set to something unlikely to clash with built-in type names or ObjectSerializer.object_type_prefix
.
This can be changed, but must be configured before any serialization or deserialization.
Unsupported Types
- Callable/signal (will be empty)
- When using JSON, dictionaries without primitive (
String
/int
/float
/bool
) keys (see below)
And classes/scripts that are not registered with ObjectSerializer.register_script
.
Edge Cases
JSON dictionary key type conversion
Since JSON only supports strings as key, only Dictionary[String, Variant]
can natively be parsed by JSON.parse_string
. This library adds support for int
/float
/bool
keys in dictionaries if typed. All types are supported as keys when using binary serialization.
class Data:
# Supported natively by JSON
var string_dict: Dictionary[String, Variant]
# Supported by library
var int_dict: Dictionary[int, Variant]
var float_dict: Dictionary[float, Variant]
var bool_dict: Dictionary[bool, Variant]
# Unsupported with JSON, works with binary
var vector_dict: Dictionary[Vector2, Variant]
JSON integer to float conversion for Variants
When using JSON serialization in Godot, integer values are converted to floats if they are typed as Variant
. This doesn't apply to typed fields or when using binary serialization.
class Data:
var value_variant: Variant
var array_variant: Array[Variant]
var dictionary_variant: Dictionary[String, Variant]
var value_typed: int
var array_typed: Array[int]
var dictionary_typed: Dictionary[String, int]
var data = Data.new()
data.value_variant = 1
data.array_variant = [1]
data.dictionary_variant.value = 1
data.value_typed = 1
data.array_typed = [1]
data.dictionary_typed.value = 1
# Serialize/deserialize through JSON
data = DictionarySerializer.deserialize_json(
DictionarySerializer.serialize_json(data)
)
# Integers in variant become floats
assert(data.value_variant == 1.0)
assert(data.array_variant == [1.0])
assert(data.dictionary_variant.value == 1.0)
# Typed fields will be of the correct type
assert(data.value_typed == 1)
assert(data.array_typed == [1])
assert(data.dictionary_typed.value == 1)
Development/Contributions
You can run test scripts located in the tests
directory for development. Example: godot --headless --quit --script tests/dictionary.gd
Contributions are welcomed to this project! Please format your changes with gdformat before opening a PR if possible. Make sure to document your changes and write a test script.
License/Support
This project is released under the MIT license. You can support my work through my GitHub sponsorship page. Thank you :)
Download
Support
If you need help or have questions about this plugin, please contact the author.
Contact Author