Skip to content

Live Balancing

Tune your game’s numbers from the dashboard and see them change in the running game in under a second. No rebuild. No restart. No code deploy.

Godot games typically store balance data as @export properties on Resources loaded from .tres files. Every time a designer wants to tweak a value — a cost, a rate, a cap — they need a code change, a rebuild, and a new build distributed to testers.

Live Balancing replaces that loop. You define tables in the Quest Data dashboard, bind your Resources to them, and the SDK handles the rest.

You don’t write any of this:

  • HTTP fetch + auth headers + JSON parse + retry on network failure
  • Disk cache + offline fallback + cache invalidation on reconnect
  • Manual row[column] → resource.set(property, value) mapping per @export field
  • Type coercion (int/float/bool from JSON strings)
  • WebSocketPeer poll loop, reconnect backoff, auth handshake
  • Re-applying all bindings when a WS push arrives
  • Per-resource change notifications (QDBindingHandle.applied signal)
  • dev_api_url vs prod_api_url routing based on OS.is_debug_build()

What stays in game code: UI listener wiring and deciding which tiles to refresh. Both are covered below.

These are the most common sources of silent failures.

id_column default is "id". The SDK looks for resource.get("id") to find the row. If your resource has building_id instead, the bind silently fails with a warning. Always pass the column explicitly:

# Wrong — looks for resource.id, which doesn't exist
QuestData.bind_balancing(building.data, "buildings")
# Correct — reads resource.building_id
QuestData.bind_balancing(building.data, "buildings", "", "building_id")

row_key_col must exist as a property on the resource AND as a column in the pivot table. The SDK reads resource.get(row_key_col) to filter rows. If the property is missing, the binding is silently skipped.

target_prop must be @export var foo: Dictionary. Non-exported properties work too, but the Godot inspector won’t show the live value.

Column names are case-sensitive and must exactly match @export property names. A typo produces no error — the value just never updates.

Open Project → Project Settings → Quest Data (enable Advanced Settings):

SettingExample
quest_data/api_keyb5c19641509c9c59...
quest_data/dev_api_urlhttp://localhost:3010/v1/track
quest_data/prod_api_urlhttps://api.questdata.io/v1/track

Missing dev_api_url is the most common cause of 404s during local development.

Open Live Ops → Game Data & Balancing and create a table. Column names must exactly match your @export property names.

Scalar table buildings:

building_idmax_levelbase_costscaling_factor
hut10500.15
sawmill82000.20

Add @export var building_id: String to your Resource class and set it in every .tres file. Pass id_column: "building_id" when binding.

Step 2 — bind_balancing (Scalar Properties)

Section titled “Step 2 — bind_balancing (Scalar Properties)”
func _ready() -> void:
for building in get_all_buildings():
QuestData.bind_balancing(building.data, "buildings", "", "building_id")

The SDK reads building.data.building_id, finds the matching row, and overwrites max_level, base_cost, and scaling_factor in-place. All existing callers that read building.data.max_level get the dashboard value automatically — zero callers to migrate.

Step 3 — bind_balancing_pivot (Dictionary Properties)

Section titled “Step 3 — bind_balancing_pivot (Dictionary Properties)”

bind_balancing handles flat @export properties. Dictionary fields — costs, production rates, anything that maps one id to one value — need a pivot table: one row per (resource, sub-key) pair.

Example: building.data.resource_costs: Dictionary containing {"stone": 5, "wood": 10}.

Create a building_costs pivot table:

building_idresource_idbase_amount
hutstone5
hutwood10
sawmillwood30

Bind it in one call:

QuestData.bind_balancing_pivot(
building.data, # Resource to update
"building_costs", # table name
"building_id", # column that identifies which rows belong to this resource
"resource_costs", # @export var resource_costs: Dictionary on the resource
"resource_id", # dict key column
"base_amount" # dict value column
)

The SDK aggregates all rows where building_id == building.data.building_id, writes {"stone": 5, "wood": 10} into building.data.resource_costs, and fires the handle’s applied signal.

Step 4 — React to Changes via QDBindingHandle

Section titled “Step 4 — React to Changes via QDBindingHandle”

Both bind_balancing and bind_balancing_pivot return a QDBindingHandle. Connect to its applied signal to refresh UI when a WS push mutates the resource:

building_tile.gd
var _handle: QDBindingHandle
func setup(building: BuildingData) -> void:
_handle = QuestData.bind_balancing_pivot(
building, "building_costs", "building_id",
"resource_costs", "resource_id", "base_amount"
)
_handle.applied.connect(_on_balancing_applied)
_update_display()
func _on_balancing_applied(_table: String) -> void:
_update_display() # resource_costs is already mutated when this fires
func _exit_tree() -> void:
_handle.unbind()

applied fires only when at least one property actually changed — no noise on no-op fetches. The resource is mutated before the signal fires, so reading it inside the callback always gives the fresh value.

Step 5 — Live Push via WebSocket (Automatic)

Section titled “Step 5 — Live Push via WebSocket (Automatic)”

Nothing extra to add. When api_key is set, the SDK opens a persistent WebSocket at boot. Dashboard edits emit a server-side push that arrives in milliseconds, triggering a force_refresh fetch and re-applying all bindings automatically.

# Diagnostic — is the live channel connected?
print(QuestData.get_realtime().is_connected_to_server())

Measured in a production idle game with 25 buildings and 3 tables (buildings scalar + building_costs pivot + building_production pivot):

MetricValue
Bind calls at boot75 total: 25 × bind_balancing + 25 × bind_balancing_pivot (costs) + 25 × bind_balancing_pivot (production)
Active SDK bindings75 unique (resource, table, target_prop) entries
Handles per visible tile3 (scalar + 2 pivot) — idempotency returns the same handle objects if bound again
Boot lag — cache hit≤1 deferred frame (~16 ms): .tres defaults visible for one frame, then overrides applied
Boot lag — cold start1 frame + HTTP round-trip to backend (50–300 ms depending on network)
WS push → tile re-renderSub-second end-to-end; on local dev indistinguishable from instant
applied fires per push1 per binding where something actually changed — silent for unaffected buildings

UI Lifecycle Warning — the queue_free Trap

Section titled “UI Lifecycle Warning — the queue_free Trap”

This is the most common mistake when combining live bindings with a dynamically rebuilt grid.

The problem: You call queue_free() on old tiles before creating new ones. queue_free is deferred — nodes stay alive until end of frame. When new tiles bind to the same resources before the old ones are freed, the SDK’s idempotency returns the same handles to both old and new tiles. At frame-end the old tiles free, their _exit_tree() calls handle.unbind(), which removes the binding entirely. New tiles are left holding a handle connected to nothing — WS pushes arrive silently.

Symptoms: Dashboard edit → no tile refresh. Closing and reopening the UI “fixes” it (the initial render re-reads the already-mutated resource), but the next push is silent again.

Fix A — don’t repopulate on hide/show, only on structural changes:

func show_building_ui() -> void:
visible = true
# Don't call _populate_building_grid() here.
# Tiles persist between hide/show; bindings stay alive; WS pushes keep firing.
func _on_building_unlocked(_building_id: String) -> void:
call_deferred("_populate_building_grid") # defer off the signal call stack

Fix B — use synchronous free() inside the repopulate function:

func _populate_building_grid() -> void:
for child in building_grid.get_children():
child.free() # NOT queue_free — _exit_tree fires NOW, unbind happens BEFORE new tiles bind
# ... create new tiles ...

The SDK caches every fetched table to disk (user://quest_gamedata.save). On next boot, cached data loads instantly before any network request fires. If the backend is unreachable:

  • Cache exists → cached values applied, game runs normally
  • No cache.tres defaults remain active, no crash

bind_balancing and bind_balancing_pivot never block the main thread.

A small BalancingRefresh helper node owns all handles for one resource and calls a single on_refresh callback — UI components get one-line binding setup.

balancing_refresh.gd
class_name BalancingRefresh
extends Node
var _resource: Resource
var _handles: Array = []
var _on_refresh: Callable = Callable()
func _init(resource: Resource = null) -> void:
_resource = resource
func set_callback(cb: Callable) -> BalancingRefresh:
_on_refresh = cb
return self
func bind_scalar(table: String, id_column: String = "id") -> BalancingRefresh:
if _resource and QuestData and QuestData.has_method("bind_balancing"):
_attach(QuestData.bind_balancing(_resource, table, "", id_column))
return self
func bind_pivot(table: String, row_key: String, target_prop: String,
pivot_key: String = "", pivot_val: String = "") -> BalancingRefresh:
if _resource and QuestData and QuestData.has_method("bind_balancing_pivot"):
_attach(QuestData.bind_balancing_pivot(
_resource, table, row_key, target_prop, pivot_key, pivot_val))
return self
func _attach(h: QDBindingHandle) -> void:
if h != null:
_handles.append(h)
h.applied.connect(_on_handle_applied)
func _on_handle_applied(_table: String) -> void:
if _on_refresh.is_valid():
_on_refresh.call()
func _exit_tree() -> void:
for h in _handles:
if h != null:
h.unbind()
_handles.clear()

Usage — one block wires scalar + two pivot tables per tile:

building_tile.gd
func setup(building: BuildingData) -> void:
var refresh := BalancingRefresh.new(building)
refresh.set_callback(_on_balancing_refresh)
add_child(refresh)
refresh.bind_scalar("buildings", "building_id")
refresh.bind_pivot("building_costs", "building_id", "resource_costs",
"resource_id", "base_amount")
refresh.bind_pivot("building_production", "building_id", "produced_resources",
"resource_id", "rate_per_second")
_on_balancing_refresh() # initial render with already-cached values
func _on_balancing_refresh() -> void:
_determine_state() # reads building.resource_costs — already mutated in-place by SDK
_update_display()

BalancingRefresh is a child node, so its lifetime follows the tile. When the tile is freed, _exit_tree() unbinds all three handles automatically — no manual cleanup in the tile itself.

What this replaces: per-table gamedata_updated subscriptions, a shared _rebuild_costs() function, identity checks inside a global balancing_applied handler, and manual disconnect in every _exit_tree. Roughly 40–80 LOC of plumbing per component.

Remove:

  • Manual pivot aggregation loops (_apply_building_costs_rows()) → replaced by bind_balancing_pivot
  • Manual gamedata_updated WS handlers for pivot tables → SDK handles re-apply automatically
  • Game-owned signal pipelines (balancing_data_refreshed) → replaced by QDBindingHandle.applied
  • Identity checks in global balancing_applied listeners → move to handle-based pattern

Keep:

  • id_column setup — still required
  • .tres default values — still active as offline fallback
  • disable_realtime test setting — still required