次の方法で共有


The core of the game service

Last time, I gave an outline of our Facebook game: now I'm going to take a look at the "game engine." The game logic is pretty straightforward:

  1. Player one initiates a game by selecting an opponent and specifying the five allocations
  2. Player two responds to the game by submitting his/her five allocations
  3. Calculate game outcome, update score data

There are other choices that could have been made, such as player one (P1) firing the game off into the system without specifying an opponent, and be matched with someone automatically - in fact, we really wanted to have both options available, but implemented the one above first, mainly so that people would invite their friends to play to help build up our user base.

I'll talk about P2's response message first. The information coming from the user will be the identifier for the game being played, the identifier for the player making the move, and the five allocations.

Note that in the actual deployed game, we use our own internal ids for players, not the Facebook id - this gives us the ability to use the game engine with other authentication mechanisms and provides an extra layer of protection of players' identities should people feel concerned about such things: certainly, as far as our team's research goes, we are not interested in people's Facebook accounts, so we should be doing what we can to avoid touching them - more about that in a subsequent blog post. Having said all that, to keep my explanation of the communications in these blog postings short, I'll describe the system as if our ids and Facebook ids were one and the same, since the management of the mapping from one to the other is really a small implementation detail.

The processing from web call to database for that message is as follows:

  1. Validate the parameters - make sure that the ids are in the correct format and check that there are five integer values all greater than or equal to zero and summing to 100.
  2. Pass the data to the "game engine" which checks that the ids refer to an existing and incomplete game with the indicated user as the second player. (In a typical game, this is where the game processing would be done, with scores being updated, etc. However, as I said last time, the actual game engine is in the database, so this layer actually does very little - it essentially verifies that the ids are valid and then invokes the database.)
  3. Pass the data to the database which updates the game table and the players' scores.

On the way back out of that chain, the completed game is returned to the client, and the web interface layer also updates score data on Facebook (a topic for a later post).

The database structure is quite simple: a Players table consisting of player id and score related data such as number of games played, won, etc. and a more interesting Games table with the following columns:

GameId - uniqueidentifier not null (that's a GUID)
Player1Id, Player2Id - nvarchar(20) not null (in the actual game these are GUIDs too, but let's pretend they're the Facebook identifiers for simplicity here)
Time1 - datetime2 not null (when the game was initiated, i.e., when P1 made a move)
Time2 - datetime2 (when the game finished, i.e., when P2 moved - note that this field can be null, which indicates that the game hasn't been completed)
A1_1 ... A1_5 - int not null (P1's five allocations)
A2_1 ... A2_5 - int (P2's allocations, null before the game has been completed)

The stored procedure for P2's move is:

CREATE PROCEDURE MakePlayer2Move
  @GameId uniqueidentifier,
  @A1 int, @A2 int, @A3 int, @A4 int, @A5 int
AS
BEGIN
  SET NOCOUNT ON;

  -- Fill in the allocations for P2
 UPDATE Games
 SET A2_1=@A1, A2_2=@A2, A2_3=@A3, A2_4=@A4, A2_5=@A5, Time2=GETUTCDATE()
  WHERE GameId=@GameId

  -- Determine P1's score for this game: 1 for each won field, 0.5 for each drawn
 DECLARE @FieldsWon1 real
 SET @FieldsWon1 = (SELECT (CASE WHEN A1_1>A2_1 THEN 1 WHEN A1_1=A2_1 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_2>A2_2 THEN 1 WHEN A1_2=A2_2 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_3>A2_3 THEN 1 WHEN A1_3=A2_3 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_4>A2_4 THEN 1 WHEN A1_4=A2_4 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_5>A2_5 THEN 1 WHEN A1_5=A2_5 THEN 0.5 ELSE 0 END) FROM Games2 WHERE GameId=@GameId)
 -- Ditto for P2
 DECLARE @FieldsWon2 real
 SET @FieldsWon2 = (SELECT (CASE WHEN A1_1<A2_1 THEN 1 WHEN A1_1=A2_1 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_2<A2_2 THEN 1 WHEN A1_2=A2_2 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_3<A2_3 THEN 1 WHEN A1_3=A2_3 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_4<A2_4 THEN 1 WHEN A1_4=A2_4 THEN 0.5 ELSE 0 END)+(CASE WHEN A1_5<A2_5 THEN 1 WHEN A1_5=A2_5 THEN 0.5 ELSE 0 END) FROM Games2 WHERE GameId=@GameId)

  -- Use those to set local variables for P1 and P2 game wins and losses
 DECLARE @Won1 int, @Lost1 int, @Won2 int, @Lost2 int
 SET @Won1=0
 SET @Won2=0
 SET @Lost1=0
 SET @Lost2=0
 IF @FieldsWon1>@FieldsWon2
 BEGIN
  SET @Won1=1
   SET @Lost2=1
 END
 IF @FieldsWon1<@FieldsWon2
  BEGIN
   SET @Won2=1
  SET @Lost1=1
  END

 -- Increment P1's score data
 UPDATE Players
 SET GamesPlayed=GamesPlayed+1, GamesWon=GamesWon+@Won1, GamesLost=GamesLost+@Lost1
 WHERE PlayerId=(SELECT Player1Id From Games WHERE GameId=@GameId)

 -- Ditto for P2
 UPDATE Players
 SET GamesPlayed=GamesPlayed+1, GamesWon=GamesWon+@Won2, GamesLost=GamesLost+@Lost2
 WHERE PlayerId=(SELECT Player2Id From Games WHERE GameId=@GameId)
END

The fields won calculations is straightforward but very ugly - SQL is not a pleasant programming language, though I do have a hope that a better SQL programmer than I am could write it more tidily! This sort of mess is a good argument in favour of using the database as "dumb storage" with all the real processing happening in, for example, C# but - as I said last time - I'm sufficiently familiar with SQL that it wasn't too painful.

This stored procedure is invoked from C# by a method in our Database class:

internal GameState MakePlayer2Move(Guid gameId, int[]allocations)
{
  using (var cmd = new SqlCommand("MakePlayer2Move", conn, transaction))
 {
  cmd.CommandType = CommandType.StoredProcedure;
  cmd.Parameters.AddWithValue("@GameId", gameId);
  cmd.Parameters.AddWithValue("@NumFriends", numFriends);
  for (int i = 1; i <= 5; ++i)
    cmd.Parameters.AddWithValue("A" + i, allocations[i - 1]);
  cmd.ExecuteNonQuery();
 }
 return GetGame(gameId);
}

A standard and fairly trivial ADO.NET database call. The class variables conn and transaction are the connection to the database and a transaction scope, respectively - because all of the game logic is in the database itself, we can manage concurrency by using database transactions, which also permits a pleasant simplification to the C# code. (One concern is that we're making the database the concurrency bottleneck, but the interactions with the database are very short and we haven't seen any problems so far.) For completeness, these two variables are initialized in the constructor via:

conn = new SqlConnection(ourConnectionString);
conn.Open();
transaction = conn.BeginTransaction();

And cleaned up in our Dispose handler by committing the transaction and disposing of both objects as you'd expect (which also means that the scope of the transaction is between creation and disposal of the Database object, something I'll make use of later).

Moving out a layer, the corresponding "game engine" routine is a really trivial wrapper for that method:

internal GameState MakeMove(Guid gameId, Guid playerId, int[] allocations)
{
  using (var db = new Database())
 {
   GameState game = db.GetGame(gameId);
   if (game == null || game.Players[1].PlayerId != playerId || game.Players[1].Time.HasValue)
   return null;
  return db.MakePlayer2Move(gameId, allocations);
 }
}

There are a couple of database calls there, but note how they're encapsulated within the same Database using block, which in turn means within the same transaction. GameState is the C# representation of the game table in the database, and is sufficiently trivial that I'll ignore it here; likewise GetGame is very obvious.

Finally, for the server anyway, the web interface exposing this is:

[OutputCache(Duration = 0, VaryByParam = "*", Location = System.Web.UI.OutputCacheLocation.None)]
public JsonResult MakeMove(Guid gameId, string playerId, string allocationString)
{
  try
 {
   int[] allocations;
  if (!ParseAllocations(allocationString, out allocations))
   throw new ArgumentException("Allocations");

   GameState game = GameEngine.Instance.MakeMove(gameId, playerId, allocations);
  // Some elided material that handles achievement and Facebook updates
  return Json(game, JsonRequestBehavior.AllowGet);
 }
 catch (Exception ex)
 {
  return Json(ex.Message, JsonRequestBehavior.AllowGet);
 }
}

private static bool ParseAllocations(string allocationsString, out int[] allocations)
{
  allocations = (from a in allocationsString.Split(',') select int.Parse(a)).ToArray();
  return allocations.Length == 5 && allocations.Sum() == 100 && !allocations.Any((a) => a < 0);
}

As a standard ASP.NET MVC service call, the web application infrastructure will have converted URL query string parameters into the strongly typed arguments to this method, and the routine is expected to return a JsonResult. Rather than have five distinct query parameters for the allocations, I have a single one which is a comma separated list of numbers, which is parsed by the unimaginatively named ParseAllocations. On success, this returns a GameState to the web browser (in JSON format); on error it returns an error string; in the special case in which the game is not found (e.g., it's been played already), the current code returns a null - perhaps it should return an error message here too, I;'m not sure which is better. (This is a slight over-simplification: as mentioned earlier, we distinguish between our internal player ids and the Facebook ids: in the actual game, we don't return a GameState but instead a modification of that which translates between the different id types.)

The attribute at the head of the method prevents any caching of the method's output between server and client, so that the client always has up to date information. I'm not going to cover ASP.NET MVC 3 at all here - there are many good sources of information about that at the end of a web search...

Switching to the player 1 initiation: a little bit of additional complexity is that we'd decided as part of the game workflow that there could be at most a single game outstanding between any two players (partly to avoid someone swamping the system by challenging the same player multiple times) so, first, subsequent submissions to the same player are ignored and, second, if it happens that P2 had already initiated a game with P1, treat P1's "initiation" move as if it were the completion move for that game. The game engine layer routine looks like:

internal GameState Invite(string playerId, string otherPlayerId, int[] allocations)
{
  using (var db = new Database())
 {
   // Is there an outstanding game?
  GameState game = db.GetUnfinishedGame(playerId, otherPlayerId);
  if (game == null)
  {
    // No, so create one
    game = db.CreateGameAndMakePlayer1Move(playerId, otherPlayerId, allocations);
  }
   else
  {
    // If there is a game, is this player really P2 (as indicated by index 1 - 0-based array)
    if (game.Players[1].PlayerId == playerId)
      db.MakePlayer2Move(game.GameId, allocations);
  }
  return game;
 }
}

The lower layers that correspond to this are similar to the code above, so I won't waste space on them.

That's the basics of game play. There are many other purely game related functions - dealing with high score tables, getting a list of the games a particular player has played, or that are awaiting a move by that player, for example - but this is the core, and is sufficient to test with a web browser or the like, issuing web requests with appriate URls and eyeballing the returned JSON. Incidentally, https://www.codeproject.com/Tips/216175/View-JSON-in-Internet-Explorer gives a useful little tweak to make JSON display directly in IE, simplfying debugging this sort of thing a little.

Next time, I'll take a look at Facebook integration.