ICON Workshop — Blackjack Part 1: SCORE

2infiniti (Justin Hsiao)
12 min readApr 16, 2019

--

In this tutorial we’re going to build one of the most classic casino games — blackjack! This tutorial series is broken down into two parts, part 1 focusing on SCORE development and part 2 focusing on web interface and ICONex integration. At the end of this tutorial series you will have a functional blackjack game on a website, and be able to play the game using IRC-2 tokens on the ICON network!

We’ll be building two SCOREs for this game, one token SCORE to mint our custom IRC-2 token to serve as blackjack chips and allow users to exchange with their ICX tokens. We’ll implement another SCORE to handle the game logic, from room creation to in-game rules etc.

Let’s start off with our custom token contract, recall from the tutorial ICON DAPP From A-Z Part 2: SCORE, we learned that to stay IRC-2 compliant, we must implement these 6 methods: name , symbol , decimals , totalSupply , balanceOf , and transfer.

Let’s first initiate a new SCORE

$ mkdir blackjack %% cd blackjack
$ tbears init chip Chip

As usual, this will generate a SCORE skeleton with a dummy hello method. Let’s go right ahead and get the IRC-2 methods implemented.

# chip.pyfrom iconservice import *TAG = 'Chip'
class Chip(IconScoreBase):
_BALANCES = 'balances'
_TOTAL_SUPPLY = 'total_supply'
_DECIMALS = 'decimals'
@eventlog(indexed=3)
def Transfer(self, _from: Address, _to: Address, _value: int, _data: bytes):
pass
def __init__(self, db: IconScoreDatabase) -> None:
super().__init__(db)
self._total_supply = VarDB(self._TOTAL_SUPPLY, db, value_type=int)
self._decimals = VarDB(self._DECIMALS, db, value_type=int)
self._balances = DictDB(self._BALANCES, db, value_type=int)
def on_install(self, _decimals: int = 8) -> None:
super().on_install()
self._total_supply.set(0)
self._decimals.set(_decimals)
def on_update(self) -> None:
super().on_update()
pass

@external(readonly=True)
def name(self) -> str:
return "Blackjack chips"
@external(readonly=True)
def symbol(self) -> str:
return "BJC"
@external(readonly=True)
def decimals(self) -> int:
return self._decimals.get()
@external(readonly=True)
def totalSupply(self) -> int:
return self._total_supply.get()
@external(readonly=True)
def balanceOf(self, _owner: Address) -> int:
return self._balances[_owner]
@external
def transfer(self, _to: Address, _value: int, _data: bytes = None):
if _value < 0:
revert('Transfer value cannot be less than zero')
if self._balances[self.msg.sender] < _value:
revert("Out of balance")
self._balances[self.msg.sender] = self._balances[self.msg.sender] - _value
self._balances[_to] = self._balances[_to] + _value
# Emits an event log `Transfer`
self.Transfer(self.msg.sender, _to, _value, _data)

The above should be fairly straight forward with basic IRC-2 methods defined. We’re only doing the most basic checks during a transfer, to see if balance is enough to make the transfer or if the transaction amount is > 0. We will need a few more functions to make this game work, to mint/burn the blackjack tokens and a bet function to transfer balances for bets.

# chip.pyclass Chip(IconScoreBase):
...
@external
def mint(self, _amount: int):
"""
This method should be invoked by CA not EOA.

:param _amount: the amount of Chips to mint
"""
if not self.msg.sender.is_contract:
revert("This method should be invoked by CA not EOA")

self._total_supply.set(self._total_supply.get() + _amount)
self._balances[self.tx.origin] = self._balances[self.tx.origin] + _amount * (10 ** self._decimals.get())

Here we check to see if the caller is from contract address (in ICON that’d be addresses starting with cx…), and not externally owned account (hx…). For the burn function we’ll do some condition checks before doing the actual burn , in a privately accessed function _burn.

@external
def burn(self, _amount: int):
if not self.msg.sender.is_contract:
revert("This method should be invoked by CA not EOA")
if self._balances[self.tx.origin] > _amount:
self._burn(self.tx.origin, _amount * (10 ** self._decimals.get()))
self.Burn(self.tx.origin, _amount * (10 ** self._decimals.get()))
else:
revert(f"You don't have enough chips to burn. Your balance: {self._balances[self.tx.origin]}")
def _burn(self, address: Address, amount: int):
self._total_supply.set(self._total_supply.get() - amount)
self._balances[address] = self._balances[address] - amount

Lastly, we’ll implement a bet function to facilitate and simulate the actual betting action and transfer the balances.

@external
def bet(self, _from: Address, _to: Address, _value: int):
if not self.msg.sender.is_contract:
revert("This method should be invoked by CA not EOA.")
self._balances[_from] = self._balances[_from] - _value
self._balances[_to] = self._balances[_to] + _value
# emit the events to eventlogs
self.Bet(_from, _to, _value)
self.Transfer(_from, _to, _value, None)

IRC-2 is based on ERC-223 standard (for IRC-2, ERC-20, ERC-223 and rationales behind each standard, please read: https://medium.com/@2infiniti/icon-dapp-from-a-z-part-2-score-af5f627e97e8). The transfer function in ERC-223 checks to see whether the receiving address is a contract, if it is, it will assume there’s a tokenFallback method, let’s define the interface

class TokenFallbackInterface(InterfaceScore):
@interface
def tokenFallback(self, _from: Address, _value: int, _data: bytes):
pass

That’s it, we now have a SCORE that allows minting blackjack chips, burning chips, and facilitating bet actions. Here’s the code in its entirety.

Let’s deploy the SCORE to the bicon testnet.

# Create a new testnet deployment configuration file
$ touch tbears_cli_config_testnet.json

Fill it with testnet configurations,

# tbears_cli_config_testnet.json
{
"uri": "https://bicon.net.solidwallet.io/api/v3",
"nid": "0x3",
"keyStore": null,
"from": "hx5638ee91e18574a1f0a29b4813578389f0e142a7",
"to": "cx0000000000000000000000000000000000000000",
"deploy": {
"stepLimit": "0x77359400",
"mode": "install",
"scoreParams": {}
},
"txresult": {},
"transfer": {
"stepLimit": "0x300000"
}
}

Then deploy (note iconkeystore3 is a keystore that we created during past tutorials, you can use any wallet you like, just make sure to fill it with some testnet ICX from http://52.88.70.222/)

$ tbears deploy chip -k iconkeystore3 -c tbears_cli_config_testnet.json

As usual, check to see if transaction was successful

$ tbears txresult 0x517f3e8cd042deee550c5e7a7d14531b42b1b6a18bc24925cf1778bde8b2b871 -u https://bicon.net.solidwallet.io/api/v3

Alternatively you can check this on the live testnet tracker

Let’s also take this opportunity to check out the contract features from our official ICONex Chrome extension

First, launch your ICONex, and under ‘Contract’ tab

paste the SCORE address that you just deployed to the testnet, you should see all the external functions

In the past tutorials, we did JSON-RPC calls from tbears CLI for SCORE quests, which can be a bit of a hassle for simple testing purposes. This built-in contract invocation directly from ICONex makes SCOREs much easier to interact with. Let’s do an experiment, we’ll call the bet function from an EOA (Externally Owned Account), see if that throws an error.

Now let’s sign this transaction

Check the tracker to see if this transaction was successful.

As expected, the transaction responded with an error by our built-in check for CA or EOA. Play with the contract calls and become familiar, you can leverage this feature to save some development time in the future.

We now have a functional IRC-2 SCORE to mint, burn and bet blackjack chips. We’re now ready to develop our game SCORE, which will handle our in-game logic, and the ability to create / leave game rooms.

Initiate a new SCORE template,

# Go back to the root level /blackjack
$ tbears init game Game

For the game SCORE, we’ll divide into different packages,

blackjack/
├── chip/
│ ├── __init__.py
│ ├── chip.py
│ └── package.json
├── game/
│ ├── __init__.py
│ ├── game.py
│ └── package.json
│─────── card/
│─────── deck/
│─────── gameroom/
│─────── hand/

Let’s start with card.py

$ cd game
$ mkdir card && mkdir deck && mkdir gameroom && mkdir hand
$ cd card
# add __init__.py to be treated as a python package for imports
$ touch __init__.py && touch card.py

A card is simply one suit plus one rank.

Now go into deck directory

$ cd ../deck
$ touch __init__.py && touch deck.py

A deck has 52 cards, 4 suits and 13 ranks each. We’ll fill the deck with all 52 cards on init and a deal function to randomly draw a card.

Next we’ll implement a hand’s logic

$ cd ../hand
$ touch __init__.py && touch hand.py

We’ll cover the basic case of a blackjack game, without complex logic from hand splitting, doubling, or buying insurance. We’ll deal with “hitting” only, which is to add cards to your hand.

Ace is a special case where it can have a value of 1 or 11, depending on if your hand is over 21 or not, you can arbitrarily use it as 1 or 11.

Now we have the cards logic defined, let’s create a new class for game rooms, where we can host multiple participants in separate rooms.

$ cd ../gameroom
$ touch __init__.py && touch gameroom.py

For game room, we’ll implement join to join a room, escape to leave a room, game_start to start a game and game_top to stop a game. For the sake of this tutorial, we’ll also fix the ante to a fixed amount per game.

We’ll be creating a web interface for the game in part 2 of this tutorial series, for that we’ll need to implement a few functions to accommodate these front-end features.

Let’s first incorporate the Chip SCORE we implemented earlier, we’ll define an interface (interfaceScore) that contains designated methods of Chip SCORE, enabling our game to use methods without repeat implementations. This is done by recording the chip SCORE’s CA via VarDB and the SCORE address to use when referenced.

# game.pyfrom iconservice import *

from .deck.deck import Deck
from .gameroom.gameroom import GameRoom
from .hand.hand import Hand

TAG = 'BLACKJACK'


class ChipInterface(InterfaceScore):

@interface
def mint(self, _value: int):
pass

@interface
def burn(self, _amount: int):
pass

@interface
def balanceOf(self, _owner: Address):
pass

@interface
def transfer(self, _to: Address, _value: int, _data: bytes = None):
pass

@interface
def bet(self, _from: Address, _to: Address, _value: int):
pass

We’ll demonstrate how to instantiate a new interface score later. Let’s get the basics down first,

# game.pyclass Game(IconScoreBase):
_TOKEN_ADDRESS = "token_address"
_GAME_ROOM = "game_room"
_GAME_ROOM_LIST = "game_room_list"
_IN_GAME_ROOM = "in_game_room"
_DECK = "deck"
_HAND = "hand"
_RESULTS = "results"
_READY = "ready"
_GAME_START_TIME = "game_start_time"
# Here we passed our chip CA on_install
def on_install(self, _tokenAddress: Address) -> None:
super().on_install()
if _tokenAddress.is_contract:
self._VDB_token_address.set(_tokenAddress)
else:
revert("Input params must be Contract Address")

def on_update(self, **kwargs) -> None:
super().on_update()
pass

def __init__(self, db: 'IconScoreDatabase') -> None:
super().__init__(db)
self._db = db
self._VDB_token_address = VarDB(self._TOKEN_ADDRESS, db, value_type=Address)
self._DDB_game_room = DictDB(self._GAME_ROOM, db, value_type=str)
self._DDB_game_start_time = DictDB(self._GAME_START_TIME, db, value_type=int)
self._DDB_in_game_room = DictDB(self._IN_GAME_ROOM, db, value_type=Address)
self._DDB_deck = DictDB(self._DECK, db, value_type=str)
self._DDB_hand = DictDB(self._HAND, db, value_type=str)
self._DDB_ready = DictDB(self._READY, db, value_type=bool)

This should be fairly intuitive with SCORE initiation and class variables that we need. Next let’s build some functions relating to game rooms, to show the list of rooms, join and escape, ante size, participants info etc.

@property
def game_room_list(self):
return ArrayDB(self._GAME_ROOM_LIST, self._db, value_type=str)
@external(readonly=True)
def showGameRoomList(self) -> list:
response = []
game_room_list = self.game_room_list

for game_room in game_room_list:
game_room_dict = json_loads(game_room)
game_room_id = game_room_dict['game_room_id']
creation_time = game_room_dict['creation_time']
prize_per_game = game_room_dict['prize_per_game']
participants = game_room_dict['participants']
room_has_vacant_seat = "is Full" if len(participants) > 1 else "has a vacant seat"
response.append(f"{game_room_id} : ({len(participants)} / 2). The room {room_has_vacant_seat}. Prize : {prize_per_game}. Creation time : {creation_time}")

return response

@external
def createRoom(self, _prizePerGame: int = 10):
# Check whether 'self.msg.sender' is now participating to game room or not
if self._DDB_in_game_room[self.msg.sender] is not None:
revert("You already joined to another room")

# Check whether the chip balance of 'self.msg.sender' exceeds the prize_per_game or not
chip = self.create_interface_score(self._VDB_token_address.get(), ChipInterface)
if chip.balanceOf(self.msg.sender) < _prizePerGame:
revert("Set the prize not to exceed your balance")

# Create the game room & get into it & Set the prize_per_game value
game_room = GameRoom(self.msg.sender, self.msg.sender, self.block.height, _prizePerGame)
game_room.join(self.msg.sender)
self._DDB_game_room[self.msg.sender] = str(game_room)

game_room_list = self.game_room_list
game_room_list.put(str(game_room))
self._DDB_in_game_room[self.msg.sender] = self.msg.sender

# Initialize the deck & hand for the participant
new_deck = Deck()
self._DDB_deck[self.msg.sender] = str(new_deck)
new_hand = Hand()
self._DDB_hand[self.msg.sender] = str(new_hand)

def _crash_room(self, game_room_id: Address):
game_room_to_crash_dict = json_loads(self._DDB_game_room[game_room_id])

game_room_to_crash = GameRoom(Address.from_string(game_room_to_crash_dict['owner']), Address.from_string(game_room_to_crash_dict['game_room_id']), game_room_to_crash_dict['creation_time'],
game_room_to_crash_dict['prize_per_game'], game_room_to_crash_dict['participants'], game_room_to_crash_dict['active'])
participants_to_escape = game_room_to_crash.participants
for partcipant in participants_to_escape:
self._DDB_in_game_room.remove(Address.from_string(partcipant))

self._DDB_game_room.remove(game_room_id)
game_room_list = list(self.game_room_list)
game_room_list.remove(json_dumps(game_room_to_crash_dict))
for count in range(len(self.game_room_list)):
self.game_room_list.pop()

for game_room in game_room_list:
self.game_room_list.put(game_room)

@external
def joinRoom(self, _gameRoomId: Address):
# Check whether the game room with game_room_id is existent or not
if self._DDB_game_room[_gameRoomId] is "":
revert(f"There is no game room which has equivalent id to {_gameRoomId}")

# Check to see if the participant already joined to another game_room
if self._DDB_in_game_room[self.msg.sender] is not None:
revert(f"You already joined another game room : {self._DDB_in_game_room[self.msg.sender]}")

game_room_dict = json_loads(self._DDB_game_room[_gameRoomId])
game_room = GameRoom(Address.from_string(game_room_dict['owner']), Address.from_string(game_room_dict['game_room_id']), game_room_dict['creation_time'],
game_room_dict['prize_per_game'], game_room_dict['participants'], game_room_dict['active'])
game_room_list = self.game_room_list

# Check the chip balance of 'self.msg.sender' before getting in
chip = self.create_interface_score(self._VDB_token_address.get(), ChipInterface)
if chip.balanceOf(self.msg.sender) < game_room.prize_per_game:
revert(f"Not enough chips to join this game room {_gameRoomId}. Require {game_room.prize_per_game} chips")

# Check the game room's participants. Max : 2
if len(game_room.participants) > 1:
revert(f"Full : Can not join to game room {_gameRoomId}")

# Get into the game room
game_room.join(self.msg.sender)
self._DDB_in_game_room[self.msg.sender] = _gameRoomId
self._DDB_game_room[_gameRoomId] = str(game_room)

game_room_index_gen = (index for index in range(len(game_room_list)) if game_room.game_room_id == Address.from_string(json_loads(game_room_list[index])['game_room_id']))

try:
index = next(game_room_index_gen)
game_room_list[index] = str(game_room)
except StopIteration:
pass

# Initialize the deck & hand for the participant
new_deck = Deck()
self._DDB_deck[self.msg.sender] = str(new_deck)
new_hand = Hand()
self._DDB_hand[self.msg.sender] = str(new_hand)

@external
def escape(self):
# Check whether 'self.msg.sender' is already participating or not
if self._DDB_in_game_room[self.msg.sender] is None:
revert(f'No game room to escape')

# Retrieve the game room ID & check the game room status
game_room_id_to_escape = self._DDB_in_game_room[self.msg.sender]
game_room_to_escape_dict = json_loads(self._DDB_game_room[game_room_id_to_escape])
game_room_to_escape = GameRoom(Address.from_string(game_room_to_escape_dict['owner']), Address.from_string(game_room_to_escape_dict['game_room_id']),
game_room_to_escape_dict['creation_time'], game_room_to_escape_dict['prize_per_game'],
game_room_to_escape_dict['participants'], game_room_to_escape_dict['active'])

if game_room_to_escape.active:
revert("The game is ongoing.")

# Escape from the game room
if game_room_to_escape.owner == self.msg.sender:
if len(game_room_to_escape.participants) == 1:
game_room_to_escape.escape(self.msg.sender)
self._crash_room(game_room_id_to_escape)
else:
revert("Owner can not escape from room which has the other participant")
else:
game_room_to_escape.escape(self.msg.sender)
self._DDB_game_room[game_room_id_to_escape] = str(game_room_to_escape)

# Set the in_game_room status of 'self.msg.sender' to None
game_room_list = self.game_room_list
game_room_index_gen = (index for index in range(len(game_room_list)) if game_room_to_escape.game_room_id == Address.from_string(json_loads(game_room_list[index])['game_room_id']))

try:
index = next(game_room_index_gen)
game_room_list[index] = str(game_room_to_escape)
except StopIteration:
pass

self._DDB_in_game_room.remove(self.msg.sender)

def _ban(self, game_room_id: Address, participant_to_ban: Address):
game_room_dict = json_loads(self._DDB_game_room[game_room_id])
game_room = GameRoom(Address.from_string(game_room_dict['owner']), Address.from_string(game_room_dict['game_room_id']), game_room_dict['creation_time'],
game_room_dict['prize_per_game'], game_room_dict['participants'], game_room_dict['active'])
if game_room.owner == participant_to_ban:
for participant in game_room.participants:
self._DDB_in_game_room.remove(Address.from_string(participant))
game_room.escape(Address.from_string(participant))
self._crash_room(game_room_id)
else:
game_room.escape(participant_to_ban)
self._DDB_game_room[game_room_id] = str(game_room)
self._DDB_in_game_room.remove(participant_to_ban)
game_room_list = self.game_room_list
game_room_index_gen = (index for index in range(len(game_room_list)) if game_room.game_room_id == Address.from_string(json_loads(game_room_list[index])['game_room_id']))

try:
index = next(game_room_index_gen)
game_room_list[index] = str(game_room)
except StopIteration:
pass

Let’s inspect the createRoom function, notice

chip = self.create_interface_score(self._VDB_token_address.get(), ChipInterface)

In this initiation, we used the interfaceScore that maps to the varDB holding our chip SCORE. We can then use the functions implemented in the SCORE, in this case we check requester’s chip balance using chip.balanceOf , without implementing balanceOf again.

we can implement this into an external method to query like so

@external(readonly=True)
def getChipBalance(self) -> int:
chip = self.create_interface_score(self._VDB_token_address.get(), ChipInterface)
return chip.balanceOf(self.msg.sender)

Rest of the game logic include gameStart that checks to see if the room has been filled or if the caller is in another room. A hit function to simulate hit in a hand, for demonstration purposes we’re fixing this up to 4 hits and the player will simply hold. Finally we need a calculate function to determine the winning logic. There are up to 2 participants per room, so the losing hand will simply transfer to the winning hand based on the fixed ante.

Here’s the entire game.py

Now we’re ready to deploy this to the testnet, we need to pass the _tokenAddress parameter, this would be the CA of the chip SCORE that you deployed earlier. Let’s modify the tbears config file,

# tbears_cli_config_testnet.json{
"uri": "https://bicon.net.solidwallet.io/api/v3",
"nid": "0x3",
"keyStore": null,
"from": "hx5638ee91e18574a1f0a29b4813578389f0e142a7",
"to": "cx0000000000000000000000000000000000000000",
"deploy": {
"stepLimit": "0x77359400",
"mode": "install",
"scoreParams": {
"_tokenAddress": "cx2070cdae8e8c268ca5125921222968f9110617bb"
}
},
"txresult": {},
"transfer": {
"stepLimit": "0x300000"
}
}

Replace the _tokenAddress with your chip SCORE, then deploy

tbears deploy game -k iconkeystore3 -c tbears_cli_config_testnet.json

and see if the transaction was successful

tbears txresult 0x3fe5b6a1c712ea7b69bcc1d1be4255796ab72260d3f182274d839be80ef70e90 -u https://bicon.net.solidwallet.io/api/v3

Let’s try to run our new SCORE, launch ICONex Chrome and go to ‘Contract’ tab, paste the new SCORE address and you should see the external functions. Let’s experiment mintChips ,

Write the transaction first. Go back to the home screen where your wallets are, click on the far right settings button to add the blackjack token

Input your chip SCORE address, the rest should be auto-filled

Add it to your wallet, and see if the BJC balance is reflected from the mintChips.

Voila, you now have plenty of blackjack chips to start gambling! Feel free to experiment other methods to see if each functions properly.

In this tutorial we implemented two SCOREs, one token contract for the blackjack IRC-2 tokens and another SCORE to build the blackjack game logic. We also learned iconscore_interface in order to re-use SCORE functions, and we learned how to invoke contract calls directly from ICONex to avoid JSON-RPC calls every time. In the next tutorial, we’ll be building a web interface for our game using the Django web framework, and allow users to play the game on a website without worrying about the blockchain technology behind! Players will be able to use their IRC-2 tokens from an ICONex wallet to start betting on the ICON network, stay tuned!

--

--