📜 ⬆️ ⬇️

Evolution on React + Redux

KDPV

Hi, Habr, I wrote an online version of the wonderful board game "Evolution: The Origin of Species" here and would like to share my notes about architecture and technical issues. I’ll clarify right away that I’m not promoting myself; rather, I’m curious to tell you about errors and features, and in return, I’ll hear a lot of new and good things about my decisions and code.


First, a little about the game, hiding under the spoiler for those who came for technical details:


About the game

The game consists of a deck of cards and food chips. Each turn is divided into phases:


Phase of development: all lay out cards in turn. The card can be put in two ways - a shirt, like an animal, or as a property on an existing one.


Power Phase: The first player rolls the dice and places the food chips in the food base. In turn, each player takes one chip from there and feeds his animal to it.


The extinction phase: those animals who did not have enough food, die, then players get new cards from the deck and start all over again.


When the deck ends, all points are calculated for animals and accumulated properties.


The properties are very different, I will not list everything, but I will give a couple of examples: “Fat Reserve”: an animal can take an additional food chip and “postpone” it as a fat reserve, so that it will survive in a hungry move. There are also paired properties linking two species, for example, "Cooperation": When one animal gets food, the second gets a food chip for free.


And one special feature of the Predator +1: an animal needs one more food to survive, but it can attack and eat others.


Actually, this is the game - not just take food chips, but also defend against predators.


If you want more examples - that is, “Big +1” (Big animal needs additional food, but only a predator with the same property can eat it) or “Camouflage” - an animal can be attacked only if the predator has the property “Sharp Vision” ".


Some, for example, "Parasite +2", can be laid out only on the opponent's animal, then he will need 2 more food chips, which will complicate his survival.


In general, the game has quite simple basic rules, but it is quite interesting and sometimes difficult to figure out all the interactions. Separately, it is worth mentioning the additions, which are about three pieces, they turn everything upside down. That is, if the first is still normal, it simply adds nine new properties (albeit with cunning mechanics), then the second, Continents, divides the table into three parts and the whole game takes place on three non-intersecting continents. And the “Plants” remove cubes from the game, and the plants, which can also be controlled, become the food base.


So, now, about the project, I will not hide it under the cut, you came for this:


Once, I decided to study the then new-fashioned React and Redux ... No, it’s not right to start with them right away, first about what allowed me to finish at least one game in my life and saved the project in general:


Tests




The fact is that I wrote in the evenings after work and, of course, not every day, but even a month later I could open a project in which I don’t remember anything and quietly start coding another feature. I'm not sure that I got unit tests, because basically I test it like this:


it('User0 creates Room, User1 logins', () => { const serverStore = mockServerStore(); //     mock,    ,    middleware const clientStore0 = mockClientStore().connect(serverStore); //   mock const clientStore1 = mockClientStore().connect(serverStore); //   clientStore0.dispatch(loginUserFormRequest('/test', 'User0', 'User0')); //    clientStore0.dispatch(roomCreateRequest()); const Room = serverStore.getState().get('rooms').first(); clientStore1.dispatch(loginUserFormRequest('/test', 'User1', 'User1')); expect(clientStore0.getState().get('room'), 'clientStore0.room').equal(Room.id); expect(clientStore0.getState().getIn(['rooms', Room.id]), 'clientStore0.rooms').equal(Room); expect(clientStore1.getState().get('room'), 'clientStore1.room').equal(null); expect(clientStore1.getState().getIn(['rooms', Room.id]), 'clientStore1.rooms').equal(Room); }); 

That is, on the one hand, I tried to test the most isolated piece of functionality, on the other - the dispatch action on the client, which itself “sends” it to the server, gets an answer, and I just check the creation of the room.


By the way, if you noticed - my tests are synchronous and work due to the synchronous mock for socket.io. I did not find anything like this on npm, so I turned it off. No, I admit, in fact this is a very controversial point, because the whole project must also be synchronous, but I will answer KISS for each tomato. Of course, I tried to rewrite everything for asynchronous tests (with async / await), but I realized that the client dispatch would have to give promises from the server, and I would have to stifle the network middleware only for tests, but somehow I don’t want to change everything. However, in theory, it is possible.


An example of a more advanced test:


When a creature with the Predator property attacks a creature with the Mimicry property, it will, if possible, redirect the attack to another creature of the same player:


 it('$A > $B m> $C', () => { //    A   B,     C const [{serverStore, ParseGame}, {clientStore0, User0, ClientGame0}, {clientStore1, User1, ClientGame1}] = mockGame(2); // mockGame( )          [{serverStore, ParseGame}, ...   ] // ParseGame     yml    ID' . //          . const gameId = ParseGame(` phase: 2 //   (      ) food: 10 //      = 10 ,   players: //   - continent: $A carn //   id "$A"   ,     TraitCarnivorous,    . - continent: $B mimicry, $C //   -   ID "$B"   ,     ID "$C". `); const {selectAnimal, selectTrait} = makeGameSelectors(serverStore.getState, gameId); //    reselect ( ),      expect(selectTrait(User1, 0, 0).type).equal('TraitMimicry'); //   ,                . //    ""    clientStore0.dispatch(traitActivateRequest('$A', 'TraitCarnivorous', '$B')); expect(selectAnimal(User0, 0).getFoodAndFat()).equal(2); //       expect(selectAnimal(User1, 0).id).equal('$B'); //    expect(selectAnimal(User1, 1)).undefined; //     =    . }); 

I have 7 such tests for mimicry:


A attacks B with mimicry, C with camouflage (B can not redirect the attack to C, because it is invisible, and A eats B)


A attacks B with mimicry, just C (the above case)


A (Predator), B (Mimicry), C (Mimicry): A attacks B, B redirects the attack to C, C redirects the attack to B back, but the game does not enter into an infinite loop, but A eats B


A (Predator), B (Mimicry), C, D: A attacks B, and the game asks player 2, with which creature (C or D) would he like to sacrifice? He answers that C, and A eats C.


A (Predator), B (Mimicry), C (Mimicry), D: A attacks B, the game asks player 2 which creature (C or D) would he like to sacrifice? He replies that C, then mimics again, and the game asks for the second time what kind of creature (B or D) this time he will sacrifice. The player answers that B, and it dies.


A (Predator), B (Mimicry), C, D: A attacks B, and the game asks player 2, with which creature (C or D) would he like to sacrifice? And he does not respond, and the game itself decides who to kill.


Asynchronous test , similar to the previous one, but where the player is not responsible for the allotted time interval of 1ms. As a "player did not answer," I use await new Promise(resolve => setTimeout(resolve, 1));


And the last test, apparently, is connected with some kind of bug: it checks that, after hunting for a creature with mimicry, a new round begins. I do not remember why.


What is this all about? To the fact that I can not worry that somewhere in my mimicry will work wrong. I can rewrite all the logic of hunting or "asking questions", and the tests will show that i screwed up everything is working.


Therefore, by the way, do not need to check the details. Only a significant logical outcome, such as being C is dead, being A has received food, and so on. At one time I tried to check some hidden parameters (like, the player has the flag "walked"), however, as a result, I just began to check that the player cannot walk again.


So in my own projects, especially at home, I recommend putting tests on all the logic. In addition to improving stability, they also help to return to the project.


Separately, about client tests - here I do not have everything so rosy, I often rewrote the client and after the fourth time I quit writing them.


Customer and design.


And now the game part of the client does not suit me at all, but I can’t think of anything better. Ideally, it would have been “Material UI Hearthstone” with a cool “visual language”, which “ Material design. Introduction , but it turned out gray rectangles with Roboto in the middle. No, well, in fact, the design doesn’t bother me at all, but there is also the “table” itself, the place where the cards, food and creatures lie. And here it is full of seams, starting from the fact that I do not contain all the information, and ending with the fact that I have a paradoxical lot of free space.


The thing is, firstly, I am a disgusting designer and from styles I prefer brutalism . Secondly, I'm lazy. And, thirdly, the game itself puts a pig - the player can have one or twenty creatures. And they can also be from one to twenty properties. And the players themselves - from two to eight. So I can not imagine how to do something sane that will scale from a couple of objects to hundreds. Perhaps the option to do everything “as in Hearthstone” with its principle “as a board game” is not the best here.


React


Let it look so-so, but it works, and in this great merit of React and his determinism.


It fills you with determination

There is not always enough will for a tough MVC / MVVM, but React does force us to take all the logic outward and ensure that under state X (which is easy to recognize), the UI will be like this. As I read from someone, "React is a function that takes a state and returns a UI." Together with Redux, it eliminates side effects and "fills with certainty," I know for sure what, where and when it happens to me. This is very cool, plus, I do not disgust with jsx, on the contrary, it is not necessary to memorize all sorts of pattern chips like {% < {{x | filter% sdfsdf}} >%}, and it is also not necessary to define scopes. I do not know how with this in vue and angular 2, but in the first, oh, those scopes. And in general, easier debazhit.


Well, all sorts of features like portals directly hit me. Indeed, I am writing a component for a room, so why not in it to stretch something in the header? And not gokoderski stuff there, but only if it contains the component <PortalTarget name='header'/>


 export class Room extends Component { ... render() { const {room, roomId, userId} = this.props; return (<div className='Room'> <Portal target='header'> <RoomControlGroup inRoom={true}/> // <=      Header' </Portal> <h1>{T.translate('App.Room.Room')} «{room.name}»</h1> <div className='flex-row'> <Card className='RoomSettings'> <CardText> <RoomSettings {...this.props}/> 

It seemed to me the most convenient to do multilanguage via i18n-react, for the design I use I use react-mdl. Separate rays of love mixed with hatred I send to the react-dnd library, it is cool.


However, React has a minus animation. Something more complicated than CSS Transitions is no longer so easy. And it turns out that the state is one, and the UI should be different.


I solved this problem in the most disgusting way, creating a monstrous monster - AnimationService. In short, he puts his middleware into the client, catches all the actions and starts the animation for the first one, puts the rest into the queue and, as soon as the animation is completed, starts the next. What gives a bunch of bugs, for example with the fact that while the cards are beautifully flying into your hand, you can not get out of the game.


On the other hand, I can animate components with Velocity.js something like this:


 export const createAnimationServiceConfig = () => ({ //     ,    animations: ({subscribe, getRef}) => { // subscribe -   Action, getRef -     //  : subscribe(" ", (done (    ), actionData, getState) => { //      ... 

In fact, I wrote it for nothing, and the only animation for which this monster came in handy is the distribution of cards (but as in Hearthstone !! 11!), So enough about it.


So, in general, with React, almost everything is fine, thanks in large part to the fact that he doesn’t mind his own business, but Redux does the logic.


Redux


It is he who does all the work on the client and on the server. And even they communicate with each other through middleware with socket.io. I did some kind of RPC, it looks something like this (get ready, now there will be a big piece of code from game.js )


 // Game Create // Request   ,    export const gameCreateRequest = (roomId, seed) => ({ type: 'gameCreateRequest' // ,     ,  , data: {roomId, seed} //   , meta: {server: true} // Middleware          }); //      ,    const gameCreateSuccess = (game) => ({ type: 'gameCreateSuccess' , data: {game} }); //   -   const gameCreateNotify = (roomId, gameId) => ({ type: 'gameCreateNotify' , data: {roomId, gameId} }); //    export const server$gameCreateSuccess = (game) => (dispatch, getState) => { //       Store dispatch(gameCreateSuccess(game)); //    Notify,    dispatch(Object.assign(gameCreateNotify(game.roomId, game.id) , {meta: {users: true}})); //       . selectPlayers4Sockets(getState, game.id).forEach(userId => { dispatch(Object.assign(gameCreateSuccess(game.toOthers(userId).toClient()) , {meta: {userId, clientOnly: true}})); }); //   ,           , ,       . //          - : // dispatch(Object.assign( // gameCreateSuccess(game.toOthers(null).toClient()) // , {meta: {clientOnly: true, users: selectPlayers4Sockets(getState, game.id)}} // )); //   .¯\(°_o)/¯ }; // ...  40  ... //    : export const gameClientToServer = { gameCreateRequest: ({roomId, seed = null}, {userId}) => (dispatch, getState) => { //   ,    ,   dispatch(server$gameCreateSuccess(game)); } // ... } export const gameServerToClient = { //   ,    gameCreateSuccess: (({game}, currentUserId) => (dispatch) => { dispatch(gameCreateSuccess(GameModelClient.fromServer(game, currentUserId))); dispatch(redirectTo('/game')); }) ... } 

The gameClientToServer object consists of actions allowed by the server to receive, so you cannot send an action like "shutdownServer" directly. And the reverse simply translates some models or something else from JSON objects into, in fact, models.


It works like this:


1) The user presses the “Start game” button.
2) React-redux action gameCreateRequest dispatcher
3) Client middleware:


 const nextResult = next(action); if (action.meta && action.meta.server) { action.meta.token = store.getState().getIn(['user', 'token']); socket.emit('action', action); } return nextResult; 

nextResult is needed for tests (which I recall are synchronous), if you call next (action) after socket.emit (), then the client reducer will process the sending action after the response from the server.


4) The server takes action:


 socket.on('action', (action) => { if (clientToServer[action.type]) { // clientToServer  ,    xxxClientToServer,   roomClientToServer  gameClientToServer const meta = {connectionId: socket.id} //    ActionCreator'  id . ,   . if (!~UNPROTECTED.indexOf(action.type)) { //       UNPROTECTED,    //   } const result = store.dispatch(clientToServer[action.type](action.data, meta)); //      gameClientToServer.gameCreateRequest    

5) As I wrote above, the server $ gameCreateSuccess is called, which dispatches the gameCreateSuccess only to the server, then gameCreateNotify and gameCreateSuccess to each of the players
6) Server Reducer catches gameCreateSuccess and creates game
7) Middleware server catches gameCreateNotify and sends it to all clients (so that they know that the game has started in such a room)
8) It also catches the subsequent gameCreateSuccess (with the game for each player), sends and does not allow to the server Reducer'u (because the meta indicates clientOnly: true)


This is how it all works.


Environment


It works on herokuapp on a free account. Which is not very good, as they require 6 hours of downtime. However, in connection with half-dead attendance (sometimes, at night, on weekdays, 3 dudes from Siberia play), this does not really bother me.


Therefore, it doesn’t bother me that the login via VC isn’t read from the database but is requested again every time. It's funny, of course - once I thought that the project had grown enough to use the database, screwed free Mongo from mlab.com, I even write VK tokens there and ... just request new ones. No, I don’t argue that someday I’ll nevertheless request statistics and Oauth tokens at login, but so far the database is useless a little more than completely.


The state of all games is stored right in redux. I somewhere saw the gloomy geniuses that keep the state in the database, but personally I do not understand why. Maybe I'm wrong.


It is going to be the first webpack, the second has not yet come out. In development, the client goes via webpackMiddleware, and the server goes via nodemon + babel-node. The only drawback is that if you change on the backend, you have to wait a long time while the frontend is rebuilt. I tried to do hot reloading for the node, but somehow it did not go. And why, for the server I have tests.


In short, I will also mention “unconventional” logging - writing is not an option to the file, because heroku erases everything, but any specialized services are either inconvenient or paid, so I found a great module for winston - winston-google-spreadsheet. Yes, he writes logs in the guglotablichku. I like more than the same loggly.


Findings:


Technical:


React, although it is already outdated (: trollface :), but consciousness overturns, and, I believe, is obligatory for familiarization.
The same about Redux.


Synchronous tests are good, but it would be a desktop or a step-by-step game I would do through asynchronously and with promises. That is, sent - waited for an answer. Then the server does not have to suffer from the inability to set a callback to any action.


Any collections need to be mapped or objects. At the very beginning I thought - hmmm, KISS, why do I need an object with animals, when I can keep them in the list. As a result, game.getAnimalById is searching through an array. Yes, mistake, I am ashamed, someday I will rewrite it.


Humanities:


First, to translate the desktop in online - a thankless task. In the sense that there are many subtleties and rules, things that are solved between players literally a couple of words turn into megabytes of code, queries and crutches. And nastochiki will always be dissatisfied with some trifle that can not be done. Plus - it is always multiplayer, and a long gameplay, and therefore the players will be small.


Secondly - I took the wrong game. The main complexity and gameplay of evolution is in the calculation of combinations and their interaction. The computer takes away all the miscalculations and the person can only choose from a couple of options. Thus, the gameplay, albeit not destroyed, but notably destroyed, as it should be thought out in advance. Well, thanks to the authors, they are pleased with additions that put everything upside down. That is, the player had one "continent" with animals, and here there are three of them hop. Cool! Interesting! Half the game rewrite, yeah, yeah: D


Summarizing - I got what I wanted. The code, in my opinion, is even beautiful in places, but in general, not disgusting (except for AnimationService, of course). Here you can fork / send a pull-request / help with development / post an issue / translate into English ru-ru.json / help with the design (these are still not subtle hints), just below you can express everything you think about all sorts of hipsters crawling on the godless neo-language. In order not to get into I am promoting, I will throw the link to the site in the comments.


')

Source: https://habr.com/ru/post/325756/


All Articles