Skip to content

How updates reach the screen

A component redraws when a value it read changes.

A component follows every value read while producing its markup, including reads inside the functions it calls, however deep:

src/status.ts2
const poisoned = (): Boolean => useServerState(Varps.POISON) > 0;

The build finds the reads in the code, so there is no dependency list to keep in step. The search stops at other components: a child follows its own reads and its parent is not redrawn for them. A read in a handler follows nothing; it is the value when the handler runs.

Each component that reads state compares its values once per client cycle, about 20 milliseconds. One case skips the check: a private cell whose every writer is a handler in the same component is redrawn by the setter as the handler returns:

src/updates.ts2
const [n, setN] = useState(0);
…
<layer x={100} y={0} w={92} h={22} onClick={() => { setN(n + 1); }}>

A shared cell gets the same treatment when the build can name every reader and every writer; otherwise all its readers check. Inventories and skills are not checked either: the client reports each change to one, and its readers redraw on the report.

The build puts each component on the cheapest tier that is correct.

tierwhat can changecost while the panel is open
cache datanothingnone
updated in placevaluesa check a cycle; a change re-sets the props that moved
rebuiltwhat existsa flip tears the region down and makes it again

A component that reads nothing is not built. It is stored in the interface’s definition, loaded when the interface opens, and never checked:

src/why-updates.ts2
const PoisonTitle = (): Component => (
<text w="fill" h={16} text="Poison" font={Fonts.BOLD} color={Colors.AMBER} />
);

A handler that is one call stays data, because the call and its constant arguments are stored with the element:

src/settings.ts2
onClick={() => { restoreDefaults(); }} />

A component with a fixed shape is built once. When a value moves, each element is found where it is and only the props that depend on the value are set again:

src/why-updates.ts2
const PoisonCount = (): Component => {
const poison = useServerState(Varps.POISON);
return <text w="fill" h={16} text={`${poison} damage`} font={Fonts.PLAIN} color={Colors.WHITE} />;
};

Ref writes, scroll positions, focus and hover survive. A .map() over a constant array, a whole collection or a container stays on this tier, because its rows are the same rows on every build.

A region is the part of a tree a condition decides. An update cannot set props on an element that does not exist, so when the answer flips, the region is torn down and made again where it sits:

src/why-updates.ts2
<layer w="fill" h={16}>
{poison > 0 && <text w="fill" h={16} text="Cure me" font={Fonts.PLAIN} color={Colors.GREEN} />}
</layer>

The rest of the component is updated in place around it. Ref writes, focus and hover inside the region are lost. A loop whose rows are decided while the game runs, such as one with .filter() in the chain, rebuilds too, and the build warns.

The tier belongs to a whole component. A read at the root of a panel puts the panel on a checked tier; the same read in the one leaf that uses it costs the leaf, and the rest stays data. A check costs in proportion to how many values a component reads, not how large it is. Every reading component is one more check a cycle, so five components reading one value each cost more than one reading five.

The client reaches every check by walking the interface tree, and it skips a hidden component without looking inside. Nothing under a hide is checked, ticks or fires until it is shown, and then it catches up:

src/deaf.ts2
<layer x={2} y={2} w={104} h={{ fill: 4 }} hide={hidden === 1}>
<Column title="Hidden while away" />
</layer>

The client also never walks into a box 0 wide or 0 tall.