Tutorial: Pong - Game Logic and AR Events

This is an example Unity project in which ARDK’s features are used to create an AR Multiplayer version of Pong. This tutorial will cover all of the Unity steps as well as C# script implementations in order to make the project work. Please note that this implementation uses low level messages to send data between players. Another version of this project will demonstrate the setup and use of High Level API objects (HLAPI) to streamline the message sending and receiving process for synchronizing players (here).

Game Logic and AR Events

Now that all of the sessions and game objects are created, it is finally time to jump into some game logic. Since the ball handles its movement in the BallBehaviour script, the GameController only handles some basic movement/collision logic and updating the score.

For the sake of consistency, only the host is allowed to manipulate most game objects and states, while the other peer must listen to messages sent by the host.

On the host’s controller, the BallBehaviour script will call the first method, GoalScored(String color), whenever it reaches a goal. The score is updated and a message will be sent to the non-host to update the score as well.

Only the host will manipulate the ball’s position. It will then send a message to the non-host player containing the position. MessagingManager calls SetBallLocation(Vector3 position) every time a new position is received from the host, and will set the ball’s position for the non-host player.

Finally, the Update() function is a Unity Event called every frame. If both players are synced and the game hasn’t started, it will allow the host to perform hit tests to spawn the game objects on a detected plane. After game start, it will do a distance calculation between the ball and the player’s avatar every frame, and if they have collided (both have a radius of 0.25 meters, collision is <= 0.5 meters), bounce the ball in a direction relative to the hit angle. If the player is the host, the hit will be performed instantly. Otherwise, a message will be sent to the host containing the hit vector to have the host perform the hit. The recently hit and hit lockout fields are used to prevent multiple hit messages from being sent in quick succession.

// Reset the ball when a goal is scored, increase score for player that scored
// Only the host should call this method
internal void GoalScored(string color)
{
  // color param is the color of the goal that the ball went into
  // we score points by getting the ball in our opponent's goal
  if (color == "red")
  {
    Debug.Log("Point scored for team blue");
    BlueScore += 1;
  }
  else
  {
    Debug.Log("Point scored for team red");
    RedScore += 1;
  }

  score.text = string.Format("Score: {0} - {1}", RedScore, BlueScore);

  _messagingManager.GoalScored(color);
}

// Set the ball location for non-host players
internal void SetBallLocation(Vector3 position)
{
  if (!_isGameStarted)
    _isGameStarted = true;

  _ball.transform.position = position;
}

// Every frame, detect if you have hit the ball
// If so, either bounce the ball (if host) or tell host to bounce the ball
private void Update()
{
  if (_isSynced && !_isGameStarted && _isHost)
  {
    if (PlatformAgnosticInput.touchCount <= 0)
      return;

    var touch = PlatformAgnosticInput.GetTouch(0);
    if (touch.phase == TouchPhase.Began)
    {
      var startGameDistance =
        Vector2.Distance
        (
          touch.position,
          new Vector2(startGameButton.transform.position.x, startGameButton.transform.position.y)
        );

      if (startGameDistance > 80)
        FindFieldLocation(touch);
    }
  }

  if (!_isGameStarted)
    return;

  if (_recentlyHit)
  {
    _hitLockout += 1;

    if (_hitLockout >= 15)
    {
      _recentlyHit = false;
      _hitLockout = 0;
    }
  }

  var ballDistance = Vector3.Distance(_player.transform.position, _ball.transform.position);
  if (ballDistance > .5 || _recentlyHit)
    return;

  Debug.Log("We hit the ball!");
  var bounceDirection = _ball.transform.position - _player.transform.position;
  bounceDirection = Vector3.Normalize(bounceDirection);
  _recentlyHit = true;

  if (_isHost)
    _ballBehaviour.Hit(bounceDirection);
  else
    _messagingManager.BallHitByPlayer(_host, bounceDirection);
}

Events

To receive updates about the shared AR state of the session, we subscribe to some ARNetworking events.

The OnFrameUpdated event is used to get the local player’s location every frame (this exists on the ARSession object, and is available in single-player AR sessions as well). A utility class MatrixUtils is used to extract a Vector3 position from the Matrix4x4 containing both position and rotation data. Similarly, an event OnPeerPoseReceived is used to get the opponent’s position. The OnPeerStateReceived event will be fired upon sync state updates for both the local peer and other peers in the same session.

// Every updated frame, get our location from the frame data and move the local player's avatar
private void OnFrameUpdated(FrameUpdatedArgs args)
{
  _location = MatrixUtils.PositionFromMatrix(args.Frame.Camera.Transform);

  if (_player == null)
    return;

  var playerPos = _player.transform.position;
  playerPos.x = _location.x;
  _player.transform.position = playerPos;
}

private void OnPeerStateReceived(PeerStateReceivedArgs args)
{
  if (_self.Identifier == args.Peer.Identifier)
    UpdateOwnState(args);
  else
    UpdatePeerState(args);
}

private void UpdatePeerState(PeerStateReceivedArgs args)
{
  if (args.State == PeerState.Stable)
  {
    _isSynced = true;

    if (_isHost)
      startGameButton.SetActive(true);
  }
}

private void UpdateOwnState(PeerStateReceivedArgs args)
{
  string message = args.State.ToString();
  score.text = message;
  Debug.Log("We reached state " + message);
}

// Upon receiving a peer's location data, take its location and move its avatar
private void OnPeerPoseReceived(PeerPoseReceivedArgs args)
{
  if (_opponent == null)
    return;

  var peerLocation = MatrixUtils.PositionFromMatrix(args.Pose);

  var opponentPosition = _opponent.transform.position;
  opponentPosition.x = peerLocation.x;
  _opponent.transform.position = opponentPosition;
}

There are also some initialization and destruction events to remove all callbacks and destroy the MessagingManager.

private void OnDidConnect(ConnectedArgs args)
{
  _self = args.Self;
  _host = args.Host;
  _isHost = args.IsHost;
}

private void OnDestroy()
{
  ARNetworkingFactory.ARNetworkingInitialized -= OnAnyARNetworkingSessionInitialized;

  if (_arNetworking != null)
  {
    _arNetworking.PeerPoseReceived -= OnPeerPoseReceived;
    _arNetworking.PeerStateReceived -= OnPeerStateReceived;
    _arNetworking.ARSession.FrameUpdated -= OnFrameUpdated;
    _arNetworking.Networking.Connected -= OnDidConnect;
  }

  if (_messagingManager != null)
  {
    _messagingManager.Destroy();
    _messagingManager = null;
  }
}

BallBehaviour

Moving on to the ball’s behavior — this script is attached to the Ball prefab, and will be created when the ball is instantiated. For the most part, this script is purely game logic and does not have much to do with ARDK. Also, only the host can call most of these methods, as the ball’s position is updated and sent to the non-host as a message every frame.

Setup and References

These are mostly game-oriented, with parameters such as the boundaries of the field, initial velocity, and position. The spawning location of the ball is cached so that it can be placed back at the center of the field after each goal.

public class BallBehaviour:
  MonoBehaviour
{
  internal GameController Controller = null;

  private Vector3 _pos;

  // Left and right boundaries of the field, in meters
  private float _lrBound = 2.5f;

  // Forward and backwards boundaries of the field, in meters
  private float _fbBound = 2.5f;

  // Initial velocity, in meters per second
  private float _initialVelocity = 1.0f;
  private Vector3 _velocity;

  // Cache the floor level, so the ball is reset properly
  private Vector3 _initialPosition;

  // Flags for whether the game has started and if the local player is the host
  private bool _isGameStarted;
  private bool _isHost;

  /// Reference to the messaging manager
  private MessagingManager _messagingManager;

  // Store the start location of the ball
  private void Start()
  {
    _initialPosition = transform.position;
  }

GameStart

This method is called by the GameController after the ball is instantiated. Relevant information such as whether the local player is the host and the initial position is set, as well as a MessagingManager and velocity if the local player is the host.

// Set up the initial conditions
internal void GameStart(bool isHost, MessagingManager messagingManager)
{
  _isHost = isHost;
  _isGameStarted = true;
  _initialPosition = transform.position;

  if (!_isHost)
    return;

  _messagingManager = messagingManager;
  _velocity = new Vector3(_initialVelocity, 0, _initialVelocity);
}

Hit

This method is called by the host’s GameController (either on its own hit or upon receiving a message from the non-host). It will bounce the ball in the proper direction and increase the ball’s speed by 10%.

// Signal that the ball has been hit, with a unit vector representing the new direction
internal void Hit(Vector3 direction)
{
  if (!_isGameStarted || !_isHost)
    return;

  _velocity = direction * _initialVelocity;
  _initialVelocity *= 1.1f;
}

Unity Events

Update() is called every frame, and updates the ball’s position with the velocity. The MessagingManager will then be used to send the ball’s position to the non-host player. If the ball goes out of bounds, reverse the correct velocity to keep it in bounds next frame.

OnTriggerEnter(Collider other) will be called whenever the ball enters a goal. The ball’s position and velocity will be reset, and a method will be called in the GameController to update the score and send a message to the non-host to update the score.

// Perform movement, send position to non-host player
private void Update()
{
  if (!_isGameStarted || !_isHost)
    return;

  _pos = gameObject.transform.position;
  _pos.x += _velocity.x * Time.deltaTime;
  _pos.z += _velocity.z * Time.deltaTime;

  transform.position = _pos;

  _messagingManager.BroadcastBallPosition(_pos);

  if (_pos.x > _initialPosition.x + _lrBound)
    _velocity.x = -_initialVelocity;
  else if (_pos.x < _initialPosition.x - _lrBound)
    _velocity.x = _initialVelocity;

  if (_pos.z > _initialPosition.z + _fbBound)
    _velocity.z = -_initialVelocity;
  else if (_pos.z < _initialPosition.z - _fbBound)
    _velocity.z = _initialVelocity;
}

// Signal to host that a goal has been scored
private void OnTriggerEnter(Collider other)
{
  if (!_isGameStarted || !_isHost)
    return;

  _initialVelocity = 1.0f;
  _velocity = new Vector3(0, 0, _initialVelocity);
  transform.position = _initialPosition;

  switch (other.gameObject.tag)
  {
    case "RedGoal":
      Controller.GoalScored("red");
      break;

    case "BlueGoal":
      Controller.GoalScored("blue");
      break;
  }
}

Previous page: Using ARDK and Game Logic

Continued in: Sending and Receiving Messages with MessagingManager