The program tree
Your program is not a flat list: it is a tree. This page describes the data structure, its invariants, and the one module that may touch it.
The shape: SequenceItem
schemas/sequence.schema.ts defines the UI shape of one node:
type SequenceItem = {
kind: 'algorithm';
id: string; // unique within the program
action: ActionName;
label: string; // translated display name
from?: number; // drag internals
sourceId?: string; // drag internals
default: boolean; // true for the level's starter blocks
count?: number; // for only
condition?: ConditionName; // if only
movable: boolean; // false = locked in place
modifiable?: boolean; // false = header fields locked
children?: readonly SequenceItem[]; // for/while/if-then bodies
elseChildren?: readonly SequenceItem[]; // if-else body
branches?: readonly {
condition: SwitchCondition;
children: SequenceItem[];
modifiable?: boolean;
}[]; // switch only
};
The whole program is SequenceItem[], always starting with the synthetic
start item (action: 'start', default: true, movable: false). The
start head is what makes "the first block runs first" trivial: the runner
walks the list in order.
The schema (SequenceItemSchema) re-validates the sequence at every mutation
boundary, so a bug in a drag handler cannot silently corrupt the program
shape.
The four zones
Every container contributes drop zones, addressed by string ids:
| Zone id | Meaning |
|---|---|
root | the top-level program |
<itemId> | that item's children (for/while/if-then bodies, switch branch bodies) |
<itemId>:else | that item's elseChildren |
<itemId>:branch-<n> | that switch's branch n |
The whole tree is addressed with these ids; components never hand-roll recursion.
The one module that mutates: domain/sequence-tree.ts
All traversal and mutation lives here, immutably (every update returns a new tree, reusing untouched branches):
getZoneItems/getZoneLength- read a zone.findItem- find a node by id, anywhere in the tree.insertAt- insert into a zone; the index is clamped past locked items, because locked (non-movable) blocks must always stay ahead of user-placed blocks.removeAt- remove by index, returning the removed item.moveWithinZone- reorder inside one zone (also clamped past locked items).updateItem- replace one node by id via an updater.updateZone- replace a whole zone's item array.sortSwitchBranches- keeps thedefaultbranch last, whatever order it arrives in.isMovableAt- the guard the drag layer uses before moving anything.
Defaults and locks
The level's defaultActions become real SequenceItems at the head of the
program, each flagged default: true and carrying the level's movable and
modifiable settings. Nested default children (level 11's switch inside
the locked while) are flagged too. The flags matter twice:
- In the UI: default blocks render in the gray variant, cannot be deleted
(
canDeletein the drag session checksdefault !== true), and locked ones cannot be moved. - In storage: the flag is the bridge that re-attaches the level's internal fields (movable, modifiable, ids, labels) after a reload. See Persistence.
Rebuilding after storage
features/level/stored-sequence.ts converts between the UI tree and the
stored shape:
-
toStoredSequence(items)- strips thestarthead and all internal fields, keeps action/count/condition/children/elseChildren/branches plus thedefaultflag. -
fromStoredSequence(stored, defaults, allowedActions, labelForAction)- rebuilds the UI tree, merging each flagged item with the matching level default (matched by action, in traversal order), and rebuilding plain items as editable user blocks. It returnsnullwhen the data is stale or inconsistent:- the program uses an action the level no longer offers, because its
allowedActionspalette changed (the player can no longer build or edit that block), or - a flag has no matching default, or
- a level default comes back unflagged (the program was hand-tinkered).
In every case the caller keeps the level's defaults, and the mount save overwrites the bad program in storage.
- the program uses an action the level no longer offers, because its
useSequence (in features/level/hooks/) owns the in-memory tree: it
restores from storage on mount, saves on every change, and exposes reset
to restore the level's defaults.
Next: Simulation.