Async multiplayer by sharing storage objects

I had a question performance wise: I have created a small test case with 2 players updating the same save file whilst in a game. Ive created a server side lua that saves the data so that there isn’t any permission issue and giving public access. When one saves his stuff a notification is being sent to the other player that something has changed, so he accesses the storage to see what changed and syncs that way is that a weird/unoptimized way of doing it or is it fine?

1 Like

Yes that should work fine. If your users are expected to be online you should use the realtime multiplayer feature instead, but for fully asynchronous multiplayer sharing a storage object like this is fine.

I don’t see any problems with this model but you should always test/benchmark with your specific use case and implementation

1 Like

Thanks @zyro I was thinking of a way to keep the save persistent amongst all users online or offline, for example a minecraft map where 3 users change it in different ways and then when they relog the changes are still there but none of them really owns the save. I guess then when everyone’s online you keep locally the “save” and keep changing it until you log out where you save it back to the server. I worry a bit about deadlocking over the network, say two users try to access the same tile.

If users are expected to be online and modifying state concurrently then it sounds like using realtime matches is a better idea. Perhaps use the approach suggested in this thread and persist match state when users disconnect, but keep it in memory while they’re online and playing.

OK so @zyro I rethought the process which hopefully might help someone else here too:
It seems that the best way is to still have a centralized approach to this so a server-clients system
where the first online guy is the server which holds the “save” functionality. The rest send him a message
say I want to dig here or build there ( still with the minecraft example ) and he handles the actual positioning and saving. Because you mentioned it needs the data to be held locally, that means he needs to notify the rest of the changes therefore we still need to use the system I originally mentioned to sync the changes. If it’s not centralized I fear it would have issues with prioritization if say 2 users do changes at the same time.

I was hoping when I started this test to get a more decentralized approach but it seems if I use more the authoritative system then the result wouldn’t be as optimized?

That might work as well but I feel like there’s a lot of complexity being introduced where clients can fall out of sync. What if the “host user” doesn’t accept the “save”, or fails to persist the data, or disconnects and you need to rotate the host?

I’m not sure why it’s so important to have a decentralized approach when coordination seems to be required. I’d strongly suggest using authoritative multiplayer for this and having the server handle the real/consistent state of the world.

so instead you are suggesting that I create my custom authoritative server which will keep the match state in the CPU and the clients will send some RPCs saying “I want to build here or mine there” and the server updates that state on every tick and on exit it saves the state.

if that’s the case what’s confusing me now is, to append to the main json state another json with the additional update do I have to create the class structure to be the same in the authoritative place as in in the client side?

local building = { position = 0, type = 0 }
local gamestate = { building = { } }

Also would I have to rewrite everything else as in your authoritative example or if I only say write match_loop does it “override” just that and the rest are as per default?

I have used both approaches for different games and I suggest you use the authoritative server instead. Here’s my lua file for a simple bluffing game and I’ll send the client side code if you’d like. match_handler.lua (14.1 KB)

The function that persists the storage is called update_player_match_storage and it’s current role is to allow returning players to get state updates for the matches they are in (in order to create, for example, an overview menu with a list of all ongoing games).

The earlier game was using Playfab initially so I used the storage solution instead of multiplayer server. It works but it’s extremely ugly and we’re fortunate our game only needs it for passive turn-based multiplayer (turns >1min) and players are almost never online at the same time.

If you have the time to change and are not extremely invested in using storage, then using a multiplayer server would be the way to go.

2 Likes

Wow thanks @GroovyAntoidDev , I now have much to study there!

My idea is slightly different but I’m not sure if it’s doable:

local Map;

local function LoadMap( context, payload )
	Map = nk.storage_read( payload )
    return nk.json_encode( Map )
end

local function SaveMap( context, payload )
	nk.storage_write( Map )
end

local function AddToMap( context, payload )
	local json = nk.json_decode( payload )
	local newBuilding = { Position = json.Position,  Type = json.Type }

	table.insert( Map.Buildings,  newBuilding ) 
	
    return nk.json_encode( Map )
end

This as far as I can understand is authoritative although without altering the default match process

1 Like

This snippet looks fine, are these all registered as rpc methods?

Assuming I understand this correctly, the LoadMap is run when the player re-connects and the other two are run as the map is being generated on the client side? It would help to know the specifics about what you’re trying to accomplish :b

Yes they are, basically I’m doing some tests for an example project.
I’m trying to do what @zyro suggested by keeping the data in memory for every match
and save it when the match ends.
The whole scenario is the following:

  • Create some data that persists in the server. That data doesn’t have an owner but can be accessed by more than one user AND updated by more than one user consecutively, let’s say 2 friends for this example.
  • Try to not overuse the nk.storage_write or nk.storage_read for performance ( although I’m not sure whether it’s that much of an issue ) by having that global variable “Map”
  • The two friends don’t have to be playing the game at the same time. So it could be one player updating the map without being in a match
  • I’m worried a bit about class structures, the local newBuilding = { Position = json.Position, Type = json.Type } is ok because it’s very simple but if I had more complex structures then should I have the same classes in both server and in clients for the json saving?

Basically everything you are trying to do is fine and it should work with no performance issues, but consider taking a look at the full authoritative module example to see an alternative if my example is hard to go through.

  1. This is fine.
  2. This is actually what the “state” variable is for inside the match_loop which, in my example, I update based on the received player messages. Also when players reconnect I send them a summary of this state variable (in match_join).
    I store all game data in the state object, and I only use update_player_match_storage to keep a small summary of ongoing games that can be used to populate a “ongoing games status” popup, but I do not store anything else to storage, it’s all in the state object.
  3. I don’t understand why you want a player to update the map (game state) if he is not in the match, but if that is a requirement then sure this is fine.
  4. The different structure is fine as long as you do not overwrite the whole object and instead only access what you need, but then you can’t do blind save/load and the functions no longer become generic (which you can see in my example too).
1 Like

look at the full authoritative module example to see an alternative if my example is hard to go through

Neither are hard but one question I had was whether I’ll lose the default functionality of matchmaking by using either of those.

I don’t understand why you want a player to update the map (game state) if he is not in the match, but if that is a requirement then sure this is fine.

I havent played much minecraft but my idea was that one player can start the map, update it, leave, then the other one logs in and finds the changes

The different structure is fine as long as you do not overwrite the whole object and instead only access what you need, but then you can’t do blind save/load and the functions no longer become generic (which you can see in my example too).

yeah I guess there’s no way around it

It seems that what I was doing wasn’t actually ok as the Map isn’t being kept in memory so every time I “AddToMap” it’s like I’m starting from scratch. I guess the M variable for the matches is special :frowning:

local Map;

local function LoadMap( context, payload )
	local new_objects = 
	{
	  {collection = "save", key = "save1", user_id = nil }
	}

	local objects = nk.storage_read( new_objects )
	
	if objects.value == nil then
		Map = { Buildings = {} }
		print('New save' )
	else
		Map = objects;
		print('Loaded save')
	end
    return nk.json_encode( Map )
end

local function SaveMap( context, payload )
	local new_objects = 
	{
		{collection = "save", key = "save1", user_id = nil, value = Map}
	}
	nk.storage_write( new_objects )
end

local function AddToMap( context, payload )
	local json = nk.json_decode( payload )
	local newBuilding = { Position = json.Position,  Type = json.Type }

	if Map == nil then
		LoadMap(context, payload)
	end


	table.insert( Map.Buildings,  newBuilding ) 

	for _, r in ipairs(Map.Buildings)
	do
	  local message = ("Position = %q,  Type = %q"):format(r.Position, r.Type)
	  nk.logger_info(message)
	end
	
    return nk.json_encode( Map )
end

@terahardstudios You should read the documentation. What you’re observing is covered explicitly in the runtime docs.

There is no “M variable” in matches. If you’re talking about match state then that’s also covered in the authoritative multiplayer docs.

1 Like

So as far as I understand one can’t reference the latest state from an RPC call, you need to do it in the loop with opCode, correct?

Yes, match state is only available and should only be used/updated in the match loop.

1 Like

So much great information in this post. Thank you both @zyro @GroovyAntoidDev
In case it helps other peeps I’ll share the loop as it works by default ( basically sends other users the message the original sender sent ):

function M.match_loop(context, dispatcher, tick, state, messages)

	for _, message in ipairs(messages) do
			for _, presence in pairs(state.presences) do
				if presence.user_id ~= message.sender.user_id then
					dispatcher.broadcast_message( message.op_code, message.data, {presence} ,  message.sender  )
				end
			end
	end
	return state
end