Creating a Cross-Platform Multi-Player Game in Unity — Part 3

In the third part of this tutorial, you’ll learn how to deal with shaky networks, provide a winning condition, and deal with clients who exit the session. By Todd Kerpelman.

Leave a rating/review
Save for later

Welcome back to Part 3 of our tutorial series on creating a multiplayer game in Unity!

In part one, you developed your game to the point where your game clients could connect to each other.

In part two, you allowed clients to join a game room, send their current position to each other, and update the game play based on the positions received. It’s starting to look like a real game

However, you uncovered a few things in that need to be addressed: you’re making network calls more frequently than you need to; the cars’ movements look a little jittery and there’s no way to end the game! You’ll take care of these things in this part of the tutorial series — and learn some game design tips along the way.

In this tutorial, you will do the following:

  • Learn some of the various techniques with handling network latency.
  • Implement a game over state.
  • Provide various ways for clients to disconnect from a game.

And when you finish, you’ll have an honest-to-goodness multiplayer game.

Reducing Network Traffic

You’re currently sending updates once every frame, which is way more than you need. Frequent updates can ensure smooth gameplay, but that comes at the cost of data usage, battery drain and potentially clogging the network by sending more updates than it can handle.

So how frequently should you send updates? The annoyingly vague answer is: “as often as you need to keep the gameplay good, but no more than that.” Frankly, update frequency is always a delicate balancing act with no “perfect” solution. For this tutorial, you’ll reduce your network update calls to six per second, which is an arbitrary amount, but it’s a good place to start.

An Aside on Compression Strategies

Cutting down your network calls from 30 times a second to 6 is a pretty good way to reduce your network traffic, but you could do even more if you wanted. For instance, you’re using a luxurious 4 bytes to represent the player’s rotation – which is probably overkill. You could reduce this down to a single byte if you took the player’s rotation (between 0 and 360), multiplied it by 256/360, and then rounded it to the nearest whole number. To translate it back, you would multiply that number again by 360/256.

There would be some loss of accuracy, of course; for instance a car originally with a z rotation of 11 degrees would end up as 11.25. But, really, is that something you would notice when these cars are going 300 pixels per second around a track?

// Converting degrees to 1 byte...
(int)(11.0 * 256 / 360) = 8

// Converting back again...
8 * 360.0 / 256.0 = 11.25

Similarly, you’re using 4 bytes each to represent your car’s position and velocity in the x and y axis. You could represent the velocity with a single byte in each axis, and you might even be able to do the same with its position. And suddenly, your update message goes from 22 bytes to 7 bytes each. What a savings!

This might seem like you’re going a little nutty with compression, particularly if you’re coming from the web world where you’re probably used to dealing with messages represented as strings and numbers in a JSON blob. But real-time multiplayer game developers tend to worry about this stuff a little more than other, saner, folks.

Decreasing Update Frequency

Open your project. If you want to start with this part, feel free to download the starter project.

Note: You will have to configure the starter project to have your own ClientID as described in “>Part One


Open GameController.cs found in the scripts folder, and add the following variable near the top:

private float _nextBroadcastTime = 0;

This variable will hold the time to determine when the next time the script should broadcast client data.

Find the following line in DoMultiplayerUpdate:


…and modify it as shown below:

if (Time.time > _nextBroadcastTime) {
    _nextBroadcastTime = Time.time + .16f;

This sends updates once every 0.16 seconds, or about six times per second.

Build and run your project; update the game on one device only, so you can compare the two versions side-by-side. What do you notice about the gameplay?

My Eyes! The Goggles Do Nothing!

The gameplay looks terrible — it’s like you’ve replaced your awesome game with a slideshow! :[

So you need to bump up the rate of your network updates, right? Not so fast; there are a few tricks of the trade that can improve your cruddy slideshow experience — without bumping up the frequency of your update calls.

Trick #1: Interpolation

The first trick to improve your gameplay is to interpolate your opponent’s position; instead of teleporting your opponent’s car to its new position, you can smooth out the action by calculating the path the car should take instead.

Add the following variables to the top of OpponentCarController:

private Vector3 _startPos;
private Vector3 _destinationPos;
private Quaternion _startRot;
private Quaternion _destinationRot;
private float _lastUpdateTime;
private float _timePerUpdate = 0.16f;

Now add the following code to Start():

_startPos = transform.position;
_startRot = transform.rotation;

This initially sets the start position of the car at the start of the game.

Next, replace all of SetCarInformation() with the following:

public void SetCarInformation(float posX, float posY, float velX, float velY, float rotZ) {
    // 1
    _startPos = transform.position;
    _startRot = transform.rotation;
    // 2
    _destinationPos = new Vector3 (posX, posY, 0);
    _destinationRot = Quaternion.Euler (0, 0, rotZ);
    _lastUpdateTime = Time.time;

Taking each numbered comment in turn:

  1. Each time you receive a position update, keep track of the current position and rotation of the car.
  2. Next, record the new position and location.
  3. Finally, record the current time — you’ll see in a moment why this is necessary.

Now, add the following code to Update():

void Update () {
    // 1
    float pctDone = (Time.time - _lastUpdateTime) / _timePerUpdate;
    if (pctDone <= 1.0) {
        // 2
        transform.position = Vector3.Lerp (_startPos, _destinationPos, pctDone);
        transform.rotation = Quaternion.Slerp (_startRot, _destinationRot, pctDone);

This is not a lot of code, but it is a lot of math. Here's what's going on:

  1. pctDone stores how far along you should be on the interpolated path, based on the assumption that it takes approximately 0.16 seconds to process an update.
  2. Then you use the value in pctDone information to update your transform's position and rotation accordingly.

Build and run your game in Unity and update it on both devices; you should notice that the gameplay runs a little more smoothly.

Things still don't look perfect, though, as the cars still lurch around the track a bit — and it only gets worse if your network conditions deteriorate. Why? Your cars travel for 0.16 seconds until they reach their new position — and then they stop dead in their tracks until you receive the next update from the network. If that update takes more than 0.16 seconds to arrive, say 0.30 seconds, then the car will sit there for 0.14 seconds until it moves again. it time to bump up the rate of your network updates? No! :] You can fix the lurchiness with a second trick from the game developer's toolbox.

Trick #2: Extrapolation

Rather than halting the car until the next update comes, you can take advantage of the velocity information you've received and keep the car moving until you get the next update. This is a cheap and easy trick that smoothes over network lag issues.

Open OpponentCarController and add the following variable:

private Vector3 _lastKnownVel;

This variable will hold the Vector location of the last known velocity of the car.

Add the following line near the end of SetCarInformation, right before you set _lastUpdateTime:

_lastKnownVel = new Vector3 (velX, velY, 0);

This sets the last known velocity based off the velocity that was used to set the car's information.

Finally, modify the if block in Update() as follows:

if (pctDone <= 1.0) {
    // Previous code
    // ...
} else {
    // Guess where we might be
    transform.position = transform.position + (_lastKnownVel * Time.deltaTime);

This sets the position based off the velocity value.

Build and run your game again; only update one of your devices to compare the motion between the two versions of the game. You should see a lot less lurching around than before.

Note: While your game certainly looks better, it's actually less accurate than before. Why? When you receive an update from another player, you're seeing their position at some point in the past.

Adding this extrapolation means it takes you longer to bring the player to the point where they were a few milliseconds ago! And if your opponent's updates take too long to arrive, their car will calmly drive off the edge of the playing field.

This loss of accuracy probably doesn't matter much in this game; if you needed to be more accurate, you could try to guess the position of your opponent's car — and you might end up with some slightly more accurate car positions.

This would probably involve calculating some accurate ping times for your clients, as well as adding a bit of AI. This is beyond the scope of this tutorial, but it would make for some great experience with AI and game physics if you wanted to tackle it on your own!

So you've got some smooth looking car action — but you still don't have a way to end the game. Time to fix that!

Finishing a Game

In this game, the race is won once a competitor completes three laps. At that point, you need to do three things:

  1. Stop the local player's car from moving.
  2. Send a "game over" message to all the other players once you've finished and note your final time.
  3. Show the game over screen once you've received a "game over" message from all other players.

Why do you need to send your final time in step 2 — why not just mark the order that you receive a "game over" message? Like everything in multiplayer games, it comes down to network lag. Imagine a situation where Player 1 finishes first, but by the time she sends a "game over" message to Player 2, Player 2 has already finished his game, so he thinks he's finished first.

And since all players in your networked game don't necessarily start at the exact same time, a faster player could receive a "game over" message by the slower player before they finish!

Crazy Time!

You can't rely on the order of messages to carry meaning in a networked game. You can only guarantee a fair resolution to the match by letting each client report their finishing times to all other clients.

Sending a Game Over Call

Open GameController find the following code in Update():

    if (_multiplayerGame) {
        // TODO: Probably need to let the game know we're done.
    } else {

...and replace it with the following code:

  if (_multiplayerGame) {
    // 1
    // 2
                                                new Vector2(0,0),
    // 3

  } else {

Looking in depth at the logic you added above:

  1. First, you tell the car controller to stop moving, at which point it will ignore all further updates from the interface.
  2. Next, you send an update to all other players with your current position, and a velocity of 0,0. This ensures your car stops at the finish line on all game clients.
  3. Finally, you call SendFinishMessage, which is a yet-to-be-written method in MultiplayerController which tells all of your opponents you've finished.

Open MultiplayerController and add the following private variable near the beginning of your class to track the length of the message:

// Byte + Byte + 1 float for finish time
private int _finishMessageLength = 6;

Now create the following method just after SendMyUpdate():

public void SendFinishMessage(float totalTime) {
    List<byte> bytes = new List<byte>(_finishMessageLength); 
    bytes.Add (_protocolVersion);
    bytes.Add ((byte)'F');
    byte[] messageToSend = bytes.ToArray ();
    PlayGamesPlatform.Instance.RealTime.SendMessageToAll (true, messageToSend);

This is quite similar to SendMyUpdate(); the 'F' character lets other players know this is an "I'm done and here's my final time" call, followed by a float value containing the total elapsed time.

A big difference is the true argument in the SendMessageToAll call, which sends this message reliably, instead of via the unreliable mechanism you've used all along. Why? This time it's extra-super-important that all players know your car has finished. If this message were lost, your game would never end.

Even though it's important that your message is delivered, the timing of your message doesn't matter all that much. Even if this call was delayed by two or three seconds, it just means that the final screen wouldn't appear for a few seconds.

Receiving the Game Over Call

Now you'll need some logic to handle this "game over" message.

Still working in MultiplayerController, add the following code to the end of the if (messageType == 'U' ... block in OnRealTimeMessageReceived:

} else if (messageType == 'F' && data.Length == _finishMessageLength) {
    // We received a final time!
    float finalTime = System.BitConverter.ToSingle(data, 2);
    Debug.Log ("Player " + senderId + " has finished with a time of " + finalTime);    

This code checks to see if the message is a game-over message, at which point, it parses the final time and prints out a log message.

Build and run your game; race your cars around the track and once a player completes three laps, they'll come to a stop and a message similar to Player p_CPT-6O-nlpXvVBAB has finished with a time of 15.67341 message should appear in your console log.

Note: To speed up testing, set _lapsRemaining to 1 in SetupMultiplayerGame of GameController.cs. But don't forget to set it back when you want to play a real game! :]

Although the cars have stopped, you still need to handle the end-game logic and figure out who won.

Add the following line to MPUpdateListener in MPInterfaces:

void PlayerFinished(string senderId, float finalTime);

This declares that PlayerFinished is part of the MPUpdateListener interface.

Now you need to handle this information in GameController! Add the followiong variable to GameController.cs:

private Dictionary<string, float> _finishTimes;

This sets up a dictionary to map the finish times to participantIDs.

Inside of SetupMultiplayerGame(), add the following line before the start of the for-loop:

_finishTimes = new Dictionary<string, float>(allPlayers.Count);

Just inside the for-loop, add the following the line of code just underneath the first line like so:

for (int i =0; i < allPlayers.Count; i++) {
    string nextParticipantId = allPlayers[i].ParticipantId;             
     _finishTimes[nextParticipantId] = -1;   // <-- New line here! 

You initialize each entry with a negative number, which is an easy way to indicate that this player hasn't finished yet.

Next, add the following method to GameController.cs:

public void PlayerFinished(string senderId, float finalTime) {
    Debug.Log ("Participant " + senderId + " has finished with a time of " + finalTime);
    if (_finishTimes[senderId] < 0) {
        _finishTimes[senderId] = finalTime;

This simply records the finishing time of this player in the dictionary.

Next, add the following method underneath the previous one:

void CheckForMPGameOver() {
    float myTime = _finishTimes [_myParticipantId];
    int fasterThanMe = 0;
    foreach (float nextTime in _finishTimes.Values) {
        if (nextTime < 0) { // Somebody's not done yet
        if (nextTime < myTime) {
    string[] places = new string[]{"1st", "2nd", "3rd", "4th"};
    gameOvertext = "Game over! You are in " + places[fasterThanMe] + " place!";
    PauseGame(); // Should be redundant at this point
    _showingGameOver = true;
    // TODO: Leave the room and go back to the main menu

In the code above you're iterating through the finish times of all of the players in your dictionary. If any of them are negative, it means they haven't finished yet and you can jump out early. Otherwise, you keep track of how many finish times are faster than the local player's so you can display the appropriate game over text. Then you set _showingGameOver to true so that your OnGUI() method knows to display the game over dialog box.

Next, add the following line to OnRealTimeMessageReceived() in MultiplayerController.cs, just after the Debug.Log line in the else-if block:

updateListener.PlayerFinished(senderId, finalTime);

Finally, you need to tell the local device that the game is done, as calling SendMessageToAll() doesn't send a message to the local player.

Open GameController.cs and in Update(), add the following code directly underneath the MultiplayerController.Instance.SendFinishMessage(_timePlayed); line:

PlayerFinished(_myParticipantId, _timePlayed);

Build and run your game on both devices; race both cars around the track and you should now have a lovely game over dialog! ...and once again, your game is stuck.

Game Over Dialog

It's high time that this game has a proper exit strategy! Fortunately, that's your very next task. :]

Leaving a Game (Normally)

Leaving a multiplayer game is quite straightforward; you simply call the Google Play platform's LeaveRoom() method.

Add the following line to the end of CheckForMPGameOver in GameController.cs, where the TODO line is:

Invoke ("LeaveMPGame", 3.0f);

This calls the unwritten LeaveMPGame after a three-second pause.

Add that method next:

void LeaveMPGame() {

Now you can add the missing LeaveGame() call to MultiplayerController.cs as follows:

public void LeaveGame() {
    PlayGamesPlatform.Instance.RealTime.LeaveRoom ();

So that disconnects you from the Google Play game room — but you haven't yet instructed your game client to do anything different, so your game will stay stuck on the game over screen.

There are a few ways to fix this; you could simply load the MainMenu scene as soon as you call LeaveMPGame. That would work, but a better method is to wait until OnLeftRoom() in MultiplayerController is called by the platform. At that point, you know you've left the room for good and your Game Controller can perform any necessary cleanup tasks.

Another advantage of this approach is that if you added other ways to leave the multiplayer room, they'd all be caught and handled by this same code path, which prevents against future redundant code.

Here's a quick diagram explaining all this:

This is the full series of calls that are made when you leave a room. (The ones in gray are handled by the library.)

This is the full series of calls that are made when you leave a room. (The ones in gray are handled by the library.)

Add the following line to MPUpdateListener in MPInterfaces:

void LeftRoomConfirmed();

This defines the interface to let any listeners know that the player has left the room.

Then, modify OnLeftRoom() in MultiplayerController.cs as follows:

public void OnLeftRoom ()
    ShowMPStatus("We have left the room.");
    if (updateListener != null) {

Once this method is called, it is safe to call LeftRoomConfirmed() on the delegate.

Finally, add the following method to GameController.cs:

public void LeftRoomConfirmed() {
    MultiplayerController.Instance.updateListener = null;
    Application.LoadLevel ("MainMenu");

Once this method is called, it is safe to move the player from the current scene, to the MainMenu scene.

Build and run your game; race around the track and when you finish, you should see the game over dialog box. After about three seconds, you'll find yourself at the main menu ready to play another game!

So, are we done?

Before you call it a day, you should test some networking edge cases, such as losing a connection altogether. Start up another game on both devices and kill the app using the task manager on one device partway through the race.

Finish the race on the other device — but as soon as your car finishes, it will wait there forever, waiting for its opponent to finish.

Leaving the Game (Less Normally)

It's a fact of life that all players won't see a game through to completion. This can happen intentionally, where a player rage-quits, has to go to an appointment, or just gets fed up with an opponent who's not playing fair; or unintentionally, where your game crashes, the player loses their network connection, or their battery dies. Your job as a game designer is to handle these unexpected quits in a clean manner.

Take the case where a player consciously leaves the game. Do you really want to encourage players to rage-quit? No, but there are lots of valid reasons someone might leave a game; many events in the real world often require you to put your device away for a moment, such as saying your wedding vows. :] To that end, you'll add a "Leave Game" button in your UI.

Open GameController.cs and add the following code to the beginning of OnGUI(), just outside of the if block:

if (_multiplayerGame) {
    if (GUI.Button (new Rect (0.0f, 0.0f, Screen.width * 0.1f, Screen.height * 0.1f), "Quit")) {

        // Tell the multiplayer controller to leave the game

This calls LeaveGame() in MultiplayerController, which in turn calls LeaveRoom() in the Google Play platform. Once your player has left the room, the platform reports this back to your game in the OnLeftRoom() callback, which will then call LeaveGameConfirmed() in your Game Controller, just as in the diagram shown earlier.

Note: This is actually a pretty terrible way for a player to quit the game. At the very least, you'd want to put up a confirmation dialog so that a player can't accidentally leave the game with a single tap on the screen.

But what about the players remaining in the game? They need to know that this player has left, so they're not sitting around waiting for him or her to finish. Well, if you've been carefully studying your console log (which is what I like to do for fun on a Saturday night), you likely saw a line like this when your opponent left the game:

Player p_CL3Ay7mbjLn16QEQAQ has left.

This means this event is being captured in your OnPeersDisconnected() listener; therefore you just need to pass that information back to the game.

Note: At the time of this writing, there is an "undocumented feature" (okay, fine, a bug) in the library where the room is considered destroyed if your opponent leaves the room and you're the only one left. You'll also receive an OnLeftRoom callback instead of an OnPeersDisconnected call. Hopefully that "feature" has been dealt with by the time this is published! :]

First, add the following line to your MPUpdateListener interface in MPInterfaces.cs file:

void PlayerLeftRoom(string participantId);

MultiplayerController will call this method when it receives a notice that somebody left the room.

Next, modify OnPeersDisconnected in MultiplayerController.cs, as follows:

public void OnPeersDisconnected (string[] participantIds)
    foreach (string participantID in participantIds) {
        ShowMPStatus("Player " + participantID + " has left.");
        if (updateListener != null) {

This loops through each player and calls your new interface method on your listener. Open GameController.cs and add that method now:

public void PlayerLeftRoom(string participantId) {
    if (_finishTimes[participantId] < 0) {
        _finishTimes[participantId] = 999999.0f;

When a player leaves a room, you record their finish time as 999999.0; this ensures CheckForMPGameOver counts this player as "finished" since the finish time is positive. Using such a large value means a player can't cheat their way to first place by quitting a game early.

The call to CheckForMPGameOver() is necessary since the disconnected player won't ever send a "game over" message; if they were the only player remaining in the game you'd want the game to end at this point.

Note: Depending on your game design, you might want to end the game early if there is only one player remaining. In Circuit Racer, it's still fun to drive around a track by yourself, so you let the local player finish off the race. On the other hand, a first person shooter would be much less fun if there were nobody left to shoot, so leaving the game early would be a better choice.

Build and run your game; start a game and end one client early by tapping the Quit button. The other player will be able to finish their game and start a new one instead of waiting around forever.

It looks a little odd to see your disconnected opponent's dead car sitting in the middle of the track, but it's easy enough to add some code to remove it from the game.

Open OpponentController.cs and add the following method:

public void HideCar() {
    gameObject.renderer.enabled = false;

In PlayerLeftRoom, add the following code just before CheckForMPGameOver():

if (_opponentScripts[participantId] != null) {

Build and run your game; start a two-player game and quit a game prematurely. The opponent's car will eventually vanish from the screen, so you know your opponent is truly gone and not just being difficult by sulking in the middle of the road. :]

So are we done now?

Close — but not yet. You've handled the situation where a player intentionally leaves the room and the Google Play game services library calls LeaveRoom on behalf of that player. Sometimes, though, the service doesn't have the chance to make a clean exit — and that's the next scenario you'll need to handle.

Leaving the Game (Abnormally)

If your player's battery dies, their game crashes, lose their cell service in the subway, or drop their phone in the toilet (it's been known to happen), your game won't have an opportunity to leave the room properly.

Try this yourself — no, don't go and drop your phone in the toilet. :] You can turn on airplane mode on one of your devices while in the middle of the game, which will kill your network connectivity, not give the Google Play games services library a chance to call LeaveRoom(), and leave the other player waiting forever for the other player to either send a "game over" message or leave the room — neither of which will happen.

The most common way to detect these scenarios is through timeouts. You're receiving approximately six updates per second for your opponent; if those calls stopped for long enough, you could probably assume something terrible has happened to the other player. Or that their game has crashed. One of the two.

Our time-out strategy

Your strategy for detecting timeouts will be pretty straightforward. Each CarOpponent will keep track of the last time they received a game update. You'll check at intervals to see if this update is longer than your timeout threshold. If it is, treat the opponent as you would if they left the game voluntarily.

Aha! OpponentCarController is already storing the last time it received an update from the network in _lastUpdateTime. You just need a way to access it.

Add the following line to OpponentCar, right beneath the spot where you declare _lastUpdateTime:

public float lastUpdateTime { get { return _lastUpdateTime; } }

That code might look a little odd to you, but it's not all that different from creating a readonly property in Objective-C. You simply create a lastUpdateTime property with only a getter (hence making it read-only), and define the getter to return the value of the private variable _lastUpdateTime.

This lets you retrieve the value of _lastUpdateTime from GameController, while only OpponentCar can set the value.

You need to make sure this threshold is realistic; you don't want players timing out right away just because _lastUpdateTime was initialized to 0!

Add the following code to Start():

_lastUpdateTime = Time.time;

This sets the time when an opponent is created.

Now you can add the logic to check for timeouts. Add the following variables to GameController:

public float timeOutThreshold = 5.0f;
private float _timeOutCheckInterval = 1.0f;
private float _nextTimeoutCheck = 0.0f;

The timeOutThreshold is the how many seconds before the player is considered gone. _timeOutCheckInterval is how often the system should check for a timeout. _nextTimeoutCheck holds the time plus the interval so it can be easily checked (versus calculating the total every iteration).

Next, add the following method to GameController:

void CheckForTimeOuts() {
    foreach (string participantId in _opponentScripts.Keys) {
        // We can skip anybody who's finished.
        if (_finishTimes[participantId] < 0) {
            if (_opponentScripts[participantId].lastUpdateTime < Time.time - timeOutThreshold) { 
                // Haven't heard from them in a while!
                Debug.Log("Haven't heard from " + participantId + " in " + timeOutThreshold + 
                          " seconds! They're outta here!");

First, you iterate through all opponent participantIDs by looking at the keys of the _opponentScripts dictionary. You then check the last time you heard from each player that hasn't finished; if you haven't heard from them in more than 5.0 seconds treat them as if they had left the game.

Note: A five-second value is great for testing, but in real life you'd want to use a value around 10 or 15 seconds, particularly if you're planning on releasing your game internationally to markets where flaky 2G networks are still common.

Finally, you need to call this method from within DoMultiplayerUpdate(). It's probably overkill to call it in every frame, so you'll call it once per second instead.

Add the following code to the beginning of DoMultiplayerUpdate().

if (Time.time > _nextTimeoutCheck) {
    _nextTimeoutCheck = Time.time + _timeOutCheckInterval;

Build and run your game one last time — and ensure you've turned off airplane mode to save pulling of hair and much gnashing of teeth. :] Start a game, then enable airplane mode on one of your devices. After a five-second pause, you should see a note in the console log that this player is gone, and their car should disappear from the track.

Are we done now?!?

Well, you're done with all the ways a player can exit the game, so you're done for this part of the tutorial at least! :]

Where to Go from Here?

So far, you've made some major improvements to your game: you've reduced your game updates to a reasonable level; retained the smooth animation thanks to the magic of interpolation and extrapolation; and dealt with the normal and abnormal ways players can leave a game.

Here's the completed project for this part of the tutorial.

There are still a number of improvements left to do before you can call your game finished, though:

  • Handling messages arriving out-of-order
  • Running your cross-platform game on Android
  • Running your game with more than two players
  • Discovering a BIG DARK SECRET about multiplayer tutorials :]

So stay tuned! You'll cover these issues (and more!) in the Part 4 of this multiplayer tutorial. As always, if you have comments or questions, feel free to join the discussion below!

Todd Kerpelman


Todd Kerpelman


Over 300 content creators. Join our team.