Module: Doom::Net::Protocol

Defined in:
lib/doom/net/protocol.rb

Overview

Wire format for both multiplayer stacks: the peer-to-peer lockstep mesh (HELLO/SETUP/TICCMD/HASH/QUIT/PEERS) and the authoritative star server (WELCOME/SNAPSHOT/INPUT/FRAME).

Kept separate from the socket so it can be tested without one, and so nothing that parses bytes off the network has any business touching a file descriptor.

Every decode path is defensive: these bytes come from the network, and a truncated or malformed packet must produce nil, never an exception. Anything unrecognised is simply dropped.

Constant Summary collapse

VERSION =
1
HELLO =

I would like to join

1
SETUP =

Here is the game you are joining, and who you are

2
TICCMD =

Input for one or more tics

3
HASH =

State fingerprint for a checkpoint tic

4
QUIT =

I am leaving

5
PEERS =

Everyone else's address, so the mesh can form

6
WELCOME =

Star-topology server packets (Net::GameServer). Separate from the peer lockstep ones above: the server is authoritative and never waits, so its packets carry finalized tics, not proposals.

7
SNAPSHOT =

server -> joiner: your id and the game, snapshot to follow

8
INPUT =

server -> joiner: one chunk of the world snapshot

9
FRAME =

client -> server: my ticcmd for a future tic

10
MAX_CMDS_PER_PACKET =

server -> clients: the finalized tics everyone must run

32
SNAPSHOT_CHUNK_BYTES =

Payload per snapshot chunk, kept well under a typical path MTU so a chunk is never itself IP-fragmented.

1024
MAX_FRAME_BYTES =

Ceiling for a FRAME packet. A frame carries every player's command, and FRAME_REDUNDANCY frames are repeated per packet against loss, so at 30 players the packet approaches the ~1500-byte mobile MTU and past it IP fragments -- and the receiver's fixed recv buffer would silently truncate it. Kept under the IPv6 minimum MTU (1280) minus headers so a FRAME never fragments; encode_frame_capped drops the oldest (most-redundant) frames to fit, always keeping at least the newest.

1200
MODES =
{ 0 => :coop, 1 => :deathmatch }.freeze

Class Method Summary collapse

Class Method Details

.decode(bytes) ⇒ Object



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/doom/net/protocol.rb', line 153

def decode(bytes)
  return nil if bytes.nil? || bytes.bytesize < 2

  bytes = bytes.b
  version, type = bytes.unpack('CC')
  return nil unless version == VERSION

  case type
  when HELLO    then decode_hello(bytes)
  when SETUP    then decode_setup(bytes)
  when TICCMD   then decode_ticcmds(bytes)
  when HASH     then decode_hash(bytes)
  when QUIT     then decode_quit(bytes)
  when PEERS    then decode_peers(bytes)
  when WELCOME  then decode_welcome(bytes)
  when SNAPSHOT then decode_snapshot_chunk(bytes)
  when INPUT    then decode_input(bytes)
  when FRAME    then decode_frame(bytes)
  end
end

.decode_frame(bytes) ⇒ Object



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/doom/net/protocol.rb', line 208

def decode_frame(bytes)
  return nil if bytes.bytesize < 3

  count = bytes.unpack('CCC')[2]
  offset = 3
  frames = []
  entry = 1 + Game::Ticcmd::PACKED_SIZE

  count.times do
    return nil if bytes.bytesize < offset + 5

    tic = bytes[offset, 4].unpack1('L<')
    offset += 4
    njoins = bytes[offset].unpack1('C')
    offset += 1
    return nil if bytes.bytesize < offset + njoins

    joins = bytes[offset, njoins].unpack('C*')
    offset += njoins
    return nil if bytes.bytesize < offset + 1

    nleaves = bytes[offset].unpack1('C')
    offset += 1
    return nil if bytes.bytesize < offset + nleaves

    leaves = bytes[offset, nleaves].unpack('C*')
    offset += nleaves
    return nil if bytes.bytesize < offset + 1

    ncmds = bytes[offset].unpack1('C')
    offset += 1
    return nil if bytes.bytesize < offset + (ncmds * entry)

    cmds = Array.new(ncmds) do |i|
      at = offset + (i * entry)
      [bytes[at].unpack1('C'), Game::Ticcmd.unpack(bytes[at + 1, Game::Ticcmd::PACKED_SIZE])]
    end
    offset += ncmds * entry
    frames << { tic: tic, joins: joins, leaves: leaves, cmds: cmds }
  end

  { type: FRAME, frames: frames }
end

.decode_hash(bytes) ⇒ Object



306
307
308
309
310
311
312
313
314
315
316
# File 'lib/doom/net/protocol.rb', line 306

def decode_hash(bytes)
  names = Game::StateHash::SECTIONS
  return nil if bytes.bytesize < 7 + (names.size * 4)

  _, _, player_id = bytes.unpack('CCC')
  tic = bytes[3, 4].unpack1('L<')
  values = bytes[7, names.size * 4].unpack('L<*')

  { type: HASH, player_id: player_id, tic: tic,
    sections: names.zip(values).to_h }
end

.decode_hello(_bytes) ⇒ Object



274
275
276
# File 'lib/doom/net/protocol.rb', line 274

def decode_hello(_bytes)
  { type: HELLO }
end

.decode_input(bytes) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/doom/net/protocol.rb', line 194

def decode_input(bytes)
  return nil if bytes.bytesize < 4

  _, _, player_id, count = bytes.unpack('CCCC')
  entry = 4 + Game::Ticcmd::PACKED_SIZE
  return nil if bytes.bytesize < 4 + (count * entry)

  cmds = Array.new(count) do |i|
    at = 4 + (i * entry)
    [bytes[at, 4].unpack1('L<'), Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])]
  end
  { type: INPUT, player_id: player_id, cmds: cmds }
end

.decode_peers(bytes) ⇒ Object



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/doom/net/protocol.rb', line 252

def decode_peers(bytes)
  return nil if bytes.bytesize < 3

  count = bytes.unpack('CCC')[2]
  offset = 3
  entries = []

  count.times do
    return nil if bytes.bytesize < offset + 1

    player_id = bytes[offset].unpack1('C')
    host, offset = decode_string(bytes, offset + 1)
    return nil if host.nil?
    return nil if bytes.bytesize < offset + 2

    entries << [player_id, host, bytes[offset, 2].unpack1('S<')]
    offset += 2
  end

  { type: PEERS, peers: entries }
end

.decode_quit(bytes) ⇒ Object



318
319
320
321
322
# File 'lib/doom/net/protocol.rb', line 318

def decode_quit(bytes)
  return nil if bytes.bytesize < 3

  { type: QUIT, player_id: bytes.unpack('CCC')[2] }
end

.decode_setup(bytes) ⇒ Object



278
279
280
281
282
283
284
285
286
287
# File 'lib/doom/net/protocol.rb', line 278

def decode_setup(bytes)
  return nil if bytes.bytesize < 7

  _, _, player_id, num_players, seed, mode, skill = bytes.unpack('CCCCCCC')
  map, = decode_string(bytes, 7)
  return nil if map.nil? || map.empty?

  { type: SETUP, player_id: player_id, num_players: num_players, seed: seed,
    mode: mode_name(mode), skill: skill, map: map }
end

.decode_snapshot_chunk(bytes) ⇒ Object



186
187
188
189
190
191
192
# File 'lib/doom/net/protocol.rb', line 186

def decode_snapshot_chunk(bytes)
  return nil if bytes.bytesize < 10

  snapshot_tic, index, total = bytes[2, 8].unpack('L<S<S<')
  { type: SNAPSHOT, snapshot_tic: snapshot_tic, index: index, total: total,
    chunk: bytes.byteslice(10, bytes.bytesize - 10) }
end

.decode_string(bytes, offset) ⇒ Object



330
331
332
333
334
335
336
337
# File 'lib/doom/net/protocol.rb', line 330

def decode_string(bytes, offset)
  return [nil, offset] if bytes.bytesize < offset + 1

  len = bytes[offset].unpack1('C')
  return [nil, offset] if bytes.bytesize < offset + 1 + len

  [bytes[offset + 1, len], offset + 1 + len]
end

.decode_ticcmds(bytes) ⇒ Object



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/doom/net/protocol.rb', line 289

def decode_ticcmds(bytes)
  return nil if bytes.bytesize < 8

  _, _, player_id, count = bytes.unpack('CCCC')
  ack = bytes[4, 4].unpack1('L<')
  entry = 4 + Game::Ticcmd::PACKED_SIZE
  return nil if bytes.bytesize < 8 + (count * entry)

  cmds = Array.new(count) do |i|
    at = 8 + (i * entry)
    tic = bytes[at, 4].unpack1('L<')
    [tic, Game::Ticcmd.unpack(bytes[at + 4, Game::Ticcmd::PACKED_SIZE])]
  end

  { type: TICCMD, player_id: player_id, ack: ack, cmds: cmds }
end

.decode_welcome(bytes) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
# File 'lib/doom/net/protocol.rb', line 174

def decode_welcome(bytes)
  return nil if bytes.bytesize < 10

  _, _, player_id, num_players, mode, skill = bytes.unpack('CCCCCC')
  snapshot_tic = bytes[6, 4].unpack1('L<')
  map, = decode_string(bytes, 10)
  return nil if map.nil? || map.empty?

  { type: WELCOME, player_id: player_id, num_players: num_players,
    mode: mode_name(mode), skill: skill, snapshot_tic: snapshot_tic, map: map }
end

.encode_frame(frames) ⇒ Object

server -> clients. A batch of finalized tics, oldest first. Each tic carries the membership changes to apply (joins and leaves are tic events, because a deathmatch spawn draws the shared RNG and must happen on the same tic everywhere) and then every player's command.



127
128
129
130
131
132
133
134
135
136
137
# File 'lib/doom/net/protocol.rb', line 127

def encode_frame(frames)
  frames = frames.last(MAX_CMDS_PER_PACKET)
  body = frames.map do |f|
    [f[:tic]].pack('L<') +
      [f[:joins].size].pack('C') + f[:joins].pack('C*') +
      [f[:leaves].size].pack('C') + f[:leaves].pack('C*') +
      [f[:cmds].size].pack('C') +
      f[:cmds].map { |id, cmd| [id].pack('C') + cmd.pack }.join
  end.join
  [VERSION, FRAME, frames.size].pack('CCC') + body
end

.encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES) ⇒ Object

Encode the newest frames that fit under max_bytes, dropping the oldest (whose only job is redundancy) until the packet fits. Always keeps at least the newest frame, so a client can still advance even when a single frame at a huge player count is itself over budget.



143
144
145
146
147
148
149
150
151
# File 'lib/doom/net/protocol.rb', line 143

def encode_frame_capped(frames, max_bytes: MAX_FRAME_BYTES)
  selected = frames
  loop do
    packet = encode_frame(selected)
    return packet if packet.bytesize <= max_bytes || selected.size <= 1

    selected = selected[1..] # drop the oldest, retry
  end
end

.encode_hash(tic, player_id, sections) ⇒ Object



74
75
76
77
# File 'lib/doom/net/protocol.rb', line 74

def encode_hash(tic, player_id, sections)
  values = Game::StateHash::SECTIONS.map { |name| sections[name].to_i }
  [VERSION, HASH, player_id].pack('CCC') + [tic].pack('L<') + values.pack('L<*')
end

.encode_helloObject

HELLO carries nothing but "let me in": the server (or host) assigns the id and tells the joiner everything else.



51
52
53
# File 'lib/doom/net/protocol.rb', line 51

def encode_hello
  [VERSION, HELLO].pack('CC')
end

.encode_input(player_id, pairs) ⇒ Object

client -> server. pairs is [[tic, Ticcmd], ...]; recent inputs are repeated so a lost packet is covered by the next, as with TICCMD.



117
118
119
120
121
# File 'lib/doom/net/protocol.rb', line 117

def encode_input(player_id, pairs)
  pairs = pairs.last(MAX_CMDS_PER_PACKET)
  body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join
  [VERSION, INPUT, player_id, pairs.size].pack('CCCC') + body
end

.encode_peers(entries) ⇒ Object

entries is [[player_id, host, port], ...]. The host sends this so clients can talk to each other directly instead of relaying everything through it, which would add a hop of latency to every command.



86
87
88
89
90
91
# File 'lib/doom/net/protocol.rb', line 86

def encode_peers(entries)
  body = entries.map do |player_id, host, port|
    [player_id].pack('C') + encode_string(host) + [port].pack('S<')
  end.join
  [VERSION, PEERS, entries.size].pack('CCC') + body
end

.encode_quit(player_id) ⇒ Object



79
80
81
# File 'lib/doom/net/protocol.rb', line 79

def encode_quit(player_id)
  [VERSION, QUIT, player_id].pack('CCC')
end

.encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:) ⇒ Object



55
56
57
58
# File 'lib/doom/net/protocol.rb', line 55

def encode_setup(player_id:, num_players:, seed:, map:, mode:, skill:)
  [VERSION, SETUP, player_id, num_players, seed, mode_code(mode), skill].pack('CCCCCCC') +
    encode_string(map)
end

.encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes) ⇒ Object



101
102
103
# File 'lib/doom/net/protocol.rb', line 101

def encode_snapshot_chunk(snapshot_tic, index, total, chunk_bytes)
  [VERSION, SNAPSHOT].pack('CC') + [snapshot_tic, index, total].pack('L<S<S<') + chunk_bytes.b
end

.encode_string(str) ⇒ Object

Length-prefixed so a name or map with odd bytes cannot run off the end.



325
326
327
328
# File 'lib/doom/net/protocol.rb', line 325

def encode_string(str)
  s = str.to_s.b[0, 255]
  [s.bytesize].pack('C') + s
end

.encode_ticcmds(player_id, pairs, ack: 0) ⇒ Object

pairs is [[tic, Ticcmd], ...]. Packets carry several commands so a lost one is covered by the next packet: a ticcmd is 7 bytes, far cheaper to repeat than to request again over a round trip.

ack is the lowest tic the sender is still waiting for. Peers resend from there, so a command cannot be lost for good by falling out of a fixed-size window -- with real packet loss that eventually deadlocks both sides, since lockstep never skips a tic.



68
69
70
71
72
# File 'lib/doom/net/protocol.rb', line 68

def encode_ticcmds(player_id, pairs, ack: 0)
  pairs = pairs.last(MAX_CMDS_PER_PACKET)
  body = pairs.map { |tic, cmd| [tic].pack('L<') + cmd.pack }.join
  [VERSION, TICCMD, player_id, pairs.size].pack('CCCC') + [ack].pack('L<') + body
end

.encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:) ⇒ Object

server -> joiner. Says who they are and what game this is; the world itself follows as SNAPSHOT chunks for the same snapshot_tic. No RNG seed is sent: the snapshot already carries the exact RNG state to resume from.



96
97
98
99
# File 'lib/doom/net/protocol.rb', line 96

def encode_welcome(player_id:, num_players:, mode:, skill:, snapshot_tic:, map:)
  [VERSION, WELCOME, player_id, num_players, mode_code(mode), skill].pack('CCCCCC') +
    [snapshot_tic].pack('L<') + encode_string(map)
end

.mode_code(mode) ⇒ Object



341
342
343
# File 'lib/doom/net/protocol.rb', line 341

def mode_code(mode)
  MODES.key(mode.to_sym) || 0
end

.mode_name(code) ⇒ Object



345
346
347
# File 'lib/doom/net/protocol.rb', line 345

def mode_name(code)
  MODES.fetch(code, :coop)
end

.snapshot_chunks(snapshot_tic, bytes) ⇒ Object

Split a full snapshot into datagram-sized chunks for one tic.



106
107
108
109
110
111
112
113
# File 'lib/doom/net/protocol.rb', line 106

def snapshot_chunks(snapshot_tic, bytes)
  bytes = bytes.b
  total = [(bytes.bytesize + SNAPSHOT_CHUNK_BYTES - 1) / SNAPSHOT_CHUNK_BYTES, 1].max
  (0...total).map do |i|
    encode_snapshot_chunk(snapshot_tic, i, total,
                          bytes.byteslice(i * SNAPSHOT_CHUNK_BYTES, SNAPSHOT_CHUNK_BYTES) || ''.b)
  end
end