class ClientEngine

Server sends authoritative copy of GameSpace for tick T0. We store that, along with pending moves generated by our player, and pending moves for other players sent to us by the server. Then we calculate further ticks of action. These are predictions and might well be wrong.

When the user requests an action at T0, we delay it by 100ms (T6). We tell the server about it immediately, but advise it not to perform the action until T6 arrives. The server rebroadcasts this information to other players. Hopefully, everyone receives all players’ actions before T6.

We render one tick after another, 60 per second, the same speed at which the server calculates them. But because we may get out of sync, we also watch for full server updates at, e.g., T15. When we get a new full update, we can discard all information about older ticks. Anything we’ve calculated past the new update must now be recalculated, applying again whatever pending player actions we have heard about.

Constants

MAX_LEAD_TICKS

If we haven’t received a full update from the server in this many ticks, stop guessing. We’re almost certainly wrong by this point.

Attributes

tick[R]
world_established?[R]

Public Class Methods

new(game_window) click to toggle source
# File lib/game_2d/client_engine.rb, line 32
def initialize(game_window)
  @game_window, @width, @height = game_window, 0, 0
  @spaces = {}
  @deltas = Hash.new {|h,tick| h[tick] = Array.new}
  @earliest_tick = @tick = @preprocessed = nil
end

Public Instance Methods

add_delta(delta) click to toggle source
# File lib/game_2d/client_engine.rb, line 90
def add_delta(delta)
  at_tick = delta[:at_tick]
  fail "Received delta without at_tick: #{delta.inspect}" unless at_tick
  if at_tick < @tick
    warn "Received delta #{@tick - at_tick} ticks late"
    if at_tick <= @earliest_tick
      warn "Discarding it - we've received registry sync at <#{@earliest_tick}>"
      return
    end
    # Invalidate old spaces that were generated without this information
    at_tick.upto(@tick) {|old_tick| @spaces.delete old_tick}
  end
  @deltas[at_tick] << delta
end
add_entity(space, json) click to toggle source
# File lib/game_2d/client_engine.rb, line 170
def add_entity(space, json)
  space.add_entity (o = Serializable.from_json(json))
  if o.is_a?(Player) && @game_window.player_name == o.player_name
    # This can be news, if the server is ahead of us.  The server
    # promises that we always have exactly one entity assigned to
    # each authenticated player, and each connected player must
    # have a unique name at any time -- so this entity has to be
    # ours.
    @game_window.player_id = o.registry_id
  end
end
add_npcs(space, npcs) click to toggle source
# File lib/game_2d/client_engine.rb, line 162
def add_npcs(space, npcs)
  npcs.each do |json|
    on_create = json.delete :on_create
    space << (entity = Serializable.from_json(json))
    on_create.call(entity) if on_create
  end
end
add_player(space, hash) click to toggle source
# File lib/game_2d/client_engine.rb, line 140
def add_player(space, hash)
  player = Serializable.from_json(hash)
  puts "Added player #{player}"
  space << player
  player.registry_id
end
add_players(space, players) click to toggle source
# File lib/game_2d/client_engine.rb, line 147
def add_players(space, players)
  players.each {|json| add_player(space, json) }
end
apply_deltas(at_tick) click to toggle source
# File lib/game_2d/client_engine.rb, line 105
def apply_deltas(at_tick)
  space = space_at(at_tick)

  @deltas[at_tick].each do |hash|
    players = hash.delete :add_players
    add_players(space, players) if players

    doomed = hash.delete :delete_entities
    delete_entities(space, doomed) if doomed

    updated = hash.delete :update_entities
    update_entities(space, updated) if updated

    snap = hash.delete :snap_to_grid
    space.snap_to_grid(snap.to_sym) if snap

    npcs = hash.delete :add_npcs
    add_npcs(space, npcs) if npcs

    move = hash.delete :move
    player_name = hash.delete :player_name
    player_id = hash.delete :player_id
    if move
      fail "No player_id sent with move #{move.inspect}" unless player_id
      apply_move(space, move, player_id.to_sym)
    end

    score_update = hash.delete :update_score
    update_score(space, score_update) if score_update

    leftovers = hash.keys - [:at_tick]
    warn "Unprocessed deltas: #{leftovers.join(', ')}" unless leftovers.empty?
  end
end
apply_move(space, move, player_id) click to toggle source
# File lib/game_2d/client_engine.rb, line 151
def apply_move(space, move, player_id)
  player = space[player_id]
  if player
    player.add_move move
  else
    # This can happen if, say, the player sent an action just before
    # death or disconnection.
    warn "No such player #{player_id}, can't apply #{move.inspect}"
  end
end
create_initial_space(at_tick, highest_id) click to toggle source
# File lib/game_2d/client_engine.rb, line 49
def create_initial_space(at_tick, highest_id)
  @earliest_tick = @tick = at_tick
  space = @spaces[@tick] = GameSpace.new(@game_window).
    establish_world(@world_name, @world_id, @width, @height)
  space.highest_id = highest_id
  space
end
delete_entities(space, doomed) click to toggle source
# File lib/game_2d/client_engine.rb, line 201
def delete_entities(space, doomed)
  doomed.each do |registry_id|
    dead = space[registry_id]
    next unless dead
    puts "Disconnected: #{dead}" if dead.is_a? Player
    space.doom dead
  end
  space.purge_doomed_entities
end
establish_world(world, at_tick) click to toggle source
# File lib/game_2d/client_engine.rb, line 39
def establish_world(world, at_tick)
  @world_name, @world_id = world[:world_name], world[:world_id]
  @width, @height = world[:cell_width], world[:cell_height]
  highest_id = world[:highest_id]
  create_initial_space(at_tick, highest_id)
  @preprocessed = at_tick
end
space() click to toggle source
# File lib/game_2d/client_engine.rb, line 86
def space
  @spaces[@tick]
end
space_at(tick) click to toggle source
# File lib/game_2d/client_engine.rb, line 57
def space_at(tick)
  return @spaces[tick] if @spaces[tick]

  fail "Can't create space at #{tick}; earliest space we know about is #{@earliest_tick}" if tick < @earliest_tick

  last_space = space_at(tick - 1)
  @spaces[tick] = new_space = GameSpace.new(@game_window).copy_from(last_space)
  apply_deltas(tick)
  new_space.update

  new_space
end
sync_registry(server_registry, highest_id, at_tick) click to toggle source

Discard anything we think we know, in favor of the registry we just got from the server

# File lib/game_2d/client_engine.rb, line 219
def sync_registry(server_registry, highest_id, at_tick)
  return unless world_established?
  @spaces.clear
  # Any older deltas are now irrelevant
  @earliest_tick.upto(at_tick - 1) {|old_tick| @deltas.delete old_tick}
  update_entities(create_initial_space(at_tick, highest_id), server_registry)

  # The server has given us a complete, finished frame.  Don't
  # create a new one until this one has been displayed once.
  @preprocessed = at_tick
end
update() click to toggle source
# File lib/game_2d/client_engine.rb, line 70
def update
  return unless world_established?

  # Display the frame we received from the server as-is
  if @preprocessed == @tick
    @preprocessed = nil
    return space_at(@tick)
  end

  if @tick - @earliest_tick >= MAX_LEAD_TICKS
    warn "Lost connection?  Running ahead of server?"
    return space_at(@tick)
  end
  space_at(@tick += 1)
end
update_entities(space, updated) click to toggle source

Returns the set of registry IDs updated or added

# File lib/game_2d/client_engine.rb, line 183
def update_entities(space, updated)
  registry_ids = Set.new
  updated.each do |json|
    registry_id = json[:registry_id]
    fail "Can't update #{entity.inspect}, no registry_id!" unless registry_id
    registry_ids << registry_id

    if my_obj = space[registry_id]
      my_obj.update_from_json(json)
      my_obj.grab!
    else
      add_entity(space, json)
    end
  end

  registry_ids
end
update_score(space, update) click to toggle source
# File lib/game_2d/client_engine.rb, line 211
def update_score(space, update)
  registry_id, score = update.to_a.first
  return unless player = space[registry_id]
  player.score = score
end