Building on Ethereum can be expensive, and many times it intuitive to gain control over your code and one way to achieve this is by utilzing bit operations. Many developers use it as a means of optimization, expecially in the case of ethereum when you code now cost you money.
Here I'd be explaining a program I wrote not long ago, just so you'd gain an understanding of a simple use case of bit operations.
Also, I assume you understand how to work with bitwise operators, and if you don't check out my article on how they work here.
Tic-tac-toe is a 2 player game where players take turns to make a finite amount of moves until either all moves has been exhausted (its a draw) or a player has won. Here is a typical tic-tac-toe game.
# an empty game # game where play x won
[ ] [ ] [ ] [x] [ ] [o]
[ ] [ ] [ ] [ ] [x] [o]
[ ] [ ] [ ] [o] [ ] [x] # Win is diagonal
# a draw
[x] [x] [o]
[o] [x] [x]
[x] [o] [o]
From the example above, 2 players are represented using to letters X
and O
and each take turns to win and also prevent the other player from winning.
With zero knowledge of bit operations, here is a typical representation of a game session:
struct Game {
uint8[9] board;
bool turn; // false = player 1, true = player 2
bool hasGameEnded;
bool playerOneWon;
bool playerTwoWon;
bool isDraw;
address playerOne;
address playerTwo;
}
However, we don't need 8 bits to store data for each position on the board in uint8[9] board;
since 8 bits is allocated to each position which gives us (bool turn;
is binary i.e 1 for true
and 0 for false
; Also, bool hasGameEnded;
, bool playerOneWon;
, playerTwoWon
and isDraw
are 4 situation a game can be and this can be represented in just 2 bits:
10
),00
),01
),11
).So here is how we'd store a game in bits
21 bits for each game
00 00 00 |
00 00 00 |= first 18 bits [000000000000000000]
00 00 00 |
[00] [0] [00 00 00 00 00 00 00 00 00]
------ ------ ------------------------------
STATE TURN BOARD
contract Tictactoe {
uint256 gameBoard = 0;
address internal playerOne;
address internal playerTwo;
constructor(address _playerTwo) {
require(_playerTwo != address(0));
playerOne = msg.sender;
playerTwo = _playerTwo;
}
/// @notice a new game is created by appending 21 bits to the current board
function newGame() external isPlayer(msg.sender) returns (uint256){
if(gameBoard > 0){
gameBoard = gameBoard << 21 | 1 << 20;
}else{
gameBoard = gameBoard | 1 << 20;
}
return gameBoard;
}
From the example above you'd see the operation gameBoard | 1 << 20
, this basically creates sort of a new 21 bits space for a new game. So here is a little break down of what is happening:
// intially
gameBoard = 0
// then add 21 bits
(1 << 20) = 100000000000000000000 (21 bits)
// then
gameBoard = gameBoard | (1 << 20) // 0 | 1 << 20
// gameboard = 100000000000000000000
When a player chooses to make a move he'd choose which place to play the move and indicate that it's the next players turn. Each move would be represented by a number like so:
[0] [1] [2] | => { [8] [7] [6] [5] [4] [3] [2] [1] [0] }
[3] [4] [5] | --------------------------------------
[6] [7] [8] | BOARD
So typically a game play would be:
Given that player1 is X
and player2 is O
- player1 makes more 0
- player2 makes more 4
- player1 makes more 3
- player2 makes more 6
- player1 makes more 1
- player2 makes more 2
[x1] [x3] [o3]
[x2] [o1] [ ]
[o2] [ ] [ ]
player 2 won :)
So I think you have a base understanding of how it works now, back to coding.
// full code @ https://github.com/0xosas/tictactoe.sol/blob/master/contracts/Tictactoe.sol
contract Tictactoe {
...
function move(uint256 _move) external returns (uint256) {
uint256 _playerId = playerId(msg.sender);
/// Since we can locate a position by a number
/// keep in mind that for position 0 played by `player1` is different
/// from position 0 player by `player2`
///
/// In each position there are 2 points [0 0] lets say [a'p, a'p]
/// where `a` is the position and `p` is the player.
///
/// Now, position zero `0` == [0'2 (player 2), 0'1 (player 1)]
///
/// If player one plays on position zero
/// 00 00 00 00 00 00 00 00 00
/// ^
/// spot zero
/// Then we'd just flip the bit there, there using
/// (1 << (position << 1))
gameBoard = gameBoard ^ (1 << ((_move << 1) + _playerId));
/// Keeping tracking of who to play next
/// Next to play is represented by the
/// 00 0 00 00 00 00 00 00 00 00 00
/// ^
/// play next
/// flips the bit for the next player
/// 0 => `player1` turn
/// 1 => `player2` turn
gameBoard = gameBoard ^ (1 << 18);
/// checks if `_playerId` has made win
/// 1 => means the `_playerId` has won
/// 2 => means no more fields to play, then it's a draw
}
Now, players can make moves and switch turns but they'd keep playing if we don't stop them when a player has won or it's a draw. So a player's win can either be HORIZONTAL
, VERTICAL
or DIAGONAL
. Representing these in the board would look like:
HORIZONTAL_MASK = 0x3f
11 11 11 |
00 00 00 | => 000000000000111111 = 0x3f
00 00 00 |
These are the HORIZONTAL wins
1. x x x 2. | 0 0 0 3. | 0 0 0
0 0 0 | x x x | 0 0 0
0 0 0 | 0 0 0 | x x x
For player 1
H1. 10 10 10
00 00 00 = 000000000000010101
00 00 00
H2. 00 00 00
10 10 10 = 000000010101000000 : H1 << 6
00 00 00
H3. 00 00 00
00 00 00 = 010101000000000000 : H2 << 6 or H1 << 12
10 10 10
Note: HORIZONTAL_MASK / 3 == H1
VERTICAL_MASK = 0x3f
00 00 11 |
00 00 11 | => 0b11000011000011 = 0x30C3
00 00 11 |
These are the VERTICAL wins
1. x 0 0 2. | 0 x 0 3. | 0 0 x
x 0 0 | 0 x 0 | 0 0 x
x 0 0 | 0 x 0 | 0 0 x
For player 1
V1. 10 00 00
10 00 00 = 000001000001000001
10 00 00
V2. 00 10 00
00 10 00 = 000100000100000100 : V1 << 2
00 10 00
V3. 00 00 10
00 00 10 = 010000010000010000 : V2 << 2 or V1 << 4
00 00 10
Note: VERTICAL_MASK / 3 == V1
11 00 00 |
00 11 00 | => 0b110000001100000011 = 0x30303
00 00 11 |
00 00 11 |
00 11 00 | => 0b1100110011 = 0x3330
11 00 00 |
These are the DIAGONAL wins
1. x 0 0 2. | 0 0 x
0 x 0 | 0 x 0
0 0 x | x 0 0
For player 1
D1. 10 00 00
00 10 00 = 010000000100000001
00 00 10
D2. 00 00 10
00 10 00 = 000001000100010000
10 00 00
BL_TO_TR_DIAGONAL_MASK / 3 == D1
BR_TO_TL_DIAGONAL_MASK / 3 == D2
contract Tictactoe {
...
function checkState(uint _playerId) public view returns (uint256){
uint256 _gameBoard = gameBoard;
unchecked {
if ((_gameBoard & HORIZONTAL_MASK) == ((HORIZONTAL_MASK / 3) << _playerId)){
return 1;
}else if((_gameBoard & (HORIZONTAL_MASK << 6)) == ((HORIZONTAL_MASK / 3) << _playerId) << 6){
return 1;
}else if((_gameBoard & (HORIZONTAL_MASK << 12)) == ((HORIZONTAL_MASK / 3) << _playerId) << 12){
return 1;
}
if((_gameBoard & VERTICAL_MASK) == (VERTICAL_MASK / 3) << _playerId){
return 1;
}else if((_gameBoard & (VERTICAL_MASK << 2)) == (VERTICAL_MASK / 3) << _playerId << 2){
return 1;
}else if((_gameBoard & (VERTICAL_MASK << 4)) == (VERTICAL_MASK / 3) << _playerId << 4){
return 1;
}
if ((gameBoard & BR_TO_TL_DIAGONAL_MASK) == (BR_TO_TL_DIAGONAL_MASK / 3) << _playerId){
return 1;
}
if ((gameBoard & BL_TO_TR_DIAGONAL_MASK) == (BL_TO_TR_DIAGONAL_MASK / 3) << _playerId){
return 1;
}
/// Checks if all fields has been played
for(uint x=0; x < 9; x++){
if(~(_gameBoard & 0xF) & 0x3 == 0x3) {
return 0;
}
_gameBoard = _gameBoard >> 2;
}
/// A Draw
return 2;
}
}
}
Having a good knowledge of bit operations is really great! also keep in mind almost all programming languages implement the operations, so it's basically applicable in most languages. These tricks are great but many times they have extremely niche applications, and that is up to you to choose when and when not to apply them.
Also, check out a similar article by fivenine here as it inspired this article.
If you found this helpful, consider following me on Twitter @0xosas.