TicTacToe Tutorial
This buildalong creates the same contract shape used by the current TicTacToe port: a fixed board, a turn flag, a successor-state move path, and terminal payout branches for wins or draws.
1. Model the Board
TicTacToe has a fixed 3x3 board, so the state schema can use FixedArray(TypeTag.Int, 9). Keep the turn in state as isAliceTurn, and keep the two player public keys in constructor props.
2. Add Win Helpers
The helpers run at author time. some(...) unrolls the eight winning lines into a fixed Script expression, and cond(...) lets the selected move count as already written while the contract checks for a win.
3. Write the Contract
import {
branches,
buildArtifact,
contract,
method,
TypeTag,
ContractDef,
FixedArray,
and,
checkSig,
cond,
div,
eq,
ExprNode,
gte,
hash160,
lit as literal,
lt,
neq,
not,
p2pkhOutput,
some,
sub,
} from '@opcat-labs/lambit';
const WIN_LINES: ReadonlyArray<readonly [number, number, number]> = [
[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 Board = FixedArray(TypeTag.Int, 9);
function boolEq(left: ExprNode, right: ExprNode): ExprNode {
return cond(eq(left, right), literal(true), literal(false));
}
function markAfterMove(
board: { at(index: ExprNode): ExprNode },
moveIndex: ExprNode,
targetIndex: number,
mark: ExprNode,
): ExprNode {
return cond(
eq(moveIndex, literal(BigInt(targetIndex))),
literal(true),
boolEq(board.at(literal(BigInt(targetIndex))), mark),
);
}
function wonAfterMove(
board: { at(index: ExprNode): ExprNode },
moveIndex: ExprNode,
mark: ExprNode,
): ExprNode {
return some(WIN_LINES, ([a, b, c]) => and(
markAfterMove(board, moveIndex, a, mark),
and(
markAfterMove(board, moveIndex, b, mark),
markAfterMove(board, moveIndex, c, mark),
),
));
}
function ticTacToeDef(): ContractDef {
return contract(
'TicTacToe',
{ alice: TypeTag.PubKey, bob: TypeTag.PubKey },
{
board: Board,
isAliceTurn: TypeTag.Bool,
},
({ props, state }) => ({
move: method(
{ playerSig: TypeTag.Sig, n: TypeTag.Int },
(playerSig, n, ctx) => {
const currentPlayer = cond(state.isAliceTurn, props.alice, props.bob);
const currentMark = cond(state.isAliceTurn, literal(1n), literal(2n));
const bounded = and(gte(n, literal(0n)), lt(n, literal(9n)));
const safeIndex = cond(bounded, n, literal(0n));
const nextBoard = state.board.set(safeIndex, currentMark);
const precondition = and(
bounded,
and(
eq(state.board.at(safeIndex), literal(0n)),
checkSig(playerSig, currentPlayer),
),
);
const won = wonAfterMove(state.board, safeIndex, currentMark);
const full = state.board.every((cell, index) => cond(
eq(index, safeIndex),
literal(true),
neq(cell, literal(0n)),
));
return branches(
{
when: won,
do: {
outputs: [p2pkhOutput(ctx.value, hash160(currentPlayer))],
check: precondition,
},
},
{
when: full,
do: {
outputs: [
p2pkhOutput(sub(ctx.value, div(ctx.value, literal(2n))), hash160(props.alice)),
p2pkhOutput(div(ctx.value, literal(2n)), hash160(props.bob)),
],
check: precondition,
},
},
{
default: {
next: {
board: nextBoard,
isAliceTurn: not(state.isAliceTurn),
},
check: precondition,
},
},
);
},
),
}),
);
}
const TicTacToe = ticTacToeDef();
const artifact = buildArtifact(TicTacToe);
console.log(artifact.contract); // TicTacToe
console.log(artifact.stateProps); // board + isAliceTurn
4. Test the Paths
The runtime test should cover a normal successor move, a winning payout, a draw payout, wrong-player signatures, occupied cells, out-of-bounds moves, and malformed output commitments. Use How to Test a Contract for the MemoryProvider flow and Output Covenants for the payout walkthrough.
The test/runtime/tictactoe.e2e.test.ts fixture is the current source of truth for the offline spend coverage.