Files
feuerwerk/tools/seed-tetris.mjs
T
rootandClaude Opus 5 558365d33f Rebuild Tetris as ten behaviors that pass values around
The board is a list of 200 numbers now instead of 200 characters, so a cell is
Item at and a landing is Set item instead of seven nodes of string surgery.

The bigger change is that the one 219-node graph is gone. Each step of a frame is
a behavior of its own - Spawn piece, Next move, Fits, Draw piece, Lock piece,
Clear lines, Show lines, Draw board, plus Piece cell for where one of the four
cells of a piece sits. None of them holds state: what they need arrives through
their inputs, every answer leaves through their outputs. That is what lets Fits
answer for turning, moving and falling alike, and Piece cell serve the fit test,
the drawing and the landing instead of being the same twenty nodes three times.

Tetris itself is down to 84 nodes and only says in which order the steps run and
which variable each answer lands in. The smoke test holds it to that: it fails if
a loop or a list access reappears in the master graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:27:01 +02:00

1037 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Writes the second example game: Tetris, built only from assets and node graphs.
*
* The board is a list of 200 numbers, one per cell: -1 for empty, otherwise the shape that came to
* rest there. Reading a cell is `Item at(grid, row·10 + column)` and clearing a full line is an
* empty row joined in front of everything above and below it – with no text encoding in between,
* so the numbers in the inspector are the numbers the game means.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
* used by three of them. None of them holds state between calls: everything they need arrives
* through their inputs and every answer leaves through their outputs, which is what lets `Fits`
* serve turning, moving and falling alike.
*
* What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
// --- board geometry --------------------------------------------------------------------------
const COLS = 10;
const ROWS = 20;
const CELL = 16;
/** Cell 0 sits at the top left of the board; these turn a column/row into the object's position. */
const CELL_X = (col) => col * CELL - (COLS * CELL) / 2 + CELL / 2;
const CELL_Y = (row) => row * CELL - (ROWS * CELL) / 2 + CELL / 2;
/** No shape has this number, so it cannot be mistaken for one that came to rest. */
const EMPTY = -1;
const EMPTY_GRID = Array.from({ length: COLS * ROWS }, () => EMPTY);
// --- colours ---------------------------------------------------------------------------------
const BACKGROUND = 0x151821;
const WELL = 0x0d1017;
const WHITE = 0xf2f5f7;
const HINT = 0x9aa3b2;
/** One per shape, in the order of `SHAPES` below – and the `colors` variable as it stands. */
const SHAPE_COLORS = [0x2ec9d6, 0xf5c542, 0xa35fd6, 0x63c74d, 0xe0553f, 0x4a7de0, 0xe08a3f];
// --- the seven shapes ------------------------------------------------------------------------
/**
* Each shape as its four cells in a box of `size` × `size`, the box being what turning rotates
* inside: (x, y) → (size − 1 − y, x) is a quarter turn clockwise. I needs a 4-box, O a 2-box.
*/
const SHAPES = [
{
name: 'I',
size: 4,
cells: [
[0, 1],
[1, 1],
[2, 1],
[3, 1],
],
},
{
name: 'O',
size: 2,
cells: [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
],
},
{
name: 'T',
size: 3,
cells: [
[1, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'S',
size: 3,
cells: [
[1, 0],
[2, 0],
[0, 1],
[1, 1],
],
},
{
name: 'Z',
size: 3,
cells: [
[0, 0],
[1, 0],
[1, 1],
[2, 1],
],
},
{
name: 'J',
size: 3,
cells: [
[0, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
{
name: 'L',
size: 3,
cells: [
[2, 0],
[0, 1],
[1, 1],
[2, 1],
],
},
];
const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]);
/**
* All 28 rotations in one list, eight numbers each (x and y of the four cells), so one rotation
* starts at (shape·4 + rotation)·8 and its n-th cell two numbers further along.
*/
function shapeTable() {
const table = [];
for (const shape of SHAPES) {
let cells = shape.cells;
for (let rotation = 0; rotation < 4; rotation++) {
for (const [x, y] of cells) table.push(x, y);
cells = turned(cells, shape.size);
}
}
return table;
}
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2));
// --- the board container ---------------------------------------------------------------------
/** The well, the 200 board cells (switched off = empty) and the four cells of the falling piece. */
const boardTemplate = [
sceneObject('well', 'well-sprite', { name: 'Well' }),
...Array.from({ length: COLS * ROWS }, (_, i) =>
sceneObject(`cell${i}`, 'cell-sprite', {
name: `c${i}`,
x: CELL_X(i % COLS),
y: CELL_Y(Math.floor(i / COLS)),
z: 1,
overrides: { enabled: false },
}),
),
...Array.from({ length: 4 }, (_, i) =>
sceneObject(`piece${i}`, 'cell-sprite', { name: `p${i}`, z: 2, overrides: { enabled: false } }),
),
];
// --- graph helpers -----------------------------------------------------------------------------
/**
* One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a
* behavior as a stack of readable blocks rather than one cloud.
*/
function graphBuilder() {
const nodes = [];
const edges = [];
let band = 0;
const at = (id, type, column, row, params = {}, ports) => {
nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params, ports));
return id;
};
const link = (from, fromPort, to, toPort) => {
edges.push(edge(`e${edges.length + 1}`, from, fromPort, to, toPort));
};
return {
at,
link,
/** Starts the next band. */
strand: () => band++,
/** Chains exec outputs: `flow(a, b, c)` runs a, then b, then c. */
flow: (...ids) => {
for (let i = 0; i < ids.length - 1; i++) link(ids[i], 'exec', ids[i + 1], 'exec');
},
get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }),
set: (id, key, column, row, value) =>
at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }),
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
done() {
const ids = new Set();
for (const n of nodes) {
if (ids.has(n.id)) throw new Error(`duplicate node id "${n.id}"`);
ids.add(n.id);
}
for (const e of edges) {
for (const end of [e.from.node, e.to.node]) {
if (!ids.has(end)) throw new Error(`edge ${e.id} points at unknown node "${end}"`);
}
}
return { nodes, edges };
},
};
}
/** `ObjectByName` with a name built at runtime: prefix + the round the loop is in. */
function objectOfRound(g, prefix, loop, namePrefix, column, row) {
const text = g.at(`${prefix}-text`, 'ToText', column, row, { decimals: 0 });
const name = g.at(`${prefix}-name`, 'Join', column + 1, row, { a: namePrefix });
const object = g.at(`${prefix}-obj`, 'ObjectByName', column + 2, row);
g.link(loop, 'index', text, 'value');
g.link(text, 'text', name, 'b');
g.link(name, 'text', object, 'name');
return object;
}
// --- behavior: Piece cell ------------------------------------------------------------------------
/**
* Where one of the four cells of a piece sits: reads the rotation out of `shapes` and adds the
* piece's own position. Used by the fit test and by the drawing, which is the whole point of it
* being a behavior – the two used to be the same twenty nodes twice.
*/
function pieceCellGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('corner', 'number'),
port('baseX', 'number'),
port('baseY', 'number'),
]);
g.at('x4', 'Multiply', 1, 2, { b: 4 });
g.at('plus', 'Add', 2, 2);
g.at('x8', 'Multiply', 3, 2, { b: 8 });
g.at('c2', 'Multiply', 1, 5, { b: 2 });
g.at('ix', 'Add', 4, 2);
g.at('iy', 'Add', 4, 5, { b: 1 });
g.at('ax', 'ItemAt', 5, 1);
g.at('ay', 'ItemAt', 5, 5);
g.at('sx', 'Add', 6, 1);
g.at('sy', 'Add', 6, 5);
g.at('end', 'BehaviorEnd', 7, 0, {}, [port('x', 'number'), port('y', 'number')]);
g.link('start', 'shape', 'x4', 'a');
g.link('x4', 'result', 'plus', 'a');
g.link('start', 'rot', 'plus', 'b');
g.link('plus', 'result', 'x8', 'a');
g.link('start', 'corner', 'c2', 'a');
g.link('x8', 'result', 'ix', 'a');
g.link('c2', 'result', 'ix', 'b');
g.link('ix', 'result', 'iy', 'a');
g.link('start', 'shapes', 'ax', 'list');
g.link('ix', 'result', 'ax', 'index');
g.link('start', 'shapes', 'ay', 'list');
g.link('iy', 'result', 'ay', 'index');
g.link('ax', 'item', 'sx', 'a');
g.link('start', 'baseX', 'sx', 'b');
g.link('ay', 'item', 'sy', 'a');
g.link('start', 'baseY', 'sy', 'b');
g.link('sx', 'result', 'end', 'x');
g.link('sy', 'result', 'end', 'y');
g.flow('start', 'end');
return g.done();
}
// --- behavior: Fits -------------------------------------------------------------------------------
/**
* Whether the four cells of a piece may stand where it is asked to: inside the well, and on empty
* board cells. Every attempt to turn, move or drop goes through this one answer.
*/
function fitsGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('grid', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('x', 'number'),
port('y', 'number'),
]);
g.set('ok', 'ok', 1, 0, true);
g.at('loop', 'Repeat', 2, 0, { times: 4 });
g.flow('start', 'ok', 'loop');
g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' });
g.link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'shape', 'rot']) g.link('start', key, 'cell', key);
g.link('loop', 'index', 'cell', 'corner');
g.link('start', 'x', 'cell', 'baseX');
g.link('start', 'y', 'cell', 'baseY');
g.at('left', 'Compare', 4, 3, { op: '<', b: 0 });
g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 });
g.at('side', 'Or', 5, 4);
g.at('bottom', 'Compare', 4, 7, { op: '>', b: ROWS - 1 });
g.at('outside', 'Or', 6, 5);
g.link('cell', 'x', 'left', 'a');
g.link('cell', 'x', 'right', 'a');
g.link('left', 'result', 'side', 'a');
g.link('right', 'result', 'side', 'b');
g.link('cell', 'y', 'bottom', 'a');
g.link('side', 'result', 'outside', 'a');
g.link('bottom', 'result', 'outside', 'b');
// A cell outside the board reads as empty here, but the check above has already caught it.
g.at('row', 'Multiply', 4, 9, { b: COLS });
g.at('index', 'Add', 5, 9);
g.at('taken', 'ItemAt', 6, 9);
g.at('busy', 'Compare', 7, 9, { op: '>=', b: 0 });
g.at('blocked', 'Or', 8, 6);
g.at('br', 'Branch', 9, 0);
g.set('no', 'ok', 10, 0, false);
g.link('cell', 'y', 'row', 'a');
g.link('row', 'result', 'index', 'a');
g.link('cell', 'x', 'index', 'b');
g.link('start', 'grid', 'taken', 'list');
g.link('index', 'result', 'taken', 'index');
g.link('taken', 'item', 'busy', 'a');
g.link('outside', 'result', 'blocked', 'a');
g.link('busy', 'result', 'blocked', 'b');
g.link('cell', 'exec', 'br', 'exec');
g.link('blocked', 'result', 'br', 'condition');
g.link('br', 'then', 'no', 'exec');
g.at('end', 'BehaviorEnd', 11, 0, {}, [port('ok', 'boolean')]);
g.get('result', 'ok', 10, 3);
g.link('loop', 'done', 'end', 'exec');
g.link('result', 'value', 'end', 'ok');
return g.done();
}
// --- behavior: Clear lines -------------------------------------------------------------------------
/**
* Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not
* move the walk on, because everything above has dropped into it – so the same row is looked at
* again, which is how two full rows on top of each other both go.
*/
function clearLinesGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.set('start-board', 'board', 1, 0);
g.set('start-count', 'cleared', 2, 0);
g.set('row', 'row', 3, 0, ROWS - 1);
g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 });
g.link('start', 'grid', 'start-board', 'value');
g.link('start', 'lines', 'start-count', 'value');
g.flow('start', 'start-board', 'start-count', 'row', 'loop');
g.get('rowvar', 'row', 5, 3);
g.at('left', 'Compare', 6, 3, { op: '>=', b: 0 });
g.at('brleft', 'Branch', 6, 0);
g.at('rowstart', 'Multiply', 7, 3, { b: COLS });
g.get('grid', 'board', 7, 5);
g.at('cells', 'SubList', 8, 4, { count: COLS });
g.at('gap', 'IndexOf', 9, 4, { value: EMPTY });
g.at('full', 'Compare', 10, 4, { op: '<', b: 0 });
g.at('brfull', 'Branch', 8, 0);
g.link('loop', 'body', 'brleft', 'exec');
g.link('rowvar', 'value', 'left', 'a');
g.link('left', 'result', 'brleft', 'condition');
g.link('rowvar', 'value', 'rowstart', 'a');
g.link('grid', 'value', 'cells', 'list');
g.link('rowstart', 'result', 'cells', 'start');
g.link('cells', 'result', 'gap', 'list');
g.link('gap', 'index', 'full', 'a');
g.link('brleft', 'then', 'brfull', 'exec');
g.link('full', 'result', 'brfull', 'condition');
g.at('above', 'SubList', 9, 7, { start: 0 });
g.at('past', 'Add', 8, 10, { b: COLS });
g.at('below', 'SubList', 9, 10, { count: COLS * ROWS });
g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS });
g.at('top', 'Concat', 10, 8);
g.at('rest', 'Concat', 11, 9);
g.set('setgrid', 'board', 10, 0);
g.get('lines', 'cleared', 11, 3);
g.at('onemore', 'Add', 12, 3, { b: 1 });
g.set('setlines', 'cleared', 12, 0);
g.link('brfull', 'then', 'setgrid', 'exec');
g.link('grid', 'value', 'above', 'list');
g.link('rowstart', 'result', 'above', 'count');
g.link('rowstart', 'result', 'past', 'a');
g.link('grid', 'value', 'below', 'list');
g.link('past', 'result', 'below', 'start');
g.link('blank', 'result', 'top', 'a');
g.link('above', 'result', 'top', 'b');
g.link('top', 'result', 'rest', 'a');
g.link('below', 'result', 'rest', 'b');
g.link('rest', 'result', 'setgrid', 'value');
g.link('lines', 'value', 'onemore', 'a');
g.link('onemore', 'result', 'setlines', 'value');
g.flow('setgrid', 'setlines');
g.at('back', 'Subtract', 9, 14, { b: 1 });
g.set('setrow', 'row', 10, 13);
g.link('brfull', 'else', 'setrow', 'exec');
g.link('rowvar', 'value', 'back', 'a');
g.link('back', 'result', 'setrow', 'value');
g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.get('outgrid', 'board', 12, 6);
g.get('outlines', 'cleared', 12, 8);
g.link('loop', 'done', 'end', 'exec');
g.link('outgrid', 'value', 'end', 'grid');
g.link('outlines', 'value', 'end', 'lines');
return g.done();
}
// --- behavior: Draw board ---------------------------------------------------------------------------
/**
* The board as it stands, cell by cell: switched off where the list says empty, in the colour of
* the shape that came to rest there otherwise. No end node – it answers nothing, it only draws.
*/
function drawBoardGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('colors', 'list')]);
g.at('loop', 'Repeat', 1, 0, { times: COLS * ROWS });
g.flow('start', 'loop');
g.at('cell', 'ItemAt', 2, 4);
g.at('filled', 'Compare', 3, 4, { op: '>=', b: 0 });
const object = objectOfRound(g, 'c', 'loop', 'c', 2, 6);
g.at('show', 'SetEnabled', 6, 0);
g.at('br', 'Branch', 7, 0);
g.at('color', 'ItemAt', 7, 4);
g.at('paint', 'SetProperty', 8, 0, { property: 'color' });
g.link('start', 'grid', 'cell', 'list');
g.link('loop', 'index', 'cell', 'index');
g.link('cell', 'item', 'filled', 'a');
g.link('loop', 'body', 'show', 'exec');
g.link(object, 'object', 'show', 'object');
g.link('filled', 'result', 'show', 'enabled');
g.link('filled', 'result', 'br', 'condition');
g.link('start', 'colors', 'color', 'list');
g.link('cell', 'item', 'color', 'index');
g.link(object, 'object', 'paint', 'object');
g.link('color', 'item', 'paint', 'value');
g.flow('show', 'br');
g.link('br', 'then', 'paint', 'exec');
return g.done();
}
// --- behavior: Spawn piece -----------------------------------------------------------------------
/** Which piece appears next and in which column: one of the seven, centred over the well. */
function spawnPieceGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('spawnX', 'list')]);
g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length });
g.at('shape', 'Floor', 2, 2);
g.at('column', 'ItemAt', 3, 2);
g.at('end', 'BehaviorEnd', 4, 0, {}, [port('shape', 'number'), port('x', 'number')]);
g.link('random', 'result', 'shape', 'value');
g.link('start', 'spawnX', 'column', 'list');
g.link('shape', 'result', 'column', 'index');
g.link('shape', 'result', 'end', 'shape');
g.link('column', 'item', 'end', 'x');
g.flow('start', 'end');
return g.done();
}
// --- behavior: Next move -------------------------------------------------------------------------
/**
* The one move a frame attempts, and nothing else: turning beats moving beats falling. It only
* proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted
* comes back as 0, which is how a key press is spent exactly once.
*/
function nextMoveGraph() {
const g = graphBuilder();
const { at, link, flow, get, set } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('px', 'number'),
port('py', 'number'),
port('rot', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
port('fall', 'number'),
port('dt', 'number'),
port('stepTime', 'number'),
]);
// The candidate starts out as where the piece already is, and no wish is spent yet.
set('i-nx', 'nx', 1, 0);
set('i-ny', 'ny', 2, 0);
set('i-nrot', 'nrot', 3, 0);
set('i-kind', 'kind', 4, 0, '');
set('i-fall', 'outFall', 5, 0);
set('i-dx', 'outDx', 6, 0);
set('i-rot', 'outRot', 7, 0);
link('start', 'px', 'i-nx', 'value');
link('start', 'py', 'i-ny', 'value');
link('start', 'rot', 'i-nrot', 'value');
link('start', 'fall', 'i-fall', 'value');
link('start', 'wantDx', 'i-dx', 'value');
link('start', 'wantRot', 'i-rot', 'value');
flow('start', 'i-nx', 'i-ny', 'i-nrot', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
g.strand();
at('t-want', 'Compare', 0, 3, { op: '>', b: 0 });
at('t-br', 'Branch', 0, 0);
at('t-plus1', 'Add', 1, 3, { b: 1 });
at('t-wrap', 'Modulo', 2, 3, { b: 4 });
set('t-set', 'nrot', 1, 0);
set('t-kind', 'kind', 2, 0, 'turn');
set('t-used', 'outRot', 3, 0, 0);
link('start', 'wantRot', 't-want', 'a');
link('t-want', 'result', 't-br', 'condition');
link('t-br', 'then', 't-set', 'exec');
link('start', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-set', 'value');
flow('t-set', 't-kind', 't-used', 'end');
// Then sideways, one column per key press.
at('m-still', 'Equals', 4, 3, { b: 0 });
at('m-moving', 'Not', 5, 3);
at('m-br', 'Branch', 5, 0);
at('m-shift', 'Add', 6, 3);
set('m-set', 'nx', 6, 0);
set('m-kind', 'kind', 7, 0, 'move');
set('m-used', 'outDx', 8, 0, 0);
link('t-br', 'else', 'm-br', 'exec');
link('start', 'wantDx', 'm-still', 'a');
link('m-still', 'result', 'm-moving', 'value');
link('m-moving', 'result', 'm-br', 'condition');
link('m-br', 'then', 'm-set', 'exec');
link('start', 'px', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-set', 'value');
flow('m-set', 'm-kind', 'm-used', 'end');
// And otherwise the clock: holding the down key lets it run ten times as fast.
g.strand();
at('f-down', 'KeyIsDown', 0, 4, { key: 'ArrowDown' });
at('f-boost', 'Multiply', 1, 4, { b: 9 });
at('f-rate', 'Add', 2, 4, { a: 1 });
at('f-step', 'Multiply', 3, 3);
at('f-add', 'Add', 4, 3);
set('f-set', 'outFall', 0, 0);
link('m-br', 'else', 'f-set', 'exec');
link('f-down', 'down', 'f-boost', 'a');
link('f-boost', 'result', 'f-rate', 'b');
link('start', 'dt', 'f-step', 'a');
link('f-rate', 'result', 'f-step', 'b');
link('start', 'fall', 'f-add', 'a');
link('f-step', 'result', 'f-add', 'b');
link('f-add', 'result', 'f-set', 'value');
get('f-fall', 'outFall', 5, 3);
at('f-due', 'Compare', 6, 3, { op: '>=' });
at('f-br', 'Branch', 5, 0);
at('f-minus', 'Subtract', 7, 3);
set('f-rest', 'outFall', 6, 0);
at('f-down1', 'Add', 7, 5, { b: 1 });
set('f-ny', 'ny', 7, 0);
set('f-kind', 'kind', 8, 0, 'fall');
flow('f-set', 'f-br');
link('f-fall', 'value', 'f-due', 'a');
link('start', 'stepTime', 'f-due', 'b');
link('f-due', 'result', 'f-br', 'condition');
link('f-br', 'then', 'f-rest', 'exec');
link('f-fall', 'value', 'f-minus', 'a');
link('start', 'stepTime', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value');
link('start', 'py', 'f-down1', 'a');
link('f-down1', 'result', 'f-ny', 'value');
flow('f-rest', 'f-ny', 'f-kind', 'end');
// Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing.
link('f-br', 'else', 'end', 'exec');
// Whatever the frame decided is handed back – the caller owns the variables, not this behavior.
g.strand();
at('end', 'BehaviorEnd', 2, 0, {}, [
port('nx', 'number'),
port('ny', 'number'),
port('nrot', 'number'),
port('kind', 'text'),
port('fall', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
]);
const back = [
['e-nx', 'nx', 'nx'],
['e-ny', 'ny', 'ny'],
['e-nrot', 'nrot', 'nrot'],
['e-kind', 'kind', 'kind'],
['e-fall', 'outFall', 'fall'],
['e-dx', 'outDx', 'wantDx'],
['e-rot', 'outRot', 'wantRot'],
];
back.forEach(([id, key, portKey], i) => {
get(id, key, 0, i * 2 + 2);
link(id, 'value', 'end', portKey);
});
return g.done();
}
// --- behavior: Draw piece ------------------------------------------------------------------------
/**
* Moves the four cell objects of the falling piece to where the piece is. This is the only place
* that touches them, so where they stand *is* where the piece is – which is what `Lock piece`
* reads back instead of working the shape out a second time.
*/
function drawPieceGraph() {
const g = graphBuilder();
const { at, link, flow } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('px', 'number'),
port('py', 'number'),
port('colors', 'list'),
]);
at('loop', 'Repeat', 1, 0, { times: 4 });
at('cell', 'RunBehavior', 2, 0, { behavior: 'piece-cell' });
flow('start', 'loop');
link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'shape', 'rot']) link('start', key, 'cell', key);
link('loop', 'index', 'cell', 'corner');
link('start', 'px', 'cell', 'baseX');
link('start', 'py', 'cell', 'baseY');
const object = objectOfRound(g, 'p', 'loop', 'p', 3, 12);
at('x', 'Multiply', 3, 4, { b: CELL });
at('atx', 'Add', 4, 4, { b: CELL_X(0) });
at('y', 'Multiply', 3, 6, { b: CELL });
at('aty', 'Add', 4, 6, { b: CELL_Y(0) });
at('color', 'ItemAt', 4, 8);
link('cell', 'x', 'x', 'a');
link('x', 'result', 'atx', 'a');
link('cell', 'y', 'y', 'a');
link('y', 'result', 'aty', 'a');
link('start', 'colors', 'color', 'list');
link('start', 'shape', 'color', 'index');
at('show', 'SetEnabled', 6, 0, { enabled: true });
at('place', 'SetPosition', 7, 0);
at('paint', 'SetProperty', 8, 0, { property: 'color' });
link('cell', 'exec', 'show', 'exec');
for (const id of ['show', 'place', 'paint']) link(object, 'object', id, 'object');
link('atx', 'result', 'place', 'x');
link('aty', 'result', 'place', 'y');
link('color', 'item', 'paint', 'value');
flow('show', 'place', 'paint');
return g.done();
}
// --- behavior: Lock piece ------------------------------------------------------------------------
/** Writes the four cells of the landed piece into the board, read back from where they stand. */
function lockPieceGraph() {
const g = graphBuilder();
const { at, link, flow, get, set } = g;
at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('shape', 'number')]);
set('take', 'board', 1, 0);
at('loop', 'Repeat', 2, 0, { times: 4 });
link('start', 'grid', 'take', 'value');
flow('start', 'take', 'loop');
const object = objectOfRound(g, 'p', 'loop', 'p', 3, 4);
at('pos', 'GetPosition', 6, 4);
at('offx', 'Add', 7, 3, { b: -CELL_X(0) });
at('offy', 'Add', 7, 6, { b: -CELL_Y(0) });
at('col', 'Divide', 8, 3, { b: CELL });
at('rowof', 'Divide', 8, 6, { b: CELL });
at('colr', 'Round', 9, 3);
at('rowr', 'Round', 9, 6);
link(object, 'object', 'pos', 'object');
link('pos', 'x', 'offx', 'a');
link('pos', 'y', 'offy', 'a');
link('offx', 'result', 'col', 'a');
link('offy', 'result', 'rowof', 'a');
link('col', 'result', 'colr', 'value');
link('rowof', 'result', 'rowr', 'value');
at('row10', 'Multiply', 10, 6, { b: COLS });
at('index', 'Add', 11, 5);
get('board', 'board', 11, 8);
at('write', 'SetItem', 12, 6);
set('keep', 'board', 3, 0);
link('rowr', 'result', 'row10', 'a');
link('row10', 'result', 'index', 'a');
link('colr', 'result', 'index', 'b');
link('board', 'value', 'write', 'list');
link('index', 'result', 'write', 'index');
link('start', 'shape', 'write', 'value');
link('write', 'result', 'keep', 'value');
link('loop', 'body', 'keep', 'exec');
get('done', 'board', 4, 3);
at('end', 'BehaviorEnd', 4, 0, {}, [port('grid', 'list')]);
link('loop', 'done', 'end', 'exec');
link('done', 'value', 'end', 'grid');
return g.done();
}
// --- behavior: Show lines ------------------------------------------------------------------------
/** The one place that writes the counter on screen. */
function showLinesGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [port('lines', 'number')]);
g.at('number', 'ToText', 1, 3, { decimals: 0 });
g.at('caption', 'Join', 2, 3, { a: 'Lines ' });
g.at('label', 'ObjectByName', 1, 5, { name: 'LinesLabel' });
g.at('write', 'SetText', 3, 0);
g.link('start', 'lines', 'number', 'value');
g.link('number', 'text', 'caption', 'b');
g.link('caption', 'text', 'write', 'text');
g.link('label', 'object', 'write', 'object');
g.flow('start', 'write');
return g.done();
}
// --- behavior: Tetris ---------------------------------------------------------------------------
/**
* The master graph. It owns the state and does none of the work: every step below is a behavior of
* its own, called as a node. What is left here is the order the steps run in and which variable
* each answer lands in.
*/
function tetrisGraph() {
const g = graphBuilder();
const { at, link, flow, get, set, strand } = g;
// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so
// this only matters when the behavior was left running in the editor.
strand();
at('s-start', 'OnStart', 0, 0);
at('s-empty', 'Fill', 0, 3, { value: EMPTY, count: COLS * ROWS });
set('s-grid', 'grid', 1, 0);
set('s-lines', 'lines', 2, 0, 0);
set('s-piece', 'hasPiece', 3, 0, false);
set('s-fall', 'fall', 4, 0, 0);
set('s-dx', 'wantDx', 5, 0, 0);
set('s-turn', 'wantRot', 6, 0, 0);
at('s-show', 'RunBehavior', 7, 0, { behavior: 'show-lines', lines: 0 });
link('s-empty', 'result', 's-grid', 'value');
flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-turn', 's-dx', 's-show');
// 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That
// way every attempt to move goes through the one test below, whatever asked for it.
strand();
at('k-left', 'OnKeyDown', 0, 0, { key: 'ArrowLeft' });
set('k-setleft', 'wantDx', 1, 0, -1);
at('k-right', 'OnKeyDown', 0, 3, { key: 'ArrowRight' });
set('k-setright', 'wantDx', 1, 3, 1);
at('k-up', 'OnKeyDown', 0, 6, { key: 'ArrowUp' });
set('k-setup', 'wantRot', 1, 6, 1);
flow('k-left', 'k-setleft');
flow('k-right', 'k-setright');
flow('k-up', 'k-setup');
// 3) Every frame does one thing: either a new piece appears, or the piece attempts one move.
strand();
at('u-update', 'OnUpdate', 0, 0);
get('u-has', 'hasPiece', 0, 2);
at('u-br', 'Branch', 1, 0);
flow('u-update', 'u-br');
link('u-has', 'value', 'u-br', 'condition');
// 3a) No piece: ask for one and write down what came back.
at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' });
get('n-columns', 'spawnX', 2, 8);
set('n-shape', 'shape', 3, 5);
set('n-rot', 'rot', 4, 5, 0);
set('n-px', 'px', 5, 5);
set('n-py', 'py', 6, 5, 0);
set('n-fall', 'fall', 7, 5, 0);
set('n-has', 'hasPiece', 8, 5, true);
link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'value', 'n-spawn', 'spawnX');
link('n-spawn', 'shape', 'n-shape', 'value');
link('n-spawn', 'x', 'n-px', 'value');
flow('n-spawn', 'n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has');
// 3b) A piece is falling: one behavior decides which single move this frame attempts.
strand();
at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' });
link('u-br', 'then', 'c-move', 'exec');
const moveIn = [
['px', 'px'],
['py', 'py'],
['rot', 'rot'],
['wantDx', 'wantDx'],
['wantRot', 'wantRot'],
['fall', 'fall'],
['stepTime', 'stepTime'],
];
moveIn.forEach(([key, variableKey], i) => {
get(`c-in-${key}`, variableKey, 0, i * 2 + 3);
link(`c-in-${key}`, 'value', 'c-move', key);
});
link('u-update', 'dt', 'c-move', 'dt');
const moveOut = [
['nx', 'nx'],
['ny', 'ny'],
['nrot', 'nrot'],
['kind', 'kind'],
['fall', 'fall'],
['wantDx', 'wantDx'],
['wantRot', 'wantRot'],
];
moveOut.forEach(([key, variableKey], i) => {
set(`c-out-${key}`, variableKey, i + 1, 0);
link('c-move', key, `c-out-${key}`, 'value');
});
flow('c-move', ...moveOut.map(([key]) => `c-out-${key}`));
// 4) The one test, and it is one node: does the candidate fit? Every attempt above ends here.
strand();
at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' });
get('t-shapes', 'shapes', 0, 3);
get('t-grid', 'grid', 0, 5);
get('t-shape', 'shape', 0, 7);
get('t-nrot', 'nrot', 0, 9);
get('t-nx', 'nx', 0, 11);
get('t-ny', 'ny', 0, 13);
flow('c-out-wantRot', 't-fits');
link('t-shapes', 'value', 't-fits', 'shapes');
link('t-grid', 'value', 't-fits', 'grid');
link('t-shape', 'value', 't-fits', 'shape');
link('t-nrot', 'value', 't-fits', 'rot');
link('t-nx', 'value', 't-fits', 'x');
link('t-ny', 'value', 't-fits', 'y');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
at('m-br', 'Branch', 1, 0);
get('m-nx', 'nx', 2, 3);
get('m-ny', 'ny', 3, 3);
get('m-nrot', 'nrot', 4, 3);
set('m-px', 'px', 2, 0);
set('m-py', 'py', 3, 0);
set('m-rot', 'rot', 4, 0);
flow('t-fits', 'm-br');
link('t-fits', 'ok', 'm-br', 'condition');
link('m-br', 'then', 'm-px', 'exec');
link('m-nx', 'value', 'm-px', 'value');
link('m-ny', 'value', 'm-py', 'value');
link('m-nrot', 'value', 'm-rot', 'value');
flow('m-px', 'm-py', 'm-rot');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'py', 4, 8);
at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Wherever the piece ended up – newly spawned or moved – it is drawn there.
strand();
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
flow('m-rot', 'd-draw');
flow('n-has', 'd-draw');
['shapes', 'shape', 'rot', 'px', 'py', 'colors'].forEach((key, i) => {
get(`d-${key}`, key, 0, i * 2 + 3);
link(`d-${key}`, 'value', 'd-draw', key);
});
// 7) It landed: it becomes part of the board, full lines go, the counter and the board follow.
strand();
at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' });
get('l-grid', 'grid', 0, 3);
get('l-shape', 'shape', 0, 5);
set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec');
link('l-grid', 'value', 'l-lock', 'grid');
link('l-shape', 'value', 'l-lock', 'shape');
link('l-lock', 'grid', 'l-setgrid', 'value');
flow('l-lock', 'l-setgrid');
at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' });
get('r-grid', 'grid', 2, 3);
get('r-lines', 'lines', 2, 5);
set('r-setgrid', 'grid', 3, 0);
set('r-setlines', 'lines', 4, 0);
link('r-grid', 'value', 'r-clear', 'grid');
link('r-lines', 'value', 'r-clear', 'lines');
link('r-clear', 'grid', 'r-setgrid', 'value');
link('r-clear', 'lines', 'r-setlines', 'value');
flow('l-setgrid', 'r-clear', 'r-setgrid', 'r-setlines');
at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' });
get('b-lines', 'lines', 5, 3);
at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' });
get('b-grid', 'grid', 6, 3);
get('b-colors', 'colors', 6, 5);
set('b-nopiece', 'hasPiece', 7, 0, false);
link('b-lines', 'value', 'b-show', 'lines');
link('b-grid', 'value', 'b-draw', 'grid');
link('b-colors', 'value', 'b-draw', 'colors');
// The board is drawn and the piece is part of it now: the next frame draws a new one.
flow('r-setlines', 'b-show', 'b-draw', 'b-nopiece');
return g.done();
}
// --- project -----------------------------------------------------------------------------------
const label = (id, name, x, y, text, size, color, align = 'center') =>
sceneObject(id, 'label', { name, x, y, overrides: { text, fontSize: size, color, align } });
/** A behavior asset whose `props` are exactly the declared variables' defaults. */
function behavior(id, name, variables, graph) {
const props = {};
for (const v of variables) props[v.key] = v.default;
return { id, type: 'behavior', name, props, variables, graph };
}
const tetrisVariables = [
variable('grid', 'list', EMPTY_GRID, 'number'),
variable('shapes', 'list', shapeTable(), 'number'),
variable('colors', 'list', SHAPE_COLORS, 'color'),
variable('spawnX', 'list', spawnColumns(), 'number'),
variable('shape', 'number', 0),
variable('rot', 'number', 0),
variable('px', 'number', 3),
variable('py', 'number', 0),
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('kind', 'text', ''),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
variable('stepTime', 'number', 0.5),
variable('lines', 'number', 0),
variable('wantDx', 'number', 0),
variable('wantRot', 'number', 0),
];
const project = {
formatVersion: 2,
id: projectId,
name: 'Tetris',
activeSceneId: 'scene1',
assets: [
{
id: 'scene1',
type: 'scene',
name: 'Tetris',
props: { backgroundColor: BACKGROUND },
objects: [
sceneObject('oboard', 'board', { name: 'Board', x: 150 }),
label('otitle', 'Title', -110, -110, 'TETRIS', 44, WHITE),
label('olines', 'LinesLabel', -110, -40, 'Lines 0', 26, WHITE),
label('ohint1', 'Hint', -110, 60, 'Left / Right · move', 16, HINT),
label('ohint2', 'Hint 2', -110, 84, 'Up · turn', 16, HINT),
label('ohint3', 'Hint 3', -110, 108, 'Down · drop faster', 16, HINT),
sceneObject('ocam', 'cam', { name: 'Main Camera' }),
sceneObject('ogame', 'tetris', { name: 'Tetris', x: -260, y: 150 }),
],
},
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
{
id: 'well-sprite',
type: 'sprite',
name: 'Well',
props: { width: COLS * CELL, height: ROWS * CELL, color: WELL },
},
{ id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } },
{
id: 'label',
type: 'text',
name: 'Label',
props: { text: 'Text', fontSize: 26, color: WHITE, align: 'center' },
},
{ id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate },
behavior('piece-cell', 'Piece cell', [], pieceCellGraph()),
behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()),
behavior(
'clear-lines',
'Clear lines',
[
variable('board', 'list', [], 'number'),
variable('cleared', 'number', 0),
variable('row', 'number', 0),
],
clearLinesGraph(),
),
behavior('draw-board', 'Draw board', [], drawBoardGraph()),
behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()),
behavior(
'next-move',
'Next move',
[
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
variable('outRot', 'number', 0),
],
nextMoveGraph(),
),
behavior('draw-piece', 'Draw piece', [], drawPieceGraph()),
behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()),
behavior('show-lines', 'Show lines', [], showLinesGraph()),
behavior('tetris', 'Tetris', tetrisVariables, tetrisGraph()),
],
};
await writeProject(projectId, baseUrl, project);