Class: Doom::Net::Lockstep

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(local_id:, player_ids:, delay: DEFAULT_DELAY) ⇒ Lockstep

Returns a new instance of Lockstep.

Raises:

  • (ArgumentError)


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

#delayObject (readonly)

Returns the value of attribute delay.



23
24
25
# File 'lib/doom/net/lockstep.rb', line 23

def delay
  @delay
end

#local_idObject (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_ticObject (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_idsObject (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_ticObject (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_ticObject

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

Returns:

  • (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

Returns:

  • (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_cmdsObject

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_onObject

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