We brew a game dev. Part 1

    Introduction


    The story began with a hackathon on developing games on the blockchain. At the start of the event, I met a man who creates board business games as a hobby (I was on the playtest of one such game), we teamed up, and together we found a team with whom they “blinded” a simple strategic game over the weekend. The hackathon passed, but the enthusiasm remained. And we came up with the idea of ​​a multiplayer card game about happiness, the world community and elections.

    In the series of articles we will reflect our path to creating a game, with a description of the rake that we have already stepped on and will step on as we move forward.
    Under the cut will be:

    • Game Summary
    • How the decision was made on what to do backend. Where will it “live” so that it does not pay for it at the development stage
    • The first steps in the development - player authentication and organization of the game search (matchmaking)
    • Future plans

    What is the game about


    Mankind is tired of world wars, depletion of resources and constant competition. Key factions agreed to use modern technology to select a single leadership. At the appointed time, the world electorate must decide on the choice of a fraction that will rule the planet for the next millennium. Key factions engage in an “honest” power struggle. In a game session, each player represents a fraction.

    This card game is about elections. Each faction has a budget for conducting the election race, sources of income increasing the budget and starting votes. At the beginning of the game, the deck with action cards is mixed and 4 cards are issued to each participant. Each turn, players can perform up to two game actions. To use the card, the player puts it on the table and, if necessary, designates the goal and deducts from the budget the cost of using the card. After the end of the round, the player can keep only one of the unused cards. At the beginning of each round, players get cards from the deck, so that at the beginning of each round, each player has 4 action cards on hand.

    At the end of rounds 3, 6 and 9, the player with the least number of votes is removed from the game. If several players have the same minimum number of votes, then all players with this result are eliminated from the game. The voices of these players go to the general pool of the electorate.

    At the end of round 12, the winner is the one with the most votes.

    Choosing a tool for the backend


    From the description of the game follows:

    1. This is multiplayer
    2. It’s necessary to somehow identify players and manage accounts
    3. The presence of a social component would benefit the game - friends, communities (clans), chats, achievements (achievements)
    4. Leaderboards and matchmaking functionality will be required.
    5. Tournament management functionality will be useful in the future
    6. Given that the game is a card game, you will need to manage the catalog of cards, you may need to store cards available to the player and compiled decks
    7. In the future, an in-game economy may be required, including in-game currency, exchange of virtual goods (cards)

    Having looked at the list of needs, I immediately came to the conclusion that making my own backend at the initial stage does not make sense and went off to google what other options are. So I found out that there are specialized cloud gaming backends, among which PlayFab (bought by Microsoft) and GameSparks (bought by Amazon) stand out.

    In general, they are functionally similar and cover basic needs. At the same time, their internal architecture is very different, the same tasks are solved a little differently, and explicit correspondences in the features are difficult to trace. Below are the positive and negative features of each platform and considerations on the topic of choice.

    Playfab


    Positive features:

    • Accounts from different games are combined into a master account
    • The gaming economy is described without a single line of code, including pricing to a separate virtual store
    • Friendly user interface
    • Microsoft Acquires Product After Acquisition
    • The cost of ownership in production by Indie Studio subscription is $ 99 (up to 100k MAU), when switching to Professional, a 1k MAU subscription will cost $ 8 (minimum account $ 300)

    Negative features:

    • Storage of game data is strictly limited, for example, in a free subscription to store data for a specific game session (if I understand everything correctly, Entity Groups are used for this) 3 slots of 500 bytes each are available
    • To organize Multiplayer, you need to connect third-party servers that will process events from clients and calculate game logic. This is either Photon on your hardware or Azure Thunderhead, and you need to not only organize the server, but also upgrade your subscription to at least Indie Studio
    • It is necessary to put up with the fact that the cloud code without autocomplete and there is no way to break into modules (or did not find?)
    • There is no normal debugger, you can only write logs in CloudScript and view

    Gamesparks


    Positive features:

    • Game data storage. Not only are there many places where you can save data (general game metadata, virtual goods, player profile, multiplayer sessions, etc.), the platform also provides a full-fledged database-as-a-service not attached to anything, moreover, both MongoDB and Redis are available immediately for different types of data. In the development environment you can store 10 MB, in combat 10 GB
    • Multiplayer is available in a free subscription (Development) with a limit of 10 simultaneous connections and 10 requests per second
    • Convenient work with CloudCode, including a built-in tool for testing and debugging (Test Harness)

    Negative features:

    • The feeling that since the purchase by Amazon (winter 2018) the tool has stagnated, there are no innovations
    • Again, after the acquisition of Amazon, tariffs became worse; earlier it was possible to use up to 10,000 MAU in production for free
    • Production cost of ownership starts at $ 300 (Standard subscription)

    Reflections


    First you have to check the concept of the game. To do this, I want to build a prototype of sticks and scotch tape without monetary investments and start play tests of game mechanics. Therefore, in the first place when choosing, I raise the opportunity to develop and test a mechanic on a free subscription.
    GameSparks satisfies this criterion, but PlayFab does not, because you will need a server that will handle the events of game clients and an Indie studio-level subscription ($ 99).

    At the same time, I accept the risk that Amazon does not develop GameSparks, which means that it may “die”. Given this and still the cost of ownership in production, I have in mind the potential need to move either to another platform or to my own backend.

    First steps in development


    Connection and Authentication


    So, the choice fell on GameSparks as a backend at the prototyping stage. The first step is to learn how to connect to the platform and authenticate the player. An important point is that the user should be able to play without registration and SMS immediately after installing the game. To do this, GameSparks offers the option of creating an anonymous profile by calling the DeviceAuthenticationRequest method, later, based on the anonymous profile, you can make a full-fledged one by connecting, for example, with your Google account.

    Given that I have brain TDD, I started by creating a test to connect the client to the game. Since in the future CloudCode will need to be written in JS, I will do integration tests in JS using mocha.js and chai.js. The first test was this:

    var expect = require("chai").expect;
    var GameClientModule = require("../src/gameClient");
    describe("Integration test", function () {
        this.timeout(0);
        it("should connect client to server", asyncfunction () {
            var gameClient = new GameClientModule.GameClient();
            expect(gameClient.connected()).is.false;
            await gameClient.connect();
            expect(gameClient.connected()).is.true;
        });
    })
    

    By default, the timeout in mocha.js is 2 seconds, I immediately make it endless, because the tests are integration. In the test, I create a game client that has not been implemented yet, check that there is no connection to the server, call the command to connect to the backend, and verify that the client has successfully connected.

    In order for the test to turn green, you need to download and add the GameSparks JS SDK to the project, as well as connect its dependencies (crypto-js and ws), and, of course, implement GameClientModule:

    var GameSparks = require("../gamesparks-javascript-sdk-2018-04-18/gamesparks-functions");
    var config = newrequire("./config.json");
    exports.GameClient = function () {
        var gamesparks = new GameSparks();
        this.connected = () => (gamesparks.connected === true);
        this.connect = function () {
            returnnewPromise(function (resolve, reject) {
                gamesparks.initPreview({
                    key: config.gameApiKey,
                    secret: config.credentialSecret,
                    credential: config.credential,
                    onInit: () => resolve(),
                    onMessage: onMessage,
                    onError: (error) => reject(error),
                    logger: console.log
                });
            });
        }
        functiononMessage(message) {
            console.log("GAME onMessage: " + JSON.stringify(message));
        }
    }
    

    In the starting implementation of the game client, the keys necessary for technical authorization to create a connection from the client application are read from the config. The connected method wraps the same field from the SDK. The most important thing happens in the connect method, it returns a promise with callbacks for a successful connection or an error, also binds the onMessage handler to the same callback. onMessage will act as the backend message processing manager, for now let it log messages to the console.

    It would seem that the work has been completed, but the test remains red. It turns out that the GameSparks JS SDK does not work with node.js; to it, you see, it lacks the browser context. Let's make him think that node is Chrome on the poppy. We go to gamesparks.js and at the very beginning add:

    if (typeofmodule === 'object' && module.exports) { // node.jsvar navigator = {
            userAgent: "Chrome/73.0.3683.103",
            vendor: "Google Inc.",
            platform: "Mac"
        };
        varwindow = {};
        window.setInterval = setInterval; // <<< используется в KeepAlive сообщениях
    }
    

    Test turned green, move on.

    As I wrote earlier, a player should be able to start playing immediately as soon as he enters the game, while I want to start accumulating analytics in activity. To do this, we bind either to the device identifier or to a randomly generated identifier. Check this will be such a test:

    it("should auth two anonymous players", asyncfunction () {
        var gameClient1 = new GameClientModule.GameClient();
        expect(gameClient1.playerId).is.undefined;
        var gameClient2 = new GameClientModule.GameClient();
        expect(gameClient2.playerId).is.undefined;
        await gameClient1.connect();
        await gameClient1.authWithCustomId("111");
        expect(gameClient1.playerId).is.equals("5b5f5614031f5bc44d59b6a9");
        await gameClient2.connect();
        await gameClient2.authWithCustomId("222");
        expect(gameClient2.playerId).is.equals("5b5f6ddb031f5bc44d59b741");
    });
    

    I decided to check immediately on 2 clients in order to make sure that each client creates his own profile on the backend. To do this, you need a method in the game client where you can pass a certain identifier external to GameSparks, and then check that the client has contacted the player’s profile. Profiles prepared in advance on the GameSparks portal.

    For implementation in GameClient add:

    this.playerId = undefined;
    this.authWithCustomId = function (customId) {
        returnnewPromise(resolve => {
            var requestData = {
                "deviceId": customId
                , "deviceOS": "NodeJS"
            }
            sendRequest("DeviceAuthenticationRequest", requestData)
                .then(response => {
                    if (response.userId) {
                        this.playerId = response.userId;
                        resolve();
                    } else {
                        reject(newError(response));
                    }
                })
                .catch(error => { console.error(error); });
        });
    }
    functionsendRequest(requestType, requestData) {
        returnnewPromise(function (resolve) {
            gamesparks.sendWithData(requestType, requestData, (response) => resolve(response));
        });
    }
    

    Implementation boils down to sending a DeviceAuthenticationRequest request, receiving the player’s identifier from the response, and placing it in the client’s property. Immediately, in a separate method, the helper sent requests to GameSparks with a wrapper in a promise.

    Both tests are green, it remains to add a connection closure and refactor.
    In GameClient, I added a method that closes the connection to the server (disconnect) and connectAsAnonymous combining connect and authWithCustomId. On the one hand, connectAsAnonymous violates the principle of single responsibility, but doesn’t seem to violate ... At the same time, it adds usability, because in tests you will often need to authenticate clients. What do you think about this?

    In tests, he added a factory method helper that creates a new instance of the game client and adds to the array of created clients. In the special mocha handler, after each running test for clients in the array, I call the disconnect method and clear this array. I don’t like “magic strings” in the code yet, so I added a dictionary with custom identifiers used in the tests.

    The final code can be viewed in the repository, a link to which I will give at the end of the article.

    Game search organization (matchmaking)


    I’ll start the matchmaking feature, which is very important for multiplayer. This system starts to work when we press the “Find a game” button in a game. She picks up either rivals, or teammates, or both of them (depending on the game). As a rule, in such systems, each player has a numeric indicator MMR (Match Making Ratio) - a personal rating of the player, which is used to select other players with the same level of skill.

    To test this functionality, I came up with the following test:

    it("should find match", asyncfunction () {
        var gameClient1 = newGameClient();
        var gameClient2 = newGameClient();
        var gameClient3 = newGameClient();
        await gameClient1.connectAsAnonymous(playerCustomIds.id1);
        await gameClient2.connectAsAnonymous(playerCustomIds.id2);
        await gameClient3.connectAsAnonymous(playerCustomIds.id3);
        await gameClient1.findStandardMatch();
        expect(gameClient1.state)
            .is.equals(GameClientModule.GameClientStates.MATCHMAKING);
        await gameClient2.findStandardMatch();
        expect(gameClient2.state)
            .is.equals(GameClientModule.GameClientStates.MATCHMAKING);
        await gameClient3.findStandardMatch();
        expect(gameClient3.state)
            .is.equals(GameClientModule.GameClientStates.MATCHMAKING);
        await sleep(3000);
        expect(gameClient1.state)
            .is.equals(GameClientModule.GameClientStates.CHALLENGE);
        expect(gameClient1.challenge, "challenge").is.not.undefined;
        expect(gameClient1.challenge.challengeId).is.not.undefined;
        expect(gameClient2.state)
            .is.equals(GameClientModule.GameClientStates.CHALLENGE);
        expect(gameClient2.challenge.challengeId)
            .is.equals(gameClient1.challenge.challengeId);
        expect(gameClient3.state)
            .is.equals(GameClientModule.GameClientStates.CHALLENGE);
        expect(gameClient3.challenge.challengeId)
            .is.equals(gameClient1.challenge.challengeId);
    });
    

    Three clients are connected to the game (in the future it is a necessary minimum for checking some scenarios) and are registered to search for the game. After registering the 3rd player on the server, a game session is formed and players must connect to it. At the same time, the state of the clients changes, and the context of the game session with the same identifier appears.

    First, prepare the backend. GameSparks has a ready-made tool for customizing the search for games, available on the path “Configurator-> Matches”. I create a new one and proceed with the setup. In addition to the standard parameters such as code, name and description of the match, the minimum and maximum number of players required for a custom game mode is indicated. I will assign the code “StandardMatch” to the created match and indicate the number of players from 2 to 3.

    Now you need to configure the rules for selecting players in the “Thresholds” section. For each threshold, the time of its action, type (absolute, relative and in percent) and boundaries are indicated.



    Suppose a player with an MMR of 19 starts searching. In the above example, the first 10 seconds will be the selection of other players with an MMR of 19 to 21. If the players were not selected, the second search border is activated, which extends the search range from 16 for the next 20 seconds 19-3) to 22 (19 + 3). Next, the third threshold is included, within which a search will be carried out for 30 seconds in the range from 14 (19-25%) to 29 (19 + 50%), while the match is considered to be completed if the minimum required number of players has been accumulated (Accept Min mark . Players).

    In fact, the mechanism is more complicated, since it takes into account the MMR of all players who managed to join a particular match. I will analyze these details when the time comes to make the rating mode of the game (not in this article). For the standard game mode, where I do not plan to use MMR yet, I need only one threshold of any kind.

    When all players have been selected, you need to create a game session and connect players to it. In GameSparks, the game session function is the “Challenge”. As part of this entity, game session data is stored, and messages are exchanged between game clients. To create a new type of Challenge, you need to go along the path “Configurator-> Challenges”. There I add a new type with the code “StandardChallenge” and indicate that the game session of this type is Turn Based, i.e. players take turns, not simultaneously. GameSparks takes control of the sequence of moves.

    In order for a client to register to search for a game, you can use a request of type MatchmakingRequest, but I would not recommend it, because the MMR value of the player is required as one of the parameters. This can lead to fraud on the part of the game client, and the client should not know any MMR, this is a backend. To register correctly for the game search, I create an arbitrary event from the client. This is done in the “Configurator-> Events” section. I call the event FindStandardMatch without attributes. Now you need to configure the reaction to this event, for this I’m going to the section of the cloud code “Configurator-> Cloud Code”, there, in the section “Events” I write the following handler for FindStandardMatch:

    var matchRequest = new SparkRequests.MatchmakingRequest();
    matchRequest.matchShortCode = "StandardMatch";
    matchRequest.skill = 0;
    matchRequest.Execute();
    

    This code registers a player in StandardMatch with an MMR of 0, so any players registered to search for a standard game will be suitable for creating a game session. In the selection of a rating match, there could be an appeal to the private data of the player profile to obtain the MMR of this type of match.

    When there are enough players to start a game session, GameSparks will send a MatchFoundMessage message to all selected players. Here you can automatically generate a game session and add players to it. To do this, in the “User Messages-> MatchFoundMessage” add the code:

    var matchData = Spark.getData();
    if (Spark.getPlayer().getPlayerId() != matchData.participants[0].id) {
        Spark.exit();
    }
    var challengeCode = "";
    var accessType = "PRIVATE";
    switch (matchData.matchShortCode) {
        case"StandardMatch":
            challengeCode = "StandardChallenge";
            break;
        default:
            Spark.exit();
    }
    var createChallengeRequest = new SparkRequests.CreateChallengeRequest();
    createChallengeRequest.challengeShortCode = challengeCode;
    createChallengeRequest.accessType = accessType;
    var tomorrow = newDate();
    tomorrow.setDate(tomorrow.getDate() + 1);
    createChallengeRequest.endTime = tomorrow.toISOString();
    createChallengeRequest.usersToChallenge = [];
    var participants = matchData.participants;
    var numberOfPlayers = participants.length;
    for (var i = 1; i < numberOfPlayers; i++) {
        createChallengeRequest.usersToChallenge.push(participants[i].id)
    }
    createChallengeRequest.Send();
    

    The code first checks that it is the first player on the list of participants. Next, a StandardChallenge instance is created on behalf of the first player and the remaining players are invited. Invited players are sent a ChallengeIssuedMessage message. Here you can envision behavior when an invitation to join the game is displayed on the client and requires confirmation by sending AcceptChallengeRequest, or you can accept the invitation in silent mode. So I’ll do it, for this in “User Messages-> ChallengeIssuedMessage” I will add the following code:

    var challangeData = Spark.getData();
    var acceptChallengeRequest = new SparkRequests.AcceptChallengeRequest();
    acceptChallengeRequest.challengeInstanceId = challangeData.challenge.challengeId;
    acceptChallengeRequest.message = "Joining";
    acceptChallengeRequest.SendAs(Spark.getPlayer().getPlayerId());
    

    The next step, GameSparks dispatches the ChallengeStartedMessage event. The global handler of this event (“Global Messages-> ChallengeStartedMessage”) is an ideal place to initialize a game session, I’ll take care of this when implementing game logic.

    The time has come for the client application. Changes in the client module:

    exports.GameClientStates = {
        IDLE: "Idle",
        MATCHMAKING: "Matchmaking",
        CHALLENGE: "Challenge"
    }
    exports.GameClient = function () {
        this.state = exports.GameClientStates.IDLE;
        this.challenge = undefined;
        functiononMessage(message) {
            switch (message["@class"]) {
                case".MatchNotFoundMessage":
                    this.state = exports.GameClientStates.IDLE;
                    break;
                case".ChallengeStartedMessage":
                    this.state = exports.GameClientStates.CHALLENGE;
                    this.challenge = message.challenge;
                    break;
                default:
                    console.log("GAME onMessage: " + JSON.stringify(message));
            }
        }
        onMessage = onMessage.bind(this);
        this.findStandardMatch = function () {
            var eventData = { eventKey: "FindStandardMatch" }
            returnnewPromise(resolve => {
                sendRequest("LogEventRequest", eventData)
                    .then(response => {
                        if (!response.error) {
                            this.state = exports.GameClientStates.MATCHMAKING;
                            resolve();
                        } else {
                            console.error(response.error);
                            reject(newError(response));
                        }
                    })
                    .catch(error => {
                        console.error(error);
                        reject(newError(error));
                    });
            });
        }
    }
    

    In accordance with the test, a couple of fields appeared on the client - state and challenge. The onMessage method has acquired a meaningful look and now responds to messages about the start of a game session and to a message that it was not possible to pick up a game. The findStandardMatch method has also been added, which sends the corresponding request to the backend. The test is green, but I'm satisfied, the selection of games mastered.

    What's next?


    In the following articles I will describe the process of developing game logic, from initializing a game session to processing moves. I’ll analyze the features of storing different types of data — a description of the game’s metadata, characteristics of the game world, data from game sessions, and data about players. Game logic will be developed through two types of testing - unit and integration.

    I will upload the sources on github in portions tied to articles.

    There is an understanding that in order to effectively advance in creating a game, you need to expand our team of enthusiasts. The artist / designer will join soon. And the guru in, for example, Unity3D, who will make the front for mobile platforms, remains to be found.

    Also popular now: