Skip to content
SiteEmail

This article covers structural data-binding , where instead of changing styles or text, the binding system adds and removes actual DOM nodes. You will learn how data-bind-if conditionally mounts elements, how data-bind-for generates lists from arrays, and how data-bind-[eventName] attaches event handlers to data-bound elements.

In the previous article, data-binding was used to change what existing elements look like: their text, their inline styles, and their CSS classes. But the set of elements in the DOM stayed the same. Many game UI screens require more than that. An inventory needs to render a variable number of item slots. A buff bar should only appear when the player actually has active buffs. A party roster grows and shrinks as squad members join or leave.

These scenarios require the binding system to modify the structure of the DOM tree itself, not just its visual properties. That is what data-bind-if and data-bind-for are for. This article also covers data-binding events, which let you wire up click handlers, hover effects, and other interactions directly from your HTML without writing addEventListener boilerplate.

The data-bind-if attribute controls whether an element exists in the rendered DOM. The expression must evaluate to a boolean (or a value that is truthy/falsy). When the condition is true, the element and all of its children are present in the DOM. When it is false, the engine removes them entirely.

This is fundamentally different from hiding an element with display: none or opacity: 0. Those CSS approaches keep the element in the DOM and the engine still has to process it during layout. data-bind-if removes the element from the tree altogether.

The simplest form uses a boolean property from the model:

import Block from '@components/Layout/Block/Block';
const StatusBadge = () => (
<Block class="roster__status-badge" attr:data-bind-if="{{PartyModel.isFull}}">
Party Full
</Block>
);

You can also use comparison expressions directly in the attribute:

import Block from '@components/Layout/Block/Block';
const RosterWarning = () => (
<Block
class="roster__warning"
attr:data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}"
>
Party incomplete! You need more members to start the raid.
</Block>
);

When PartyModel.members contains fewer entries than PartyModel.requiredSize, the warning element is mounted into the DOM. The moment the condition becomes false (enough members have joined), the engine removes the element entirely.

Negate the expression with !:

<!-- Show a "Ready" indicator only when NOT in combat -->
<div class="roster__ready-indicator" data-bind-if="!{{PartyModel.isInCombat}}">Ready to Deploy</div>

While data-bind-if handles the “show or hide” case, data-bind-for handles the “repeat N times” case. It takes an array property from a model and stamps out a copy of the element (and its children) for each item in that array. This is similar in concept to frameworks like React’s map, SolidJS’s <For> component, or Vue’s v-for directive, but it runs natively inside the Gameface engine.

The attribute value follows the pattern iteratorName:{{model.arrayProperty}} where iteratorName is the name of the variable that will be used to iterate over the array and {{model.arrayProperty}} is an array property from the model.

Consider the following model and it’s members array property:

party-model-members.js
// Each list item must be an object
PartyModel.members = [{ name: "Aria" }, { name: "Bronn" }, { name: "Chen" }];

To display the names of the members, keep a single static list container and put data-bind-for on a child wrapper. Bind the iterator only on elements inside that wrapper:

import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterList = () => (
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<TextBlock attr:data-bind-value="{{member.name}}" />
</Block>
</Block>
);

The engine clones the data-bind-for element and its children once per array item. The iterator (member here) is in scope only on descendants, using {{member.name}}, {{member.level}}, and so on. Three objects in PartyModel.members produce three .roster__member-entry wrappers inside .roster__list.

The iterator name in iteratorName:{{model.array}} is arbitrary; use a label that matches the data (for example member, item).

You can also capture the loop counter alongside the iterator by using a comma-separated syntax:

<div class="roster__list">
<div class="roster__member-entry" data-bind-for="index, member:{{PartyModel.members}}">
<span data-bind-value="{{index}}"></span>.
<span data-bind-value="{{member.name}}"></span>
</div>
</div>

The index variable is zero-based and increments for each element. If you only need the index and not the iterator (for example, to generate numbered placeholders), you can use _ as a throwaway for the iterator:

<!-- Generate 5 empty slots based on array length, only using the index -->
<div class="roster__list">
<div class="roster__member-entry" data-bind-for="index, _:{{PartyModel.slots}}">
<div class="roster__slot" data-bind-value="{{index}} + 1"></div>
</div>
</div>

Let us apply data-bind-for to our party roster. Each member in the array has a name, a class (warrior, mage, etc.), a level, and an HP percentage. The template stamps out a card for each member:

RosterList.tsx
import Block from '@components/Layout/Block/Block';
import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterList = () => (
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<Block class="roster__member">
<Block class="roster__member-header">
<TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" />
<InlineTextBlock class="roster__member-level">
Lv. <span data-bind-value="{{member.level}}" />
</InlineTextBlock>
</Block>
<TextBlock class="roster__member-class" attr:data-bind-value="{{member.role}}" />
<Block class="roster__hp-track">
<Block
class="roster__hp-fill"
attr:data-bind-style-width="{{member.hpPercent}} + '%'"
attr:data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30"
/>
</Block>
</Block>
</Block>
</Block>
);

Inside a data-bind-for template, the binding types from the previous article still apply: data-bind-value, data-bind-style-*, data-bind-class-toggle, and data-bind-if on child elements. Each instance references properties through the iterator (member) rather than a global model name.

You can nest data-bind-if inside a data-bind-for template to conditionally show parts of each repeated element. For example, to display a “Leader” badge only for the party leader:

RosterWithLeader.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterWithLeader = () => (
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<Block class="roster__member">
<TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" />
<TextBlock class="roster__leader-badge" attr:data-bind-if="{{member.isLeader}}">
Leader
</TextBlock>
</Block>
</Block>
</Block>
);

The data-bind-if inside the loop evaluates per iteration using the member iterator, so each member card independently decides whether to show the badge.

Bindings covered so far push model state to the DOM. Player input flows the other way: clicks, hovers, and focus changes call handlers you declare in HTML. A common approach is addEventListener on each node:

// In a SolidJS component, event handlers go directly on JSX elements:
const RosterMember = (props: { member: any; index: number }) => (
<Block class="roster__member" onClick={(event) => selectMember(event, props.index)}>
{/* member content */}
</Block>
);

This works, but it has two problems in the context of data-bound UIs. First, if data-bind-for regenerates the list (because the array changed), your manually attached listeners are gone, and you need to re-attach them. Second, you lose the direct connection between the event and the model data for that specific iteration.

Gameface solves this with data-binding event attributes . The syntax is data-bind-[eventName], where [eventName] is any supported DOM event (like click, mouseenter, mousedown, etc.). The attribute’s value is a function call expression: you call an ordinary JavaScript function in scope and pass the event, the element, and any bound values from the model or from a data-bind-for iterator.

<div data-bind-click="selectPartyMember(event, this, {{member}})">
<!-- ... -->
</div>

The three arguments available in a data-binding event expression are:

  • event: The standard JavaScript Event object from the fired event.
  • this: A reference to the DOM element that the handler is registered on.
  • {{member}} (or any model/iterator reference): The bound data for this element, resolved at the time the event fires.

Let us add click handling to the party roster so that clicking a member card selects them. The repeat wrapper (.roster__member-entry) carries only data-bind-for. The inner .roster__member card carries data-bind-click and references {{member}} as a descendant of that wrapper.

RosterWithClick.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
const RosterWithClick = () => (
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<Block
class="roster__member"
attr:data-bind-click="selectPartyMember(event, this, {{member}})"
>
<TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" />
<InlineTextBlock class="roster__member-level">
Lv. <span data-bind-value="{{member.level}}" />
</InlineTextBlock>
</Block>
</Block>
</Block>
);

When the player clicks on a member card, the engine calls selectPartyMember with the click event, the clicked DOM element, and the full member object for that iteration. No manual addEventListener setup is needed, and the binding survives array changes because the engine re-attaches handlers automatically when it regenerates the list.

Here is what the mock implementation might look like:

party-mock.js
function selectPartyMember(event, element, memberData) {
// Update plain fields on the model (no methods on the synced object)
PartyModel.selectedMemberName = memberData.name;
engine.updateWholeModel(PartyModel);
engine.synchronizeModels();
}
engine.whenReady.then(() => {
engine.createJSModel("PartyModel", {
members: [
{ name: "Kira", role: "Scout", level: 28, hpPercent: 92, isLeader: true },
{ name: "Thane", role: "Guardian", level: 31, hpPercent: 45, isLeader: false },
{ name: "Lyra", role: "Medic", level: 26, hpPercent: 78, isLeader: false },
],
requiredSize: 4,
isFull: false,
selectedMemberName: "",
});
engine.synchronizeModels();
});

The data-bind-[eventName] syntax supports a wide range of DOM events. Here are the most commonly used categories:

Mouse events: click, dblclick, mousedown, mouseup, mouseover, mouseout, mouseenter, mouseleave, mousemove

Keyboard events: keydown, keyup, keypress

Focus events: focus, blur, focusin, focusout

Input events: input, change

Touch events: touchstart, touchend

Other events: scroll, wheel, resize, load, error, abort

You can attach multiple event bindings to the same element. Each one is a separate attribute:

RosterMultiEvent.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
const RosterMultiEvent = () => (
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<Block
class="roster__member"
attr:data-bind-click="selectPartyMember(event, this, {{member}})"
attr:data-bind-mouseenter="previewPartyMember(event, this, {{member}})"
attr:data-bind-mouseleave="clearPartyPreview(event, this)"
>
<TextBlock attr:data-bind-value="{{member.name}}" />
</Block>
</Block>
</Block>
);

This gives each member card three distinct interactions: a click to select, a hover-in to preview, and a hover-out to clear the preview. All three handlers have access to the same member context from the loop.

party-roster-hover-handlers.js
function previewPartyMember(event, element, memberData) {
// Update the PartyModel preview fields with the hovered member's data
PartyModel.previewName = memberData.name;
PartyModel.previewClass = memberData.heroClass;
PartyModel.previewHealth = memberData.health;
engine.updateWholeModel(PartyModel);
engine.synchronizeModels();
}
function clearPartyPreview(event, element) {
// Clear preview fields when the mouse leaves
PartyModel.previewName = '';
PartyModel.previewClass = '';
PartyModel.previewHealth = 0;
engine.updateWholeModel(PartyModel);
engine.synchronizeModels();
}

Let us combine every concept from this article into a complete party roster panel. The component uses data-bind-if for conditional warnings, data-bind-for to generate the member list, and data-bind-click for selection handling. It also incorporates data-bind-value, data-bind-style-width, and data-bind-class-toggle from the previous article.

Here is the full HTML template:

PartyRoster.tsx
import Block from '@components/Layout/Block/Block';
import TextBlock from '@components/Basic/TextBlock/TextBlock';
import InlineTextBlock from '@components/Basic/InlineTextBlock/InlineTextBlock';
import Button from '@components/Basic/Button/Button';
const PartyRoster = () => (
<Block class="roster">
<div class="roster__title">Squad Roster</div>
<InlineTextBlock
class="roster__warning"
attr:data-bind-if="{{PartyModel.members}}.length < {{PartyModel.requiredSize}}"
>
Roster incomplete! Need{' '}
<span data-bind-value="{{PartyModel.requiredSize}} - {{PartyModel.members}}.length" />
&nbsp;more to deploy.
</InlineTextBlock>
<Block class="roster__list">
<Block class="roster__member-entry" attr:data-bind-for="member:{{PartyModel.members}}">
<Block
class="roster__member"
attr:data-bind-click="selectPartyMember(event, this, {{member}})"
attr:data-bind-class-toggle="roster__member--selected: {{member.name}} === {{PartyModel.selectedMemberName}}"
>
<Block class="roster__member-header">
<TextBlock class="roster__member-name" attr:data-bind-value="{{member.name}}" />
<TextBlock class="roster__leader-badge" attr:data-bind-if="{{member.isLeader}}">
Leader
</TextBlock>
<InlineTextBlock class="roster__member-level">
Lv. <span data-bind-value="{{member.level}}" />
</InlineTextBlock>
</Block>
<TextBlock class="roster__member-role" attr:data-bind-value="{{member.role}}" />
<Block class="roster__hp-track">
<Block
class="roster__hp-fill"
attr:data-bind-style-width="{{member.hpPercent}} + '%'"
attr:data-bind-class-toggle="roster__hp-fill--low: {{member.hpPercent}} < 30"
/>
</Block>
</Block>
</Block>
</Block>
<Button
class="roster__deploy-btn"
attr:data-bind-if="{{PartyModel.members}}.length >= {{PartyModel.requiredSize}}"
attr:data-bind-click="deployPartySquad(event)"
>
Deploy Squad
</Button>
</Block>
);
export default PartyRoster;
The CSS that styles the roster panel.
party-roster.css
.roster {
width: 320px;
background-color: rgba(15, 15, 20, 0.92);
border: 1px solid #2a2a35;
border-top: 3px solid #00DCFF;
padding: 16px;
font-family: 'Barlow Semi Condensed', sans-serif;
color: #cccccc;
}
.roster__title {
font-size: 1.1rem;
font-weight: bold;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 12px;
}
.roster__warning {
background-color: rgba(255, 180, 0, 0.12);
border-left: 3px solid #F5BC35;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 0.85rem;
color: #F5BC35;
}
.roster__list {
display: flex;
flex-direction: column;
}
/* Repeat wrapper: only carries data-bind-for; spacing lives here so layout matches a flat member list */
.roster__member-entry {
margin-bottom: 6px;
}
.roster__member {
padding: 10px 12px;
border: 1px solid #2a2a35;
cursor: pointer;
transition: border-color 0.15s ease;
}
.roster__member:hover {
border-color: #00DCFF;
}
.roster__member--selected {
border-color: #00DCFF;
background-color: rgba(0, 220, 255, 0.08);
}
.roster__member-header {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.roster__member-name {
font-size: 1rem;
font-weight: bold;
color: #ffffff;
margin-right: 8px;
}
.roster__leader-badge {
font-size: 0.7rem;
font-weight: bold;
color: #F5BC35;
text-transform: uppercase;
border: 1px solid #F5BC35;
padding: 1px 5px;
margin-right: auto;
}
.roster__member-level {
font-size: 0.8rem;
color: #00DCFF;
margin-left: auto;
}
.roster__member-role {
font-size: 0.8rem;
color: #888888;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.roster__hp-track {
width: 100%;
height: 5px;
background-color: #1a1a22;
overflow: hidden;
}
.roster__hp-fill {
height: 100%;
background-color: #00DCFF;
transition: width 0.15s linear;
}
.roster__hp-fill--low {
background-color: #ff2222;
}
.roster__deploy-btn {
margin-top: 12px;
padding: 10px;
text-align: center;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: #0a0a0a;
background-color: #00DCFF;
cursor: pointer;
transition: opacity 0.15s ease;
}
.roster__deploy-btn:hover {
opacity: 0.85;
}

And the JavaScript that creates the mock model, with handlers defined outside the model object:

party-roster-mock.js
function selectPartyMember(event, element, memberData) {
PartyModel.selectedMemberName = memberData.name;
engine.updateWholeModel(PartyModel);
engine.synchronizeModels();
}
function deployPartySquad(event) {
console.log("Deploying squad!");
}
engine.whenReady.then(() => {
engine.createJSModel("PartyModel", {
requiredSize: 4,
selectedMemberName: "",
members: [
{ name: "Kira", role: "Scout", level: 28, hpPercent: 92, isLeader: true },
{ name: "Thane", role: "Guardian", level: 31, hpPercent: 45, isLeader: false },
{ name: "Lyra", role: "Medic", level: 26, hpPercent: 78, isLeader: false },
],
});
engine.synchronizeModels();
});

This panel combines structural and visual bindings:

  • data-bind-if shows the roster warning and deploy control based on members.length vs requiredSize.
  • data-bind-for and data-bind-click build the member list and route clicks with {{member}} context.
  • data-bind-value, data-bind-style-width, and data-bind-class-toggle drive text and HP styling inside each card.

The entire panel is declarative. Manipulating the members array in the model will automatically update the UI, adding a fourth member to the members array would automatically generate a new card, hide the warning, and reveal the deploy button.