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.
The Problem
Section titled “The Problem”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.
What the SDK Takes Care Of
Section titled “What the SDK Takes Care Of”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@exportfield - Type coercion (
int/float/boolfrom JSON strings) - WebSocketPeer poll loop, reconnect backoff, auth handshake
- Re-applying all bindings when a WS push arrives
- Per-resource change notifications (
QDBindingHandle.appliedsignal) dev_api_urlvsprod_api_urlrouting based onOS.is_debug_build()
What stays in game code: UI listener wiring and deciding which tiles to refresh. Both are covered below.
Naming Conventions You Cannot Ignore
Section titled “Naming Conventions You Cannot Ignore”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 existQuestData.bind_balancing(building.data, "buildings")
# Correct — reads resource.building_idQuestData.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.
Prerequisites
Section titled “Prerequisites”Open Project → Project Settings → Quest Data (enable Advanced Settings):
| Setting | Example |
|---|---|
quest_data/api_key | b5c19641509c9c59... |
quest_data/dev_api_url | http://localhost:3010/v1/track |
quest_data/prod_api_url | https://api.questdata.io/v1/track |
Missing dev_api_url is the most common cause of 404s during local development.
Step 1 — Create Tables in the Dashboard
Section titled “Step 1 — Create Tables in the Dashboard”Open Live Ops → Game Data & Balancing and create a table. Column names must exactly match your @export property names.
Scalar table buildings:
| building_id | max_level | base_cost | scaling_factor |
|---|---|---|---|
| hut | 10 | 50 | 0.15 |
| sawmill | 8 | 200 | 0.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_id | resource_id | base_amount |
|---|---|---|
| hut | stone | 5 |
| hut | wood | 10 |
| sawmill | wood | 30 |
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:
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())Real-World Numbers
Section titled “Real-World Numbers”Measured in a production idle game with 25 buildings and 3 tables (buildings scalar + building_costs pivot + building_production pivot):
| Metric | Value |
|---|---|
| Bind calls at boot | 75 total: 25 × bind_balancing + 25 × bind_balancing_pivot (costs) + 25 × bind_balancing_pivot (production) |
| Active SDK bindings | 75 unique (resource, table, target_prop) entries |
| Handles per visible tile | 3 (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 start | 1 frame + HTTP round-trip to backend (50–300 ms depending on network) |
| WS push → tile re-render | Sub-second end-to-end; on local dev indistinguishable from instant |
applied fires per push | 1 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 stackFix 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 ...Offline Behaviour
Section titled “Offline Behaviour”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 →
.tresdefaults remain active, no crash
bind_balancing and bind_balancing_pivot never block the main thread.
Full Example — BalancingRefresh Wrapper
Section titled “Full Example — BalancingRefresh Wrapper”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.
class_name BalancingRefreshextends Node
var _resource: Resourcevar _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:
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.
Migration Checklist (pre-v1.12)
Section titled “Migration Checklist (pre-v1.12)”Remove:
- Manual pivot aggregation loops (
_apply_building_costs_rows()) → replaced bybind_balancing_pivot - Manual
gamedata_updatedWS handlers for pivot tables → SDK handles re-apply automatically - Game-owned signal pipelines (
balancing_data_refreshed) → replaced byQDBindingHandle.applied - Identity checks in global
balancing_appliedlisteners → move to handle-based pattern
Keep:
id_columnsetup — still required.tresdefault values — still active as offline fallbackdisable_realtimetest setting — still required
Next Steps
Section titled “Next Steps”- Remote Config & Game Data SDK reference — full function reference for
bind_balancing,bind_balancing_pivot, andQDBindingHandle - Game Data & Balancing Dashboard — create tables, import CSV, view version history