Tic-tac-toe

The React tutorial builds this game to teach lifting state up, immutability, list keys, and time travel. Most of those are the cost of holding state in JavaScript. Here the state is in the DOM, so they never come up. What is left is the game itself: whose turn it is, who won, and a history to step back through.

Next player: X
Winner:
Draw

    Uses: Util.js, Val.js

    <div ttt-space>
        <div ttt-status>
            <div val val-fx="If" val-key="next">Next player: <span val val-fx="Text" val-key="mark">X</span></div>
            <div val val-fx="If" val-key="winner" style="display:none">Winner: <span val val-fx="Text" val-key="mark"></span></div>
            <div val val-fx="If" val-key="draw" style="display:none">Draw</div>
        </div>
        <div ttt-board val-tpl="[ttt-cell-tpl]"></div>
        <template ttt-cell-tpl>
            <button ttt-cell val val-fx="Text" val-key="mark" onclick="Ttt.Play(this)"></button>
        </template>
        <ol ttt-history val-tpl="[ttt-move-tpl]"></ol>
        <template ttt-move-tpl>
            <li ttt-move val val-fx="Attr" val-key="board" val-attr="ttt-move-board">
                <button val val-fx="Text" val-key="label" onclick="Ttt.Jump(this)"></button>
            </li>
        </template>
        <script>Ttt.Init(document.currentScript);</script>
    </div>
    const Ttt = (() => {
        const lines = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
    
        const Winner = (m) => {
            for (const [a, b, c] of lines) {
                if (m[a] && m[a] === m[b] && m[a] === m[c]) return m[a];
            }
            return null;
        };
    
        const Turn = (m) => m.filter(Boolean).length % 2 ? 'O' : 'X';
    
        const Board = (space) => space.one('[ttt-board]');
        const Marks = (space) => Board(space).varr().map(c => c.mark);
    
        const Status = (space, m) => {
            const winner = Winner(m);
            space.one('[ttt-status]').val(
                winner ? { winner: { mark: winner } } :
                m.every(Boolean) ? { draw: true } :
                { next: { mark: Turn(m) } }
            );
        };
    
        const Current = (space) => space.one('[ttt-move][ttt-current]');
    
        const Select = (move) => {
            move.up('[ttt-space]').all('[ttt-move]').each(m => m.attr('ttt-current', null));
            move.attr('ttt-current', '');
        };
    
        const Snapshot = (space, m) => {
            const history = space.one('[ttt-history]');
            const index = history.children.length;
            history.vappend([{
                board: m.join(','),
                label: index === 0 ? 'Go to game start' : `Go to move #${index}`,
            }]);
            Select(history.lastElementChild);
        };
    
        const Play = (ctx) => {
            const space = ctx.up('[ttt-space]');
            const cell = ctx.up('[ttt-cell]');
            const before = Marks(space);
            if (Winner(before) || cell.val().mark) return;
    
            const current = Current(space);
            while (current.nextElementSibling) current.nextElementSibling.remove();
    
            cell.val({ mark: Turn(before) });
    
            const m = Marks(space);
            Status(space, m);
            Snapshot(space, m);
        };
    
        const Jump = (ctx) => {
            const space = ctx.up('[ttt-space]');
            const move = ctx.up('[ttt-move]');
            const m = move.val().board.split(',');
    
            Board(space).clear().vappend(m.map(mark => ({ mark })));
            Status(space, m);
            Select(move);
        };
    
        const Init = (ctx) => {
            const space = ctx.up('[ttt-space]');
            Board(space).vappend(Array.from({ length: 9 }, () => ({ mark: '' })));
            Snapshot(space, Marks(space));
        };
    
        return { Play, Jump, Init };
    })();

    Each handler takes the event's ctx and resolves what it needs through the contract. Play calls ctx.up('[ttt-cell]') to find its cell. up returns the element itself when it already matches, so the cell is looked up, not assumed. The mark is read and written through val, not touched as raw text. Whose turn it is isn't stored anywhere — it's the parity of the marks on the board — and who won is read off the same nine cells; both are computed when needed, never held. There is no board array in JavaScript to keep in agreement with the screen. The nine cells are the board, and the only state there is. Nothing is lifted, nothing is copied.

    Winning is the one piece of real game logic, and it is the same plain function React uses: eight lines, checked for three equal marks. Its input is the nine cells read from the DOM.

    Time travel

    Every move appends an entry to [ttt-history], carrying a snapshot of the board read straight off the cells, as its own DOM state. The active entry is marked with ttt-current.

    Clicking an entry runs Jump. It writes that snapshot back into the cells and moves ttt-current there; the turn simply follows from the restored board. The later entries stay. The board is back in the past, but the future is still in the history, so a step forward still works. Only when a move is played from a past point does Play drop every entry after the current one, and the new move starts a fresh line. That is a real timeline that branches, not an eager delete.

    There is no move index into an immutable array, and no store to rewind. The past is a list of real nodes. Going back is reading one of them.

    Next: Kanban →