Cluster Sample
Summary
This sample creates a lobby which manages game instances. Players connect to the lobby and are sent to an available game instance. The game selects a group of winners and then spawn a new game instance for those players. Other players are sent back to the lobby.
Requirements
Define consts
In Kinematicsoup/Reactor/Common, create a Consts.cs file to hold const values we will use on the client and server. The consts are ids for properties and RPCs. Add the following to your script: Consts.cs
public class Prop
{
public const uint NAME = 0;
}
public class RPC
{
public const uint TOKEN = 0;
public const uint GAME_OVER = 1;
public const uint MOVE_TO_ROOM = 2;
public const uint RETURN_TO_LOBBY = 3;
public const uint PLAYER_DATA = 4;
public const uint ACKNOWLEDGE_PLAYER_DATA = 5;
}
Create the lobby scene
- Create a new scene and name it 'Lobby'.
- Create a game object named 'Lobby Room' and add a ksRoomType.
- Save the scene.
- Add the scene to the build settings with File > Build Settings > Add Open Scenes.
Create the game scene
- Save your scene and create a new scene named 'Game'.
- Create a game object named 'Game Room' and add a ksRoomType.
- Save the scene.
- Add the scene to the build settings with File > Build Settings > Add Open Scenes.
Create a room launcher script
- Add a new server room script to the 'Lobby Room' game object and name it 'sRoomLauncher'.
- The new script will be used to launch rooms and send players to those rooms when they are ready.
sRoomLauncher.cs
using System.Collections.Generic;
using KS.Reactor.Server;
using KS.Reactor;
public class sRoomLauncher : ksServerRoomScript
{
private ksAsyncResult<ksRoomInfo> m_task;
private List<ksIServerPlayer> m_players = new List<ksIServerPlayer>();
public bool IsLaunchingRoom
{
get { return m_task != null; }
}
public override void Initialize()
{
Room.OnUpdate[0] += Update;
}
public override void Detached()
{
Room.OnUpdate[0] -= Update;
}
public override void Update()
{
// Check if the launch room task is complete
if (!Cluster.IsConnected || m_task == null || !m_task.IsCompleted)
{
return;
}
if (!string.IsNullOrEmpty(m_task.Error))
{
ksLog.Error(this, "Error starting game: " + m_task.Error);
}
else
{
SendPlayers(m_task.Result);
}
m_task = null;
}
// Launches a room and sends players in the given player list to that room once it is ready
public void LaunchRoom(IEnumerable<ksIServerPlayer> players, string name, string scene, string roomType)
{
if (Cluster.IsConnected && !IsLaunchingRoom)
{
m_players.Clear();
m_players.AddRange(players);
ksLog.Info(this, "Starting room " + scene);
m_task = Cluster.StartRoom(name, scene, roomType);
}
}
// Sends players to the given room
private void SendPlayers(ksRoomInfo roomInfo)
{
// Convert room info to json and send it to players
Room.CallRPC(m_players, RPC.MOVE_TO_ROOM, roomInfo.ToJSON().Print());
}
}
Create a lobby script
- Add a new server room script to the 'Lobby Room' game object and name it 'sLobby'
- The lobby starts a game room and sends players to it once there are at least 10 players or after 30 seconds.
sLobby.cs
using System.Collections.Generic;
using KS.Reactor.Server;
using KS.Reactor;
public class sLobby : ksServerRoomScript
{
private sRoomLauncher m_roomLauncher;
private float m_timer = 0f;
public override void Initialize()
{
Room.OnUpdate[0] += Update;
m_roomLauncher = Scripts.Get<sRoomLauncher>();
}
public override void Detached()
{
Room.OnUpdate[0] -= Update;
}
public override void Update()
{
// Do nothing if we aren't connected to the cluster, we are already launching a room, or no players are
// connected.
if (!Cluster.IsConnected || m_roomLauncher.IsLaunchingRoom || Room.ConnectedPlayerCount == 0)
{
return;
}
// Start a game room when there are 10 or more players and at least 2 seconds have passed since the last game,
// or 30 seconds have passed.
if (m_timer < 2f)
{
m_timer += Time.Delta;
return;
}
m_timer += Time.Delta;
if (Room.Players.Count >= 10 || m_timer >= 30f)
{
// "Game1" is the name of the room (can be anything you want).
// "Game" is the name of the scene to launch.
// "Game Room" is the name of the room type game object in the scene to launch.
m_roomLauncher.LaunchRoom(Room.Players, "Game1", "Game", "Game Room");
m_timer = 0f;
}
}
}
Create a connect script
- Add a new Monobehaviour to the 'Lobby Room' game object and name it 'Connect', or add and modify your existing connect script.
- Add a static ksRoomInfo field.
- In the Start function, connect to the room in the static room info field if it is set.
Connect.cs
using System.Collections.Generic;
using UnityEngine;
using KS.Reactor;
using KS.Reactor.Client;
using KS.Reactor.Client.Unity;
public class Connect : MonoBehaviour
{
public bool UseLiveServer = false;
private ksRoom m_room;
public static ksRoomInfo RoomInfo;
void Start()
{
// If RoomInfo is set, connect to that room.
if (RoomInfo != null)
{
ConnectToServer(RoomInfo);
RoomInfo = null;
}
else if (UseLiveServer)
{
ksReactor.GetServers(OnGetServers);
}
else
{
ConnectToServer(GetComponent<ksRoomType>().GetRoomInfo());
}
}
private void OnGetServers(List<ksRoomInfo> roomList, string error)
{
if (error != null)
{
ksLog.Error("Error fetching servers. " + error);
return;
}
if (roomList == null || roomList.Count == 0)
{
ksLog.Warning("No servers found.");
return;
}
ConnectToServer(roomList[0]);
}
private void ConnectToServer(ksRoomInfo roomInfo)
{
m_room = new ksRoom(roomInfo);
m_room.OnConnect += OnConnect;
m_room.OnDisconnect += OnDisconnect;
m_room.Connect();
}
private void OnConnect(ksBaseRoom.ConnectError status, uint customStatus)
{
ksLog.Info("Connect status " + status);
}
private void OnDisconnect(ksBaseRoom.DisconnectError status)
{
ksLog.Info("Disconnect status " + status);
}
}
Create a client room script
- Add a new client room script to the 'Lobby Room' game object and name it 'cRoom'.
- The client room responds to server RPCs to switch scenes and connect to new rooms.
- Save and build the scene.
cRoom.cs
using UnityEngine.SceneManagement;
using KS.Reactor.Client.Unity.Adaptors;
using KS.Reactor;
public class cRoom : ksRoomScript
{
[ksRPC(RPC.MOVE_TO_ROOM)]
private void OnMoveToRoom(string jsonStr)
{
// Disconnect from the current room
ksReactor.Service.LeaveRoom(Room);
// Parse new room info from json
Connect.RoomInfo = ksRoomInfo.FromJSON(ksJSON.Parse(jsonStr));
// Load the scene for the new room
SceneManager.LoadScene(Connect.RoomInfo.Scene);
}
}
Add scripts to the game room
- Load the Game scene.
- Add a Connect script to the 'Game Room' game object.
- Add a cRoom script to the 'Game Room' game object.
- Save and build the scene.
Run and connect to the local cluster
- Load the Lobby scene.
- Click Reactor > Launch Local Cluster to bring up the local Cluster window.
- Change the Launch Scene to 'Lobby'. The Launch Room should say 'Lobby Room'. Leave the other settings as the defaults.
- Click 'Launch Local Cluster'.
- The local cluster should now be running and the local server logs panel will show you the cluster logs. You can use the drop down in the log panel to view logs for the rooms. The first entry after the cluster should be for the lobby. Select it to view the lobby logs.
- Make sure 'Use Live Server' is not checked when inspecting the 'Lobby Room' Connect script.
- Enter play mode. You will connect to the lobby.
- After 30 seconds, the lobby server logs should say 'Starting room Game'.
- Once the game room is started, you will load the Game scene and connect to the game room.
- Stop the cluster by closing its command prompt window.
Create a game room script
- Load the Game scene.
- Add an sRoomLauncher to the 'Game Room' game object.
- Add a new server room script to the 'Game Room' game object and name it 'sGameRoom'
- The game room randomly chooses winners and losers.
- If there are any winners, it launches a new game room and sends the winners to the new room. Losers are sent back to the lobby.
- The game room shutsdown after the game is finished and players are sent to the next room.
- The game room shutsdown if no players are connected for 60 seconds.
sGameRoom.cs
using System.Collections.Generic;
using KS.Reactor.Server;
using KS.Reactor;
public class sGameRoom : ksServerRoomScript
{
private ksRandom m_rand = new ksRandom();
private sRoomLauncher m_roomLauncher;
private float m_gameTimer = 0f;
private float m_shutdownTimer = 0f;
private bool m_gameOver = false;
public override void Initialize()
{
// Setting IsPublic to false prevents this room from appearing in the server list when clients call
// ksReactor.GetServers.
Room.IsPublic = false;
Room.OnUpdate[0] += Update;
m_roomLauncher = Scripts.Get<sRoomLauncher>();
}
public override void Detached()
{
Room.OnUpdate[0] -= Update;
}
public override void Update()
{
if (m_gameOver)
{
if (!m_roomLauncher.IsLaunchingRoom)
{
// Stop the room one second after the game is over and the next game (if there is one) is started.
m_shutdownTimer += Time.Delta;
if (m_shutdownTimer >= 1f)
{
Room.ShutDown();
}
}
return;
}
if (Room.ConnectedPlayerCount <= 0)
{
// Stop the room if no players are connected for 60s.
m_shutdownTimer += Time.Delta;
if (m_shutdownTimer >= 60f)
{
Room.ShutDown();
}
return;
}
m_shutdownTimer = 0f;
// Choose random winners after 10 seconds.
if (m_gameTimer < 10f)
{
m_gameTimer += Time.Delta;
if (m_gameTimer >= 10f)
{
List<ksIServerPlayer> winners = new List<ksIServerPlayer>();
List<ksIServerPlayer> losers = new List<ksIServerPlayer>();
// Each player has a 50-50 chance of being a winner of loser.
foreach (ksIServerPlayer player in Room.Players)
{
if (m_rand.Next(2) == 0)
{
winners.Add(player);
}
else
{
losers.Add(player);
}
}
FinishGame(winners, losers);
}
}
}
private void FinishGame(List<ksIServerPlayer> winners, List<ksIServerPlayer> losers)
{
m_gameOver = true;
m_shutdownTimer = 0f;
// Tell the winners they won.
Room.CallRPC(winners, RPC.GAME_OVER, true);
// Tell the losers they lost.
Room.CallRPC(losers, RPC.GAME_OVER, false);
if (winners.Count <= 0)
{
// Send all players back to the lobby.
Room.CallRPC(RPC.RETURN_TO_LOBBY);
}
else
{
// Send losers back to the lobby.
Room.CallRPC(losers, RPC.RETURN_TO_LOBBY);
// Start the next game and send the winners to it.
m_roomLauncher.LaunchRoom(winners, "Game2", "Game", "Game Room");
}
}
}
Handle game over and return to lobby RPCs in the client room script
- Open 'cRoom.cs'.
- Add a OnGameOver function to log whether you win or lose.
- Add a OnReturnToLobby function to return to the lobby.
cRoom.cs
public class cRoom : ksRoomScript
{
...
[ksRPC(RPC.GAME_OVER)]
private void OnGameOver(bool isWinner)
{
if (isWinner)
{
ksLog.Info(this, "You Win!");
}
else
{
ksLog.Info(this, "You Lose!");
}
}
[ksRPC(RPC.RETURN_TO_LOBBY)]
private void OnReturnToLobby()
{
ksReactor.Service.LeaveRoom(Room);
SceneManager.LoadScene("Lobby");
}
}
Run and connect to the local cluster
- Save and build the scene.
- Load the Lobby scene.
- Launch the local cluster with the same settings as before.
- Make sure 'Use Live Server' is not checked when inspecting the 'Lobby Room' Connect script.
- Enter play mode. You will connect to the lobby.
- After 30 seconds, the lobby server logs should say 'Starting room Game'.
- One the game room is started, you will load the Game scene and connect to the game room.
- After 10 seconds in the game room, you will randomly win or lose and see a 'You Win!' or 'You Lose!' message in the Unity logs. If you win, you will reload the scene and connect to a new game room. If you lose, you will load the Lobby scene and reconnect to the lobby.
- Stop the cluster by closing its command prompt window.
Create a player script
- Add a new server player script to the 'Lobby Room' game object and name it 'sPlayer'.
- Create a long token field. We will use tokens to identify and authenticate players when they move between rooms.
sPlayer.cs
public class sPlayer : ksServerPlayerScript
{
public long Token;
}
- Save the scene.
- Open the Game scene.
- Add the sPlayer script to the 'Game Room' game object and save the scene.
Authenticate players and add bots in the lobby script
- Add a ksRandom field.
- Add a HashSet
field to store tokens. - Add a GenerateToken function that generates a unique token.
- Add OnPlayerJoin and OnPlayerLeave functions. The OnPlayerJoin function assigns a token to the player and adds it to the token set. The OnPlayerLeave function removes it from the set so it can be reused.
- Add an authenticate fuction that sets the player's name to a value provided by the client.
- Modify the Update function to add a virtual player (bot) every 2 seconds.
sLobby.cs
public class sLobby : ksServerRoomScript
{
...
private ksRandom m_rand = new ksRandom();
private HashSet<long> m_tokens = new HashSet<long>();
public override void Initialize()
{
...
Room.OnPlayerJoin += OnPlayerJoin;
Room.OnPlayerLeave += OnPlayerLeave;
}
public override void Detached()
{
...
Room.OnPlayerJoin -= OnPlayerJoin;
Room.OnPlayerLeave -= OnPlayerLeave;
}
public override uint Authenticate(ksIServerPlayer player, ksMultiType[] args)
{
if (args.Length != 1)
{
// Returning a non-zero value means authentication failed.
return 1;
}
// Set the name to the value provided by the client. You can do your own validation here.
player.Properties[Prop.NAME] = args[0];
return 0;
}
public override void Update()
{
if (!Cluster.IsConnected || m_roomLauncher.IsLaunchingRoom || Room.ConnectedPlayerCount == 0)
{
return;
}
if (Room.Time.Frame % (60 * 2) == 0)
{
string name = "bot" + m_rand.Next(1000);
// Create a virtual player. The parameter 'name' is passed to the Authenticate function.
Room.CreateVirtualPlayer(name);
}
...
}
private void OnPlayerJoin(ksIServerPlayer player)
{
ksLog.Info(this, player.Properties[Prop.NAME] + " joined");
if (!player.IsVirtual)
{
// Generate a unique token for this player.
long token = GenerateToken();
player.Scripts.Get<sPlayer>().Token = token;
// Send the token to the player.
Room.CallRPC(player, RPC.TOKEN, token);
}
}
private void OnPlayerLeave(ksIServerPlayer player)
{
ksLog.Info(this, player.Properties[Prop.NAME] + " left");
if (!player.IsVirtual)
{
// Remove the token so it can be reused.
m_tokens.Remove(player.Scripts.Get<sPlayer>().Token);
}
}
private long GenerateToken()
{
long token = 0;
do
{
unchecked
{
token = (uint)m_rand.Next();
token <<= 32;
token += (uint)m_rand.Next();
}
} while (!m_tokens.Add(token) && token != 0);
return token;
}
}
Send a username or token when connecting
- Open 'Connect.cs'.
- Add an optional token parameter to ConnectToServer. If a token is non-zero, pass it to Room.Connect. Otherwise pass a username.
- Pass the token to ConnectToServer in the Start function if the RoomInfo is set.
Connect.cs
public class Connect : MonoBehaviour
{
...
public static long Token;
void Start()
{
// If RoomInfo is set, connect to that room.
if (RoomInfo != null)
{
ConnectToServer(RoomInfo, Token);
RoomInfo = null;
}
...
}
...
private void ConnectToServer(ksRoomInfo roomInfo, long token = 0)
{
m_room = new ksRoom(roomInfo);
m_room.OnConnect += OnConnect;
m_room.OnDisconnect += OnDisconnect;
// The parameters passed to Connect will be passed to the Authenticate function on the server.
if (token != 0)
{
// If token is non-zero, we are connecting to a game room and send our token to authenticate and identify
// us.
m_room.Connect(token);
}
else
{
// We are connecting to a lobby. "MyName" is our username. You can change this to anything.
m_room.Connect("MyName");
}
}
}
Set the token in the client room script
- Open 'cRoom.cs'.
- Add an OnSetToken function that sets the token.
cRoom.cs
public class cRoom : ksRoomScript
{
[ksRPC(RPC.TOKEN)]
private void OnSetToken(long token)
{
Connect.Token = token;
}
...
}
Use cluster RPCs to send player data to new rooms
- Open 'sRoomLauncher.cs'.
- Add a private ksRoomInfo field.
- Change the IsLaunchingRoom property to also check if that the ksRoomInfo is not null.
- In the Update function, when the task completes, instead of sending players to the new room, use a cluster RPC to send player data to the new room that it will use to authenticate players, and store the room info.
- In the LaunchRoom function, don't launch the room if all the players being sent to the room are bots.
- In the SendPlayers function, disconnect virtual players (bots).
- Add an OnAcknowledgePlayerData that sends the players to the new room when it acknowledges it received the player data.
- Add OnAcknowledgePlayerData as a Cluster RPC handler in Initialize and remove it in Detached.
sRoomLauncher.cs
public class sRoomLauncher : ksServerRoomScript
{
...
private ksRoomInfo m_roomInfo;
public bool IsLaunchingRoom
{
get { return m_task != null || m_roomInfo != null; }
}
public override void Initialize()
{
Room.OnUpdate[0] += Update;
Cluster.OnRPC[RPC.ACKNOWLEDGE_PLAYER_DATA] += OnAcknowledgePlayerData;
}
public override void Detached()
{
Room.OnUpdate[0] -= Update;
Cluster.OnRPC[RPC.ACKNOWLEDGE_PLAYER_DATA] += OnAcknowledgePlayerData;
}
public override void Update()
{
...
else
{
m_roomInfo = m_task.Result;
// Send player data to the room we just started.
ksMultiType[] args = new ksMultiType[m_players.Count * 2];
int index = 0;
for (int i = 0; i < m_players.Count; i++)
{
// The token identifies the player. Players use this token to connect.
args[index++] = m_players[i].Scripts.Get<sPlayer>().Token;
args[index++] = m_players[i].Properties[Prop.NAME];
}
Cluster.CallRoomRPC(new uint[] { m_roomInfo.Id }, RPC.PLAYER_DATA, args);
}
m_task = null;
}
// Launches a room and sends players in the given player list to that room once it is ready.
public void LaunchRoom(IEnumerable<ksIServerPlayer> players, string name, string scene, string roomType)
{
if (Cluster.IsConnected && !IsLaunchingRoom)
{
// Check that there's at least one connected (non-virtual) player in the list before launching a room.
bool hasConnectedPlayer = false;
foreach (ksIServerPlayer player in players)
{
if (!player.IsVirtual)
{
hasConnectedPlayer = true;
break;
}
}
if (!hasConnectedPlayer)
{
return;
}
...
}
}
// Sends players to the given room
private void SendPlayers(ksRoomInfo roomInfo)
{
...
// Disconnect virtual players (bots).
foreach (ksIServerPlayer player in m_players)
{
if (player.IsVirtual)
{
player.Disconnect();
}
}
}
// Once the new room acknowledges it received the player data, tell the players to switch rooms
private void OnAcknowledgePlayerData(uint roomId, ksMultiType[] args)
{
if (m_roomInfo != null)
{
ksLog.Info(this, "Acknowledged player data. Sending players");
SendPlayers(m_roomInfo);
m_roomInfo = null;
}
}
}
Authenticate players in the game room
- Open 'sGameRoom.cs'.
- Add a private dictionary field m_playerData that maps tokens to usernames.
- Add a private dictionary field m_playerTokens that maps player ids to tokens.
- Add an Authenticate function that sets a player's name by retrieving it from the player data using their token. If they do not provide a valid token, they do not pass authentication. Store their token in the player tokens dictionary.
- Log the names of eliminated players in the FinishGame function.
- Add an OnPlayerJoin function that retrieves a players token from the player tokens map and sets it. We have to do this here instead of in Authenticate since player scripts are loaded when Authenticate is called.
- Add an OnGetPlayerData function that fills the player data dictionary with the received data and calls a cluster RPCs to tell the room that started this room it got the player data.
- Add the OnPlayerJoin and OnGetPlayerData handlers in Initialize, and remove them in detached.
sGameRoom.cs
public class sGameRoom : ksServerRoomScript
{
...
private Dictionary<long, string> m_playerData = new Dictionary<long, string>();
private Dictionary<uint, long> m_playerTokens = new Dictionary<uint, long>();
public override void Initialize()
{
...
Room.OnPlayerJoin += OnPlayerJoin;
Cluster.OnRPC[RPC.PLAYER_DATA] += OnGetPlayerData;
}
public override void Detached()
{
...
Room.OnPlayerJoin -= OnPlayerJoin;
Cluster.OnRPC[RPC.PLAYER_DATA] -= OnGetPlayerData;
}
...
public override uint Authenticate(ksIServerPlayer player, ksMultiType[] args)
{
if (args.Length != 1)
{
return 1;
}
if (player.IsVirtual)
{
// If the player is virtual, the argument is the player name.
player.Properties[Prop.NAME] = args[0];
return 0;
}
long token = args[0];
string name;
// The player must supply a token that is in the player data to authenticate.
if (m_playerData.TryGetValue(token, out name))
{
// Remove the token so it cannot be reused.
m_playerData.Remove(token);
player.Properties[Prop.NAME] = name;
// Player scripts aren't loaded yet, so we store the token in a dictionary so we can set it once scripts
// are loaded in OnPlayerJoin.
m_playerTokens[player.Id] = token;
return 0;
}
// Invalid token.
return 1;
}
private void FinishGame(List<ksIServerPlayer> winners, List<ksIServerPlayer> losers)
{
...
// Log elimination message.
string message = "eliminated: ";
for (int i = 0; i < losers.Count; i++)
{
if (i > 0)
{
message += ", ";
}
message += losers[i].Properties[Prop.NAME];
}
ksLog.Info(this, message);
}
private void OnPlayerJoin(ksIServerPlayer player)
{
if (!player.IsVirtual)
{
player.Scripts.Get<sPlayer>().Token = m_playerTokens[player.Id];
}
}
private void OnGetPlayerData(uint roomId, ksMultiType[] args)
{
for (int i = 0; i + 1 < args.Length; i += 2)
{
long token = args[i];
string name = args[i + 1];
if (token == 0)
{
// 0 token means this is a virtual player.
Room.CreateVirtualPlayer(name);
}
else
{
m_playerData[token] = name;
}
}
// Tell the room that started us that we received the player data.
Cluster.CallRoomRPC(new uint[] { roomId }, RPC.ACKNOWLEDGE_PLAYER_DATA);
}
}
Run and connect to the local cluster
- Open the Lobby scene.
- Launch the local cluster with the same settings as before.
- Make sure 'Use Live Server' is not checked when inspecting the 'Lobby Room' Connect script.
- Enter play mode. You will connect to the lobby.
- You should see a message in the server logs for the lobby saying 'MyName connected' (or whatever you changed 'MyName' to). Every 2 seconds you should see a message about a bot connecting.
- After 10 players (bots) are connected, the lobby server logs should say 'Starting room Game'.
- One the game room is started, you will load the Game scene and connect to the game room.
- After 10 seconds in the game room, you will randomly with or lose and see a 'You Win!' or 'You Lose!' message in the Unity logs. If you win, you will reload the scene and connect to a new game room. If you lose, you will load the Lobby scene and reconnect to the lobby.
- The names of all players who lost will be logged in the Game room logs.
- Stop the cluster by closing its command prompt window.
Run and connect to an online cluster
- Click Reactor > Publishing.
- Give the image a name and version and click Publish.
- Login to https://console.kinematicsoup.com/
- Click on your project in the Shared Projects section.
- Click the Images tab.
- Click Launch in the row for the image and version you just published.
- Give your cluster a name.
- Check 'Launch Cluster'.
- Select 'Lobby' for the scene.
- The Room should say 'Lobby Room'
- 'Is Room Public' should be checked.
- Check 'Keep Room Alive'.
- Click Launch.
- In Unity, make sure 'Use Live Server' is checked when inspecting the 'Lobby Room' Connect script.
- Enter play mode. You should connect to the live cluster this time. You should load the game scene and connect to the game room in about 30 seconds once enough bots are connected.
Stopping the cluster
- When you are done, go to the Sessions tab in the web console.
- Expand the cluster to see the rooms running in the cluster.
- Click the X in the Stop column for the cluster, then click the checkmark to stop the cluster. It make take several seconds for the cluster to stop. When you stop the cluster, the rooms in the cluster will keep running, but lose their connection to the cluster.
- Repeat for each room in the cluster to stop all the rooms.