Posted May 22, 2020 by Creeepling
...but it is done. You can now slay menacing white, red and purple dots, as well as the fearsome boss, together with a friend or three. If you do - tell me how it goes!
(Players who wanna play co-op need to have Steam open and my previous game, Wienne, has to be in their Steam library. It's free, and there's no need to download it - as long as you add it, everything should work. This second requirement is temporary!)
Right now, the multiplayer feature is really, really raw. You can connect to each other, all unit movement is synchronized, all damage taken is synchronized. For now, that's all. You will not see each other's spells, and enemies will use their attacks at different times and on different targets. That will be changed, but, for now, that's the way it is.
Now, for those interested, I will write a couple words about my experience with implementing Steamworks P2P into a Unity game.
I am using Facepunch wrapper for Steamworks. While it is sort of documented, it's pretty hard to find many code/project examples to use it, so a lot of it was sort of poking in the dark. For now, this is how things work. Almost no code, just general logic, and if you have any questions - feel free to ask!
1) You need to connect to Steam, which is explained in the Facepunch Steamworks wiki right here.
2) In order to connect to other players, you have to create a lobby. I was poking and prodding in the dark trying to figure out if I maybe need to create a server first, but no, you simply call async SteamMatchmaking.CreateLobbyAsync(playerCount), and you're good.
3) There's plenty of events which fire whenever stuff happens in the lobby. For example, SteamMatchmaking.OnLobbyEntered fires on the client of the person who enters a lobby. It fires when you create a lobby, too. Manage events for entering, leaving lobby, changing player data and whatever else you need.
4) Finding lobbies is pretty straightforward, my two lines for it looked like this:
LobbyQuery q = SteamMatchmaking.LobbyList;
Lobby[] lobbies = await q.RequestAsync();
In my game ui, I tried to assign lobby owner name to a text field, so that I could see who it is who created a lobby. Something along the lines of uitext.text = lobby.Owner.name. Sounds reasonable, right? But for some reason you don't see the owner until you have actually entered the lobby. So instead, I put the owner's name into the lobby data as I create it(lobby?.Value.SetData("name", SteamClient.Name);), and I'm able to pull that out. You can put anything else into that data.
Lobbies are joined via lobby SteamId.
5) One of the things you can do is store player-specific data with SetMemberData(), which can be retrieved by anyone else. I used it to store player vairables which I would need to be aware of before the game started - for example, chosen character class. It triggers OnLobbyMemberDataChanged event, which provides you a Lobby and a Friend. At first, I was foolish enough to try to use that Friend's id to try and retrieve the data from the lobby variable I stored before. But that current lobby variable takes some time to get updated, so the "correct" way is using the lobby data provided by the event.
6) The game starts with lobby.SetGameServer(SteamId), and it triggers the OnLobbyGameCreated event. That is where I a) call AcceptP2PSessionWithUser on every other member of the lobby(I feel like this is somehow wrong, but it seems to work so far). And this is where the provided functionality ends and chaos begins. Despite the fact that this is p2p, I use the lobby owner as a host for various purposes, such as map generation and enemy AI.
I supposed I could have stored player information in the special lobby data, but I chose to keep a custom NetworkPlayer class, which would contain character class id, player steamid, a custom int16 player id(1-4, assigned automatically during lobby assembly) and a bool. Somehow, I prefer having my own class with proper variables, rather then going into the lobby data and converting strings all the time.
So, p2p. Basically, you get to send byte[] data over to other players, and they will recieve those packets with sender's steamID alongside it. In order to differentiate between what data the current packet contains, I put a char in the beginning of it. For example, 'h' means health change and 't' means teleport. Using a char provides me with a lot of flexibility with what the data can contain, while keeping the size of the data fairly small. I'm using Buffer.BlockCopy to write byte arrays on the sender, and BitConverter.To___() to retrieve data, in the order it was written.
For now, I have no idea how to do this optimally. Right now, server tickrate is 0.05f. Every 0.05f units check whether their position has changed since the last check, and, if it has, they throw their data over to be encoded into bytes, along with their unique int16 id, which is kept in sync between clients, mostly thanks to the fact that units spawn one by one. There's a lock() and a static variable which are supposed to help with that, but there might be a risk that the count can get desynced when two rooms are spawned at once by two different players. Damage is synced "on demand". Whenever a unit has their health changed, they throw the amount, along with their id, over to be encoded.
At first I was sending every position individually, but whenever I would get up to 50+ moving units I was facing problems. The non-host players would get horrible performance. I figured that continuously decoding tiny datasets of char, int16 and 3xfloat was putting a strain, so I slightly changed the architecture. Now, instead of every unit sending their positions idividually, all the movement data accumulated over 0.05sec would be sent out in a single large packet, and decyphered with a single function call. That fixed the stutter issue. So, just in case I would have multiple dots ticking on a dozen of enemies, I also made damage info transfer behave in the same way - sent out in a single pile.
Another thing I could've done is making sure that movement in my game is as deterministic as possible. That pretty much means no physics. I think that's the way it is right now, anyway. So, whenever a new move order would be issued, that order would be sent over to the other client, and executed independently on each. That means both movement button presses, and unit AI. I still might use that option, but we will see. Right now this is the first multiplayer build which is sort of, kind of, stable. I think. :D
7) Who-does-what. It's fairly simple when the player is alone, but when it comes to multiplayer, certain things become tricky. For example, monsters spawn when a player approaches a room. How should that work? Should the host be the one to track player position and trigger monster spawns? Or should peers inform each other whenever they come close to a room? At first, I tried the host doing that, by reading collisions between the other player's objects and the room's spawn trigger. But either there were some other bugs(took a while to get everything right), or unity doesn't consistently track collisions that happen off-camera. I guess, I could use a separate camera to make sure that they happen, but I don't like this solution, so I made peers inform each other about monster spawns.
8) Some things remain local. For example, loot is client-specifics, and so are health drops, destructibles and interactions with shops and shrines. Right now it is impossible to pass items between players, but that will be implemented later. Oh, that reminds me!
9) The map. It is randomly generated: you provide the game with the map's size, it chooses 2 points at the top and 2 at the bottom, and builds two paths from bottom to top. If the paths do not connect, it builds a third path between random points of the paths already built. There's some magic and seemingly random coefficients behind the scenes which make sure that the paths don't turn out straight. The built paths go into a 2D array of 1s and 0s, 0 - no room, 1 - room; and to the top of the generated map goes the boss room: a 2. That map is used to spawn in rooms, which are chosen from a pool based on the surrounding rooms(right now, there are multiple "square" rooms, capable of having 1-4 connections, a horizontal left-to-right room and a "topper" room). Rooms use the map to see which sides they need to "open" and which sides need to be walled. Rooms also individually generate a shop or an altar(or nothing) for themselves, in one of the several random positions in the room. Destructible placement is also randomized.
For a multiplayer game, this seems wrong to me. When I will be rebuilding the map system, the thing that will be generated is a key, which will contain infromation for every room. Out of that key, the rooms will be constructed on every client.
Right now, the game, as it is creating the rooms, makes a four-digit code, in which the 1st position is the id of the event in the room(shop/altar/nothing), 2nd - the id of the event's position inside the room, 3 is the type of the room(square/horizontal/top/boss) and 4 is the id of the room within that pool. That 2D array is converted into a byte array, sent over to other players, and rebuilt into an actual map.
I think that's about it, for now. I might take a break from network stuff and build another class, before doing stuff such as projectile sync, trading and all other stuff. If you're trying to build a similar p2p system on your own, or if you know of better ways of doing things I'm describing here - I'll be happy to hear from you.
Thanks for geting this far :D