Multi Arenas and Multi Players Tic-Tac-Toe Web Game powered by React V16, NodeJS V14 and socket.io V2.3
- Clone the project
- On the working folder call
npm install
npm run server- spin up the servernpm start- building the client and openlocalhost:3000on default browser
The whole project base on React and socket.io and some hands-write libs of mine.
Client side are heavily depends on useSocket, a custom React Hooks that brings models updates from the server.
Server heavily depends on ReactiveModel, a custom data structure that base on JS proxy, to know and response to any
change on the Arena/Game/User models.
Response can be as a sent model update to the client through socket.io
or let the AI know about the change and respond to it.
Game represented by Arena and Game ideas.
Arena is all about to settle place for the game to run,
and it means a different things in the client and in the server.
On the client, Arena component is the dimension place that the game can show all is stages:
INVITATION GAME END CANCEL LOADING.
On the server, Arena is a function that warp the game instance and take care of everything
that should me made to have a game that can be played. like: socket connection, memory free,
tracks Users and game models and emit the changes, etc.
In this architecture Game are isolated and focused on updates his model.
So different games can be established with the same arena wrapper function.
Before we dive, some - Helpers
Or start diving
- App
- Header
- Login User
- Title
- Connectivity indicator Symbol
- Main
- Router path=/register
- Register
- Router path=/
- Arenas
- UserList, two views. top10 and all users
- For each user's arenas: Arena[Stage=INVITATION|GAME|CANCEL|LOADING]
- Arenas
- Router path=/register
- Header
Arena can be in 4 stage
- INVITATION - in this stage Invited user asked to Approve or Decline the invitation. the inviting user see waiting screen with Abort Button.
- GAME - in this stage players see the board and all the need for playing
- CANCEL - all player see cancel message on the screen with Ok button to remove the Arean.
- LOADING - default stage, show when Arena whiting for game model to arrive from server.
Data comes from the server on a socket.io stream.
socket.io-client open one connection to the server.
And split it, virtually, to separate namespace under the main namespace.
Before any game can be made, client send the value from localstorage.userId to
the server on uid search query parameter.
If uid exist on the server's users collection user.model send back.
Then client navigate to main page of game.
Until then registered-form page showed.
Every time user click on user on User List Componnent, a challenge event send to server with the user to challenge.
and a list of all arenas-id send back on arenas event.
It is the responsible of the client to use each areana-id to connect to corresponding
/game-${game.id} namespace and listen to update of Arena model from there.
So on the client side that arena-id's list used to create Arena Component
and Arena Compoent use the custom hook useSocket to be regularly updated from game-${game.id}/update
namespace/event
useSocket hook also return the socket that used for the connection (each namespace has different socket instance).
so arena component can used it to emit updates back to the arena instance on the server.
From Arena Compoent it just simple Functional Component all the way down, without any special logic
The server implantation is relatively simple.
socket.io establish listeners for connections request and manage all
connections to the client.
express are added after thought just to bring sanity test on the remote server.
On each new connection the server get uid from search query request.
And check if that uid is a id of some registered user.
If some user found with that id server emits 3 different events back to client through socket.io.
userevent with theuser.modelarenasevent with a list of allarenas idthat this user belongs tousers listevent with the list ofusers modelthat exist on this server
Also, server start to listen for 2 events on that socket.
challengeevent, that called withuserto challenge, and createArenafor them to manage the battle.remove-arenaevent, that called witharena-idto remove arena from user's arena list
If user not exist, the server listen for user-register event
and wait it called with with the name of new user. after that server create new user and start
listen to the event mentioned above.
When Arena "created" it actually created a instance of Game
and open namespace call game-${game.id}.
The game.id also added to two users .arenas list . that action make the all .arenas list
send back to the clients. clients use this ids to establish the connection to the arena's namesapce.
So they can start got updates of the game model on update event.
Each socket that connect to game's namespace got 3 listeners for events that trigger actions that managed the game
cancelevent, to cancel game before it start. either from the inviting or invited users.approveevent, to approve game and start it by the invited user.playerSelectCellevent, to make a move on ongoing game.
socket also got another kind of update, on a game-errors event.
That event sent a specific errors just to user's socket that made the errors.
After each action any update to game-model emitted to client with update event, so the loop is close.
There is 2 main data model that used for this app in the server, User and Game,
and 2 collections Singletons to manage them, Users and Arenas.
Represent by:
class User {
constructor(name) {
this.id = uid();
this.model = ReactiveModel({
id: this.id,
name,
color: randomColor(),
score: 0,
disconnect: false
})
/* arenas id of this user */
this.arenas = new ReactiveSet();
}
connect(){
// change model disconnected to false
}
disconnect() {
// cancel all games participation in;
// change model disconnected to true
}
}Other parts of the code tracks after user.model and user.arenas. user.model is what consider
user for the client. so it is the public trackble and reflected part of the user that sent to the client.
user.arenas is a list of all arenas id that user participate in. It also trackable and reflected to the client.
Arenas is outside of the user.model because it not realy what made the users, and it changed a lot.
so it designs separate from the user model.
although technically if it was in user model the code was still work;
Users are singleton that inherited from LetMap. it manages the all users instance
and supply create that behave as a factory function that warp the user instance
and proxy his model change outside so who can listen to User.on('update, cb) notify
also when user model update.
new class Users extends LetMap {
create(name, useAI = false) {
// create user instance and register it
// proxy `user.model` change as `Users.on('update', cb)`
// warp the user with AI if asked
}
list(withDisconnected = false) {
// return all users model for send to socket client.
// can filter out disconnected or not
}
clearDisconnectUsers(){
// check all register users and delete all of them that disconnected and not
// participate in any game
}
}Represent by:
class Game {
constructor(user1, user2, turnTime = 5) {
this.id = uid();
this.model = ReactiveModel({
id: this.id,
playersId: [user1.id, user2.id],
board: Array(9).fill(''),
turn: 0,
nextTurn: 0,
isStarted: false,
isCanceled: false,
turnTime,
stage: 'INVITATION',
})
this.errors = new LetMap(userid => ReactiveModel([]))
}
cancel(userid, force = false) {
// change model stage to 'CANCEL'
// stop turn time out timer
// recored who cancel the game
}
approve(userid) {
// change model stage to 'GAME'
// start turn timer
}
selectCell(userid, cellNumber) {
// check if the move is ligal
// update model board with the new move
// check for and condiations, and mark the wins and losers
}
}So basically it what you accept from a tic-tac-teo game.
game.model is the part that represent and reflected to the clients.
game.errors is a channel for each player to know his errors.
Arenas are singleton that inherited from LetMap. it manages the all games instance
and supply createGame that behave as a factory function that warp the game instance
and provide all it need to function on the serve without expose it to the connection architecture,
and the real user instance.
new class Arenas extends LetMap {
createGame(user1 : User, user2 : User) {
// check ligal stuff
// create a game instance and register it
// listen to users areans changes, and `Users` to know if it the time to free up the game
// establis namespace socket connection for the game
// listen to game`s model change and reflect it to the sockets clients
// listen to commands that come from players socket and moved them to game methods
// update user instance with the right score
}
list() {
// return all games instances
}
attach(io) {
// helpers to set the main socket connection for `createGame` function
}
}export default LetMap extend Map, mixed with EventEmitter
Class that extend the regular js Map and expose new method
for(k). That method brings the value's key if it exist
or if not exist create it with pre provide template .
LetMap also emits events on any keys updates or create.
import LetMap from './helpers/let-map'
const sockets = new LetMap( nsp => new Manger.socket(nsp))
//... later on code
const socket = sockets.for(`game/game-${gameId}`) Create a new instance of the data structure LetMap
| Parameter | type | description |
|---|---|---|
struct |
any\function |
move directly to setStuct(struct) go there for more details |
Save the template struct for missing keys For future use by for(k) method.
| Parameter | type | description |
|---|---|---|
struct |
object\function\primitive |
Template for the letmap.for(). used to creating new keys. |
This method is like map.get but if k not exist it creates & return it based on struct template.
It work Like that:
If struct is function it called with the k and return's value is the value of that new key.
If struct.constructor exist it used to create the new value for the key. new struct.constructor.
If any of that. struct assume as primitive value and used as-is to create the new value's key.
| Parameter | type | description |
|---|---|---|
k |
any |
used as the key of extended Map |
Register callback to specific key change.
| Parameter | type | description |
|---|---|---|
eventname |
string |
Eventname is the key that update. also update is a special event that emit every time set was updated. |
fn |
function |
called as fn(new value, old value) |
Thin wrapper around EventEmitter.off
Used for manual emit events and let all callbacks to know that.
Some object value of some key updated (because letMat not do any deep checking).
After call it, it also emit general update event
| Parameter | type | description |
|---|---|---|
key |
string |
event name should be the name of the key. |
nv ov ... |
any |
all arguments transfer as is to EventEmitter.emit |
Behave exactly like Map.set but emit events
Behave exactly like Map.get
export default ReactiveModel factory
Factory (that can used as decorator in future js) that return deep
trackable object. the model expose observe(cb) and any changed to any key of the model in any deep,
even if the key value assigned to different variable, call cb(model).
As a side effect any object that get from the ReactiveModel is also ReactiveModel with
is own observe method
const model = ReactiveModel({
id: 1234,
board: Array.from(Array(9)),
turn: 0,
})
model.observe(model=>{
socket.emit('game-update', model)
})
// later on the code
model.board[4] = 'X'; // in the next eventloop's tick callbacks calledCreate and return the ReactiveModel around the model. orginal model not touched at all.
| Parameter | type | description |
|---|---|---|
| model | object | object to deep track-proxy on |
Register cb function to called on any model's changed.
It returns disconnect function that remove the cb when called.
| Parameter | type | description |
|---|---|---|
| cb | function(model) | function to call on any model change with |
export default ReactiveSet extends Set
The name is similar, but it almost not related to ReactiveModel. Expect from they both have the observe function.
this thin extends of Set bring observer(cb) that called immediate each time value added or delete from the set
and when ReactiveSet cleared.
import ReactiveSet from './helpers/reactive-set'
const arenas = ReactiveSet();
//later in the code
socket.on('challange', _=>{
arena.add( new Arena(user1Id, user2Id) )
})
// other place in the code
const disconnect = arenas.observe( (action,arena) =>{
if(action==="ADD")
arena.model.observe( makeMove );
if(action === "DELETE")
arena.model.unobserve( makeMove );
})create new instance of ReactiveSet()
| Parameter | type | description |
|---|---|---|
as Set |
any | got any argument that constracture of Set can get |
register a cb that called cb(actionType, value, diconectedFn) every time ReactiveSet updated.
i.e. after ADD DELETE or CLEAR
| Parameter | type | description |
|---|---|---|
| cb | function | function to call after set update. cb called with actionType , value that we mention, and unregistered callback to remove this callback |
remove cb from callback updated list
Behave the same as Set expect. but trigger the callback's observed.
export useSocket(namespace/event: string, defaultValue: any)export useLoginUser()export useIsConnected()
const [user, socket] = useSocket('user',{})
// or
const [game, gameSocket] = useSocket('game-${id}/update', {});Hook that let component to get last data from {namespace}/{event}'s socket.io's stream.
And bring the socket.io's socket that created and used specialy for that namespace,
so a component can send back notification to exact namespace connection on the server.
Under the surface the module listen all events from socket.io
and save them on LetMap under namespace/event key. so it ready when useSocket ask for them.
It also creates react's useState and register it's setter so when data on that key updated
it can force update the component
const loginUser = useLoginUser()Thin wrapper around useSocket('user', {}) that just return
the login user.
const isConnected = useConnected()Return the status of main mangaer.connected, and force render when status change from
connected to disconnected
- There no use of redis to help with scaling
socket.io, and the architecture not tested on scaling environment - There is no use for any redundant
reduxstyle design pattern as it unnecessary and make the code bloat - Try to make the code more readable than magic. But for Infrastructure I must do some deep magic, for it to work cleanr in other parts;
- some workarround because bugs on
socket.iolib and using same helpers lib on server and client
- All events registered are literal string for the simplicity and clarity
- No implantation yet for history playback . There is no reason not to add in the future.
- most of the file un documents because lake of time and believe of self explanation.
- implement user disconnect from the server as garbage-collector methods. every
Xseconds. - no implement for limit number of users that send back to each new connection
- no specific time-out
turnMarkercomponent, I just reuseUsercomponent on color view
This project start as challenge from TalkSpace Company that ask for implement Tic-Tac-Toe game.
All right reserve to pery mimon that this project created by him. There is a right to learn and clone this code for commercial and study propose, Provided that:
- Add credit to Pery Mimon on your
Readme.mdof your source code - Link to this original repository. so peoples can start it
- Notify me by email to pery.mimon@gmail.com about the credit.
