Use react and websocket to make a game of Gobang

Time:2020-9-29

This is not a tutorial. The reason is that I want to use my spare time to make a MMORPG toy. Who hasn’t a dream to realize his own game world? I’m not a game practitioner. All the knowledge about the game comes from all kinds of fragmented information on the Internet. So I want to publish the production process on the Internet in this way of “live broadcast”, so that if there is anything wrong, the wrong cognition will happen Be pointed out.

And this is a lead piece to test the effect and whether I can stick to it.

On the game of Gobang

Front end I useReact, the back end implements aKoa+WebScoketServer. Both front and rearTypeScriptWhyTS? There is a lot of discussion about whether to use or not in the community, and the reason I have here is very simple: I like it.

Project construction

Three node packages are created in the game directory:

  • client
  • server
  • lib

The Lib package is reserved, and some modules used by the front and rear end may be placed

  1. usecreate-react-appestablishclientpackage

    cd client
    yarn create react-app . --template=typescript
  2. initializationserverAndlibpackage

    cd server
    yarn init --yes
    yarn add -D typescript ts-node nodemon 
    npx tsc --init

The current directory structure looks like this:Use react and websocket to make a game of Gobang

Step 1: get the front-end UI out and have a visible interface

Modify the App.tsx

export default function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <Switch>
          <Route path="/" exact component={Login} />
          <Route path="/lobby" component={Lobby} />
          <Route path="/battle" component={BattleField} />
        </Switch>
      </BrowserRouter>
    </div>
  );
}

The three pages are the entrance, the hall and the war room.

Login module

I will simply handle the authentication here. The user enters the name, and the server returns a token after judging it is valid. Then the client connects with the websocket server by the name. Don’t do such a complicated set of registration and landing.

Login.tsx

export default function Login() {
    const [name, setName] = useState('');
    const ref = createRef<HTMLInputElement>();
    const [{ logged, loading, error }, doLogin] = Connect();
    const history = useHistory();
    useEffect(() => {
        if (logged) {
            history.push('/lobby');
        }
        ref.current?.focus();
    }, [history, logged, ref]);
    return (
        <div className="login">
            <form onSubmit={e => {
                e.preventDefault();
                doLogin(name);
            }}>
                <h3>FIVE IN A ROW</h3>
                <input
                    ref={ref}
                    placeholder="Enter your name"
                    value={name}
                    onChange={e => setName(e.target.value)}
                    disabled={loading}
                />
                <button type="submit" disabled={loading}>Login</button>
                <div className="err">{error}</div>
            </form>
        </div>
    )
}

The login logic is placed in the networking module, where I will initiate a websocket connection

Networking.ts

type State = {
    loading: boolean,
    error: string,
    logged: boolean,
    name: string,
    token: string,
}

const Connect = (): [State, React.Dispatch<string>] => {
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState('');
    const [logged, setLogged] = useState(false);
    const [name, setName] = useState('');
    const [token, setToken] = useState('');
    useEffect(() => {
        const request = async () => {
            setError('');
            setLoading(true);
            const res = await fetch('/api/login', {
                body: JSON.stringify({ name }),
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            });
            setLoading(false);

            if (res.status === 200) {
                const { token } = await res.json();
                setToken(token);
                setLogged(true);
            } else {
                const errmsg = await res.text();
                setError(errmsg);
            }

        }
        if (name && !logged) {
            request();
        }
    }, [name, logged]);
    return [{ loading, error, logged, name, token }, setName];
}

export { Connect }

In fact, I do this with react hooksclick and send requestThis kind of demand always feels very awkward. Maybe it’s the wrong way to open it, or maybe it’s the wrong understanding? Isn’t useeffect used to solve this problem? If there is an expert to see this article, please answer your doubts.

Now it looks like this:

Use react and websocket to make a game of Gobang

With a good start, it’s time to write a little bit of back-end code.

The first time of Step2 server

There is no scaffold tool in the background to help automatic configuration. First of all, we need to do a little preparatory work.

  1. modifypackage.json:

    "scripts": {
       "start": "nodemon src/index.ts"
    },
    "nodemonConfig": {
      "watch": ["src"],
      "events": {
         "start": "node -e 'console.clear()'"
       }
    }

    Use nodemon to start the project and monitor the SRC folder.

  2. modifytsconfig.json

     "moduleResolution": "node",
     "target": "ES2015", 
     "module": "commonjs", 
     "lib": ["ES2015"], 
     "outDir": "./lib", 
     "rootDir": "./src",
  3. Install the necessary dependencies

    yarn add koa koa-route ws koa-bodyparser 

    PS: I like to add it when installing dependencies-EThese NPM libraries change too fast. Sometimes a small version is upgraded every other time, and the original usage is not correct.

Start to write code, complete the login function first.index.ts

import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import routes from 'koa-route';
import GameServer from './game-server';
import tokenUtil from './token';
const gameServer = new GameServer();
const app = new Koa();
app.use(bodyParser());
app.use(routes.post('/api/login', ctx => {
    const name: string = ctx.request.body.name || '';
    if (!/^[a-zA-Z\u4E00-\u9FA5][a-zA-Z0-9\u4E00-\u9FA5_-]{3,8}$/.test(name)) {
        ctx.status = 406;
        ctx.body = 'Invalid player name supplied.';
        return;
    }
    if (gameServer.isPlayerExists(name)) {
        ctx.status = 409;
        ctx.body = `${name} already exists.`;
        return;
    }
    ctx.body = { token: tokenUtil.create(name) };
}));

const PORT = process.env.PORT || 5000;
const httpServer = app.listen(PORT, () => {
    console.log(`Server started on port ${PORT}.`);
})

As mentioned earlier, we use a simple token to mean it.

token.ts

import crypto from 'crypto';
const SECRET = 'puppy and kitten';
export default {
    create(name: string) {
        return crypto.createHash('sha256').update(name + SECRET).digest('base64');
    },

    check(name: string, token: string) {
        return this.create(name) === token;
    }
}

Gameserver is blank at the moment. I’ll fill it in slowly.

export default class GameServer {
    initialize(httpServer: Server) {

    }

    isPlayerExists(name: string) {
        return true;
    }
}

In the initialization method, KOA’s httpserver is used to create websocketserver. Since session is not used for authentication, there are only two ways to verify in the handshake phase: URL with parameters and SEC wesbsocket protocl. I choose the second one.

initialize(httpServer: Server) {
    const wss = new WebSocket.Server({ noServer: true });
    httpServer.on('upgrade', async (req: IncomingMessage, socket: Socket, head: Buffer) => {
        try {
            const conn = await this.acceptConnection(wss, req, socket, head);
            this.addConnection(conn);
        } catch (_) {
            console.error(_);
        }
    })
}

acceptConnection(wss: WebSocket.Server, req: IncomingMessage, socket: Socket, head: Buffer) {
    return new Promise<Connection>((resolve, reject) => {
        const [name, authenticated] = this.authenticate(req);
        if (authenticated) {
            wss.handleUpgrade(req, socket, head, ws => {
                const conn = new Connection(ws, name);
                resolve(conn);
            });
            if (socket.destroyed) {
                reject('Handshake failed.')
            }
        } else {
            socket.write('HTTP/1.1 401\r\n');
            socket.destroy();
            reject('Unauthorized.');
        }
    });
}

authenticate(req: IncomingMessage):[string, boolean] {
   const userInfo = (req.headers['sec-websocket-protocol'] as string) ?? '';
   const [name, token] = userInfo.split(', ');
   return [name, tokenUtil.check(decodeURIComponent(name), decodeURIComponent(token))];
}

Using protocol to transfer parameters requires URL encoding conversion, otherwise the browser will report an error. Try changing the client code.

Networking.ts add to:

const connection = new Connection();

Modify the original login code

if (res.status === 200) {
   const { token } = await res.json();
 + connection.name = name;
 + connection.token = token;
 + connection.connect();
   setToken(token);
   setLogged(true);
} 

Connection.ts

export default class Connection extends EventEmitter {
    name?: string;
    token?: string;
    ws?: WebSocket;
    readyState = -1;
    connect(url = 'ws://localhost:5000') {
        if (!this.name || !this.token)
            return;
        const ws = this.ws = new WebSocket(url, [encodeURIComponent(this.name), encodeURIComponent(this.token)]);
        this.ws.onopen = () => {
            this.readyState = ws.readyState;
            this.emit('open');
            console.log('connected to server.')
        }
        this.ws.onclose = (e) => {
            this.readyState = ws.readyState;
            this.emit('close', e.code, e.reason);
        }
        this.ws.onerror = () => {
            this.emit('error');
            this.readyState = ws.readyState;
        }
    }
}

It seems that there is no problem. Next, continue to work on the server.

game-server.ts

let uniqueId = 100;
export default class GameServer {
    connections: Connection[] = [];

    addConnection(conn: Connection) {
        conn.id = uniqueId++;
        conn.on('close', id => {
            this.removeConnection(id);
        })
        this.connections.push(conn);
    }

    removeConnection(id: number) {
        for(let i=this.connections.length-1; i>-1; i--) {
            let conn = this.connections[i];
            if (conn.id === id) {
                conn.destory();
                this.connections.splice(i, 1);
                break;
            }
        }
    }

    isPlayerExists(name: string) {
        return this.connections.find(c => c.name === name);
    }
}

I need to save the connection and delete it after it is closed. The isplayerexists method is completed. Next, the connection class (server side)

export default class Connection extends EventEmitter {
    on!: (event: 'close', listener: (this: Connection, id: number) => void) => this;

    id: number = 0;
    isClosed = false;
    constructor(public socket: WebSocket, public name: string) {
        super();
        socket.on('close', () => this.close());
    }

    close() {
        this.isClosed = true;
        this.emit('close', this.id);
    }

    // close socket && clean it up
    destory(code?: number) {
        this.socket.removeAllListeners();
        this.socket.close(code);
        this.removeAllListeners();
    }
}

The destory method cleans everything up. The close method here does not actively close the socket connection, but issues events and is managed by Gameserver.

Next, we need to consider how to deal with data interaction. Considering that the player’s behavior in the hall is completely different from that in the war room, it is a good way to use different processing modules for different behaviors. Add a processor object to the connection class to handle the interaction in different states. The processor objects include enter, leave, and hungup methods. For example, the enter method of the hall processor can send the list of current open rooms to the client, and the leave method can broadcast to the hall users to update the list.

Define processor

export abstract class Processor {
    abstract handle(message:any):void;
    abstract enter(): void;
    abstract leave(): void;
    abstract hungup(): void;
}

Modify connection class

close() {
    ...
    this.processor.hungup();
}

_processor!: Processor;

get processor() {
    return this._processor;
}

set processor(current: Processor) {
    if (this._processor) {
        this._processor.leave();
    }
    this._processor = current;
    current.enter();
}

When Gameserver creates a connection, it gives an initial processor

addConnection(conn: Connection) {
    conn.id = uniqueId++;
    conn.processor = new LobbyProcessor(conn, this);
    conn.on('close', id => {
        this.removeConnection(id);
    })
    this.connections.push(conn);
}

LobbyProcess

export default class LobbyProcessor extends Processor {
    constructor(public conn: Connection, public gs: GameServer) {
        super()
    }
    enter() {
        console.log(`Player ${this.conn.name} joined. num of players:${this.gs.connections.length}`);
    }

    handle(message:any) {}

    leave() {}

    hungup() {
        console.log(`Player ${this.conn.name} left.`)
    }
}

Take a look at the effect
Use react and websocket to make a game of Gobang

When players enter the hall, the first thing the hall processor needs to do is send the current hall room list to players. For this reason, the server needs to create a player object to process the game related information. If all these are bound to the connection object, it is undoubtedly very silly.

Create a lobby class to manage the game hall. The object passed in by lobbyprocessor is no longer Gameserver. Gameserver focuses on the creation and deletion of network connections.

export default class Player extends EventEmitter {
    roomId: number = 0;
    id: number;
    name: string;
    constructor(public connection: Connection) {
        super();
        this.id = connection.id;
        this.name = connection.name;
    }
}

GameServer.ts

lobby: Lobby = new Lobby();
addConnection(conn: Connection) {
    ....
    conn.processor = new LobbyProcessor(conn, this.lobby);
}

Lobby.ts

export default class Lobby {
    players: Player[] = [];

    get numOfPlayers() {
        return this.players.length;
    }

    addPlayer(player: Player) {
        this.players.push(player);
    }

    removePlayer(player: Player) {
        for (let i = this.players.length - 1; i > -1; i--) {
            if (player.id === this.players[i].id) {
                this.players.splice(i, 1);
                break;
            }
        }
    }
}

The hall processor creates a player object, adds a player to the hall, and broadcasts it to all users in the hall

export default class LobbyProcessor extends Processor {
    player: Player;
    constructor(public conn: Connection, public lobby: Lobby) {
        super();
        this.player = new Player(conn);
    }
    enter() {
        this.lobby.addPlayer(this.player);
        this.lobby.boardcast(`Player ${this.player.name} joined.`, this.player.id);
        console.log(`Player ${this.player.name} joined. num of players:${this.lobby.numOfPlayers}`);
    }

    leave() {

    }

    hungup() {
        this.lobby.removePlayer(this.player);
        console.log(`Player ${this.player.name} left.`);
    }
}

To achieve Lobby.boardcast

boardcast(message: any, ignoreId?: number) {
    this.players.forEach(player => {
        if (player.id !== ignoreId) {
            player.connection.send(message);
        }
    })
}

Connection.ts

send(message: any) {
    this.socket.send(JSON.stringify(message));
}

Processing of step 3 message structure

So far, we have implemented a HTTP server and a websocket server, and their authentication system is associated. The websocket server implements the connection manager and a set of ways to manage the connection interaction (processor). It is time to consider the problem of message structure. The previous code simply broadcast a text string, which is for real-time network applications It is far from enough. We need to agree on a set of message structure, and the server and the client follow the rules to serialize / deserialize the message package.

There are many solutions in this area, such as protobuf and msgpack. Here I use the native JSON of JavaScript runtime. In order to make full use of typescript type checking and intelligent prompt, I need to do a little auxiliary work.

The basic idea is as follows: use typescript interface to declare the message package structure, and then parse the declaration file to automatically generate TS code for serialization and deserialization. The previously created lib package is finally in use, and the serialization and deserialization of message packets are available to both client and server. In lib, I’ll create a protocol class to process messages. After that, both client and server refer to the package.

PS: it’s inconvenient to need TSC build after the contents in the Lib package are completed. It seems that if you use project references, this pain point can be well solved. However, I have tried it and it seems that TS node does not support it very well. Is it my wrong way to open it? I’m looking forward to someone telling me a better solution.

Build a packet.ts Used to define the message structure

export enum Types {
    ROOM_LIST = 1
}

type Room = {
    id: number,
    members: number[]
}

export interface HELLO_WORLD {
    action: Types.ROOM_LIST,
    rooms: Room[]
}

I expect to use it in the following form:

const msg = Protocol.CREATE_HELLO_WORLD(roomList);
// msg = {action:1, rooms:roomList}
let message = Protocol.decode(jsonString); 
//The type of message is message
//In this way, we can take advantage of the perceptual function of typescript message.action=1 Hello is obtained_ The corresponding intelligent prompt of world.

PS: in the decode method, you can even add the validity check of the package to check whether the values of each field match. This is a little more complicated. I will not do it for the moment.

typescriptThe compiler can generate ast based on TS file, which makes things easier. You just need to get every field of the message package and use the corresponding type writing method.

establishgenerater.js(for convenience, the generator does not need TS)

const fs = require('fs');
const ts = require('typescript');
const spawn = require('cross-spawn');
console.clear();
const args = process.argv.splice(2);
const source = args[0] || 'proto.ts';
const dest = args[3] || 'protocol.ts';

if (!fs.existsSync(source)) {
    console.error('Source declaration file does not exists');
    process.exit(0);
}

const program = ts.createProgram([source], {
    target: ts.ScriptTarget.Latest
});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(source);

let packets = [];
const creates = [];
ts.forEachChild(sourceFile, n => {
    if (ts.isInterfaceDeclaration(n)) {
        const symbol = checker.getSymbolAtLocation(n.name);
        const packetName = symbol.getName();
        let fn_args = [];
        let members = [];
        symbol.members.forEach(member => {
            const type = checker.getTypeOfSymbolAtLocation(member, member.valueDeclaration);
            const memberName = member.getName();
            if (!type.isLiteral()) {
                members.push(memberName)
                fn_args.push(`${memberName}:${checker.typeToString(type)}`)
            } else {
                members.push(`${memberName}:${type.value}`);
            }
        });
        const template = `export function CREATE_${packetName}(${fn_args.join(',')}): ${packetName} {
            return {${members.join(',')}}}`;
        creates.push(template);
        packets.push(packetName);
    }
});

let sourceContent = fs.readFileSync(source).toString();
const exportAllMessage = `export type Message = ` + packets.join(' | ') + ';';
sourceContent += '\r\n' + exportAllMessage + '\r\n';

sourceContent += creates.join('\r\n');
sourceContent += `\r\nexport function decode(raw: string): Message | undefined {
    let obj = undefined;
    try {
        obj = JSON.parse(raw);
    } catch (_) {
        return undefined;
    }
    if (!obj || !('action' in obj)) return undefined;
    return obj as Message;
}
`
fs.writeFileSync(dest, sourceContent);

const child = spawn('npx', ['tsc', '-d', 'protocol.ts'], { stdio: 'inherit' });
child.on('close', code => {
    if (code !== 0) {
        console.error('creation failed.')
    }
    console.log('done');
});

Next, I want to go to the server to try out the contractnpm linkOr useyarn workspace)

First add the relevant method parameter types.

import { Message } from 'lib/protocol';

//Connection.ts
send(message: Message) 

//Lobby.ts
 boardcast(message: Message, ignoreId?: number) 

modify LobbyProcessor.enter , the user enters the hall and sends it a list of rooms in the hall

 enter() {
    this.lobby.addPlayer(this.player);
    console.log(`Player ${this.player.name} joined. num of players:${this.lobby.numOfPlayers}`);
    this.conn.send(CREATE_ROOM_LIST(this.lobby.serializeRoomList()));
}

Serializeroomlist is to change the current room list into the format defined in protocol.

rooms: Room[] = [];
serializeRoomList() {
    return this.rooms.map(room => room.serialize())
}

New additions Room.ts Slowly fill it in.

import Player from "./Player";
let uniqueId = 100;
export default class Room {
    id: number;
    members: Player[] = [];
    constructor() {
        this.id = uniqueId++;
    }

    serialize() {
        return {
            id: this.id,
            members: this.members.map(p => p.id)
        }
    }
}

Step4 create room

Now the client connection is ready to receive the room list. The next step is to create a room.

The message to create a room is simple, just an action. Add the receiving method of the connection class

constructor() {
    ....
    socket.on('message', data => this.receiveData(data));
}

receiveData(data: WebSocket.Data) {
    const message = decode(data as string);
    message && this.processor.handle(message);
}

If you receive create_ Room package, inform the hall to create a room, the current player enters the room as the host, and changes the connection processor to room processor

LobbyProcessor.ts

handle(message: Message) {
    switch (message.action) {
        case Types.CREATE_ROOM:
            const room = this.lobby.createRoom(this.player);
            this.player.enterRoom(room);
            this.conn.processor = new RoomProcessor(this.conn, this.lobby, this.player);
            break;
    }
}

Lobby.createRoom

createRoom(host: Player) {
    const room = new Room();
    room.host = host;
    this.rooms.push(room);
    this.boardcast(CREATE_ROOM_LIST(this.serializeRoomList()), host.id);
    return room;
}

Player.ts

enterRoom(room: Room) {
    this.roomId = room.id;
    room.members.push(this);
}

leaveRoom(room: Room) {
    this.roomId = 0;
    room.removePlayer(this);
}

In roomprocessor, the enter method sends a message to the player to enter the room, and the hungup method does some cleaning work after the player exits.

enter() {
    const room = this.lobby.findRoom(this.player.roomId);
    room && this.conn.send(CREATE_ENTER_ROOM(room.id, room.host === this.player));
}

hungup() {
    this.lobby.removePlayer(this.player);
    if (this.player.roomId) {
        const room = this.lobby.findRoom(this.player.roomId);
        if (room) {
            this.player.leaveRoom(room);
            this.lobby.removeOfResetRoom(room);
        }
    }
    console.log(`Player ${this.player.name} left.`);
}

After the player exits the room, if there is no one else in the room, the room will be deleted. If the owner of the room is not in the room, it will inherit the general system in order. Lobby.ts

removeRoom(id: number) {
    for (let i = this.rooms.length - 1; i > -1; i--) {
        if (this.rooms[i].id === id) {
            this.rooms.splice(i, 1);
            break;
        }
    }
    this.boardcast(CREATE_ROOM_LIST(this.serializeRoomList()));
}

transferRoom(room: Room) {

}

removeOfResetRoom(room: Room) {
    if (room.members.length === 0) {
        this.removeRoom(room.id);
    } else if (!room.host) {
        this.transferRoom(room);
    }
}

The front end needs to do a little work to implement the room list and send the create room command

Connection.ts

connect() {
    ...
    this.ws.onopen = () => {
        this.readyState = ws.readyState;
        this.emit('connect');
    }

    this.ws.onmessage = (e) => {
        const message = decode(e.data);
        message && this.onMessage(message);
    }
}

onMessage(message: Message) {
    switch (message.action) {
        case Types.ROOM_LIST:
            this.emit('roomList', message.rooms);
            break;
        case Types.ENTER_ROOM:
            this.emit('enterRoom', message.roomId, message.isHost);
            break;
    }
}

send(message: Message) {
        this.ws?.send(JSON.stringify(message));
    }

createRoom() {
    this.send(CREATE_CREATE_ROOM());
}

enterRoom(id: number) {
    //
}

Lobby.tsx

export default function Lobby() {
    const history = useHistory();
    const [roomList, setRoomList] = useState<Room[]>([]);
    const [connected, setConnected] = useState(false);
    useEffect(() => {
        connection.on('connect', () => {
            setConnected(true);
        });
        connection.on('roomList', (rooms: Room[]) => {
            setRoomList(rooms);
        });
        connection.on('enterRoom', (roomId: number, isHost: boolean) => {
            history.push('/battle')
        });
        if (!connection.token) {
            history.push('/');

        } else {
            connection.connect();
        }
        return () => {
            connection.removeAllListeners();
        }
    }, [history])
    return (
        <div className="lobby">
            {
                roomList.map(room =>
                    (
                        <RoomCompoent
                            key={room.id}
                            {...room}
                            onClick={roomId => connection.enterRoom(roomId)}
                        />
                    )
                )
            }
            <button className="newRoom" disabled={!connected} onClick={() => connection.createRoom()}>NEW</button>
        </div>
    )
}

Put the call of connect here. Before the connection is successful, the new button cannot be clicked

Room.tsx

export default (props: RoomProps) => {
    const { id, members } = props;
    let className = 'room';
    if (members.length > 1)
        className += ' busy';

    const onClick = () => {
        (members.length === 1) && props.onClick(id);
    }
    return (
        <div className={className} onClick={onClick}>
            {members.map((m, i) => {
                const cls = i === 0 ? 'member' : 'member sec';
                return <div key={i} className={cls}></div>
            })}
            <div className="roomId">{id}</div>
        </div>
    )
}

The room component is very simple. It shows the room number and the players in the room. I use two circles. Take a look at the current effect:
Use react and websocket to make a game of Gobang

I thought that it should be done quickly, but after a long time of busy work, I started to start. Let’s go here first. Jiyou called me lol to fry fish, which is to be continued.

Code stamp here

Series two

Recommended Today

Basic knowledge of react

React.FunctionComponent ReactProvides a component typeReact.FunctionComponent, can be abbreviatedReact.FC, Can receive a genericp, the default is{} children, return aReact.ReactNodeThis onechildrenIt’s anythingcomponentAll of them Static propertiesdefaultProps, the default property of the component, which can not be passed externally. interface IHelloProps { message?: string; } const Hello: React.FunctionComponent<IHelloProps> = (props) => { return <h2>{props.message}</h2>; }; Hello.defaultProps = { […]