I’m making a Terraria/Starbound-like game. It follows the pretty standard pattern of:
Players can create worlds and characters locally
Players can “host & play”, selecting a world and a local character
Other Players (friends) can join a hosted game
No plans for dedicated servers
There many many games that follow this pattern; It’s my daughter’s favourite genre, and so we’ve played many such games together including:
Corekeeper
Terraria
Starbound
Raft
Palworld
Portal Knights
Minecraft dungeons
Minecraft (though admittedly we use realms)
Of these games, I think Raft does it best because it doesn’t use the sh***y steam overlay, which I consider to be a rubbish user experience. Rather, if you click “Join”, then if any of your friends are hosting a game, they are listed and you can just join them without leaving the game. It’s super simple and clean. However, I’m fine to start with the steam overlay as an MVP.
My requirements:
It needs to work reliability (so, AFAIK, will need NAT punchthrough & relay fallback)
I’d like to use Netcode for GameObjects, because I’m familiar with it, and have so far used it to implement multiplayer functionality (testing locally with multiple app instances)
I’d like to use Steam for user identity, authentication etc., because this is a no-brainer
What approach (libraires etc) would you recommend?
From what I understand so far, I basically need to be using steamworks as the transport, and then everything else higher in the stack is out-the-box Unity?
So, I should use facepunch (for convenience), then implement a custom transport and select this transport from the standard netcode NetworkManager ?
You’ll want to stick with Relay first and foremost until late in development.
Hole punching can always be added at a later point, and by that time perhaps NGO / Unity Relay has already added punching support.
To the best of my knowledge, but I’m not perfectly certain yet, you would have to use the Lobby service and check for every Lobby client whether punchthrough will work for them, but if any one of them fails you will have to revert everyone to use the Relay service. I theorized about this here plus providing extra details on punchthrough.
Like you said <ou’ll need the Steam transport. Perhaps this already has punchthrough support, I know that Epic’s P2P networking SDK has it too. If Steam has it it should be in the docs.
Other than that, Service wise you need Relay which requires Authentication (anonymous suffices) but only if Steam doesn’t provide Relay.
Just a heads up that this a genre that’s really difficult to implement with networking, primarily because of the ever-changing world. You can quickly run into issues where the game consumes too much memory, or the player actions lead to an unusually high traffic unless you optimize for those two things.
The naive approach would be having a Tiles[,] multidimensional array for the world, and synchronizing that in its entirety for every change. Assume a 500x500 world and a single int for indexing tiles and that array amounts to 1 MB already. And you’d have 250,000 tiles which definitely cannot all be GameObjects.
A game like this lends itself best to Entities and Netcode for Entities due to possibly processing large amounts of data every frame.
Yes of course, a gameobject per tile would be silly.
I’ve already written my own “NetworkTilemap”. It works as follows:
It works in terms of chunks of 64x64 tiles.
Initially, the client requests full chunks around the player (or a specified location)
The server uses RLE to encode chunks and send them to the client. Typically, a chunk is 100-400 bytes
RLE is very efficient because I don’t synchronise the raw “tile”, but rather the type. E.g. I may have 100 different sprites for “brown dirt”, but only need to send one type across. The client then uses ruletiles and a hash of the tile offset to decide which sprite to show and how to add “random” variation (deterministically).
After a whole chunk has been synced, only deltas are sent thereafter (i.e. individual tile changes made by players are enemies). These can be batched if, for example, a large explosion changes many tiles
Also worthy of note: my tiles are 3D. I’ve achieved this without gameobjects by implementing my own tilemap renderer which runs on top of a regular Unity TileMap. It basically uses Graphics.RenderMesh to draw tiles efficiently (it could even use GPU instancing, though I’ve not felt the need to add this yet). It works with RuleTiles too, and is pretty lightweight and performant.
I did consider using Unity’s entity framework, but it really wasn’t worth the complexity. The only “heavy lifting” this genre of game needs is for terrain, which was very easy to implement as described above.
I might need to do something clever with enemies (and projectiles) if I want to have hundreds of them on-screen at once, but again I’m happy to do my own thing instead of using DOTS.
I just wanted to share my thoughts, so far, on DOTS:
The DOTS paradigm is basically how I used to build games 25 years ago in plain C, without object orientation, so it’s something I’m very familiar with. The big difference is that my data-orientated structures were always purpose-built and never generic.
DOTS achieves the same paradigm in a generic way, and looks to do it very well. However, I’m reluctant to mix both DOTs with the standard GameObjects approach (this would also mean mixing both Netcode for Entities and Netcode for GameObjects). Therefore, my options are:
Migrate fully to DOTS, and take the “hit” of additional development effort for many objects that really don’t need DOTS performance (like the player object itself)
Stick with the standard GameObjects paradigm, and “sprinkle” my own data-orientation as and where I need it (such as I have for synching tilemaps).
While I would probably generally recommend 1) , I’m happy to go with 2) because: I have many years experience in data orientation and network optimisation (I’ve written my own library similar to Photon Quantum in the past, with a massive focus on eliminating latency and have built games on top of it). For me personally, and for this particular genre, I think I’ll get much better productivity out of 2), and I’m confident that I can overcome any performance issues I might encounter.
That said, as an experiment and a learning opportunity, I may fork by project and attempt to migrate just one entity type (let’s say projectiles) to DOTS… then I’ll know for sure how well DOTS mixes with standard. It might turn out that they play happily together, in which case I may use DOTS to speed up development for specific cases where data-orientation is worthwhile.
Most projectiles travel in a straight line, ignoring gravity. Even with gravity, it’s just a constant vector added every (fixed) update. So you really only need to synchronize “fired weapon” with position and direction and every client can spawn a local-only projectile that spawns at position and flies in the direction, with the speed and type of projectile already known to clients.
Projectile updates need to happen in FixedUpdate to ensure they stay in sync for all clients.
Enemies are similar, except here you’d have a statemachine or behaviour tree that dictatates that the enemy will do the same thing on all clients in a given fixed update step. This would however stop working if anything influences the enemy that may happen in Update or isn’t synchronized with all clients. So if the enemy gets hit, it still has to be verified by the server both whether it was a hit and how much damage gets deducted (if it matters where it got hit).
Me too. I’d prefer to go all-in but that really complicates matters.
But like you said you can go a long way by having game-specific structures that you burst through a parallel job, such as tile chunk updates or the projectile transform changes and that’ll likely be all you need to get top speed.
The beauty of this genre is that it’s “non-competitive”, so it’s fine to use all sorts of local-only tricks to completely eliminate latency challenges.
So if the enemy gets hit, it still has to be verified by the server both whether it was a hit and how much damage gets deducted (if it matters where it got hit).
I think it would be fine, in this genre, to let the client decide where it has hit the boss. The only concern would be if the “local” boss instances get so far out of sync across players that other players’ interactions begin to look wonky. So I was thinking something like this:
Boss health, and high-level behaviour state (e.g. what phase the boss is in) is owned by the server/host, and synced “normally”
It’s locally decided if projectiles hit the boss (though this would ofc trigger a server call to action the hit so others see the hit)
Boss transform, and physics (e.g. rigid body if it uses one) is local, but gets synced at a low frequency (e.g. 1 per second) or upon certain events (e.g. boss takes a big physical hit)
Some boss behaviours/actions are decided locally, and synced with the server. (e.g. boss wants to shoot a mortar at a particular trajectory to hit a moving player. It decides the trajectory local to that player. Actually firing the projectile is synced so that other players see it.)