There is a concept called "synchronous networking", that means that all actions in the game are deterministic and thus will return the same result on all devices. If that is a given then you simply execute all actions on all devices in the same order and will end at the same state.
onMovePlayerLeft(player):
player.X += 1
Now instead of sending over the result of onMovePlayerLeft, you just send over the fact that the "onMovePlayerLeft" was executed. Upon receiving the message you can than execute exactly the same code and both games are in the same state again.
Almost all calculations are deterministic as long as they don't depend on the hardware or the OS. (Unfortunately you will have to avoid using floats as different CPUs implement floats differently.)
But Beast, how does that help?! A dice roll isn't deterministic!
No, that is wrong. Random numbers as we use them in games aren't actually random, they are pseudo random. If the state of the RNG is the same the result will also be the same.
So if your game is synchronous then you just need to make sure that both clients use exactly the same seed at the start of the session.
Edit: But Beast, I don't want the player to know the result of the throw before hand!
In that case you'll need to negotiate a secret every time you want to make a throw.
This could look like this.
Peer A: I want to roll a dice for Action and need a RNG Seed for that.
Peer A: The checksum of my part of the seed will be 459023.
Peer B: The checksum of my part of the seed will be 390564.
Peer A: My part of the seed is 38000.
Peer B: My part of the seed is 76000.
The checksum is needed to enforce that the seed is generated before the peer has received the other seed.
Now you can generate the actual value of the negotinated seed by doing some simple math, such as (38000 + 76000) / 2.