Cluster SpaceWar2
Overview
The SpaceWar2 example is a tribute to the homonymous game developed in the 60s, one of the earliest computer games in history! The game was originally developed for standalone SmartFoxServer 2X; in this tutorial we discuss its porting to the SmartFoxServer 2X Cluster in the Overcast environment.
The original purpose of this example was to showcase the capabilities of SmartFoxServer's MMORooms in a realtime game featuring flying starships, weapon shots, collisions, etc. This is still what you will find in this version, but you will also learn how logic can be split between two server-side Extensions, one deployed on the Lobby Node and one on the Game Nodes, and how the two Extensions can, to a certain extent, interoperate.
The example implements a structure and an application flow very similar to those discussed in the Cluster Tic-Tac-Toe tutorial, including the buddy list management, user profile management, invitations and non-trivial match-making logic, but with the latter moved on the server side.
After the steps to connect to the Lobby Node and login are executed in the Login scene, the Lobby scene allows interacting with buddies or launch a new game by selecting one of the available levels. When the connection to the Game Node is completed and the target Game Room joined, the game switches to the Game scene, where the user can select a starship (among three types with different characteristics) while waiting for more players to join. Here the user can also invite buddies to join. When the minimum number of players is reached, a countdown is launched and the actual game starts. All starships are spawned in a region of space and can be controlled using the keyboard: left and right arrow keys to rotate the starship, up arrow key to activate the thruster, space key and "c" key to fire the weapons.
In this document we assume that you already went through the previous cluster tutorials, where we explained the subdivision of the application into three scenes, how to create a GlobalManager class to share the connections to both the Lobby and Game Nodes among scenes, and the buddy list, user profile and invitations implementation.
Download
Click on the following button to download the complete pack of Unity examples for the cluster.
Download examples packSetup & run
In order to setup and run the example, follow these steps:
- unzip the examples package;
- make sure you have a cluster running in Overcast, possibly with a single Game Node;
- connect to the Lobby Node with the AdminTool, go to the Extension Manager module and create the SpaceWar2-Cluster folder, then upload the content of folder /Server/Cluster_SpaceWar2/Extension-Java/deploy/lobby to it;
- in the AdminTool, go to Zone Configurator module → Cluster Zone → Zone Extension tab and configure the Extension:
- Name: SpaceWar2-Cluster
- Type: JAVA
- Main class: sfs2x.extensions.games.spacewar2.lobby.SW2ZoneExtension
- in the AdminTool, go to Zone Configurator module → Cluster Zone → Buddy List tab and activate the Buddy List system;
- restart the Lobby Node, so that the updated Zone configuration is loaded and the Extension initialized;
- connect to the Game Node with the AdminTool, go to the Extension Manager module and create the SpaceWar2-Cluster folder, then upload the content of folder /Server/Cluster_SpaceWar2/Extension-Java/deploy/game to it (*);
- launch the Unity Hub, click on the Open button and navigate to the /Client/Cluster_SpaceWar2 folder;
- if prompted, select the Unity Editor version to use (v2021.3 or later is recommended);
- go to the Project panel, click on the /Assets/Scenes folder and double click on the Login scene to open it;
- click on the Controller game object and change the Host setting in the Editor's Inspector panel to the IP address or domain name of your Lobby Node;
- click on the Play button to run the example in the Unity Editor, or go to the Build settings panel and build it for your target platform of choice.
The client's C# code is in the /Assets/Scripts folder and the SmartFoxServer 2X client API DLLs are in the /Assets/Plugins folder. Read this introduction to understand why multiple DLLs are used.
Server-side Extensions
The game features two server-side Java Extensions: a Zone Extension deployed on the Lobby Node and a Room Extension deployed on the Game Node/s.
In order to access their source code, create and setup a new project in your Java IDE of choice as described in the Writing the first Java Extension tutorial of the SmartFoxServer 2X documentation. Copy the content of the /Server/Cluster_SpaceWar2/Extension-Java/src folder to your project' source folder. Note that when building the project, you will need to generate two separate jar files, one for the Lobby Node and one for the Game Node, containing the classes under packages /sfs2x/extensions/games/spacewar2/lobby + .../shared and /sfs2x/extensions/games/spacewar2/game + .../shared respectively.
(*) If more than one Game Node is running in your cluster, you will need to either execute step 7 above on each node, or follow the procedure described under Cluster Extension deployment.
The game logic
The logic of the cluster version of the SpaceWar2 game is the same we discussed in SmartFoxServer documentation for its standalone version.
As soon as the minimum number of players required to play the game is reached inside the Game Room, a countdown signals the game start. A simulation is then started on both the client and server side. The client collects the user input (thruster, weapon firing) and sends requests to the Game Node. The server side logic validates the requests, updates the game state and the server-side simulation accordingly, then sends updates to the clients so that they can synchronize their simulation as well.
You should refer to the original tutorial for an in-depth description and analysis of the following topics:
- the MMORoom entity, the SmartFoxServer Room type used to represent the game, and its configuration;
- the strategies and techniques adopted to deal with clients synchronization in this type of realtime game (movement prediction, interpolation, latency compensation, etc);
- the game entities configuration;
- the behavior between the Game Room joining instant and the actual game start;
- the proximity update implementation, which takes care of showing and hiding enemy starships and weapon shots depending on their spatial distance from the player starship;
- the implementation of starship controls, weapons firing and collisions detection, both on the client and the server side.
Where needed, in the next section of this tutorial discussing the game flow, we will highlight the minor differences in game logic between the cluster version and the standalone version.
Introduction to code
Let's briefly discuss how the code of this example is laid out.
Client code
The structure of the client code is the same we already discussed in previous cluster tutorials, including the GlobalManager singleton class used to share the connections to the Lobby and Game Nodes among the example scenes and the BuddyChatHistoryManager static class serving the same buddy list features implementation from the Cluster Tic-Tac-Toe example.
With respect to that example, this version of the LobbySceneController class has a few differences which match the approach of the standalone version of SpaceWar2, as further discussed in the next section of this tutorial:
- the code to send invitations to buddies has been removed: this feature is here implemented by the Game scene, where players can invite friends while waiting for the game to start;
- a single player property (experience) is used by the match-making system; this is still editable on the client side for testing purpose, but the server-side Extension also updates it when a game ends based on the player performance;
- the logic to join or create a Game Room is located on the server side entirely, and it is invoked by a dedicated
ExtensionRequestinstance sent to the Lobby Node when the level to play is selected by the user.
Just like in the version of SpaceWar2 for SmartFoxServer standalone, the Game scene contains a dedicated game object called Game Engine. The client-side simulation logic is implemented by the GameEngine class attached to that game object, while the GameSceneController class is in charge of the communication with the Game Node (and marginally with the Lobby Node) as it takes care of listening to events fired by the game engine, send requests to the server and handle all the game- and non-game-specific SmartFox events, updating the game state on the client as needed and the game view accordingly.
Server code
As already mentioned, on the server side this example makes use of both a Zone Extension and a Room Extension.
The Zone Extension takes care of loading the game configuration contained in the external SpaceWar2.config file (global settings, game levels, starships and weapons). This is shared in the cluster by means of Cluster Globals.
The Extension also receives from clients the request to enter a game: it locates a suitable Game Node, looks for an existing MMORoom meeting the requirements defined by match criteria (or, if one can't be found, creates a new MMORoom) and joins the requester in. The MMORoom search-or-create procedure is executed automatically by the Lobby Node thanks to the server-side Cluster API's quickJoinOrCreateRoom() method we will discuss later.
The Zone Extension is statically assigned to the predefined Cluster Zone on the Lobby Node.
The Room Extension transfers the game configuration to clients so that users can choose their starship. The Extension also contains the core logic of the game: it updates the game state, processes all events and client requests and runs the game's master simulation. Specifically, the SW2RoomExtension class takes care of receiving the requests from clients through a couple of handlers and sending responses to clients to update the game state. The actual simulation logic instead is contained in the Game class. In this way we put in place the separation of duties which makes the code much more friendly to maintain.
The Room Extension is dynamically assigned to every MMORoom when they are created on a Game Node.
The updated game flow
In this section we will discuss the changes to the game flow with respect to the standalone version of our game, moving from the client code to the server code and back as needed.
Zone Extension initialization
As soon as the Cluster Zone configuration is loaded when the Lobby Node is restarted (see Setup & run above), the init() method on the SW2ZoneExtension class is called.
@Override
public void init()
{
try
{
// Load configuration file
loadGameConfig();
// Add client request handler
addRequestHandler(Constants.REQ_INIT, new StartGameReqHandler());
// Add game nodes event handler (events coming from game nodes)
addEventHandler(SFSEventType.GAME_NODE_EVENT, new GameNodeEventHandler());
}
catch (IOException e)
{
trace(ExtensionLogLevel.ERROR, "SpaceWar 2 configuration could not be loaded");
}
trace(ExtensionLogLevel.INFO, "SpaceWar 2 Zone Extension initialized");
}
First of all, the initialization process executes the loadGameConfig() method, which loads the game external configuration (SpaceWar2.config file, deployed on the Lobby Node together with the Zone Extension). While in the standalone version only the game levels were extracted from the configuration, here the full configuration is processed and added to a Map. This map is then set as global in the cluster: Cluster Globals is an entity made available by the server-side Cluster API allowing to transfer serializable data to all Game Nodes in the cluster.
private void loadGameConfig() throws IOException
{
// Load configuration file
String cfgData = FileUtils.readFileToString(new File(this.getCurrentFolder() + "SpaceWar2.config"));
...
// Create cluster-global configuration object
Map<String, Serializable> config = new HashMap<String, Serializable>();
config.put(Constants.CONFIG_SETTINGS, gameCfg);
config.put(Constants.CONFIG_LEVELS, levelsCfg);
config.put(Constants.CONFIG_STARSHIPS, starshipsCfg);
config.put(Constants.CONFIG_WEAPONS, weaponsCfg);
// Assign configuration objects to cluster globals
SFSCluster.getLobbyService().getGlobals().set(config);
trace(ExtensionLogLevel.INFO, "SpaceWar 2 configuration loaded");
}
Next, just like in the standalone version of the game, the Zone Extension's init() method adds its own request handler represented by the inner StartGameReqHandler class, which is in charge of receiving requests from clients to start a game. We will discuss this class in the Joining a Room section below.
The Extension initialization also adds an event handler specific to the cluster: the server-side event of type SFSEventType.GAME_NODE_EVENT is a mechanism provided by the Cluster API which allows the Game Nodes to trigger specific actions on the Lobby side. This will be discussed in the Game over section below.
The Lobby scene
After connecting to the Lobby Node and performing the login, the GlobalManager singleton switches the game to the Lobby scene as usual.
The scene features a buddy list, a panel to privately chat with buddies and a profile panel where the properties of the user in the Buddy List system can be set. In the same panel the user experience, saved in a User Variable, is displayed by means of "rating stars". Just like for the Tic-Tac-Toe example, the actual experience of players should be determined by the game logic automatically, based on their performance, and saved to a database. With respect to that example, here the experience value can still be set manually (in order to test the match-making logic), but it is also updated automatically by the Extension as described in the Game over chapter below.
The main portion of the view shows the available levels, which correspond to those defined in the server-side configuration file. Each sprite representing a level has a click event listener associated, which triggers the game launch process.
Joining a Room
When the user clicks one of the two available levels, its listener calls the RequestStartGame() method which triggers a simple request to the Zone Extension on the Lobby Node, containing the name of the selected level.
private void RequestStartGame()
{
// Add level name to request parameters
ISFSObject reqParams = new SFSObject();
reqParams.PutUtfString("name", selectedLevel);
// Send request to Zone Extension
sfsLobby.Send(new ExtensionRequest(ZONE_EXT_REQ_INIT, reqParams));
}
On the server side, the request is handled by the the handleClientRequest() method of the StartGameReqHandler class instantiated by the Extension when initialized. The request handler checks the game configuration to validate the selected level name and executes the createOrJoinRoom() method.
private void createOrJoinRoom(User player, String levelName) throws SFSJoinRoomException, SFSCreateRoomException
{
// Set the MMORoom settings to create a new Room if needed
CreateMMORoomSettings mmors = new CreateMMORoomSettings();
mmors.setGroupId(Constants.ROOMS_GROUP_NAME);
mmors.setName("SW2_" + System.currentTimeMillis());
mmors.setMaxUsers(50);
mmors.setDynamic(true);
mmors.setAutoRemoveMode(SFSRoomRemoveMode.WHEN_EMPTY);
mmors.setExtension(new CreateMMORoomSettings.RoomExtensionSettings("SpaceWar2-Cluster", "sfs2x.extensions.games.spacewar2.game.SW2RoomExtension"));
mmors.setDefaultAOI(new Vec3D(1300, 750, 0));
mmors.setUserMaxLimboSeconds(gameCfg.getInt(Constants.SETTING_GAME_ABORT_SECS) + gameCfg.getInt(Constants.SETTING_GAME_START_SECS) + 20);
mmors.setProximityListUpdateMillis(20);
mmors.setSendAOIEntryPoint(false);
// Set requirements to allow users find the Room (see match expression below) and other settings in Room Variables
List<RoomVariable> roomVars = new ArrayList<RoomVariable>();
roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_GAME_LEVEL, levelName));
roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_USER_MIN_XP, player.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue()));
roomVars.add(new SFSRoomVariable(Constants.ROOMVAR_GAME_STATE, Constants.GAMESTATE_WAITING));
mmors.setRoomVariables(roomVars);
// Set the match expression to search for an existing Room (see Room Variables set above)
MatchExpression exp = new MatchExpression(Constants.ROOMVAR_GAME_LEVEL, StringMatch.EQUALS, levelName)
.and(Constants.ROOMVAR_USER_MIN_XP, NumberMatch.LESS_THAN_OR_EQUAL_TO, player.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue())
.and(Constants.ROOMVAR_GAME_STATE, NumberMatch.LESS_THAN_OR_EQUAL_TO, (gameCfg.getBool(Constants.SETTING_ALLOW_MIDGAME_JOIN) ? Constants.GAMESTATE_RUNNING : Constants.GAMESTATE_WAITING));
// Execute
SFSCluster.getLobbyService().getApi().quickJoinOrCreateRoom(player, exp, Arrays.asList(Constants.ROOMS_GROUP_NAME), mmors, true);
}
The first part of the method is dedicated to collecting the settings of the MMORoom, in case an existing one to join is not found and it must be created. This is done through the CreateMMORoomSettings class, which extends the CreateRoomSettings class by adding parameters specific to MMORooms.
The first block of parameters includes some generic Room parameters, like the name, the maximum number of users, etc. Notice that the MMORoom is assigned to the "spacewar2" Room Group and it is instructed to instantiate the provided Room Extension.
The second block of parameters is related to the actual MMORoom configuration. Here you will find all the settings discussed in the MMORoom configuration chapter of the tutorial for the standalone version of the game.
The third block sets a number of Room Variables representing common data attached to the Room and shared among all users who joined it. These settings are needed to make the Room "selectable" by the match-making system to let other users join it. The variables contain the level name, the minimum required experience to join the MMORoom (equal to the experience of the user who sent the request) and the initial state of the game when the Room is created, which is "waiting" (...for players to join it).
The last step before calling the relevant API method is to define the Match Expression the server should apply to look for available Rooms to join the user in. The code above concatenates three expressions with the and() method. All three conditions must be satisfied to make an existing Room eligible to be joined by the user who sent the request: the game level played in the Room must be equal to the requested level; the minimum required experience set for the game must be equal to or lower than the experience of the requester; the game must not be running yet (unless mid-game join is allowed in the external game settings file).
After the Room configuration and Match Expression definition are ready, it is time to call the quickJoinOrCreateRoom() method provided by the server-side Cluster API. This triggers the same behavior of its client-side counterpart we saw in previous tutorials: the Lobby Node locates the target Game Node through the Load Balancing logic, executes the Match Making logic and, if a Room is found, dispatches the CONNECTION_REQUIRED event to the client. If the Room can't be found, a new Room is created on the target Game Node, before sending the notification.
If a new MMORoom is created, the associated Room Extension is initialized. The Extension's init() method retrieves the game configuration from the Cluster Globals object and adds the event and request handlers required by the game logic.
@Override
public void init()
{
room = (MMORoom) this.getParentRoom();
// Get a reference to the SmartFoxServer instance
sfs = SmartFoxServer.getInstance();
// Get a reference to the MMO dedicated API
mmoApi = sfs.getAPIManager().getMMOApi();
// Get level id from Room Variables
String levelId = room.getVariable(Constants.ROOMVAR_GAME_LEVEL).getStringValue();
// Get configuration from cluster globals
IGlobals globals = SFSCluster.getGameService().getGlobals();
gameCfg = (SFSObject)globals.get(Constants.CONFIG_SETTINGS);
levelCfg = ((SFSObject)globals.get(Constants.CONFIG_LEVELS)).getSFSObject(levelId);
starshipsCfg = (SFSObject)globals.get(Constants.CONFIG_STARSHIPS);
weaponsCfg = (SFSObject)globals.get(Constants.CONFIG_WEAPONS);
// Register handler for user leave room events
addEventHandler(SFSEventType.USER_LEAVE_ROOM, UserLeaveRoomEventHandler.class);
addEventHandler(SFSEventType.USER_DISCONNECT, UserLeaveRoomEventHandler.class);
// Register handlers for client requests
addRequestHandler(Constants.REQ_INIT, InitRequestHandler.class);
addRequestHandler(Constants.REQ_CONTROL, ControlRequestHandler.class);
...
}
The OnGameNodeConnectionRequired() handler on the GlobalManager singleton receives the CONNECTION_REQUIRED event and starts the connect → login → join process on the Game Node, as already described in previous tutorials. Upon successful Game Room join, the Game scene is loaded.
Before the game starts
As soon as the Game scene is loaded, a "waiting for players" interface is displayed where users can selected their starship and invite buddies to play.
The game logic at this stage is exactly the same as in the standalone version: the Room Extension monitors the users entering the Room and signals the actual game start after a short countdown. The only minor difference is in the buddy invitation: as already discussed in the cluster version of the Tic-Tac-Toe example, the Buddy List system is active on the Lobby Node, allowing users to interact (chat, invitations) regardless of the Room they joined or even the Game Node they are connected to.
For this reason the invitation process must be initiated on the Lobby Node, which we do by sending the cluster-specific request represented by the ClusterInviteUsersRequest class.
public void OnInviteBuddyButtonClick(BuddyInviteItem buddyinviteItem, Buddy buddy)
{
// Disable button
buddyinviteItem.inviteButton.interactable = false;
// Send invitation
ClusterTarget target = new ClusterTarget(sfsGame.NodeId, sfsGame.LastJoinedRoom.Id);
List<object> invitedBuddies = new List<object>();
invitedBuddies.Add(buddy);
ISFSObject invParams = new SFSObject();
invParams.PutUtfString("level", sfsGame.LastJoinedRoom.GetVariable("gameLevel").GetStringValue());
sfsLobby.Send(new ClusterInviteUsersRequest(target, invitedBuddies, 15, invParams));
}
The request requires the target Game Node and Room to be provided by means of the ClusterTarget class and the Buddy object. We also send a custom invitation parameter containing the name of the level to be displayed in the invitation message to the buddy.
If the invited user accepts the invitation (by mens of the InvitationReplyRequest class), the Lobby Node dispatches the CONNECTION_REQUIRED event to the client, which in turn starts the same connect → login → join process on the Game Node we already mentioned before.
Game over
After the actual game is started, it progresses just like for the standalone version until the player is destroyed, or they destroy all opponent starships. This is detected on the Game Node by the server-side simulation (see the run() method on the Game class), which then calls the notifyGameOver() method on the Room Extension's main class.
public void notifyGameOver(Integer[] userIds, int remainingPlayers)
{
int ranking = remainingPlayers + 1;
boolean isTied = userIds.length > 1;
// Retrieve list of users
List<User> users = new ArrayList<User>();
for (int userId : userIds)
{
User user = room.getUserById(userId);
users.add(user);
// Update user experience
int changeXp = 0;
if (ranking == 1)
changeXp = +1;
else if (ranking <= 4)
changeXp = -1;
if (changeXp != 0)
{
// Dispatch event to lobby node
Map<String, Object> nodeEvtParams = new HashMap<String, Object>();
nodeEvtParams.put("id","gameover");
nodeEvtParams.put("u", user.getName());
nodeEvtParams.put("xpc", changeXp);
SFSCluster.getGameService().getApi().dispatchGameEventToLobby(nodeEvtParams);
}
}
// Send Extension response with game ranking
ISFSObject params = new SFSObject();
params.putInt("r", ranking);
params.putBool("t", isTied);
this.send(Constants.RESP_GAME_OVER, params, users);
// Stop game
if (remainingPlayers == 0)
{
// Set game as over in Room Variables
RoomVariable rv = new SFSRoomVariable(Constants.ROOMVAR_GAME_STATE, Constants.GAMESTATE_OVER);
sfsApi.setRoomVariables(null, room, Arrays.asList(new RoomVariable[]{rv}));
// Stop scheduled task
gameTask.cancel(false);
gameTask = null;
trace(ExtensionLogLevel.INFO, "Game in Room " + room.getName() + " is over");
}
}
First of all, the method calculates the new experience value based on the ranking of the player (if the user ranked 1st, their experience is increased; if they ranked 4th or more, it is decreased), then this is notified to both the Lobby Node and the client, before the game is stopped by means of the Room Variable describing the game state.
The notification to the Lobby Node is sent by the dispatchGameEventToLobby() method, made available by the server-side Cluster API. The GAME_NODE_EVENT event is received by the Zone Extension on the Lobby Node and processed by the dedicated handler we added during the Extension initialization.
public class GameNodeEventHandler extends BaseServerEventHandler
{
@Override
public void handleServerEvent(ISFSEvent event) throws SFSException
{
@SuppressWarnings("unchecked")
Map<String, Object> evtParams = (Map<String, Object>) event.getParameter(SFSEventParam.CLUSTER_EVENT_PARAMS);
String id = (String) evtParams.get("id");
if (id.equals("gameover"))
{
String userName = (String) evtParams.get("u");
int changeXp = (Integer) evtParams.get("xpc");
User user = getApi().getUserByName(userName);
// Calculate new user experience
int currXp = user.getVariable(Constants.USERVAR_EXPERIENCE).getIntValue();
int newXp = currXp + changeXp;
if (newXp < 0)
newXp = 0;
else if (newXp > 5)
newXp = Constants.SETTING_MAX_USER_XP;
// Update user experience
if (newXp != currXp)
{
UserVariable uv = new SFSUserVariable(Constants.USERVAR_EXPERIENCE, newXp);
getApi().setUserVariables(user, Arrays.asList(new UserVariable[]{uv}));
}
}
}
}
The new experience value is save in the player's dedicated User Variable, where it can be accessed by the Lobby Node itself for match-making purpose as we already discussed (see the createOrJoinRoom() method on the Zone Extension).
More resources
You can learn more about development in the Overcast Cluster environment by consulting the following resources:
