06 / 20Module 1: A Rell backend with FT4 accounts
Perform basic operations
This part of the tutorial covers the core operations for running a Tic Tac Toe game on the Chromia blockchain: creating a game, starting new games, and placing moves on the board. When you finish, you will be able to implement and test these basic operations in your decentralized game.
Add data through transactions
On Chromia, you write data to the blockchain by submitting transactions. Each transaction contains one or more database operations. The operations below handle creating games, joining them, and making moves.
Create a game
operation create_game() { val account = auth.authenticate(); functions.create_game(account);}create_game creates a new instance of the predefined game entity. For that instance, it fills in starting values such as created_by and created_at, and marks every board slot (slot_1 to slot_9) as slot_states.free.
Join a game
operation join_game(entities.game) { val account = auth.authenticate(); functions.join_game(game, account);}join_game lets a second player enter an existing game. It authenticates the player's account, then writes it to the player2_account_rowid field of that game instance. Unauthorized players cannot join.
Make a move
operation make_move(entities.game, slot: integer) { val account = auth.authenticate(); functions.make_move(game, slot, account);}make_move records a player's mark by updating the matching slot of the game instance. Before it saves the move, it checks that:
- The slot number falls between 1 and 9.
- The player is authorized and the turn belongs to them.
- The game is active.
- Nobody has already taken the chosen slot.
Once these checks pass, make_move stores the move in the game state, then determines whether the game is won, drawn, or continues with the other player's turn.
Test the operations
Unit tests confirm that your game logic behaves the way you intend. Write and run them to verify each operation.
Set up the test module
-
Go to the test folder in your project directory. Create it if it is missing.
-
Inside the test folder, add a file named
module.rell. Your test cases go here. -
Put this header at the top of
module.rell:
@test module;import tic_tac_toe.{ game, game_status, slot_states };import tic_tac_toe.external.{ create_game, get_games_by_status, join_game, make_move, get_game_by_id };import lib.ft4.accounts.{ account, auth_descriptor };import lib.ft4.core.accounts.strategies.open.{ ras_open };import lib.ft4.test.core. { create_auth_descriptor, ft_auth_operation_for };import lib.ft4.external.accounts.strategies.{ register_account };@test module;marks the module as one that holds test cases.- The
importstatements pull in what you need from thetic_tac_toeandlib.ft4libraries:- Game management functions from the
tic_tac_toe.externalmodule:create_game,get_games_by_status,join_game,make_move, andget_game_by_id. - Account and authentication utilities (
account,auth_descriptor,register_account) for creating and managing the player accounts your tests use. - Test utilities (
create_auth_descriptor,ft_auth_operation_for) for test-specific setup and authentication.
- Game management functions from the
These imports give module.rell access to the main game functions and player management operations, so you can start writing test cases.
Write test cases
The following tests check that the basic game operations behave correctly.
Create account test
This test creates user accounts with the create_test_user_data function and confirms they were added by checking how many accounts exist.
function test_create_accounts() { create_test_user_data(rell.test.keypairs.bob); create_test_user_data(rell.test.keypairs.eve); assert_equals(get_all_account().size(), 2);}What the test does:
Test: test_create_accounts
- Initialize accounts:
- Call
create_test_user_datafor two users,bob_kp(Bob) andeve_kp(Eve), passing each user's keypair fromrell.test.keypairs.
- Call
- Verify account creation:
- Get the total account count with
get_all_account().size(). - Check that the count is exactly two with
assert_equals(get_all_account().size(), 2).
- Get the total account count with
Create game test
These tests confirm that a player can create a game and that the creation rules are enforced.
function test_create_game() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val last_block_time = rell.test.last_block_time; create_test_game(bob_kp); val all_created_games = get_games_by_status(game_status.created); val all_created_games_expected = [ ( id = 6, created_by = bob_account.id, created_at = last_block_time, player1_account_id = bob_account.id, player2_account_id = null, last_step_account_id = bob_account.id, last_step_at = last_block_time, winner = null, slot_1 = slot_states.free, slot_2 = slot_states.free, slot_3 = slot_states.free, slot_4 = slot_states.free, slot_5 = slot_states.free, slot_6 = slot_states.free, slot_7 = slot_states.free, slot_8 = slot_states.free, slot_9 = slot_states.free, status = game_status.created ) ]; assert_equals(all_created_games.to_gtv_pretty(), all_created_games_expected.to_gtv_pretty());}function test_create_game_if_already_in_one_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); create_test_game(bob_kp); rell.test.tx() .op(ft_auth_operation_for(bob_kp.pub)) .op(create_game()) .sign(bob_kp) .run_must_fail( "Player %s are already participating in an ongoing game and cannot join another.".format(bob_kp.pub.hash()) );}What the tests do:
Test: test_create_game
- Initialize test user data:
- Create a keypair and account data for Bob with
create_test_user_data.
- Create a keypair and account data for Bob with
- Set up and record initial game state:
- Save the current blockchain timestamp as
last_block_time. - Create a game owned by Bob by calling
create_test_game(bob_kp).
- Save the current blockchain timestamp as
- Verify created games:
- Fetch games whose status is
createdthroughget_games_by_status. - Build the expected state in
all_created_games_expected: Bob's ID in both thecreated_byandplayer1_account_idfields, and every slot set tofree. - Compare actual and expected state with
assert_equalsto confirm the new game was set up correctly.
- Fetch games whose status is
Test: test_create_game_if_already_in_one_must_fail
- Set up keypair and create initial game:
- Prepare Bob's keypair.
- Create a first game with
create_test_game(bob_kp).
- Attempt to create a second game and expect failure:
- Call
create_gamefor Bob again while his first game is still ongoing. - Wrap the call in
run_must_failso the test passes only if the transaction fails, and check for an error message stating that a player already in a game cannot join another one.
- Call
Join game test
These tests confirm that a user can join a game and that an active game accepts no players beyond the second one. They create a game, add a second player, and check the resulting state, then verify that joining an already active game fails.
function test_join_game() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, eve_account) = create_test_user_data(rell.test.keypairs.eve); val game = create_test_game(bob_kp); val all_created_games = get_games_by_status(game_status.created); join_test_game(game, eve_kp); val game_data = get_game_by_id(game.rowid.to_integer()); val game_data_expected = ( id = game.rowid, created_by = bob_account.id, created_at = all_created_games[0].created_at, player1_account_id = bob_account.id, player2_account_id = eve_account.id, last_step_account_id = bob_account.id, last_step_at = all_created_games[0].last_step_at, winner = null, slot_1 = slot_states.free, slot_2 = slot_states.free, slot_3 = slot_states.free, slot_4 = slot_states.free, slot_5 = slot_states.free, slot_6 = slot_states.free, slot_7 = slot_states.free, slot_8 = slot_states.free, slot_9 = slot_states.free, status = game_status.active ); assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty());}function test_join_active_game_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val (alice_kp, _) = create_test_user_data(rell.test.keypairs.alice); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); join_test_game_must_fail(game, alice_kp, "The game has already started.");}What the tests do:
Test: test_join_game
- Initialize accounts:
- Call
create_test_user_dataforbob_kp(Bob) andeve_kp(Eve) with their keypairs fromrell.test.keypairs.
- Call
- Create and retrieve game:
- Have Bob create a game through
create_test_game(bob_kp). - Look up the game with
get_games_by_status(game_status.created), which confirms it is in the "created" state.
- Have Bob create a game through
- Join game:
- Add Eve to the game with
join_test_game(game, eve_kp).
- Add Eve to the game with
- Verify game state:
- Load the game data with
get_game_by_id(game.rowid.to_integer()). - Build the expected state,
game_data_expected, which requires:- Bob recorded as creator (
created_by = bob_account.id) - Bob and Eve both assigned as players
- A status of
game_status.active
- Bob recorded as creator (
- Check that the two match with
assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty()).
- Load the game data with
Test: test_join_active_game_must_fail
- Initialize accounts:
- Create accounts for three users with
create_test_user_data:bob_kp(Bob),eve_kp(Eve), andalice_kp(Alice).
- Create accounts for three users with
- Create and join game:
- Bob creates a game through
create_test_game(bob_kp). - Eve joins it through
join_test_game(game, eve_kp).
- Bob creates a game through
- Attempt to join active game:
- Call
join_test_game_must_fail(game, alice_kp, "The game has already started.")to confirm that Alice cannot join the active game, since a game allows only two players.
- Call
Make move test
These tests confirm that players can place moves and that the game rules are enforced.
function test_make_move() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, eve_account) = create_test_user_data(rell.test.keypairs.eve); val game_created_time = rell.test.last_block_time; val game = create_test_game(bob_kp); join_test_game(game, eve_kp); val last_move_time = rell.test.last_block_time; make_test_move(game, 1, eve_kp); val game_data = get_game_by_id(game.rowid.to_integer()); val game_data_expected = ( id = game.rowid, created_by = bob_account.id, created_at = game_created_time, player1_account_id = bob_account.id, player2_account_id = eve_account.id, last_step_account_id = eve_account.id, last_step_at = last_move_time, winner = null, slot_1 = slot_states.one, slot_2 = slot_states.free, slot_3 = slot_states.free, slot_4 = slot_states.free, slot_5 = slot_states.free, slot_6 = slot_states.free, slot_7 = slot_states.free, slot_8 = slot_states.free, slot_9 = slot_states.free, status = game_status.active ); assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty());}function test_move_on_wrong_turn_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move_must_fail(game, 1, bob_kp, "Action denied: incorrect player attempting to move.");}function test_move_with_wrong_slot_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move_must_fail(game, 10, eve_kp, "Wrong slot.");}function test_move_by_non_participant_slot_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val (alice_kp, _) = create_test_user_data(rell.test.keypairs.alice); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move_must_fail(game, 1, alice_kp, "Player is not a participant in this game.");}function test_move_on_taken_slot_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move(game, 1, eve_kp); make_test_move_must_fail(game, 1, bob_kp, "Slot already taken.");}function test_move_in_non_active_game_must_fail() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val game = create_test_game(bob_kp); make_test_move_must_fail(game, 1, bob_kp, "Game is not active.");}What the tests do:
Test: test_make_move
- Initialize test user data:
- Create keypairs and accounts for Bob and Eve with
create_test_user_data.
- Create keypairs and accounts for Bob and Eve with
- Create and join game:
- Bob creates a game through
create_test_game(bob_kp). - Eve joins through
join_test_game(game, eve_kp).
- Bob creates a game through
- Make a move:
- Eve takes slot 1 with
make_test_move(game, 1, eve_kp).
- Eve takes slot 1 with
- Verify game state after move:
- Load the updated game with
get_game_by_id. - Build the expected game data, including the updated player details and slot states.
- Compare actual and expected data with
assert_equalsto confirm the move was stored correctly.
- Load the updated game with
Test: test_move_on_wrong_turn_must_fail
- Set up keypairs:
- Prepare keypairs for Bob and Eve.
- Create game and join:
- Bob creates a game and Eve joins it.
- Attempt move on wrong turn:
- Bob tries to move during Eve's turn through
make_test_move_must_fail. The move must fail with a matching error message.
- Bob tries to move during Eve's turn through
Test: test_move_with_wrong_slot_must_fail
- Set up keypairs:
- Prepare keypairs for Bob and Eve.
- Create game and join:
- Bob creates a game and Eve joins it.
- Attempt move with invalid slot:
- Eve tries slot 10, which does not exist. The move must fail with the message "Wrong slot."
Test: test_move_by_non_participant_slot_must_fail
- Set up keypairs:
- Prepare keypairs for Bob, Eve, and Alice.
- Create game and join:
- Bob creates a game and Eve joins it.
- Attempt move by a non-participant:
- Alice tries to move. The move must fail with the message "Player is not a participant in this game."
Test: test_move_on_taken_slot_must_fail
- Set up keypairs:
- Prepare keypairs for Bob and Eve.
- Create game and join:
- Bob creates a game and Eve joins it.
- Make first move:
- Eve takes slot 1.
- Attempt move in taken slot:
- Bob tries the same slot. The move must fail with the message "Slot already taken."
Test: test_move_in_non_active_game_must_fail
- Set up keypairs:
- Prepare a keypair for Bob.
- Create game:
- Bob creates a game.
- Attempt to move in a non-active game:
- Bob tries to move. The move must fail with the message "Game is not active."
Create play full game test
function test_play_full_game_first_player_win() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, eve_account) = create_test_user_data(rell.test.keypairs.eve); val game_created_time = rell.test.last_block_time; val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move(game, 1, eve_kp); make_test_move(game, 4, bob_kp); make_test_move(game, 3, eve_kp); make_test_move(game, 5, bob_kp); make_test_move(game, 9, eve_kp); val last_move_time = rell.test.last_block_time; make_test_move(game, 6, bob_kp); val game_data = get_game_by_id(game.rowid.to_integer()); val game_data_expected = ( id = game.rowid, created_by = bob_account.id, created_at = game_created_time, player1_account_id = bob_account.id, player2_account_id = eve_account.id, last_step_account_id = bob_account.id, last_step_at = last_move_time, winner = bob_account.id, slot_1 = slot_states.one, slot_2 = slot_states.free, slot_3 = slot_states.one, slot_4 = slot_states.zero, slot_5 = slot_states.zero, slot_6 = slot_states.zero, slot_7 = slot_states.free, slot_8 = slot_states.free, slot_9 = slot_states.one, status = game_status.winner ); assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty());}function test_play_full_game_second_player_win() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, eve_account) = create_test_user_data(rell.test.keypairs.eve); val game_created_time = rell.test.last_block_time; val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move(game, 1, eve_kp); make_test_move(game, 2, bob_kp); make_test_move(game, 4, eve_kp); make_test_move(game, 3, bob_kp); val last_move_time = rell.test.last_block_time; make_test_move(game, 7, eve_kp); val game_data = get_game_by_id(game.rowid.to_integer()); val game_data_expected = ( id = game.rowid, created_by = bob_account.id, created_at = game_created_time, player1_account_id = bob_account.id, player2_account_id = eve_account.id, last_step_account_id = eve_account.id, last_step_at = last_move_time, winner = eve_account.id, slot_1 = slot_states.one, slot_2 = slot_states.zero, slot_3 = slot_states.zero, slot_4 = slot_states.one, slot_5 = slot_states.free, slot_6 = slot_states.free, slot_7 = slot_states.one, slot_8 = slot_states.free, slot_9 = slot_states.free, status = game_status.winner ); assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty());}function test_play_full_game_draw() { val (bob_kp, bob_account) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, eve_account) = create_test_user_data(rell.test.keypairs.eve); val game_created_time = rell.test.last_block_time; val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move(game, 2, eve_kp); make_test_move(game, 1, bob_kp); make_test_move(game, 4, eve_kp); make_test_move(game, 3, bob_kp); make_test_move(game, 5, eve_kp); make_test_move(game, 6, bob_kp); make_test_move(game, 7, eve_kp); make_test_move(game, 8, bob_kp); val last_move_time = rell.test.last_block_time; make_test_move(game, 9, eve_kp); val game_data = get_game_by_id(game.rowid.to_integer()); val game_data_expected = ( id = game.rowid, created_by = bob_account.id, created_at = game_created_time, player1_account_id = bob_account.id, player2_account_id = eve_account.id, last_step_account_id = eve_account.id, last_step_at = last_move_time, winner = null, slot_1 = slot_states.zero, slot_2 = slot_states.one, slot_3 = slot_states.zero, slot_4 = slot_states.one, slot_5 = slot_states.one, slot_6 = slot_states.zero, slot_7 = slot_states.one, slot_8 = slot_states.zero, slot_9 = slot_states.one, status = game_status.draw ); assert_equals(game_data.to_gtv_pretty(), game_data_expected.to_gtv_pretty());}function test_create_new_game_after_completion() { val (bob_kp, _) = create_test_user_data(rell.test.keypairs.bob); val (eve_kp, _) = create_test_user_data(rell.test.keypairs.eve); val game = create_test_game(bob_kp); join_test_game(game, eve_kp); make_test_move(game, 1, eve_kp); make_test_move(game, 4, bob_kp); make_test_move(game, 3, eve_kp); make_test_move(game, 5, bob_kp); make_test_move(game, 9, eve_kp); make_test_move(game, 6, bob_kp); create_test_game(bob_kp); val all_created_games = get_games_by_status(game_status.created); assert_equals(all_created_games.size(), 1);}What the tests do:
Test: test_play_full_game_first_player_win
- Initialize keypairs:
- Create the keypairs
bob_kpandeve_kpso Bob and Eve can play.
- Create the keypairs
- Start a game:
- Bob creates the game through
create_test_game(bob_kp).
- Bob creates the game through
- Join and play:
- Eve joins through
join_test_game(game, eve_kp). - A series of moves plays out the game until Bob wins on move 6.
- Eve joins through
- Verify outcome:
- Load the game data and confirm that:
- The winner is
bob_account.id. - The board shows the expected winning pattern.
- The status is
game_status.winner.
- The winner is
- Load the game data and confirm that:
Test: test_play_full_game_second_player_win
- Initialize keypairs and start a game:
- Same setup as the previous test: create keypairs and have Bob create the game.
- Simulate gameplay:
- A series of moves ends with Eve winning on move 7.
- Verify outcome:
- Load the game data and confirm that:
- The winner is
eve_account.id. - The board shows the expected winning pattern.
- The status is
game_status.winner.
- The winner is
- Load the game data and confirm that:
Test: test_play_full_game_draw
- Initialize and start a game:
- Same setup as the tests above.
- Simulate gameplay:
- A series of moves ends in a draw, with no winning line formed.
- Verify outcome:
- Confirm that:
- The winner is
null, meaning the game was drawn. - Every slot is filled and no winning pattern exists.
- The status is
game_status.draw.
- The winner is
- Confirm that:
Test: test_create_new_game_after_completion
- Initialize keypairs and start a game:
- Create keypairs and have Bob create a game.
- Simulate gameplay and restart:
- A series of moves finishes the game with a win.
- After the game ends, create a new game for Bob, then run the check below.
- Verify outcome:
- Confirm that exactly one game has the status
game_status.created.
- Confirm that exactly one game has the status
Run the tests
With the tests written, run them to check that your dapp works.
-
In a terminal, change to your project's root directory.
-
Execute the tests with this command:
chr testThe command runs the tests listed under test: modules in chromia.yml. When every test passes, the output confirms that your game behaves as expected.
You have now implemented and tested the core operations of a decentralized Tic Tac Toe game on the Chromia blockchain.
The next part of the tutorial covers more advanced features, including game state persistence and user input validation.