UPDATE
Have a look at part 2 of the series, with focus on optimization.
Perhaps the most primitive use of Google App Engine Channel API is the use of chat between two people in real time. This concept can be extended to chatrooms – where many people chat simultaneously with each other, or gamerooms – where many people play some game simultaneously in real time amongst each other.
The docs don’t say much about the quota details of Channel API, so here it is
Free Quota
Channel API Calls 46,310,400
Channels Created 8,640
Channel Data Sent 1,046.00 GBytes
Paid Quota
Channel API Calls 91,995,495
Channels Created 95,040
Channel Data Sent 2,088.13 GBytes
The example application tic-tac-toe shows to a quite good level how to use Channel API for playing duo-player game. I find some scope for improvements in that app. Let’s review them :
- Everytime a player starts a new game, a new client id (for channel) is created. The cap provided on Channels Creates is few, so this should be used judiciously
- The game room generates depends on the User. So, if the user who initiated the play reloads the page, the game is gone. This works for the case of tic-tac-toe but does not go for chatrooms or gamerooms
Apart from above issues, there are somethings that a serious gameroom/ chatroom needs and they are as follows:
- Automatic room creation. The room is automatically created as soon as number of players exceed the MAX_PLAYERS limit
- Every player has some assets (in a gameroom), and this keeps on changing with actions performed. So there needs to be state persistent information stored at the backend
- If the player reloads the page or opens a couple of new tabs, consistency of information should be maintained.
- Usually in a chat, when someone joins late, they want to be updated of the chats that took place when they were not there
.
Using above points as my guiding light, I tried to create an app which does the above in a net and beautiful manner. The app’s source code is located at http://code.google.com/p/pranav/source/browse/chat-channel.
Structure
The concept of the gameroom/chatroom goes like this – When a player wants to play a game, he is taken to a room. The room could be a new room (if no room exists or all others are full), or an existing room.
There are two entity kinds – Game and PlayerGame. The Game Entity Kind contains information about the game room. This is where you can keep a track of all the events happening in a room like maybe chat between users, or collaborative drawing or the moves in a tic-tac-toe. The PlayerGame is a child entity of the Game entity, which contains the information about the game the user is playing. For those games which go in a sequential manner where player1 does something and then player2 does something, PlayerGame could have very well been inside the Game entity, but in a game where many players can do multiple actions and their actions affect their state in some way or the other datastore congestion might occur. To avoid all those, its made in a separate entity.
class Game(db.Model):
“””
The Model to store the details of a particular
game room.
“””
players = db.StringListProperty()
chat = db.TextProperty()
active = db.BooleanProperty(default = True)
created = db.DateTimeProperty(auto_now_add = True)
updated = db.DateTimeProperty(auto_now = True)
class PlayerGame(db.Model):
“””
Stores information about which player is added into which game
room. This of course assumes that a player can be in only one
game room at a time.
“””
assets = db.TextProperty(default = ”)
created = db.DateTimeProperty(auto_now_add = True)
updated = db.DateTimeProperty(auto_now = True)
Then we need two wrapper Classes so that we can abstract the functioning. Our classes can very well be Player and Tournament.
class Player(object):
“””
A Player class
“””
userid = None
assets = ”
name = ”
updates = {}
_player = None
def __init__(self, userid=None):
self.userid = userid
def _get_tournament(self):
if self.userid:
return Game.all(keys_only=True).filter(‘players = ‘, self.userid).get()
def get_player(self):
if self._player:
return self._player
if self.userid:
self._player = PlayerGame.get_by_key_name(self.userid,
parent = self._get_tournament())
return self._player
def get_gameroom(self):
pg = self.get_player()
if pg:
return pg.parent().key().name()
def die(self):
playergame_key = db.Key.from_path(‘PlayerGame’, self.userid)
game = Game.all().filter(‘user = ‘, self.userid).get()
def txn(userid, pg_key, g):
db.delete(pg_key)
g.users.pop(userid)
db.put(g)
db.run_in_transaction(txn, self.userid, playergame_key, game_key)
The Player class contains all the wrapper functions that a player can possible do inside a chatroom/gameroom. Similarly, there shall be a Tournament class which will do all the activities that can be done in a tournament
class Tournament(object):
“””
A class for tournament
“””
game = None
room = None
delta_chat = ”
updates = {}
def __init__(self, room=None):
self.updates = {}
self.room = room
def get_tournament(self):
if self.game:
return self.game
if self.room:
self.game = Game.get_by_key_name(self.room)
return self.game
def can_add_player(self):
if MAX_PARTICIPANTS == -1:
return True
return len(self.get_tournament().players) < MAX_PARTICIPANTS
def add_player(self, player):
if self.can_add_player():
game = self.get_tournament()
if player.userid not in game.players:
game.players.append(player.userid)
db.put(game)
PlayerGame.get_or_insert(key_name = player.userid,
parent = game)
self.updates.update({'new_player' : 1,
'name' : player.userid})
self.send_update()
else:
return self.new(player)
def new(self, player):
"A new room shall be created only if there is a player to go into"
if not player: return
self.room = str(time.time())
self.game = Game(key_name = self.room,
players = [player.userid])
playergame = PlayerGame(key_name = player.userid,
parent = self.game)
db.put([self.game, playergame])
memcache.set(LATEST_GAMEROOM, self.room)
return self.room
@classmethod
def join_new_or_latest(cls, player):
latest_roomkey = memcache.get(LATEST_GAMEROOM)
if latest_roomkey:
cls(latest_roomkey).add_player(player)
return latest_roomkey
return cls().new(player)
@classmethod
def continue_tournament(cls, player):
"When the player was already in a tournament continue from there itself"
channel.send_message(player.get_channel(), cls().get_all_updates())
def chat(self, player, message):
delta_chat = message
game = self.get_tournament()
if game.chat:
chat = game.chat + '
' + delta_chat
else:
chat = delta_chat
game.chat = chat
db.put(game)
self.updates.update({'delta_chat' : delta_chat})
self.send_update()
def get_player_channels(self):
game = self.get_tournament()
return [Player(x).get_channel() for x in game.players]
def get_game_message(self):
update = self.updates
return simplejson.dumps(update)
def get_all_updates(self):
update = self.updates
update.update({'all' : 'all'})
return simplejson.dumps(update)
def send_update(self):
message = self.get_game_message()
for channel_id in self.get_player_channels():
channel.send_message(channel_id, message)
Flow
Whenever a user comes to the app, first of all it is checked if he was in an existing room or not. If found, then his activities are continued from that stage. If not found then the user is taken to a room. The room could be a new room, if all others are full, or no room is present. The user can perform activities in that room. Filling of rooms goes in First Come, First Serve order. So, when the next user wishes to join a room, the latest room created is checked for vacancy and the user is dropped there. The user is then free to perform actions and have fun.
What is typically important here is that, initially a Channel is created between the client and the server, and then using that same channel all the future communication for rooms goes. One advantage of this is that we don’t need to create a lot of channels for every user. For this it is very important that the algo which is used to create channel client id has only one variable as userid. (What will happen if we have two variables like userID and timestamp to create the channel client id?).
def gen_channel(userid):
return md5.md5(userid).hexdigest()
After the channel is created, the client send requests to join the room. This is taken care by the server and accordingly the user is dropped into a new room or an existing room
class JoinGame(BaseHandler):
def post(self):
“””
Processes the req from a client/player to join a gameroom
“””
user = users.get_current_user()
if not user:
return self.redirect(users.create_login_url(‘/’))
userid = user.user_id()
logging.info(‘join chat call by %s’%userid)
player = Player(userid)
if not player.get_gameroom():
gameroom = Tournament.join_new_or_latest(player)
return
Tournament.continue_tournament(player)
This is in a nutshell, a proof of concept about building huge chat-rooms/ game-rooms using Channel API of Google App Engine.
Resources
Read more about optimizing this code in part 2 of the series.
- Source Code: http://code.google.com/p/pranav/source/browse/chat-channel
- Channel API: http://code.google.com/appengine/docs/python/channel
- Discuss: Google Groups Discussion
- Tic Tac Toe App: http://code.google.com/p/channel-tac-toe
There are many more things that needs to be taken care of to increase speed and performance, but that shall come in another blog post