Extending the Property interface (C++ integration)

Overview

The Property interface type-erases access to a native property, allowing the SDK to interact with it without requiring knowledge of its concrete C++ type. You may need to implement a custom Property to support specific behaviors, such as dynamically-typed properties, binding values from another scripting layer (e.g. Lua), or integrating with an engine’s reflection system to avoid manual binding code.

Example: Integrating a Custom Reflection System

This section demonstrates how the Property interface can be extended to integrate a custom reflection system. The example illustrates several important concepts:

  • Property type-erases access to native C++ data.
  • The object and UserData pointers do not have to point directly to the exposed C++ object. They may represent intermediate handles, wrapper pointers, or any abstraction meaningful to the property’s implementation.
  • Returned UserData pointers must remain valid for the duration of the call or until cohtml::IViewListener::OnBindingsReleased if Safe data binding is disabled.

Scenario

We want to expose the following C++ model:

struct Weapon { std::string name = "cool weapon"; };
struct Player { Weapon weapon; };
struct Model  { Player player; };

Model g_Model;

So it can be accessed in the UI as:

<div data-bind-value="{{model.player.weapon.name}}"></div>

and from JavaScript as:

model.player.weapon.name

Instead of binding properties directly to C++ members, we route all access through a custom reflection layer.

Reflection handles (not real objects)

The SDK never interacts with Model, Player, or Weapon directly. Instead, it works with opaque handles resolved by user code.

struct ObjectHandle
{
	void* instance = nullptr; // Actual C++ object or value
	std::string_view typeName; // Reflection metadata
};

ObjectHandle& AsObjectHandle(void* handle) { return *static_cast<ObjectHandle*>(handle); }

Stable ObjectHandle Pool

The SDK does not take ownership of UserData pointers returned by Property::ToObject() and the other conversion methods. Therefore, all returned ObjectHandle objects must have stable lifetime. In Safe data binding mode, the UserData pointers returned by the Property interfaces should remain valid only for the duration of the call, while the top level model handle should remain valid until the model is unregistered or until cohtml::IViewListener::OnBindingsReleased is called.

In this example, stability is ensured by storing all ObjectHandle instances in a global pool:

std::unordered_map<std::string, ObjectHandle> g_ObjectPool;

std::string MakePoolKey(void* instance, std::string_view propertyName)
{
	return std::to_string(reinterpret_cast<size_t>(instance)) + propertyName;
}

ObjectHandle* GetOrCreateHandle(void* instance, std::string_view typeName)
{
	assert(instance != nullptr);

	const std::string key = MakePoolKey(instance, typeName);
	auto& entry = g_ObjectPool[key];

	entry.instance = instance;
	entry.typeName = typeName;

	return &entry;
}

Property resolution helper

Each property access resolves the next object in the chain via the reflection layer:

ObjectHandle* ResolveProperty(const ObjectHandle& parent, std::string_view name)
{
	void* instance = nullptr;
	std::string_view typeName;

	if (parent.typeName == "Weapon" && name == "name")
	{
		instance = &static_cast<Weapon*>(parent.instance)->name;
		typeName = "String";
	}

	if (parent.typeName == "Player" && name == "weapon")
	{
		instance = &static_cast<Player*>(parent.instance)->weapon;
		typeName = "Weapon";
	}

	if (parent.typeName == "Model" && name == "player")
	{
		instance = &static_cast<Model*>(parent.instance)->player;
		typeName = "Player";
	}

	return instance ? GetOrCreateHandle(instance, typeName) : nullptr;
}

This function centralizes reflection logic and ensures all returned objects come from the stable pool.

Primitive property (string)

Primitive properties resolve a value and bind it directly. They are copied into the binding system and do not require UserData.

class PrimitiveProperty : public Property {

public:
	PrimitiveProperty(const char* name) : Property(name)
	{
	}

	void* Bind(Binder* binder, void* object) const override
	{
		binder->PropertyName(GetName());
		return BindValue(binder, object);
	}

	void* BindValue(Binder* binder, void* object) const override
	{
		ObjectHandle* persistentInstance = ResolveProperty(AsObjectHandle(object), m_Name);
		if (persistentInstance->typeName != "String")
		{
            binder->BindNull();
			return object; // not implemented
		}

		const std::string& str = *static_cast<std::string*>(persistentInstance->instance);
		CoherentBindInternal(binder, str);
		return object;
	}

	bool ToString(void* object, char* buffer, size_t* length) const override
	{
		ObjectHandle* persistentInstance = ResolveProperty(AsObjectHandle(object), m_Name);

		if (persistentInstance->typeName == "String")
		{
			std::string& str = *static_cast<std::string*>(persistentInstance->instance);

			const size_t toCopy = std::min(*length, str.length());
			std::memcpy(buffer, str.data(), toCopy);
			*length = str.length();
			return true;
		}

		return false;
	}

	Property* Clone() const override
	{
		return new PrimitiveProperty(*this);
	}

	// Non-relevant pure-virtual methods omitted for simplicity
};

Object property (continues the chain)

class ObjectProperty : public Property {

public:
	ObjectProperty(const char* name) : Property(name)
	{
	}

	void* Bind(Binder* binder, void* object) const override
	{
		binder->PropertyName(GetName());
		return BindValue(binder, object);
	}

	void* BindValue(Binder* binder, void* object) const override
	{
		ObjectHandle* persistentInstance = ResolveProperty(AsObjectHandle(object), m_Name);
		CoherentBindInternal(binder, persistentInstance);
		return object;
	}

	Property* Clone() const override
	{
		return new ObjectProperty(*this);
	}

	bool ToObject(Binder* binder, void* object, ObjectInfo* objectInfo) const override
	{
		Binder::BindingMode save = binder->GetMode();
		binder->SetMode(Binder::BindingMode::BM_GetTypeInfo);
		BindValue(binder, object);
		binder->SetMode(save);
		TypeInfo* typeInfo = binder->GetTypeInfo();

		ObjectHandle* persistentInstance = ResolveProperty(AsObjectHandle(object), m_Name);

		*objectInfo = cohtml::ObjectInfo{
			cohtml::ElementType::ET_UserType,
			typeInfo,
			persistentInstance
		};

		return true;
	}

    // Non-relevant pure-virtual methods removed for simplicity
};

Registering the reflected type

Properties are registered dynamically based on reflected type information:

void RegisterReflectionSystemObject(cohtml::Binder * binder, ObjectHandle* object)
{
	if (auto type = binder->RegisterType(object->typeName.data(), object))
	{
		// Register all properties
		if (object->typeName == "Weapon")
		{
			binder->AddProperty(object, PrimitiveProperty("name"));
		}
		else if (object->typeName == "Player")
		{
			binder->AddProperty(object, ObjectProperty("weapon"));
		}
		else if (object->typeName == "Model")
		{
			binder->AddProperty(object, ObjectProperty("player"));
		}
	}
}

void CoherentBind(cohtml::Binder * binder, ObjectHandle* object)
{
	RegisterReflectionSystemObject(binder, object);
}

Usage

ObjectHandle* modelHandle = GetOrCreateHandle(&g_Model, "Model");
view->CreateModel("model", &modelHandle);
view->SynchronizeModels();
<div data-bind-value="{{model.player.weapon.name}}"></div>
model.player.weapon.name

Key takeaways

  • The Property API operates on borrowed pointers; the SDK does not manage their lifetime.
  • UserData does not need to be the actual C++ object - it only needs to uniquely and stably represent it.
  • Pooling ObjectHandle instances ensures stable identity across bindings, correct lifetime semantics and compatibility with both safe and unsafe data binding modes.
  • Reflection logic and lifetime management are cleanly separated, making this pattern suitable for real-world reflection systems (custom RTTI, ECS, scripting, editor-driven data).

This separation of concerns is the key to integrating complex native object models safely and predictably.

Dynamically-typed object data binding

Gameface has out-of-the-box support for std::variant by including cohtml/Binding/Variant.h. That can be useful if your models come from another scripting language, for example Python or Lua.

Binding dynamically typed objects can also be achieved by extending the Property interface, which is useful when custom binding logic is required. The following section demonstrates how to implement such a Property and serves as a general guideline for understanding and extending the Property interface.

You can check the following two samples that explain how to setup such objects:

  • ProtobufDataBind sample
  • DynamicallyTypedDataBinding sample (using std::variant). The sample also demonstrates how to implement a vector of std::variant elements

Here is an overview of the steps to create dynamically exposed objects using std::variant, which are explained in greater details in the above-mentioned sample. We’ll keep the code snippets here as short as possible, because the APIs might change. The samples will be maintained up-to-date:

  1. Create a Model Class that will keep the dynamic property. If you also need a vector holding dynamic property, you need to wrap it in a struct, because vector<std::variant<...>> is not supported directly.
using DynamicType = std::variant<int, std::string, DamageData>;

// If you need a vector of dynamically typed elements, you need to wrap them in a struct/class
struct DynamicWrapper
{
	DynamicType m_Data;
};

struct DynamicModel
{
	DynamicType m_DamageData; // dynamically typed property
	std::vector<DynamicWrapper> m_DynTypedArray; // vector of dynamically typed elements
};
  1. Create a template class with a template specialization that extends cohtml::TypedProperty<ValueType*> and holds an address to the property data. It acts as a middleman between C++ and JavaScript.

  2. Override the pure virtual function cohtml::Property::Clone() which will create a dynamic copy of the current template class and override the pure virtual function cohtml::TypedProperty::GetValue() which will return the current value for the cloned data object. These functions are used internally by Gameface.

  3. Override the pure virtual functions cohtml::Property::Read() and cohtml::Property::ReadValue() to handle different types of data and assign the next type from cohtml::Binder::PeekValueType() into std::variant

  4. Override the pure virtual functions cohtml::Property::Bind() and cohtml::Property::BindValue() to bind the active data type with cohtml::CoherentBindInternal() from std::variant using std::visit

  5. Create overrides of the virtual functions cohtml::Property::ToBoolean(), cohtml::Property::ToNumber(float), cohtml::Property::ToNumber(double), cohtml::Property::ToString(), cohtml::Property::ToColor() using std::visit for compatibility in casting a type from JavaScript to other types when needed by the SDK.

  6. Create override of the virtual function cohtml::Property::ToObject() to handle complex (user types) in the std::variant.

  7. Create a factory method for convenient creation of objects from our property.

template <typename P>
DynamicProperty<P> CreateProperty(const char* name, P p)
{
    return DynamicProperty<P>(name, p);
}
  1. Create CoherentBind() functions for the DynamicModel and the DynamicWrapper types that bind the dynamic property using cohtml::Binder::AddProperty() and the factory method we created, so the data will get recognized by the UI.