diff --git a/Makefile b/Makefile index 5e398654..3a4c04e9 100644 --- a/Makefile +++ b/Makefile @@ -73,13 +73,11 @@ dev.build-local: dev.run: dev.build-local ./bin/overlord --v=5 & - ./bin/overworker --overlordhost ws://localhost:8000/wso - - + ./bin/overworker --overlordhost localhost:8000 dev.run-docker: docker build . -t cloud-game-local docker stop cloud-game-local || true docker rm cloud-game-local || true # Overlord and worker should be run separately. - docker run --privileged -v $PWD/games:/cloud-game/games -d --name cloud-game-local -p 8000:8000 -p 9000:9000 cloud-game-local bash -c "overlord --v=5 & overworker --overlordhost ws://localhost:8000/wso" + docker run --privileged -v $PWD/games:/cloud-game/games -d --name cloud-game-local -p 8000:8000 -p 9000:9000 cloud-game-local bash -c "overlord --v=5 & overworker --overlordhost localhost:8000" diff --git a/README.md b/README.md index e08b087a..4278d7d4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Crowd play: **[Pokemon Emerald](http://cloudretro.io/?id=652e45d78d2b91cd%7CPoke 2. Cross-platform compatibility: The game is run on web browser, the most universal built-in app. No console, plugin, external app or devices are needed. Chrome with the latest version and fully WebRTC support is recommended for the game. 3. Emulator agnostic: The game can be played directly without any extra effort to set up the gaming emulator or platform. 4. Collaborate gameplay: Follow the idea of crowdplay([TwitchPlaysPokemon](https://en.wikipedia.org/wiki/Twitch_Plays_Pok%C3%A9mon)), multiple players can play the same game together by addressing the same deeplink. The game experience is powered by cloud-gaming, so the game is much smoother. [Check CrowdPlay section](#crowd-play-play-game-together) -5. Vertically scaled: The infrastructure is designed to be able to scale under high traffic by adding more instances. +5. Horizontally scaled: The infrastructure is designed to be able to scale under high traffic by adding more instances. 6. Cloud storage: Game state is storing on online storage, so you can come back and continue playing your incomplete game later. 7. Online multiplayer: 2nd, 3rd player can join the same game in the same room. diff --git a/docs/img/overlord.png b/docs/img/overlord.png index 0723ff04..9eb927be 100644 Binary files a/docs/img/overlord.png and b/docs/img/overlord.png differ diff --git a/pkg/config/config.go b/pkg/config/config.go index 9e55f7eb..042a8757 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,7 +5,6 @@ import ( "time" ) -const defaultoverlord = "ws://localhost:9000/wso" const DefaultSTUNTURN = `[{"urls":"stun:stun-turn.webgame2d.com:3478"},{"urls":"turn:stun-turn.webgame2d.com:3478","username":"root","credential":"root"}]` const CODEC_VP8 = "VP8" const CODEC_H264 = "H264" diff --git a/pkg/config/worker/config.go b/pkg/config/worker/config.go index b80bf3b0..33f79284 100644 --- a/pkg/config/worker/config.go +++ b/pkg/config/worker/config.go @@ -14,6 +14,7 @@ type Config struct { EnableAspectRatio bool Width int Height int + Zone string MonitoringConfig monitoring.ServerMonitoringConfig } @@ -21,11 +22,12 @@ type Config struct { func NewDefaultConfig() Config { return Config{ Port: 8800, - OverlordAddress: "ws://localhost:8000/wso", + OverlordAddress: "localhost:8000", Scale: 1, EnableAspectRatio: false, Width: 320, Height: 240, + Zone: "", MonitoringConfig: monitoring.ServerMonitoringConfig{ Port: 6601, URLPrefix: "/worker", @@ -37,6 +39,7 @@ func NewDefaultConfig() Config { func (c *Config) AddFlags(fs *pflag.FlagSet) *Config { fs.IntVarP(&c.Port, "port", "", 8800, "OverWorker server port") fs.StringVarP(&c.OverlordAddress, "overlordhost", "", c.OverlordAddress, "OverWorker URL to connect") + fs.StringVarP(&c.Zone, "zone", "z", c.Zone, "Zone of the worker") fs.IntVarP(&c.Scale, "scale", "s", c.Scale, "Set output viewport scale factor") fs.BoolVarP(&c.EnableAspectRatio, "ar", "", c.EnableAspectRatio, "Enable Aspect Ratio") diff --git a/pkg/overlord/handlers.go b/pkg/overlord/handlers.go index b055c5eb..634a396d 100644 --- a/pkg/overlord/handlers.go +++ b/pkg/overlord/handlers.go @@ -7,7 +7,6 @@ import ( "html/template" "log" "math" - "math/rand" "net/http" "strings" @@ -25,9 +24,9 @@ const ( type Server struct { cfg Config - // roomToServer map roomID to workerID - roomToServer map[string]string - // workerClients are the map serverID to worker Client + // roomToWorker map roomID to workerID + roomToWorker map[string]string + // workerClients are the map workerID to worker Client workerClients map[string]*WorkerClient } @@ -40,7 +39,7 @@ func NewServer(cfg Config) *Server { // Mapping serverID to client workerClients: map[string]*WorkerClient{}, // Mapping roomID to server - roomToServer: map[string]string{}, + roomToWorker: map[string]string{}, } } @@ -80,8 +79,11 @@ func (o *Server) WSO(w http.ResponseWriter, r *http.Request) { // Register to workersClients map the client connection address := util.GetRemoteAddress(c) - fmt.Println("Is public", util.IsPublicIP(address)) - fmt.Println("Mode: ", *config.Mode, config.ProdEnv) + // Zone of the worker + zone := r.URL.Query().Get("zone") + + fmt.Printf("Is public: %v zone: %v\n", util.IsPublicIP(address), zone) + if !util.IsPublicIP(address) && *config.Mode == config.ProdEnv { // Don't accept private IP for worker's address in prod mode // However, if the worker in the same host with overlord, we can get public IP of worker @@ -93,7 +95,7 @@ func (o *Server) WSO(w http.ResponseWriter, r *http.Request) { return } } - client := NewWorkerClient(c, serverID, address, fmt.Sprintf(config.StunTurnTemplate, address, address)) + client := NewWorkerClient(c, serverID, address, fmt.Sprintf(config.StunTurnTemplate, address, address), zone) o.workerClients[serverID] = client defer o.cleanConnection(client, serverID) @@ -110,9 +112,9 @@ func (o *Server) WSO(w http.ResponseWriter, r *http.Request) { client.Listen() } -// WSO handles all connections from frontend to overlord +// WSO handles all connections from user/frontend to overlord func (o *Server) WS(w http.ResponseWriter, r *http.Request) { - log.Println("Browser connected to overlord") + log.Println("A user connected to overlord ", r.URL) defer func() { if r := recover(); r != nil { log.Println("Warn: Something wrong. Recovered in ", r) @@ -134,18 +136,28 @@ func (o *Server) WS(w http.ResponseWriter, r *http.Request) { // get roomID if it is embeded in request. Server will pair the frontend with the server running the room. It only happens when we are trying to access a running room over share link. // TODO: Update link to the wiki roomID := r.URL.Query().Get("room_id") + // zone param is to pick worker in that zone only + // if there is no zone param, we can pic + userZone := r.URL.Query().Get("zone") + if roomID != "" { log.Printf("Detected roomID %v from URL", roomID) - if workerID, ok := o.roomToServer[roomID]; ok { + if workerID, ok := o.roomToWorker[roomID]; ok { workerClient = o.workerClients[workerID] - log.Printf("Found running server with id=%v client=%v", workerID, workerClient) + if userZone != "" && workerClient.Zone != userZone { + // if there is zone param, we need to ensure ther worker in that zone + // if not we consider the room is missing + workerClient = nil + } else { + log.Printf("Found running server with id=%v client=%v", workerID, workerClient) + } } } // If there is no existing server to connect to, we find the best possible worker for the frontend if workerClient == nil { // Get best server for frontend to connect to - workerClient, err = o.getBestWorkerClient(client) + workerClient, err = o.getBestWorkerClient(client, userZone) if err != nil { return } @@ -187,7 +199,7 @@ func (o *Server) WS(w http.ResponseWriter, r *http.Request) { wssession.WorkerClient.IsAvailable = true } -func (o *Server) getBestWorkerClient(client *BrowserClient) (*WorkerClient, error) { +func (o *Server) getBestWorkerClient(client *BrowserClient, zone string) (*WorkerClient, error) { if o.cfg.DebugHost != "" { log.Println("Connecting to debug host instead prod servers", o.cfg.DebugHost) wc := o.getWorkerFromAddress(o.cfg.DebugHost) @@ -200,14 +212,7 @@ func (o *Server) getBestWorkerClient(client *BrowserClient) (*WorkerClient, erro workerClients := o.getAvailableWorkers() - var serverID string - var err error - if config.MatchWorkerRandom { - serverID, err = findBestServerRandom(workerClients) - } else { - serverID, err = findBestServerFromBrowser(workerClients, client) - } - + serverID, err := findBestServerFromBrowser(workerClients, client, zone) if err != nil { log.Println(err) return nil, err @@ -239,27 +244,9 @@ func (o *Server) getWorkerFromAddress(address string) *WorkerClient { return nil } -// findBestServer returns the best server for a session -func findBestServerRandom(workerClients map[string]*WorkerClient) (string, error) { - // TODO: Find best Server by latency, currently return by ping - if len(workerClients) == 0 { - return "", errors.New("No server found") - } - - r := rand.Intn(len(workerClients)) - for k := range workerClients { - if r == 0 { - return k, nil - } - r-- - } - - return "", errors.New("No server found") -} - // findBestServerFromBrowser returns the best server for a session // All workers addresses are sent to user and user will ping to get latency -func findBestServerFromBrowser(workerClients map[string]*WorkerClient, client *BrowserClient) (string, error) { +func findBestServerFromBrowser(workerClients map[string]*WorkerClient, client *BrowserClient, zone string) (string, error) { // TODO: Find best Server by latency, currently return by ping if len(workerClients) == 0 { return "", errors.New("No server found") @@ -277,6 +264,11 @@ func findBestServerFromBrowser(workerClients map[string]*WorkerClient, client *B // get the worker with lowest latency to user for wc, l := range latencies { + if zone != "" && wc.Zone != zone { + // skip worker not in the zone if zone param is given + continue + } + if l < minLatency { bestWorker = wc minLatency = l @@ -328,9 +320,9 @@ func (o *Server) cleanConnection(client *WorkerClient, serverID string) { // Remove serverID from servers delete(o.workerClients, serverID) // Clean all rooms connecting to that server - for roomID, roomServer := range o.roomToServer { + for roomID, roomServer := range o.roomToWorker { if roomServer == serverID { - delete(o.roomToServer, roomID) + delete(o.roomToWorker, roomID) } } diff --git a/pkg/overlord/worker.go b/pkg/overlord/worker.go index e303fc8a..03a28b85 100644 --- a/pkg/overlord/worker.go +++ b/pkg/overlord/worker.go @@ -13,6 +13,7 @@ type WorkerClient struct { Address string StunTurnServer string IsAvailable bool + Zone string } // RouteWorker are all routes server received from worker @@ -21,8 +22,8 @@ func (o *Server) RouteWorker(workerClient *WorkerClient) { // RoomID is global so it is managed by overlord. workerClient.Receive("registerRoom", func(resp cws.WSPacket) cws.WSPacket { log.Printf("Overlord: Received registerRoom room %s from worker %s", resp.Data, workerClient.ServerID) - o.roomToServer[resp.Data] = workerClient.ServerID - log.Printf("Overlord: Current room list is: %+v", o.roomToServer) + o.roomToWorker[resp.Data] = workerClient.ServerID + log.Printf("Overlord: Current room list is: %+v", o.roomToWorker) return cws.WSPacket{ ID: "registerRoom", @@ -32,8 +33,8 @@ func (o *Server) RouteWorker(workerClient *WorkerClient) { // closeRoom event from a worker, when worker close a room workerClient.Receive("closeRoom", func(resp cws.WSPacket) cws.WSPacket { log.Printf("Overlord: Received closeRoom room %s from worker %s", resp.Data, workerClient.ServerID) - delete(o.roomToServer, resp.Data) - log.Printf("Overlord: Current room list is: %+v", o.roomToServer) + delete(o.roomToWorker, resp.Data) + log.Printf("Overlord: Current room list is: %+v", o.roomToWorker) return cws.WSPacket{ ID: "closeRoom", @@ -43,10 +44,10 @@ func (o *Server) RouteWorker(workerClient *WorkerClient) { // getRoom returns the server ID based on requested roomID. workerClient.Receive("getRoom", func(resp cws.WSPacket) cws.WSPacket { log.Println("Overlord: Received a getroom request") - log.Println("Result: ", o.roomToServer[resp.Data]) + log.Println("Result: ", o.roomToWorker[resp.Data]) return cws.WSPacket{ ID: "getRoom", - Data: o.roomToServer[resp.Data], + Data: o.roomToWorker[resp.Data], } }) @@ -56,12 +57,13 @@ func (o *Server) RouteWorker(workerClient *WorkerClient) { } // NewWorkerClient returns a client connecting to worker. This connection exchanges information between workers and server -func NewWorkerClient(c *websocket.Conn, serverID string, address string, stunturn string) *WorkerClient { +func NewWorkerClient(c *websocket.Conn, serverID string, address string, stunturn string, zone string) *WorkerClient { return &WorkerClient{ Client: cws.NewClient(c), ServerID: serverID, Address: address, StunTurnServer: stunturn, IsAvailable: true, + Zone: zone, } } diff --git a/pkg/worker/handlers.go b/pkg/worker/handlers.go index d4014380..d8f3ac0c 100644 --- a/pkg/worker/handlers.go +++ b/pkg/worker/handlers.go @@ -2,6 +2,7 @@ package worker import ( "log" + "net/url" "os" "path" "time" @@ -58,7 +59,7 @@ func NewHandler(cfg worker.Config) *Handler { // Run starts a Handler running logic func (h *Handler) Run() { for { - oClient, err := setupOverlordConnection(h.overlordHost) + oClient, err := setupOverlordConnection(h.overlordHost, h.cfg.Zone) if err != nil { log.Println("Cannot connect to overlord. Retrying...") time.Sleep(time.Second) @@ -74,8 +75,16 @@ func (h *Handler) Run() { } } -func setupOverlordConnection(ohost string) (*OverlordClient, error) { - conn, err := createOverlordConnection(ohost) +func setupOverlordConnection(ohost string, zone string) (*OverlordClient, error) { + overlordURL := url.URL{ + Scheme: "ws", + Host: ohost, + Path: "/wso", + RawQuery: "zone=" + zone, + } + log.Println("Worker connecting to overlord:", overlordURL.String()) + + conn, err := createOverlordConnection(overlordURL.String()) if err != nil { return nil, err } diff --git a/web/js/init.js b/web/js/init.js index 87d49689..596731c6 100644 --- a/web/js/init.js +++ b/web/js/init.js @@ -7,7 +7,7 @@ $(document).ready(() => { joystick.init(); touch.init(); - const roomId = room.loadMaybe(); + [roomId, zone] = room.loadMaybe(); // if from URL -> start game immediately! - socket.init(roomId); + socket.init(roomId, zone); }); diff --git a/web/js/network/socket.js b/web/js/network/socket.js index 52eb9823..3b111352 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -11,8 +11,10 @@ const socket = (() => { let conn; let curPacketId = ''; - const init = (roomId) => { - conn = new WebSocket(`ws://${location.host}/ws${roomId ? `?room_id=${roomId}` : ''}`); + const init = (roomId, zone) => { + const paramString = new URLSearchParams({roomId: roomId, zone: zone}) + + conn = new WebSocket(`ws://${location.host}/ws?${paramString.toString()}`); // Clear old roomID conn.onopen = () => { diff --git a/web/js/room.js b/web/js/room.js index c1a7a6e8..ef9866f5 100644 --- a/web/js/room.js +++ b/web/js/room.js @@ -11,7 +11,11 @@ const room = (() => { // !to rewrite const parseURLForRoom = () => { let queryDict = {}; + let regex = /^\/?([A-Za-z]*)\/?/g; + var zone = regex.exec(location.pathname)[1] + var room = null + // get room from URL location.search.substr(1) .split('&') .forEach((item) => { @@ -19,10 +23,10 @@ const room = (() => { }); if (typeof queryDict.id === 'string') { - return decodeURIComponent(queryDict.id); + room = decodeURIComponent(queryDict.id); } - return null; + return [room, zone]; }; event.sub(GAME_ROOM_AVAILABLE, data => { @@ -50,12 +54,15 @@ const room = (() => { //roomID = loadRoomID(); // Shared URL second - const parsedId = parseURLForRoom(); + const [parsedId, czone] = parseURLForRoom(); if (parsedId !== null) { id = parsedId; } + if (czone !== null) { + zone = czone; + } - return id; + return [id, zone]; }, copyToClipboard: () => { const el = document.createElement('textarea');