Class: Doom::Net::Lockstep
- Inherits:
-
Object
- Object
- Doom::Net::Lockstep
- Defined in:
- lib/doom/net/lockstep.rb
Overview
Lockstep tic scheduling, with no socket attached.
Every peer simulates every player. Only ticcmds travel, and a tic runs only once the commands of all players for that tic are in hand. Two peers feeding equal commands to equal worlds stay equal; nothing else needs to be synchronised.
Local input is scheduled delay tics into the future, which is what buys
the network time to deliver it: input sampled now is consumed a few tics
from now, by which point everyone has it. The first delay tics run on
neutral commands, so the game starts without waiting for anybody.
When a command is missing the simulation stalls rather than guessing. That is the deal lockstep makes: everyone waits for the slowest peer, and nobody ever has to take back something that already happened.
Constant Summary collapse
- DEFAULT_DELAY =
~57ms at 35Hz
2
Instance Attribute Summary collapse
-
#delay ⇒ Object
readonly
Returns the value of attribute delay.
-
#local_id ⇒ Object
readonly
Returns the value of attribute local_id.
-
#next_tic ⇒ Object
readonly
Returns the value of attribute next_tic.
-
#player_ids ⇒ Object
readonly
Returns the value of attribute player_ids.
-
#sample_tic ⇒ Object
readonly
Returns the value of attribute sample_tic.
Instance Method Summary collapse
-
#ack_tic ⇒ Object
The lowest tic still missing locally: what peers should resend from.
-
#initialize(local_id:, player_ids:, delay: DEFAULT_DELAY) ⇒ Lockstep
constructor
A new instance of Lockstep.
-
#local_since(ack, count) ⇒ Object
Local commands to put in a packet for a peer that is waiting on
ack. -
#note_ack(player_id, tic) ⇒ Object
Where a peer says it has got to.
- #ready? ⇒ Boolean
-
#receive(tic, player_id, cmd) ⇒ Object
A command from a peer.
-
#run_ready(world, limit: 10) ⇒ Object
Run every tic whose commands have arrived.
- #stalled? ⇒ Boolean
-
#submit_local(cmd) ⇒ Object
Schedule this frame's input.
-
#take_cmds ⇒ Object
Commands for the current tic, advancing to the next.
-
#waiting_on ⇒ Object
Players holding up the current tic.
Constructor Details
#initialize(local_id:, player_ids:, delay: DEFAULT_DELAY) ⇒ Lockstep
Returns a new instance of Lockstep.
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/doom/net/lockstep.rb', line 25 def initialize(local_id:, player_ids:, delay: DEFAULT_DELAY) raise ArgumentError, 'delay must be >= 1' if delay < 1 raise ArgumentError, 'local_id must be one of player_ids' unless player_ids.include?(local_id) @local_id = local_id @player_ids = player_ids.dup.freeze @delay = delay @buffer = {} # tic => { player_id => Ticcmd } @peer_acks = {} # player_id => lowest tic they still need @local_sent = {} # tic => Ticcmd, kept for retransmission padding @next_tic = 1 # next tic to simulate @sample_tic = delay + 1 # tic the next local sample is for # Prime the opening tics so play starts immediately instead of waiting # a delay's worth of round trips for commands nobody has sent yet. (1..delay).each do |tic| @player_ids.each { |id| store(tic, id, Game::Ticcmd.none) } end end |
Instance Attribute Details
#delay ⇒ Object (readonly)
Returns the value of attribute delay.
23 24 25 |
# File 'lib/doom/net/lockstep.rb', line 23 def delay @delay end |
#local_id ⇒ Object (readonly)
Returns the value of attribute local_id.
23 24 25 |
# File 'lib/doom/net/lockstep.rb', line 23 def local_id @local_id end |
#next_tic ⇒ Object (readonly)
Returns the value of attribute next_tic.
23 24 25 |
# File 'lib/doom/net/lockstep.rb', line 23 def next_tic @next_tic end |
#player_ids ⇒ Object (readonly)
Returns the value of attribute player_ids.
23 24 25 |
# File 'lib/doom/net/lockstep.rb', line 23 def player_ids @player_ids end |
#sample_tic ⇒ Object (readonly)
Returns the value of attribute sample_tic.
23 24 25 |
# File 'lib/doom/net/lockstep.rb', line 23 def sample_tic @sample_tic end |
Instance Method Details
#ack_tic ⇒ Object
The lowest tic still missing locally: what peers should resend from.
122 123 124 |
# File 'lib/doom/net/lockstep.rb', line 122 def ack_tic @next_tic end |
#local_since(ack, count) ⇒ Object
Local commands to put in a packet for a peer that is waiting on ack.
Resending from the peer's own position, rather than just the newest
few, is what makes loss survivable: a fixed window drops a command for
good once it slides past, and lockstep never skips a tic, so both sides
would deadlock on it. count still bounds the packet.
114 115 116 117 118 119 |
# File 'lib/doom/net/lockstep.rb', line 114 def local_since(ack, count) tics = @local_sent.keys.sort pending = tics.select { |tic| tic >= ack } pending = tics.last(count) if pending.empty? pending.first(count).map { |tic| [tic, @local_sent[tic]] } end |
#note_ack(player_id, tic) ⇒ Object
Where a peer says it has got to. Recorded so we never discard a command that somebody still needs.
128 129 130 131 132 133 |
# File 'lib/doom/net/lockstep.rb', line 128 def note_ack(player_id, tic) return unless @player_ids.include?(player_id) && player_id != @local_id current = @peer_acks[player_id] @peer_acks[player_id] = tic if current.nil? || tic > current end |
#ready? ⇒ Boolean
72 73 74 |
# File 'lib/doom/net/lockstep.rb', line 72 def ready? missing_for(@next_tic).empty? end |
#receive(tic, player_id, cmd) ⇒ Object
A command from a peer. Commands for tics already simulated are dropped; duplicates are ignored rather than overwriting, since the transport deliberately resends recent commands and a late copy must not change a tic that is already decided.
63 64 65 66 67 68 69 70 |
# File 'lib/doom/net/lockstep.rb', line 63 def receive(tic, player_id, cmd) return false if tic < @next_tic return false unless @player_ids.include?(player_id) return false if @buffer.dig(tic, player_id) store(tic, player_id, cmd) true end |
#run_ready(world, limit: 10) ⇒ Object
Run every tic whose commands have arrived. Returns how many ran, so a caller can tell a stall from an idle moment.
99 100 101 102 103 104 105 106 |
# File 'lib/doom/net/lockstep.rb', line 99 def run_ready(world, limit: 10) ran = 0 while ran < limit && (cmds = take_cmds) world.run_tic(cmds) ran += 1 end ran end |
#stalled? ⇒ Boolean
82 83 84 |
# File 'lib/doom/net/lockstep.rb', line 82 def stalled? !ready? end |
#submit_local(cmd) ⇒ Object
Schedule this frame's input. Returns the tic it was scheduled for, which is what the sender must label the outgoing packet with.
47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/doom/net/lockstep.rb', line 47 def submit_local(cmd) tic = @sample_tic @sample_tic += 1 store(tic, @local_id, cmd) @local_sent[tic] = cmd # Prune here too, not only when tics run: a stalled peer keeps sampling # input while unable to advance, which is exactly when the history # would otherwise grow without bound. prune tic end |
#take_cmds ⇒ Object
Commands for the current tic, advancing to the next. Returns nil if the tic is not ready.
88 89 90 91 92 93 94 95 |
# File 'lib/doom/net/lockstep.rb', line 88 def take_cmds return nil unless ready? cmds = @buffer.delete(@next_tic) @next_tic += 1 prune cmds end |
#waiting_on ⇒ Object
Players holding up the current tic. Worth surfacing: a stall with no explanation looks like a freeze.
78 79 80 |
# File 'lib/doom/net/lockstep.rb', line 78 def waiting_on missing_for(@next_tic) end |