1 - Overcooked! 2 Modding

Welcome to my modding documentation!

Simple Modding

Leaderboard Mod

Simple mods require only C# code editing, and can be done easily with dnSpy, optionally BepInEx.

See for example my Leaderboard Mod on GitHub (the screenshot above).

Advanced Modding

12 egg mod

More sophisticated mods also require adding new Unity assets.

See for example my Breakfast Mod (a gameplay video with Overtyr, greeny, and BunnyBrittany).

1.1 - Simple Modding

Modding that only requires editing C# code.

Basics of the Game Structure

Overcooked! 2 is written in Unity version 2017.4.8 with no IL2CPP compilation.

The game files contain these major parts that we care about:

  • Overcooked2.exe - the main executable
  • Overcooked2_Data/StreamingAssets/Windows/ - all the Unity assets (models, textures, sounds, etc.)
  • Overcooked2_Data/Managed/Assembly-CSharp.dll - all the C# code used by the assets

For simple modding, we only need to edit the Assembly-CSharp.dll.

Editing C# Code

First, we need to inspect and understand the C# code we care about. dnSpy is a great tool for this.

To edit the code, we can either

  • Use dnSpy to directly edit the dll. This is the simplest method; or
  • Use BepInEx to dynamically inject modified code at runtime. This requires a bit more setup, but is highly recommended for more complex code editing, because it allows you to manage your code with version control, and makes the code injection easily reproducible.

There is plenty of tutorial and documentation on how to use dnSpy and BepInEx, so I won’t go into details here.

How do I …?

The Overcooked codebase is fairly complex, so it’s not always easy to find where certain game logic is. A fair amount of investigation is needed in general to make complex mods.

Refer to the Overcooked! 2 Tech Docs for some documentation about the trickest parts of the game code.

The rest is up to your imagination and determination. Good luck!

1.2 - Advanced Modding

Beyond editing C# code, you can also add new Unity assets.

Limitations of C# Editing

There is a lot you can do with just editing C#, but there are also strong limitations:

  • It’s impossible to add new 3D models.
  • It’s hard or impossible to add new 2D content.
  • It’s very difficult to modify parameters that are encoded in Unity assets rather than in the code.
    • For example, the list of recipes that are available for a level is not encoded in Assembly-CSharp.dll, but in assets that are specific to the level. It’s possible to change this in the code, but it’s very awkward and requires creative trickery.

Difficulty of Unity Asset Editing

You may have heard of tools like AssetStudio or UABE that can extract and edit Unity assets. Unfortunately, none of these work well for this game. There are a few reasons:

  • These tools mostly specialize in replacing graphical or audio assets, but most Overcooked mods would also require editing ScriptableObject objects (essentially, simple serialized objects without graphics). That makes these tools rather awkward to use.
  • The game uses streaming assets, which are newer than the old Unity asset formats supported by these outdated tools. I wasn’t able to get any of these to work even for basic asset replacement.

Using Unity Itself to Add Assets

The fact that the game uses StreamingAssets is actually very helpful to us. It means that we can compile an asset bundle using the official Unity Editor, and then just load it at runtime.

This is so clean - we don’t need to modify any of the existing assets at all. If we need to replace an existing asset, we can always just edit the code to modify some variable to point to our new asset.

However, there is a serious challenge. It’s not very useful to just add fresh new assets. These assets need references to existing assets. This becomes a real problem when creating these assets in Unity, since we don’t have the existing assets to reference at all.

It would be nice if we could load existing assets into the Unity Editor, but that’s not how it works. The Unity compilation process is lossy so that it’s not possible to revert the compiled assets back to editable assets.

A Creative Solution

We’ll first use uTinyRipper to reverse-engineer the game into a Unity project. This is far from perfect - there are numerous problems with the generated project, but it will give us the existing assets just so that they can be referenced. If we create a new asset this way and compile it into a new asset bundle (without the existing assets), when loading it at runtime, the new asset will reference the real existing asset in the game (because they have the same object ID as the reverse-engineered assets).

This is the basic idea, but there are a few more major issues to work out:

  • The generated project is not compilable, and Unity will refuse to export asset bundles unless we make it compilable.
  • uTinyRipper cannot decompile shaders, so any new asset we create has defective materials that must be fixed at runtime.
  • We cannot easily add new C#-backed components that are referenced by new assets, because there is a circular dependency problem.

Discord

Use this link to join my Overcooked Modding and TAS Discord Server!

2 - Overcooked! 2 TAS

Automated high-score gameplays

Showcase (2023)

Showcase (2020)

What is a TAS?

TAS stands for Tool-Assisted Speedrun, but in the context of Overcooked, it means a high-score gameplay with the aid of tools.

This game is challenging for a TAS because it is a multiplyer game. Simply slowing down the game, for example, would not give much of an advantage, and it could only be done with local co-op. Online gameplays obviously cannot be slowed down.

For that reason, the only feasible way to do a TAS for this game is to automate the entire gameplay.

Automation Strategies

There are some quite distinct approaches:

  • Arrange raw keystrokes. This is the simplest but perhaps most annoying approach. The idea is to write a script to send keystrokes at precisely the right time. It’s annoying because it’s very difficult to get the timing right, and it’s also very difficult to test and debug. That being said, the legendary player G_U_A has managed to do this for Carnival of Chaos 3-4.
  • Arrange high-level actions. This is the approach I took. A script is still written, but the script contains higher-level actions such as “pick up this item here”, “chop this item until done”, etc. This requires interpreting the action in the context of the game’s state, and turning it into a sequence of raw game inputs. It’s easier to write, but still suffers from the problem that testing the script takes a long time.
  • Write an AI. Another idea is to write an AI to automatically play the game without scripts. I’m not sure how to do this especially since the game is so complicated, but it would be amazing to see.

Solving the Testing Problem

To test any script, we need to run it from the beginning of a level. We can speed up the gameplay with a well known trick Time.captureFramerate, but still, it is an \(O(N^2)\) time endeavor to arrange a level, so to speak. It would be nice if we can test a script from any point in the level, so we can incrementally write the script and test it as we go.

Offline Simulation (2020)

In 2020, my approach was to write an offline simulator that takes the game state and a list of actions and runs these actions against the game state without using the actual game. The simulator needed to be reasonably accurate compared to the real game, but it didn’t need to be perfect. A special tool was made to arrange the chefs’ actions and simulate it offline at the same time. Once in a while, we can connect to the real game and simulate it from the beginning, to make sure that the script does work in the game. We can then continue from where the real game left off and continue arranging with offline simulation.

The following video demonstrates this offline simulation implementation used for the 2020 TAS:

One big limitation with the offline simulation approach is that behavior that relies on physics (e.g. collision) is very difficult to simulate accurately. This makes it impractical to arrange a script for any level that requires throwing many items onto the floor.

Online Frame Warping (2023)

In 2023, I rebooted my interest in Overcooked TAS and decided to implement a new approach: make the game instantly “warp” to any previously recorded frame.

With this technology, a script can be tested from any point of the level, and all mechanics of the game are supported, because the real game is used. This gets around the limitations of the offline simulation approach, most importantly, physics are simulated accurately.

This is extremely challenging. The game is not built for suddenly changing the game state (unlike some other games where it’s possible to join a game in the middle), and although I’m doing TAS as a local co-op, the game’s architecture is written in a way that local games also use the online networking code. So the game may be in the middle of synchronizing some changes from server to client while I want to issue a warping command to suddenly transition to a completely different state.

Nonetheless, with enough effort I managed to implement this. Here is a video demonstrating the technology:

And here’s a more complete video explaining how the 2023 TAS was done:

Source Code

The project is currently in development at https://github.com/hpmv/overcooked-supercharged.

Discord

Join my Discord server if you would like to use the TAS tool and/or contribute to the project!

3 - Overcooked! 2 Tech Docs

Understanding the game, little by little

See the docs in this section for a pretty incomplete coverage of the game’s code.

It mentions “warping” quite a bit, which refers to the technique used for TAS.

3.1 - The Entity System

Foundation of the multiplayer system in Overcooked! 2

Basics of Multiplayer

The game is written with multiplayer in mind, and even single player gameplay uses the same stack as the multiplayer scenario, just with a single client.

The host of a game is always the server, and all players in the game (including the host) are clients.

Generally speaking, the clients are only responsible for rendering and input, whereas the server is responsible for the game logic. For example, when a player grabs an tomato from a crate, its client sends a message to the server that “I will pickup something from the crate”, and the server decides to spawn a tomato and put it in the chef’s hands, and synchronizes that information to the clients. Even if the player is the host, it goes through the same code path, except the networking part just calls local code.

The physics simulation is somewhat of an exception. Both the server and the client run physics simulation. I don’t yet understand how that works.

Basics of the entity system

Each object during the gameplay that requires multiplayer synchronization has an entity ID. The entity IDs are agreed upon at all times between the server and the clients. The bidirectional mapping between entity IDs and their corresponding GameObjects are kept by EntitySerialisationRegistry as static fields. The registry also keeps track of some other properties.

  • The staticness of the fields are relied upon by the deserialization code. When receiving a message over the network references an object by its the entity ID, the message is deserialized directly into a corresponding GameObject, by accessing EntitySerialisationRegistry statically. This is a questionable design decision, but in the face of Unity (which uses static objects all over the place), it doesn’t stand out.

Synchronizing Entities

Initially, when the level is first launched, the starting objects (such as counters, mixers, plates, chefs, etc.) are assigned entity IDs by iterating through the objects:

  • See EntitySerialisationRegistry.SetupSynchronisation which then calls LinkAllEntitiesToSynchronisationScripts().
  • This assignment is done independently by both the server and all the (non-server) clients, and the agreement of the IDs depends on the iteration order of the game objects in the scene. This is presumably deterministic.
  • The initial state of all the initial objects are not synchronized over the network; rather, the server and clients just assume they all have identical contents in the Unity scene.

Each synchronized entity (i.e. GameObject) can contain multiple components, each of which may provide its own synchronization logic:

  • Each component has a base type, a server synchronizer, and a client synchronizer.
  • For example, a cutting board typically contains components like Workstation and AttachStation. The Workstation component corresponds to the functionality of a cutting board, whereas AttachStation provides the ability to place anything on the counter (such as a plate). The two components interact: if the attached object is a WorkableItem, then the Workstation may be used to “work on” the item, which is the game’s way of saying, chopping it.
  • In this example, when SetupSynchronisation is called, the server will attach ServerWorkstation and ServerAttachStation to the same GameObject. A client (including the host’s) will attach ClientWorkstation and ClientAttachStation.
  • The pair of ServerWorkstation and ClientWorkstation work with each other to synchronize the state for just that aspect of the cutting board object.
  • The synchronization of each type of component uses a different entity message. For Workstation the message type is WorkstationMessage. The message contains all states represented by this component.

Spawning an entity

An entity is spawned by specifying a spawner entity and the prefab to be spawned.

  • On the server side this is done via NetworkUtils.ServerSpawnPrefab(GameObject spawner, GameObject prefab).
    • For example, ServerPlateReturnStation.CreateStack calls NetworkUtils.ServerSpawnPrefab(this.gameObject, this.m_returnStation.m_stackPrefab), the latter being the prefab for a dirty plate stack.
  • On initialization, any object that can spawn something calls NetworkUtils.RegisterSpawnablePrefab. This will add a SpawnableEntityCollection onto the GameObject if it’s not already there, and add the prefab to the list of spawnables. Inside NetworkUtils.ServerSpawnPrefab, the SpawnableEntityCollection is requested from the spawner GameObject via the interface INetworkEntitySpawner (whose only implementation is SpawnableEntityCollection).
  • When calling NetworkUtils.ServerSpawnPrefab, the spawned object is automatically registered as a new entity in the EntitySerialisationRegistry, and it automatically calls EntitySerialisationRegistry.StartSynchronisingEntry on the resulting GameObject.
    • This happens before calling ServerMessenger.SpawnEntity to notify clients about the spawning of the entity. This implies that StartSynchronisingEntry should not immediately emit any synchronization messages, or else the message will just be dropped at the client side.
    • The function also checks if the spawned object is a PhysicalAttachment, which generally means something that has a container object that has a RigidBody. If so, it additionally registers the container object as a separate entity, and instead of calling ServerMessenger.SpawnEntity, calls ServerMessenger.SpawnPhysicalAttachment instead - this is a separate network message type. This message includes both entity IDs.
      • See the client side at ClientSynchronisationReceiver.OnSpawnPhysicalAttachmentMessageReceived.

Messaging and Serialization

All the message types and entity message types are registered in MultiplayerController.Awake.

When are Entities Synchronized

Most entities are synchronized when the corresponding server component calls SendServerData, which is then processed by the client side when it arrives.

Some entities are synchronized automatically by the entity system, if they override GetServerUpdate. Periodically (once every few frames), the ServerSynchronisationScheduler.SynchroniseList function will send an EntitySynchronisationMessage to synchronize all such entities to the clients.

These automatically synchronized entities are ServerWorldObjectSynchroniser (for physics synchronization), ServerCookingHandler, ServerIngredientContainer, and ServerMixingHandler.

3.2 - Ingredient Bugs

How weird things happen in the game

Mega Burger

Some people found this funny bug where you can make a mega-burger:

Example video: https://www.bilibili.com/video/BV1ZX4y157Ra/

Let’s see what happened.

ServerPreparationContainer is the primary component of a burger bun; here’s the code that is invoked when trying to place something onto it:

public bool CanHandlePlacement(ICarrier _carrier, Vector2 _directionXZ, PlacementContext _context)
{
    // Get the thing the chef is holding (a plate of ingredients, in this case).
    GameObject gameObject = _carrier.InspectCarriedItem();
    // Gets the definition of the plate's contents. In this case it would be 3 separate items
    // (cooked meat, cooked meat, pineapple). See below.
    AssembledDefinitionNode[] orderDefinitionOfCarriedItem = this.GetOrderDefinitionOfCarriedItem(gameObject);
    // This is the plate's ServerIngredientContainer component.
    ServerIngredientContainer component = gameObject.GetComponent<ServerIngredientContainer>();
    // It indeed is a plate.
    Plate component2 = gameObject.GetComponent<Plate>();
    // See below. Passes if individual ingredients are each eligible, and also container is not
    // overfilled in capacity. 
    if (!this.CanAddOrderContents(orderDefinitionOfCarriedItem))
    {
        // Tray is the rectangular tray, not the case here.
        Tray component3 = gameObject.GetComponent<Tray>();
        if (component3 != null)
        {
            return true;
        }
        // If the check above fails, and the thing is not a plate, or its ingredients can't
        // individually be inserted, then fail.
        if (!(component2 != null) || !(component != null) || !this.CanTransferToContainer(component))
        {
            return false;
        }
    }
    // Finally check if the thing we're inserting is a plate (true), or the two plating steps are the same (plating steps are like,
    // cup vs plate vs tray).
    return !(component2 != null) || !(this.m_preparationContainer.m_ingredientOrderNode.m_platingStep != component2.m_platingStep);

    // To summarize, if the thing we're transferring from is a plate, we succeed if either:
    //  - The plates' contents are individually eligible for the target, and the target wouldn't be overfilled; OR
    //  - the plate's ServerIngredientContainer's individual elements are transferrable to the target (but without checking
    //    target's capacity!)
}
// ServerPreparationContainer::GetOrderDefinitionOfCarriedItem
private AssembledDefinitionNode[] GetOrderDefinitionOfCarriedItem(GameObject _carriedItem)
{
    IBaseCookable cookingHandler = null;
    // A plate is not cookable, so doesn't apply here.
    ServerCookableContainer component = _carriedItem.GetComponent<ServerCookableContainer>();
    if (component != null)
    {
        cookingHandler = component.GetCookingHandler();
    }
    return this.m_preparationContainer.GetOrderDefinitionOfCarriedItem(_carriedItem, this.m_itemContainer, cookingHandler);
}

// PreparationContainer::GetOrderDefinitionOfCarriedItem
public AssembledDefinitionNode[] GetOrderDefinitionOfCarriedItem(GameObject _carriedItem, IIngredientContents _itemContainer, IBaseCookable _cookingHandler)
{
    AssembledDefinitionNode[] result = null;
    if (_carriedItem.GetComponent<CookableContainer>() != null)
    {
        ClientIngredientContainer component = _carriedItem.GetComponent<ClientIngredientContainer>();
        IContainerTransferBehaviour containerTransferBehaviour = _carriedItem.RequireInterface<IContainerTransferBehaviour>();
        if (component.HasContents() && containerTransferBehaviour.CanTransferToContainer(_itemContainer))
        {
            CookableContainer component2 = _carriedItem.GetComponent<CookableContainer>();
            ClientMixableContainer component3 = _carriedItem.GetComponent<ClientMixableContainer>();
            AssembledDefinitionNode cookableMixableContents = null;
            bool isMixed = false;
            if (component3 != null)
            {
                cookableMixableContents = component3.GetOrderComposition();
                isMixed = component3.GetMixingHandler().IsMixed();
            }
            CookedCompositeAssembledNode cookedCompositeAssembledNode = component2.GetOrderComposition(_itemContainer, _cookingHandler, cookableMixableContents, isMixed) as CookedCompositeAssembledNode;
            cookedCompositeAssembledNode.m_composition = new AssembledDefinitionNode[]
            {
                component.GetContentsElement(0)
            };
            result = new AssembledDefinitionNode[]
            {
                cookedCompositeAssembledNode
            };
        }
    }
    else if (_carriedItem.GetComponent<IngredientPropertiesComponent>() != null)
    {
        IngredientPropertiesComponent component4 = _carriedItem.GetComponent<IngredientPropertiesComponent>();
        result = new AssembledDefinitionNode[]
        {
            component4.GetOrderComposition()
        };
    }
    // This case applies.
    else if (_carriedItem.GetComponent<Plate>() != null)
    {
        ClientIngredientContainer component5 = _carriedItem.GetComponent<ClientIngredientContainer>();
        result = component5.GetContents();
    }
    return result;
}

// ClientIngredientContainer::GetContents. Why Client and not Server? I have no idea.
public AssembledDefinitionNode[] GetContents()
{
    return this.m_contents.ToArray();
}
// ServerPreparationContainer::CanAddOrderContents - can we add these things into the bun?
public bool CanAddOrderContents(AssembledDefinitionNode[] _contents)
{
    if (_contents != null)
    {
        foreach (AssembledDefinitionNode toAdd in _contents)
        {
            // Check each individual ingredient.
            if (!this.CanAddIngredient(toAdd))
            {
                return false;
            }
        }
        // Then check the whole container.
        // This does nothing more than checking the capacity.
        return this.m_itemContainer.CanTakeContents(_contents);
    }
    return false;
}

// ServerPreparationContainer::CanAddIngredient
protected virtual bool CanAddIngredient(AssembledDefinitionNode _toAdd)
{
    CompositeAssembledNode asOrderComposite = this.GetAsOrderComposite();
    return asOrderComposite.CanAddOrderNode(_toAdd, true);
}

// CompositeAssembledNode::CanAddOrderNode
// Basically checks that each ingredient is allowable for this container,
// and that we don't already have too many (e.g. can't insert two lettuces).
public bool CanAddOrderNode(AssembledDefinitionNode _toAdd, bool _raw = false)
{
    if (!_raw && this.m_freeObject != null && this.m_freeObject.RequestInterface<IHandleOrderModification>() != null)
    {
        IHandleOrderModification handleOrderModification = this.m_freeObject.RequestInterface<IHandleOrderModification>();
        return handleOrderModification.CanAddOrderContents(new AssembledDefinitionNode[]
        {
            _toAdd
        });
    }
    int num = 0;
    foreach (AssembledDefinitionNode node in this.m_composition)
    {
        if (AssembledDefinitionNode.Matching(node, _toAdd))
        {
            num++;
        }
    }
    Predicate<OrderContentRestriction> match = (OrderContentRestriction _specifier) => AssembledDefinitionNode.Matching(_specifier.m_content, _toAdd);
    OrderContentRestriction orderContentRestriction = this.m_permittedEntries.Find(match);
    return orderContentRestriction != null && num < orderContentRestriction.m_amountAllowed && !this.CompositionContainsRestrictedNode(_toAdd, orderContentRestriction);
}

Also:

// ServerPreparationContainer::CanTransferToContainer. It basically checks every component of
// the container's contents.
public virtual bool CanTransferToContainer(IIngredientContents _container)
{
    if (_container.HasContents())
    {
        for (int i = 0; i < _container.GetContentsCount(); i++)
        {
            AssembledDefinitionNode contentsElement = _container.GetContentsElement(i);
            if (!this.CanAddIngredient(contentsElement))
            {
                return false;
            }
        }
    }
    return true;
}

3.3 - Kitchen Flow Controller

Recipes, orders, and score-keeping.

The Kitchen Flow Controller (ServerKitchenFlowControllerBase and ClientKitchenFlowControllerBase) is responsible for keeping track of:

  • the list of orders currently on the screen, what each order is and their timer
  • when to spawn the next order, and the RNG state that should be used to derive the next order
  • the score and tip streak for the “team” (which there can be two of, in case of Versus mode)
  • managing the PlateReturnController (see plates.md)

Maintaining the order list (Server side)

The order list is maintained in an inner structure called ServerOrderControllerBase. It maintains:

  • RoundDataBase m_roundData which is the implementation used to generate new orders
  • RoundInstanceDataBase m_roundInstanceData, which is the current RNG state of the m_roundData
  • m_nextOrderID, starting at 1, responsible for assigning an OrderID for the next order
    • OrderID is used to identify orders internally – since the active orders list shifts around.
  • List<ServerOrderData> m_activeOrders which is the list of currently active orders
  • float m_timerUntilOrder, the amount of time until the next order should be automatically added (only relevant if there are currently 2, 3, or 4 orders)
  • m_comboIndex, which is the index of the last delivered order.

There are also a couple of other fields that are not quite relevant.

The ServerOrderData is a serializable class that maintains:

  • The OrderID
  • The RecipeList.Entry which is a reference to one element in the level’s RecipeList
  • float Lifetime, the time allowed for the order
  • float Remaining, the remaining time allowed for the order before it expires

In the ServerOrderControllerBase::Update() function:

  • The remaining time of each order is decreased by the time elapsed; if the remaining time is <= 0 then the timeout callback is invoked;
  • The m_timerUntilOrder is decreased by the time elapsed
  • If there are fewer than 5 orders and it’s time to add a new order, or if there are fewer than 2 orders, generate a new order, invoke the order added callback, and reset m_timerUntilOrder.

Now coming to ServerTeamMonitor, which hosts the ServerOrderControllerBase as its field; it also has two other fields, TeamMonitor and TeamMonitor.TeamScoreStats. The former hosts nothing but the RecipeFlowGUI, which we’ll describe when talking about the client side later. The latter hosts the scores of the team - which we discuss in the scoring section.

ServerTeamMonitor::OnFoodDelivered function is called when a dish is delivered by the player:

public virtual bool OnFoodDelivered(AssembledDefinitionNode _definition, PlatingStepData _plateType, out OrderID o_orderID, out RecipeList.Entry o_entry, out float o_timePropRemainingPercentage, out bool o_wasCombo)
{
    o_orderID = default(OrderID);
    o_entry = null;
    o_timePropRemainingPercentage = 0f;
    o_wasCombo = false;
    // This matches the recipe against the orders. It'll return the matching order with the
    // least amount of time remaining. If there are no matching orders it returns false.
    bool flag = this.m_ordersController.FindBestOrderForRecipe(_definition, _plateType, out o_orderID, out o_timePropRemainingPercentage);
    if (flag)
    {
        // Calculates whether the order was a combo, as this affects scoring.
        o_entry = this.m_ordersController.GetRecipe(o_orderID);
        bool restart = this.m_score.TotalCombo == 0;
        o_wasCombo = this.m_ordersController.IsComboOrder(o_orderID, restart);
        // Removes the order from the list of orders.
        this.m_ordersController.RemoveOrder(o_orderID);
        return true;
    }
    return false;
}

Now coming back to the root struct, ServerKitchenFlowControllerBase, which hosts a collection of ServerTeamMonitors; for simplicity we only look at co-op games, i.e. one team.

  • OnOrderAdded is called when the ServerOrderControllerBase invokes the order added callback. Its only task here is to call SendOrderAdded to send this as a KitchenFlowMessage to the client side.
  • OnOrderExpired is similar, and invokes SendOrderExpired to send to the client side. Additionally it updates a few fields such as resetting the combo on the team’s score struct, and resets the order’s timer.
    • When sending the data over to the client side, the data also includes the complete updated score data.
  • OnFoodDelivered is called externally. It calls the ServerTeamMonitor’s OnFoodDelivered and
    • if successful, calls OnSuccessfulDelivery; this computes how to affect the team’s score stats and then calls SendDeliverySuccess to send it to the client. The message once again includes the full score data.
    • if failing, calls OnFailedDelivery, which resets the combo in the score struct and similarly calls SendDeliveryFailed to share it with the client.
  • Note that there’s also a way to send only the score to the client, but this is only used when cheating to skip a level with certain number of stars.

These OnXXX Methods are actually overridden by the concrete implementation ServerCampaignFlowController (or ServerCompetitiveFlowController, which we don’t cover here). The additional logic controls minor things like updating the timer suppressor when the first dish is delivered successfully.

Warping on server side

The KitchenFlowMessage can allow us to maintain on the controller side:

  • For ServerOrderControllerBase:
    • The list of active orders, by incremental computation (deletion is done by successful delivery)
    • The next order ID, which should be the added order’s ID + 1
    • The combo index, which can be derived from what was delivered
    • The m_timerUntilOrder timer, by decrementing per frame and resetting upon adding orders
    • !!! The RoundInstanceDataBase is not recoverable as is, so would need a custom implementation
  • For each order:
    • The whole ServerOrderData, which comes straight from deserialization;
    • except the Remaining time is kept by decrementing per frame
  • For the ServerTeamMonitor, the TeamScoreStats struct is wholly replaced each time by deserialization.
  • For the ServerKitchenFlowControllerBase, there aren’t any meaningful fields to warp.

Maintaining the order list (client side)

The client side mostly mirrors the server side but is more complicated because it additionally needs to maintain the GUI rendering of the order list.

In ClientKitchenFlowControllerBase, we maintain:

  • (through a subclass) a ClientTeamMonitor, which contains:
    • a TeamMonitor, same as ServerTeamMonitor
    • a ClientOrderControllerBase
    • a TeamMonitor.TeamScoreStats
  • A DataStore m_dataStore; this seems to be an in-memory transient key-value store used for notifying UI rendering logic to update UI elements.
  • OrderDefinitionNode[] m_cachedRecipeList, AssembledDefinitionNode[] m_cachedAssembledRecipes, and CookingStepData[] m_cachedCookingStepList. They… don’t seem to actually do anything.

The events from the server are handled as:

protected virtual void OnSuccessfulDelivery(TeamID _teamID, OrderID _orderID, float _timePropRemainingPercentage, int _tip, bool _wasCombo, ClientPlateStation _station)
{
    GameUtils.TriggerAudio(GameOneShotAudioTag.SuccessfulDelivery, base.gameObject.layer);
    ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
    int uID = monitorForTeam.OrdersController.GetRecipe(_orderID).m_order.m_uID;
    // See ClientOrderControllerBase.
    monitorForTeam.OrdersController.OnFoodDelivered(true, _orderID);
    // Writes into the DataStore with the key "score.team" to update UI.
    this.UpdateScoreUI(_teamID);
    // This is to keep track of level objectives which don't seem to be used in the game?
    if (this.m_onMealDelivered != null)
    {
        this.m_onMealDelivered(uID, _wasCombo);
    }
    // Achievement stuff.
    OvercookedAchievementManager overcookedAchievementManager = GameUtils.RequestManager<OvercookedAchievementManager>();
    if (overcookedAchievementManager != null)
    {
        overcookedAchievementManager.IncStat(1, 1f, ControlPadInput.PadNum.One);
        overcookedAchievementManager.AddIDStat(22, uID, ControlPadInput.PadNum.One);
        overcookedAchievementManager.AddIDStat(100, uID, ControlPadInput.PadNum.One);
        overcookedAchievementManager.AddIDStat(500, uID, ControlPadInput.PadNum.One);
        overcookedAchievementManager.AddIDStat(801, uID, ControlPadInput.PadNum.One);
    }
}

protected virtual void OnFailedDelivery(TeamID _teamID, OrderID _orderID)
{
    ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
    monitorForTeam.OrdersController.OnFoodDelivered(false, _orderID);
    this.UpdateScoreUI(_teamID);
    // Also for level objectives which aren't useful.
    if (this.m_onFailedDelivery != null)
    {
        this.m_onFailedDelivery();
    }
}

protected virtual void OnOrderAdded(TeamID _teamID, Serialisable _orderData)
{
    ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
    monitorForTeam.OrdersController.AddNewOrder(_orderData);
    this.UpdateScoreUI(_teamID);
}

protected virtual void OnOrderExpired(TeamID _teamID, OrderID _orderID)
{
    ClientTeamMonitor monitorForTeam = this.GetMonitorForTeam(_teamID);
    monitorForTeam.OrdersController.OnOrderExpired(_orderID);
    this.UpdateScoreUI(_teamID);
}

Note that the actual concrete class ClientCampaignFlowController overrides these methods to call a few extra things in ClientCampaignMode. This includes updating the score tip display (DataStore key “score.tip”), and timer suppression update.

Into the ClientOrderControllerBase,

public virtual void OnFoodDelivered(bool _success, OrderID _orderID)
{
    if (_success)
    {
        ClientOrderControllerBase.ActiveOrder activeOrder = this.m_activeOrders.Find((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
        if (activeOrder != null)
        {
            // Tells the GUI to use an animation to remove the order item. It's not immediately removed
            // from the rendered list but only after the animation is done.
            this.m_gui.RemoveElement(activeOrder.UIToken, new RecipeSuccessAnimation());
        }
        // Mirroring of the server side's active order list.
        this.m_activeOrders.RemoveAll((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
    }
    else
    {
        for (int i = 0; i < this.m_activeOrders.Count; i++)
        {
            // On failure animate all items with the failure animation.
            this.m_gui.PlayAnimationOnElement(this.m_activeOrders[i].UIToken, new RecipeFailureAnimation());
        }
    }
}

public virtual void AddNewOrder(Serialisable _data)
{
    ServerOrderData data = (ServerOrderData)_data;
    RecipeList.Entry entry = new RecipeList.Entry();
    entry.Copy(data.RecipeListEntry);
    // Adds the item to the GUI.
    RecipeFlowGUI.ElementToken token = this.m_gui.AddElement(entry.m_order, data.Lifetime, this.m_expiredDoNothingCallback);
    // Adds the item to the mirrored list of active orders.
    ClientOrderControllerBase.ActiveOrder item = new ClientOrderControllerBase.ActiveOrder(data.ID, entry, token);
    this.m_activeOrders.Add(item);
}

public virtual void OnOrderExpired(OrderID _orderID)
{
    GameUtils.TriggerAudio(GameOneShotAudioTag.RecipeTimeOut, this.m_gui.gameObject.layer);
    ClientOrderControllerBase.ActiveOrder activeOrder = this.m_activeOrders.Find((ClientOrderControllerBase.ActiveOrder x) => x.ID == _orderID);
    if (activeOrder != null)
    {
        // Order expiration is only a GUI effect because on the client side we don't mirror
        // the amount of time remaining (only graphically).
        this.m_gui.PlayAnimationOnElement(activeOrder.UIToken, new RecipeFailureAnimation());
        this.m_gui.ResetElementTimer(activeOrder.UIToken);
    }
}

Now we go into the RecipeFlowGUI class, which is responsible for rendering the order list.

It maintains:

  • int m_nextIndex, used to number newly added recipe widgets (each order is one recipe widget).
  • List<RecipeFlowGUI.RecipeWidgetData> m_widgets, the list of active widgets.
  • List<RecipeFlowGUI.RecipeWidgetData> m_dyingWidgets, the list of widgets being deleted but still going through the deletion animation.
  • (not important) List<RecipeFlowGUI.RecipeWidgetData> m_ordererWidgets, which is used only as a local variable to merge sort the m_widgets and m_dyingWidgets lists.
  • (not important) bool[] m_occupiedTables which simulates tables at a restaurant but is not actually displayed or used anywhere in the game. We still need to maintain it though because each order does have a table ID.

The actions in RecipeFlowGUI are:

public RecipeFlowGUI.ElementToken AddElement(OrderDefinitionNode _data, float _timeLimit, VoidGeneric<RecipeFlowGUI.ElementToken> _expirationCallback)
{
    int tableNumber = this.ClaimUnoccupiedTable();  // not important

    // Sets up the UI element to render the recipe.
    // When the UI element is first set up, there's an initial animation transition to slide the
    // recipe into place.
    GameObject obj = GameUtils.InstantiateUIController(this.m_recipeWidgetPrefab.gameObject, base.transform as RectTransform);
    RecipeWidgetUIController recipeWidgetUIController = obj.RequireComponent<RecipeWidgetUIController>();
    recipeWidgetUIController.SetupFromOrderDefinition(_data, tableNumber);

    // Sets up the data structure to maintain the UI element.
    RecipeFlowGUI.RecipeWidgetData recipeWidgetData = new RecipeFlowGUI.RecipeWidgetData(recipeWidgetUIController, 0f, this.m_nextIndex, _timeLimit, _expirationCallback);
    this.m_nextIndex++;
    // adds it to the active list.
    this.m_widgets.Add(recipeWidgetData);
    return new RecipeFlowGUI.ElementToken(recipeWidgetData);
}

public void RemoveElement(RecipeFlowGUI.ElementToken _token, WidgetAnimation _deathAnim = null)
{
    for (int i = this.m_widgets.Count - 1; i >= 0; i--)
    {
        RecipeFlowGUI.RecipeWidgetData recipeWidgetData = this.m_widgets[i];
        if (new RecipeFlowGUI.ElementToken(recipeWidgetData) == _token)
        {
            if (_deathAnim != null)
            {
                // If there's an animation, add it to the list of dying widgets and
                // play the animation.
                this.ReleaseTable(recipeWidgetData.m_widget.GetTableNumber());
                recipeWidgetData.m_widget.PlayAnimation(_deathAnim);
                this.m_dyingWidgets.Add(recipeWidgetData);
            }
            this.m_widgets.RemoveAt(i);
        }
    }
}

// Called in `ClientOrderControllerBase` to decrement each order's remaining time.
public void UpdateTimers(float _dt)
{
    for (int i = 0; i < this.m_widgets.Count; i++)
    {
        float timePropRemaining = this.m_widgets[i].m_widget.GetTimePropRemaining();
        float num = timePropRemaining;
        num = Mathf.Max(num - _dt / this.m_widgets[i].m_timeLimit, 0f);
        this.m_widgets[i].m_widget.SetTimePropRemaining(num);
        if (timePropRemaining > 0f && num <= 0f)
        {
            // This doesn't seem to do anything.
            this.m_widgets[i].m_expirationCallback(new RecipeFlowGUI.ElementToken(this.m_widgets[i]));
        }
    }
}

private void Update()
{
    // Remove those dying widgets that finished their deletion animations.
    this.m_dyingWidgets.RemoveAll(delegate(RecipeFlowGUI.RecipeWidgetData obj)
    {
        if (!obj.m_widget.IsPlayingAnimation())
        {
            UnityEngine.Object.Destroy(obj.m_widget.gameObject);
            return true;
        }
        return false;
    });
    // Layouts the remaining widgets.
    this.LayoutWidgets();
}

private void LayoutWidgets()
{
    // Merge sorts the active and dying widgets.
    this.FindAllWidgetsOrdered(ref this.m_ordererWidgets);
    float distanceFromEndOfScreen = this.m_distanceFromEndOfScreen;
    float distanceBetweenOrders = this.m_distanceBetweenOrders;
    float num = distanceFromEndOfScreen - distanceBetweenOrders;
    for (int i = 0; i < this.m_ordererWidgets.Count; i++)
    {
        RecipeWidgetUIController widget = this.m_ordererWidgets[i].m_widget;
        float width = widget.GetBounds().width;
        // There's nothing fancy going on here. The entry animation is done within the UI prefab.
        // When removing a widget there's no animation for the remaining widgets to slide over.
        RectTransformExtension rectTransformExtension = widget.gameObject.RequireComponent<RectTransformExtension>();
        float num2 = num + distanceBetweenOrders;
        rectTransformExtension.AnchorOffset = new Vector2(0f, 0f);
        rectTransformExtension.PixelOffset = new Vector2(num2, 0f);
        num = num2 + width;
    }
}

Finally, the RecipeWidgetUIController:

public void SetupFromOrderDefinition(OrderDefinitionNode _data, int _tableNumber)
{
    this.m_recipeTree = _data.m_orderGuiDescription;
    this.m_tableNumber = _tableNumber;
    // Delays the preparation of the actual UI.
    base.StartCoroutine(this.RefreshSubObjectsAtEndOfFrame());
    if (T17InGameFlow.Instance != null)
    {
        T17InGameFlow.Instance.RegisterOnPauseMenuVisibilityChanged(new BaseMenuBehaviour.BaseMenuBehaviourEvent(this.OnPauseMenuVisibilityChange));
    }
}

private IEnumerator RefreshSubObjectsAtEndOfFrame()
{
    yield return new WaitForEndOfFrame();
    base.RefreshSubElements();
    yield break;
}

// From base class UISubElementContainer:
public void RefreshSubElements()
{
    // Does some stuff, mostly calling OnCreateSubobjects.
    this.EnsureImagesExist();
    this.m_debugActivated = true;
    this.RefreshSubObjectProperties();
}

And then the OnCreateSubobjects function adds the animator, sets up the recipe tiles, etc.

Warping the client side

  • Update the m_activeOrders in the ClientOrderControllerBase - just overwrite the entire list.
  • Very intrusively update the RecipeFlowGUI:
    • Overwrite the m_nextIndex - we can make it arbitrarily high, maybe just don’t reuse any IDs.
    • Overwrite m_widgets to be the currently active orders; reinitialize all of them, force set the timePropRemaining of each order
      • The OnCreateSubobjects callback need to pulled earlier. To do this, instead of invoking SetupFromOrderDefinition, we will do that ourselves (it’s just the m_recipeTree and m_tableNumber), and then call RefreshSubElements.
    • Overwrite m_dyingWidgets to be empty.
    • Overwrite m_occupiedTables to be consistent with whatever indexes assigned to our newly created orders.
  • Completely replace the ClientTeamMonitor’s score struct and refresh DataStore “score.team” key.
    • Note, not “score.tip” key. That’s for showing up the tip amount above the serving station.

Scoring

public class TeamScoreStats : Serialisable {
    // ...
    public int TotalBaseScore;
    public int TotalTipsScore;
    public int TotalMultiplier;
    public int TotalCombo;
    public int TotalTimeExpireDeductions;
    public bool ComboMaintained = true;
    public int TotalSuccessfulDeliveries;
}

3.4 - Delivering Plates

How plates are delivered

ServerPlateStation is the serving station.

  • It keeps a list of ServerPlateReturnStations which corresponds to the stations where dirty plates will spawn. There can be multiple because there may be multiple kinds of plates (cups and plates, e.g.).
  • IKitchenOrderHandler m_orderHandler handles the delivery of the order.
    • Implemented by ServerKitchenFlowControllerBase. The OnFoodDelivered calls its m_plateReturnController.FoodDelivered, which adds a PlatesPendingReturn to its list of pending plates.
    • A PlatesPendingReturn specifies the return station, the timer, and the PlatingStepData (identifying the kind of plate).
    • PlatingStepData is just an m_uID plus a sprite. The actual prefab for the dirty plate is created by the PlateReturnStation as a spawn.
  • Logic is triggered by OnItemAdded, registered on the m_attachStation (sibling component) via RegisterOnItemAdded.

3.5 - Controls

All fields of ClientPlayerControlsImpl_Default

ClientPlayerControlsImpl_Default

Fields:

  • private PlayerControlsImpl_Default m_controlsImpl; The associated PlayerControlsImpl_Default component of the same object.
  • private ClientInteractable m_lastInteracted; The last object interacted with. Set in BeginInteraction and cleared in EndInteraction. Seems to only be queried by code that affects animations?
  • private ClientInteractable m_predictedInteracted; Part of some super complicated interaction logic:
    private void Update_Interact(float _deltaTime, bool isUsePressed, bool justPressed)
    {
        // This is the highlighted object to be interacted.
        ClientInteractable highlighted = this.m_controls.CurrentInteractionObjects.m_interactable;
    
        // Object already being interacted
        bool alreadyInteracting = this.m_predictedInteracted != null;
        bool hasHighlight = highlighted != null;
        bool isCurrentInteractionSticky = alreadyInteracting && this.m_predictedInteracted.InteractionIsSticky();
    
        if (isUsePressed && hasHighlight && !alreadyInteracting)
        {
            // If we aren't already interacting with something, and the interaction button is down (doesn't have to
            // be just pressed), and we have a highlighted object to interact with, then start the interaction
            // with that object.
            this.m_predictedInteracted = highlighted;
            ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, highlighted);
        }
        else if (!hasHighlight && alreadyInteracting && isCurrentInteractionSticky)
        {
            // If current interaction is sticky (i.e. does not require pressing), but there is no longer a
            // currently highlighted object, then stop interacting.
            this.m_predictedInteracted = null;
            ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null);
        }
        else if (!isUsePressed && alreadyInteracting && !isCurrentInteractionSticky)
        {
            // If current interaction is not sticky (requires continuous pressing) but the key is
            // no longer down, then stop interacting. e.g. fire extingisher, washer jet.
            this.m_predictedInteracted = null;
            ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null);
        }
        else if (hasHighlight && highlighted != this.m_predictedInteracted)
        {
            // If the currently highlighted object is different from the currently interacted object,
            // stop interacting.
            this.m_predictedInteracted = null;
            ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.Interact, base.gameObject, null);
        }
    
        if (justPressed && highlighted != null)
        {
            // Independently of everything else, if we had *just* pressed the use button and we have a highlighted
            // object to use, trigger the interaction with that object.
            ClientMessenger.ChefEventMessage(ChefEventMessage.ChefEventType.TriggerInteract, base.gameObject, highlighted);
        }
    }
    
  • private Transform m_Transform; Transform associated with the chef.
  • private ClientInteractable m_sessionInteraction; This is set while the chef is in an active “Session Interaction”, i.e. an interaction that makes the chef uncontrollable before it ends; e.g. operating the raft control, or being inside a cannon.
  • private PlayerControls m_controls; The non-synchronizing PlayerControls object.
  • private PlayerControls.ControlSchemeData m_controlScheme; Provides information for the control scheme, i.e. what keys are used to dash, interact, etc.
  • private GameObject m_controlObject; The chef.
  • private CollisionRecorder m_collisionRecorder; Records recent collisions, used to compute dash collision with another player.
  • private PlayerIDProvider m_controlsPlayer; Component on the chef that provides the ID of the player.
  • private ICarrier m_iCarrier; Component on the chef that deals with carrying objects. TODO: this is satisfied by both ServerPlayerAttachmentCarrier and ClientPlayerAttachmentCarrier.
  • private IClientThrower m_iThrower; Component on the chef that deals with throwing objects.
  • private IClientHandleCatch m_iCatcher; Component on the chef that deals with catching objects.
  • private Vector3 m_lastVelocity; Velocity on the last “chef movement” frame. Being used in Update_Movement to compute friction:
    Vector3 vector3 = this.ProgressVelocityWrtFriction(this.m_lastVelocity, this.m_controls.Motion.GetVelocity(), vector2, this.GetSurfaceData());
    this.m_controls.Motion.SetVelocity(vector3);
    this.m_lastVelocity = vector3;
    
  • private float m_dashTimer = float.MinValue;
    • Remaining time of a dash action; CAN BE NEGATIVE. When pressing dash, the dash timer is set to PlayerControls.MovementData.DashTime. Then on every “chef movement” frame this is decremented by 1/60 of a second, and continues to go below zero.
    • This is also used to compute the dash cooldown (this is why it needs to go below zero).
    • It is also used to compute dash collision impact.
  • private float m_attemptedDistanceCounter; Does not seem to be actually useful. Only read and written here:
    this.m_attemptedDistanceCounter += vector2.magnitude * _deltaTime;
    if (this.m_attemptedDistanceCounter >= movement.FootstepLength)
    {
    	this.m_attemptedDistanceCounter %= movement.FootstepLength;
    }
    
  • private bool m_aimingThrow;
    • If true, the chef is aiming to throw an item.
    • This is used to show/hide the throw indicator.
    • It is also used to suppress movement, see m_movementInputSuppressed.
  • private bool m_movementInputSuppressed; If true, the chef does not move with the controls (the control’s movement axes are effectively zero); however it will still move with the dash velocity.
  • private Vector3 m_lastMoveInputDirection;
    • This stores the last movement input direction while the chef is aiming (if input magnitude is >= m_controls.m_analogEnableDeadzoneThreshold, which is 0.25).
    • After exiting from aiming mode or a session interaction, the movement suppression is only lifted when either
      • the movement input magnitude is less than m_controls.m_analogEnableDeadzoneThreshold
      • the movement input direction is more than m_controls.m_analogEnableAngleThreshold (which is 15 degrees) away from the m_lastMoveInputDirection.
  • private float m_impactStartTime = float.MaxValue; The description is misleading; this is the total duration of the impact. It is set in ApplyImpact and not changed after.
  • private float m_impactTimer = float.MinValue; This is the remaining time of the impact. It is set to the same value as m_impactStartTime at the beginning of ApplyImpact, and then it decrements by 1/60 of a second every “movement frame”.
  • private Vector3 m_impactVelocity = Vector3.zero; The initial impact velocity. This is only set in ApplyImpact and then used to calculate the chef velocity (along with movement and dash) in Update_Movement. The calculation formula is very similar to dash.
  • private float m_timeOffGround;
    • Increments when the chef is not in collision with ground; resets to zero otherwise.
    • If this value reaches m_controls.m_timeBeforeFalling, ``m_isFalling` is set to true.
    • The only thing that m_isFalling controls is a trigger that starts the falling animation.
  • private bool m_isFalling; See m_timeOffGround.
  • private VoidGeneric<ClientInteractable> m_interactTriggerCallback; Used for animations.
  • private VoidGeneric<GameObject> m_throwTriggerCallback; Used for animations.
  • private VoidGeneric<bool> m_fallingTriggerCallback; Used for animations.
  • private PlayerControlsHelper.ControlAxisData m_controlAxisData;
    • This is a frame-temporary axis data built by Update_Movement and then used in Update_Rotation and UpdateMovementSuppression.
  • private PlayerIDProvider m_playerIDProvider; This should point to the same object as m_controlsPlayer.
  • private float m_LeftOverTime; Remaining time to calculate “movement frame”. This is incremented by Time.deltaTime every game frame, and then a movement frame happens as 1/60 second is subtracted from m_LeftOverTime.
  • private OrderedMessageReceivedCallback m_onChefEffectReceived; A callback used to handle received ChefEffect messages, i.e. multiplayer impact and dash.
  • private uint m_entityID; The entity ID of the chef.
  • private float m_lastPickupTimestamp;
    • Timestamp of the last pickup action. This time is from ClientTime.Time(), which is some complicated synchronized time?
    • This is used to prevent another pickup within 0.5 seconds (m_controls.m_pickupDelay).
  • private LayerMask m_SlopedGroundMask = 0; Something to do with gravity.

Fields of player control classes that require persisting for TAS warping

  • ClientInteractable m_predictedInteracted - persisted as an entity reference. Needed to calculate next frame interaction decisions.
  • NOT m_sessionInteraction - we already record movement suppression, and in general it’s not a big deal because the controller won’t be trying to move the chef when it’s trying to interact with something, and we’ll also make sure that any sort of interactable will actually disable the control of the chef. Movement suppression is generally just not that reliable anyway because dashing is not prevented; it’s only good for aiming, really.
  • NOT m_collisionRecorder - colliding with another chef is unlikely to be helpful for TAS, we’ll just avoid it in general so it’s OK if this is not warped perfectly.
  • Vector3 m_lastVelocity; unlike rigidbody’s velocity, this is NOT impacted by pausing, because Update_Impl doesn’t call any of the movement update functions if the game is paused.
  • float m_dashTimer
  • bool m_aimingThrow
  • bool m_movementInputSuppressed
  • Vector3 m_lastMoveInputDirection
  • float m_impactStartTime
  • float m_impactTimer
  • Vector3 m_impactVelocity
  • float m_LeftOverTime
  • NOT float m_lastPickupTimestamp: We will limit this on the controller side. Otherwise this would require modding the field itself since it doesn’t hold a pausable timestamp right now.

3.6 - Cooking

Entities relevant to cooking

Cooking Handler

ServerCookingHandler manages the progress and state of cooking.

public void SetCookingProgress(float _cookingProgress)
{
    CookingHandler cookingHandler = this.GetCookingHandler();
    this.m_ServerData.m_cookingProgress = _cookingProgress;
    CookingUIController.State cookingState = this.m_ServerData.m_cookingState;
    if (this.IsBurning())
    {
        this.m_ServerData.m_cookingState = CookingUIController.State.Ruined;
    }
    else if (_cookingProgress >= cookingHandler.m_cookingtime)
    {
        if (this.m_ServerData.m_cookingState == CookingUIController.State.Progressing)
        {
            this.m_ServerData.m_cookingState = CookingUIController.State.Completed;
        }
        if (_cookingProgress > 1.3f * cookingHandler.m_cookingtime)
        {
            this.m_ServerData.m_cookingState = CookingUIController.State.OverDoing;
        }
    }
    else
    {
        this.m_ServerData.m_cookingState = CookingUIController.State.Progressing;
    }
    if (this.m_ServerData.m_cookingState != cookingState)
    {
        this.m_cookingStateChangedCallback(this.m_ServerData.m_cookingState);
    }
}

The cooking state is completely determined by the progress.

The m_cookingStateChangedCallback is mostly used for cosmetics. It changes the order composition (because it changes uncooked ingredients to cooked ones), and so can affect certain cosmetic functions. There’s one exception to the cosmetics which is ServerCookingStation.OnItemAdded, which updates whether the cooking station should be cooking. Either way, calling this more than once shouldn’t hurt.

Also, ServerCookingHandler’s synchronization is one of the very few components that synchronize automatically. It does so by using the EntitySynchronisationMessage instead of EntityEventMessage. The message is automatically sent by ServerSynchronisationScheduler by querying the component’s GetServerUpdate().

ServerMixingHandler

Same with ServerCookingHandler.

ServerCookableContainer

This is mostly a helper component. It does not have its own synchronized entity message.

ServerIngredientContainer

This also uses GetServerUpdate() and is synchronized automatically. So correctness isn’t really an issue. We just need to make sure to call OnContentsChanged() upon warping.

ServerCookingStation

This controls two parameters: m_isCooking and m_isTurnedOn.

3.7 - Throwing

Throwing, catching, and landing

Throwing Basics

Throwing is pretty non-trivial in this game.

  • AttachmentThrower is a component that must be present for anything that can throw an object. There are two I’m aware of: chef and teleportal. The ServerAttachmentThrower::ThrowItem is the one performing the throwing action, but throwing is triggered via a few steps, starting elsewhere:
    • For chef, the ClientPlayerControlsImpl_Default listens for controller events in Update_Throw() and then requests a throw by sending an ChefEventType.Throw event to the server. See Chef Throwing later
    • For teleportal, the ServerTeleportalAttachmentReceiver has a coroutine that initiates the throw when receiving a teleported throwable object.
  • ServerThrowableItem is a component that is present on anything that can be thrown. Its two important methods are CanHandleThrow and HandleThrow:
    • CanHandleThrow makes an object conditionally throwable. (Anything that is never throwable just doesn’t have the ThrowableItem component attached to it at all.) It has only one implementation, by ServerPreparationContainer (used by burger buns, tortillas, etc.) which is only throwable if it doesn’t have anything inside it.
    • HandleThrow does a few things. See later in Throwing Details.

Chef Throwing

After ClientPlayerControlsImpl_Default.Update_Throw decides to send a throw event, the server counterpart, ServerPlayerControlsImpl_Default.ReceiveThrowEvent(GameObject _target) processes the throwing.

public void ReceiveThrowEvent(GameObject _target)
{
    if (_target != null)
    {
        if (this.m_iCarrier.InspectCarriedItem() == null || PlayerControlsHelper.IsHeldItemInsideStaticCollision(this.m_controls))
        {
            return;
        }
        IThrowable throwable = _target.RequireInterface<IThrowable>();
        Vector3 v = this.m_controlObject.transform.forward.normalized.XZ();
        if (throwable.CanHandleThrow(this.m_iThrower, v))
        {
            this.m_iCarrier.TakeItem();
            throwable.HandleThrow(this.m_iThrower, v);
            EntitySerialisationEntry entry = EntitySerialisationRegistry.GetEntry(_target);
            this.SendServerEvent(new InputEventMessage(InputEventMessage.InputEventType.EndThrow)
            {
                entityId = entry.m_Header.m_uEntityID
            });
        }
        else
        {
            this.m_iThrower.OnFailedToThrowItem(this.m_iCarrier.InspectCarriedItem());
        }
    }
}

First, this.m_iCarrier is the ICarrier component on the same chef; .InspectCarriedItem() returns the item currently being carried by the chef. It’s it’s null then we bail.

Then, we check if the item is inside static collision:

public static bool IsHeldItemInsideStaticCollision(PlayerControls _control)
{
    if (PlayerControlsHelper.s_staticCollisionLayerMask == 0)
    {
        PlayerControlsHelper.s_staticCollisionLayerMask = LayerMask.GetMask(new string[]
        {
            "Default",
            "Ground",
            "Walls",
            "Worktops",
            "PlateStationBlock",
            "CookingStationBlock"
        });
    }
    ICarrier carrier = _control.gameObject.RequireInterface<ICarrier>();
    IAttachment attachment = carrier.InspectCarriedItem().RequestInterface<IAttachment>();
    Collider collider = attachment.AccessGameObject().RequestComponent<Collider>();
    Bounds bounds = collider.bounds;
    Quaternion rotation = collider.transform.rotation;
    Vector3 position = collider.transform.position;
    Vector3 halfExtents = bounds.extents * 0.5f;
    int num = Physics.OverlapBoxNonAlloc(bounds.center, halfExtents, PlayerControlsHelper.s_collisions, rotation, PlayerControlsHelper.s_staticCollisionLayerMask, QueryTriggerInteraction.Ignore);
    return num > 0;
}

This corresponds to the behavior that if, say, a cooking station is right in the chef’s face, the chef cannot throw anything. Similarly one cannot throw something into the wall.

After that, we check for CanHandleThrow, which we discussed above. If this fails, we call OnFailedToThrowItem, whose only implementation by ServerAttachmentThrower does nothing, so we can ignore this.

Next, we TakeItem() from the chef, which detaches the item from the chef.

Then we call ServerThrowableItem.HandleThrow. Before we dig into it, we look at the final thing that happens after: sending a EndThrow message to the clients. This does nothing important except triggering a sound effect on the clients as well as some chef animations.

Throwing Details

Here’s the function that performs the throwing: ServerThrowableItem.HandleThrow:

public void HandleThrow(IThrower _thrower, Vector2 _directionXZ)
{
    _thrower.ThrowItem(base.gameObject, _directionXZ);
    this.m_thrower = _thrower;
    this.m_previousThrower = _thrower;
    if (this.m_attachment == null)
    {
        this.m_attachment = base.gameObject.RequestInterface<IAttachment>();
    }
    if (this.m_attachment != null)
    {
        this.m_attachment.RegisterAttachChangedCallback(this.m_OnAttachChanged);
    }
    if (this.m_pickupReferral != null)
    {
        this.m_pickupReferral.SetHandlePickupReferree(this);
    }
    this.m_flying = true;
    this.SendStateMessage(this.m_flying, ((MonoBehaviour)_thrower).gameObject);
    this.ResumeAndClearThrowStartCollisions();
    this.m_ignoredCollidersCount = Physics.OverlapBoxNonAlloc(this.m_Transform.position, this.m_Collider.bounds.extents, this.m_ThrowStartColliders, this.m_Transform.rotation, ServerThrowableItem.ms_AttachmentsLayer);
    for (int i = 0; i < this.m_ignoredCollidersCount; i++)
    {
        Physics.IgnoreCollision(this.m_Collider, this.m_ThrowStartColliders[i], true);
    }
}

The first call, ThrowItem, is implemented by ServerAttachmentThrower:

public void ThrowItem(GameObject _object, Vector2 _directionXZ)
{
    IAttachment attachment = _object.RequestInterface<IAttachment>();
    if (attachment != null)
    {
        Vector3 velocity = this.CalculateThrowVelocity(_directionXZ);
        attachment.AccessMotion().SetVelocity(velocity);
        ICatchable catchable = _object.RequestInterface<ICatchable>();
        if (catchable != null)
        {
            this.AlertPotentialCatchers(catchable, attachment.AccessGameObject().transform.position, _directionXZ);
        }
    }
    this.m_throwCallback(_object);
}

It sets a velocity on the attachment (which basically forwards the velocity to the Rigidbody container), and then gets the ICatchable side of the throwable and calls AlertPotentialCatchers. The final m_throwCallback is always a no-op (nobody registers any logic on it).

private void AlertPotentialCatchers(ICatchable _object, Vector3 _position, Vector2 _directionXZ)
{
    Vector3 direction = VectorUtils.FromXZ(_directionXZ, 0f);
    int num = Physics.SphereCastNonAlloc(_position, 1f, direction, ServerAttachmentThrower.ms_raycastHits, 10f, ServerAttachmentThrower.m_playersLayerMask);
    for (int i = 0; i < num; i++)
    {
        RaycastHit raycastHit = ServerAttachmentThrower.ms_raycastHits[i];
        GameObject gameObject = raycastHit.collider.gameObject;
        if (!(gameObject == base.gameObject))
        {
            IHandleCatch handleCatch = gameObject.RequestInterface<IHandleCatch>();
            if (handleCatch != null)
            {
                handleCatch.AlertToThrownItem(_object, this, _directionXZ);
            }
        }
    }
}

This queries the physics system for anything that intersects a radius-1 ray in the thrown direction. For any such objects that isn’t the thrower itself, and implements IHandleCatch, it calls AlertToThrownItem.

Of the five implementations of IHandleCatch, only one has non-empty logic: ServerAttachmentCatcher.AlertToThrownItem, i.e. another potentially catching chef:

public void AlertToThrownItem(ICatchable _thrown, IThrower _thrower, Vector2 _directionXZ)
{
    if (this.m_controls != null && this.m_controls.GetDirectlyUnderPlayerControl())
    {
        return;
    }
    if (this.m_trackedThrowable != null)
    {
        IThrowable throwable = this.m_trackedThrowable.RequireInterface<IThrowable>();
        if (throwable.IsFlying())
        {
            return;
        }
    }
    if (this.m_carrier.InspectCarriedItem() == null && this.m_controls.GetCurrentlyInteracting() == null)
    {
        this.m_trackedThrowable = _thrown.AccessGameObject();
        this.SendTrackingData(this.m_trackedThrowable);
    }
}

If the potential catching chef is under a player’s control (e.g. not in a cannon, not piloting a raft, not being the non-active chef of single player), then there’s nothing to do.

If the previously tracked throwable is still flying, there’s also nothing to do.

Otherwise, if the chef is not currently carrying something, and it’s not currently doing something (e.g. chopping), start tracking this throwable and sends the same to the client side that also sets its m_trackedThrowable. This is then used in Update_Rotation to automatically face the thrown object. In other words, AlertPotentialCatchers is only for the purpose of non-active player rotation in single player.

Let’s come back to HandleThrow. The next thing that happens is setting m_thrower and m_previousThrower:

  • m_thrower is used for three purposes:
    • It is used to prevent in-flight collision with the thrower itself, in ServerThrowableItem.OnCollisionEnter.
    • It is used in ServerAttachmentThrower.CanHandleCatch to prevent catching an object thrown by the same chef.
    • It is used in ServerHeatedStation.HandleCatch and ServerIngredientCatcher.HandleCatch for achievement tracking.
  • m_previousThrower is used for ServerRubbishBin for achievement tracking only.

Next, it lazily fetches IAttachment for the item itself (only implemented by ServerPhysicalAttachment), and then registers a callback to call OnAttachChanged which basically forcefully ends the flight if someone else claims attachment.

Next, it sets the pickup referree of the item to itself, whose CanHandlePickup function returns whether the object is flying. (I doubt this would become important without some bad network latency, such as someone picking up an object and before the message arrives, someone else already threw the object).

Next it sets m_flying. This field is used in a few places:

  • It prevents pickup in its CanHandlePickup implementation.
  • OnCollisionEnter does nothing if the object is not flying.
  • It is used to update flight time to detect landing.
  • It is used by ServerAttachmentCatcher’s throwable tracking to detect if the object is still flying.
  • It is used by ServerAttachStation.CanHandleCatch to only catch an object if it’s NOT flying (TODO: this is cryptic).
  • It is used by ServerCatchableItem.AllowCatch to return false if the object is not flying or has only been flying for a short time.
  • It is added to the object’s ServerLimitedQuantityItem as an invincibility condition if the object is flying.

Next, it sends a message that the object is flying. On the client side (ClientThrowableItem), this triggers StartFlight:

private void StartFlight(IClientThrower _thrower)
{
    this.m_isFlying = true;
    this.m_thrower = _thrower;
    if (this.m_pickupReferral != null)
    {
        this.m_pickupReferral.SetHandlePickupReferree(this);
    }
    if (this.m_throwableItem.m_throwParticle != null)
    {
        Transform parent = NetworkUtils.FindVisualRoot(base.gameObject);
        GameObject gameObject = this.m_throwableItem.m_throwParticle.InstantiateOnParent(parent, true);
        this.m_pfx = gameObject.GetComponent<ParticleSystem>();
    }
}

m_isFlying here is used for the similar handle pickup referree’s CanHandlePickup, as well as ClientPlayerControlsImpl_Default.OnThrowableCollision, which applies knockback if an object is thrown onto a chef. Let’s not go into that mess here.

After that is a similar handle pickup referree, and particle effects.

Going back to server’s HandleThrow. Next is ResumeAndClearThrowStartCollisions():

private void ResumeAndClearThrowStartCollisions()
{
    for (int i = 0; i < this.m_ignoredCollidersCount; i++)
    {
        Collider collider = this.m_ThrowStartColliders[i];
        if (collider != null && collider.gameObject != null)
        {
            Physics.IgnoreCollision(this.m_Collider, collider, false);
            this.m_ThrowStartColliders[i] = null;
        }
    }
    this.m_ignoredCollidersCount = 0;
}

this is to clear the previously ignored collisions, if any somehow remains (because the same thing is done in EndFlight).

Finally, we check for any initially colliding items (within the “Attachments” layer) with the thrown item, and ignore collisions for all of them. This allows us to throw an object over another object (such as if there’s a strawberry on the counter right in front of us we can still throw another strawberry over it).

During Flight

ServerThrowableItem.UpdateSynchronising does this every frame:

public override void UpdateSynchronising()
{
    float deltaTime = TimeManager.GetDeltaTime(base.gameObject);
    if (this.IsFlying())
    {
        this.m_flightTimer += deltaTime;
        if (this.m_flightTimer >= 0.1f)
        {
            IAttachment component = base.GetComponent<IAttachment>();
            if (component.AccessMotion().GetVelocity().sqrMagnitude < 0.0225f)
            {
                this.EndFlight();
            }
            if (this.m_flightTimer >= 5f)
            {
                this.EndFlight();
            }
            this.ResumeAndClearThrowStartCollisions();
        }
    }
    else
    {
        this.m_throwerTimer -= deltaTime;
        if (this.m_throwerTimer <= 0f)
        {
            this.m_previousThrower = null;
        }
    }
}

This essentially says that:

  • End the flight if we have been flying for more than 5 seconds, or for at least 0.1 second and the velocity is below 0.15.
  • If we’ve been flying for at least 0.1 second, regardless of these conditions, call ResumeAndClearThrowStartCollisions(), i.e. stop ignoring the initial collisions.
    • According to the game data, the initial throwing velocity is 18. In 0.1 second the object should have travelled for 1.8, which is longer than the diagonal of a grid cell (1.2 * sqrt(2)). So definitely we would have cleared any obstacles in this time.
  • If not flying, then the m_previousThrower field times out after a timeout. This is unimportant; we’ve seen earlier that this field is only used for tracking rubbish bin achievement.

In addition, there’s the ServerThrowableItem.OnCollisionEnter, which is a Unity lifecycle event when the object collides with something:

private void OnCollisionEnter(Collision _collision)
{
    if (!this.IsFlying())
    {
        return;
    }
    IThrower thrower = _collision.gameObject.RequestInterface<IThrower>();
    if (thrower != null && thrower == this.m_thrower)
    {
        return;
    }
    Vector3 normal = _collision.contacts[0].normal;
    float num = Vector3.Angle(normal, Vector3.up);
    if (num <= 45f)
    {
        this.EndFlight();
        this.m_landedCallback(_collision.gameObject);
    }
}
  • If it’s not flying anymore, do nothing.
  • If the collision is with the original thrower, do nothing.
  • If the collision contact normal is less than 45 degrees with the up direction, end flight and trigger landing (i.e. the thing is more similar to a ground, not a wall).
    • the m_landedCallback is always no-op. The only registrant of this callback is ServerSplattable (which is some logic that destroys the throwable if someone throws it in a hazard, like fire); however, ServerSplattable is not attached to Splattable objects (it’s missing in MultiplayerController), so it’s never instantiated.

Landing or Ending Flight

Flight is ended by any of the following:

  • Colliding with an eligible object at an eligible angle in ServerThrowableItem.OnCollisionEnter.
  • Reaching a low speed or exceeding flight time in ServerThrowableItem.UpdateSynchronising.
  • Being attached to a parent, in ServerThrowableItem.OnAttachChanged.

EndFlight() is called in any of these cases, which effectively undoes all the steps of HandleThrow(), and additionally the m_previousThrower and m_throwerTimer is set. The former is used for achievement tracking only, and the latter is just a timeout to set the former to null.

public void EndFlight()
{
    if (this.m_pickupReferral != null && this.m_pickupReferral.GetHandlePickupReferree() == this)
    {
        this.m_pickupReferral.SetHandlePickupReferree(null);
    }
    if (this.m_attachment != null)
    {
        this.m_attachment.UnregisterAttachChangedCallback(this.m_OnAttachChanged);
    }
    this.m_previousThrower = this.m_thrower;
    this.m_throwerTimer = this.m_throwableItem.m_throwerTimeout;
    this.m_thrower = null;
    this.m_flightTimer = 0f;
    this.m_flying = false;
    this.SendStateMessage(this.m_flying, null);
    this.ResumeAndClearThrowStartCollisions();
}

Catching

Catching has three aspects: the catchable item, the catcher, and the catching processor.

Catchable item

On the catchable item side, there’s a single interface ICatchable whose only implementation is ServerCatchableItem. (The counterpart, ClientCatchableItem, is an empty class.)

It is a pretty simple class that contains only one important function (comments inlined):

public bool AllowCatch(IHandleCatch _catcher, Vector2 _directionXZ)
{
    if (this.m_throwable == null)
    {
        // This is just defensive programming in case the object does not have
        // a IThrowable component.
        return false;
    }
    GameObject gameObject = (_catcher as MonoBehaviour).gameObject;
    if (_catcher != null && gameObject == null)
    {
        // This looks like a very weird condition but Unity Objects have an
        // internal reference that may be null, and the operator == is
        // overloaded to compare equal to null if the internal reference is null.
        // So it's possible for the _catcher (who is an ICatchable, not a
        // UnityEngine.Object) to not be null but its gameObject's operator==
        // to return null. This means the object is already destroyed.
        //
        // Still, this is bad programming because _catcher can't be null anyway
        // or it would trigger an null reference exception above.
        return false;
    }
    if (!(gameObject.RequestComponent<ServerAttachStation>() != null) && !(gameObject.RequestComponent<ServerIngredientContainer>() != null))
    {
        if (!this.m_throwable.IsFlying() || (this.m_throwable.IsFlying() && this.m_throwable.GetFlightTime() < 0.1f))
        {
            return false;
        }
    }
    // The item is catchable (from the item's perspective) if:
    //  - The catcher is a ServerAttachStation; or
    //  - The catcher is a ServerIngredientContainer; or
    //  - The item has been flying for more than 0.1f and it's still flying.
    return true;
}

Catcher

On the catcher side the interface is IHandleCatch. This generally queries AllowCatch on the catchable item first, so the catcher can be the sole decider.

There are five implementations of IHandleCatch:

  • ServerAttachmentCatcher: chefs. Note that teleportals don’t seem to use this component.
  • ServerAttachStation: countertops and anything that act like countertops, like chopping boards.
  • ServerHeatedStation: these are the heating stations in campfire levels where chopped firewood can be thrown in. The component is also used in Surf n’ Turf barbeque stations and even coal furnaces but those don’t catch anything.
  • ServerIngredientCatcher: containers that can catch ingredients, e.g. pans, mixers, pots.
  • ServerPlayerAttachmentCarrier.BlockCatching: this is a special implementation used to block catching when an object is being held by a chef.

Let’s go through these one by one:

ServerAttachmentCatcher:

public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    if (!_object.AllowCatch(this, _directionXZ))
    {
        return false;
    }
    GameObject gameObject = _object.AccessGameObject();
    if (this.m_carrier.InspectCarriedItem() != null)
    {
        // If chef is already holding something, don't catch.
        return false;
    }
    IThrower thrower = base.gameObject.RequestInterface<IThrower>();
    if (thrower != null)
    {
        IThrowable throwable = gameObject.RequireInterface<IThrowable>();
        if (throwable.GetThrower() == thrower)
        {
            // The chef throwing the object can't catch it (e.g. smashing
            // against a wall.)
            return false;
        }
    }
    // In game data, this is 1.8. Catching will not happen if the item is
    // further away from the chef than 1.8 (1.5 * grid size).
    float catchDistance = this.m_catcher.m_catchDistance;
    if ((base.transform.position - gameObject.transform.position).sqrMagnitude > catchDistance * catchDistance)
    {
        return false;
    }
    // Finally, catching can only happen if the chef is facing less than 120 degrees
    // from the direction the item is flying (horizontal directions only).
    //
    // Note that catching at 120 degrees may seem weird but can happen if someone throws
    // from the side but slightly behind you, towards a spot in front of you. If the
    // thrown object would hit you first, it would knock you to the side instead of
    // triggering a catch.
    IAttachment attachment = gameObject.RequireInterface<IAttachment>();
    Vector2 from = attachment.AccessMotion().GetVelocity().XZ();
    Vector2 a = base.transform.forward.XZ();
    float num = Vector2.Angle(from, -a);
    return num <= this.m_catcher.m_catchAngleMax;
}

public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    GameObject @object = _object.AccessGameObject();
    // Cause the chef to carry the item.
    this.m_carrier.CarryItem(@object);
    // Stop tracking the item (for chef rotation; see the throwing section).
    this.m_trackedThrowable = null;
    this.SendTrackingData(this.m_trackedThrowable);
}

ServerAttachStation:

public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    if (!_object.AllowCatch(this, _directionXZ))
    {
        return false;
    }
    if (this.HasItem())
    {
        // If there's something on top of the AttachStation that can also catch, forward the call to that.
        IHandleCatch handleCatch = this.m_item.AccessGameObject().RequestInterface<IHandleCatch>();
        if (handleCatch != null)
        {
            return handleCatch.CanHandleCatch(_object, _directionXZ);
        }
    }
    IThrowable throwable = _object.AccessGameObject().RequestInterface<IThrowable>();
    // Conditions for catching:
    //  - Object is throwable (should always be true for catchable objects).
    //  - The object is NOT flying.
    //  - The station can catch things (this is a setting that the game devs can toggle).
    //  - The object would normally be attachable to the station (this is a complicated query, but
    //    can only be true if the station is empty to begin with. It handles things like, a frier
    //    cannot be attached to a normal heating station).
    return throwable != null && !throwable.IsFlying() && this.m_station.m_canCatch && this.CanAttachToSelf(_object.AccessGameObject(), new PlacementContext(PlacementContext.Source.Game));
}

public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    if (this.HasItem())
    {
        // Forward catching to the catcher on the station if there is one.
        IHandleCatch handleCatch = this.m_item.AccessGameObject().RequestInterface<IHandleCatch>();
        if (handleCatch != null)
        {
            handleCatch.HandleCatch(_object, _directionXZ);
        }
    }
    else
    {
        // Otherwise just simply attach the object.
        this.AttachObject(_object.AccessGameObject(), default(PlacementContext));
    }
}

ServerHeatedStation:

public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    if (!_object.AllowCatch(this, _directionXZ))
    {
        return false;
    }
    // Conditions for catching:
    //  - If this is a heat transfer item (i.e. firewood); OR
    //  - If this is normally catchable by the associated attach station.
    IHeatTransferBehaviour heatTransferBehaviour = _object.AccessGameObject().RequestInterface<IHeatTransferBehaviour>();
    return (heatTransferBehaviour != null && heatTransferBehaviour.CanTransferToContainer(this)) || (this.m_attachStation != null && this.m_attachStation.CanHandleCatch(_object, _directionXZ));
}

// This one is pretty straight forward.
public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    GameObject gameObject = _object.AccessGameObject();
    IHeatTransferBehaviour heatTransferBehaviour = gameObject.RequestInterface<IHeatTransferBehaviour>();
    if (heatTransferBehaviour != null)
    {
        IThrowable throwable = gameObject.RequireInterface<IThrowable>();
        IThrower thrower = throwable.GetThrower();
        if (thrower != null)
        {
            this.BurnAchievement((thrower as MonoBehaviour).gameObject, gameObject);
        }
        ICarrier carrier = new ServerHeatedStation.HolderAdapter(gameObject);
        heatTransferBehaviour.TransferToContainer(carrier, this);
        this.SynchroniseItemAdded();
        return;
    }
    if (this.m_attachStation != null && this.m_attachStation.CanHandleCatch(_object, _directionXZ))
    {
        this.m_attachStation.HandleCatch(_object, _directionXZ);
    }
}

ServerIngredientCatcher:

public bool CanHandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    if (!_object.AllowCatch(this, _directionXZ))
    {
        return false;
    }
    // If m_requireAttached is configured for this object, only catch if
    // the container is on an attach station. (For example, mixer can't catch
    // food if it's on the floor).
    if (this.m_catcher.m_requireAttached)
    {
        if (this.m_attachment == null)
        {
            return false;
        }
        if (!this.m_attachment.IsAttached())
        {
            return false;
        }
    }
    // This is a complicated query that determines whether the container can
    // accept an ingredient of this type. For example you cannot throw onion into
    // a pan.
    if (!this.m_allowCatchingCallback(_object.AccessGameObject()))
    {
        return false;
    }
    // A further complicated query to make sure the item can be transferred to the container.
    IOrderDefinition orderDefinition = _object.AccessGameObject().RequestInterface<IOrderDefinition>();
    if (orderDefinition != null && orderDefinition.GetOrderComposition().Simpilfy() != AssembledDefinitionNode.NullNode)
    {
        IContainerTransferBehaviour containerTransferBehaviour = _object.AccessGameObject().RequestInterface<IContainerTransferBehaviour>();
        ServerIngredientContainer component = base.gameObject.GetComponent<ServerIngredientContainer>();
        if (containerTransferBehaviour != null && component != null && containerTransferBehaviour.CanTransferToContainer(component))
        {
            return component.CanAddIngredient(orderDefinition.GetOrderComposition());
        }
    }
    return false;
}

public void HandleCatch(ICatchable _object, Vector2 _directionXZ)
{
    GameObject gameObject = _object.AccessGameObject();
    IThrowable throwable = gameObject.RequireInterface<IThrowable>();
    IThrower thrower = throwable.GetThrower();
    if (thrower != null)
    {
        GameObject gameObject2 = (thrower as MonoBehaviour).gameObject;
        ServerMessenger.Achievement(gameObject2, 12, 1);
    }
    IOrderDefinition orderDefinition = gameObject.RequireInterface<IOrderDefinition>();
    if (orderDefinition.GetOrderComposition().Simpilfy() != AssembledDefinitionNode.NullNode)
    {
        IContainerTransferBehaviour containerTransferBehaviour = gameObject.RequireInterface<IContainerTransferBehaviour>();
        ServerIngredientContainer component = base.gameObject.GetComponent<ServerIngredientContainer>();
        if (containerTransferBehaviour != null && component != null && containerTransferBehaviour.CanTransferToContainer(component))
        {
            // Have the item decide how to transfer to the container.
            containerTransferBehaviour.TransferToContainer(null, component, true);
            // Send clients a message about the change of contents.
            component.InformOfInternalChange();
            // Destroy the thrown item.
            gameObject.SetActive(false);
            NetworkUtils.DestroyObject(gameObject);
        }
    }
}

Finally, ServerPlayerAttachmentCarrier.BlockCatching.CanHandleCatch just returns false.

Catching processor

Catchable items and catchers both do not actually trigger any catching. They only decide whether something can be caught and what to do when catching. There appears to be two ways that the catching action is actually triggered:

  • ServerAttachmentCatchingProxy. It is automatically added to objects with a HeatedStation, AttachStation, or IngredientCatcher component (see MultiplayerController.AsyncScanEntities).
    • On every frame, this component will check all its collisions that are IAttachment, and calls AttemptToCatch.
      private void AttemptToCatch(GameObject _object)
      {
      	ICatchable catchable = _object.RequestInterface<ICatchable>();
      	if (catchable != null)
      	{
      		IHandleCatch controllingCatchingHandler = this.GetControllingCatchingHandler();
      		if (controllingCatchingHandler != null)
      		{
      			Vector2 directionXZ = (_object.transform.position - base.transform.position).SafeNormalised(base.transform.forward).XZ();
      			if (controllingCatchingHandler.CanHandleCatch(catchable, directionXZ))
      			{
      				controllingCatchingHandler.HandleCatch(catchable, directionXZ);
      			}
      		}
      	}
      	else
      	{
      		Vector2 param = (_object.transform.position - base.transform.position).SafeNormalised(base.transform.forward).XZ();
      		if (this.m_uncatchableItemCallback != null)
      		{
                  // This is only registered by one party: ServerAttachStation.HandleUncatchableItem. It's used to
                  // "catch" stuff like plates that happen to collide with the station.
      			this.m_uncatchableItemCallback(_object, param);
      		}
      	}
      }
      
      public IHandleCatch GetControllingCatchingHandler()
      {
          // If there's a referree then return it.
      	if (this.m_iHandleCatchReferree != null)
      	{
      		return this.m_iHandleCatchReferree;
      	}
          // Otherwise return the IHandleCatch with the highest priority, if there are multiple
          // of these on the same GameObject.
          // I think this is only needed between ServerAttachStation and ServerHeatedStation,
          // where the latter returns a int.MaxValue.
      	IHandleCatch handleCatch = null;
      	for (int i = 0; i < this.m_iHandleCatches.Length; i++)
      	{
      		IHandleCatch handleCatch2 = this.m_iHandleCatches[i];
      		if ((!(handleCatch2 is MonoBehaviour) || (handleCatch2 as MonoBehaviour).enabled) && (handleCatch == null || handleCatch2.GetCatchingPriority() > handleCatch.GetCatchingPriority()))
      		{
      			handleCatch = handleCatch2;
      		}
      	}
      	return handleCatch;
      }
      
    • The m_iHandleCatchReferree is set in two cases:
      • ServerAttachStation sets the referree on the item to the station, if the item has a ServerAttachmentCatchingProxy. In other words, if we place a mixer bowl on a counter, and the mixer bowl detects a collision, we force the handling to be done by the station and not the mixer bowl.
      • When a chef is carrying a ServerAttachmentCatchingProxy, the referree is set to ServerPlayerAttachmentCarrier.BlockCatching, which prevents the carried item from catching anything.
      • Both of these cases should only be relevant when the attached item is a ingredient catcher. I can’t think of any other cases.
  • ServerPlayerControlsImpl_Default implements the chef catching behavior:
    private void Update_Catch(float _deltaTime)
    {
        if (this.m_controls.m_bRespawning)
        {
            return;
        }
        // See below.
        ICatchable catchable = this.m_controls.ScanForCatch();
        if (catchable != null)
        {
            MonoBehaviour monoBehaviour = (MonoBehaviour)catchable;
            if (monoBehaviour == null || monoBehaviour.gameObject == null)
            {
                return;
            }
            Vector2 normalized = this.m_controlObject.transform.forward.XZ().normalized;
            if (this.m_iCatcher.CanHandleCatch(catchable, normalized))
            {
                this.m_iCatcher.HandleCatch(catchable, normalized);
                EntitySerialisationEntry entry = EntitySerialisationRegistry.GetEntry(monoBehaviour.gameObject);
                // This only triggers some audio and pfx on the client side. The actual attachment behavior
                // is already sent over in the CarryItem call inside HandleCatch.
                this.SendServerEvent(new InputEventMessage(InputEventMessage.InputEventType.Catch)
                {
                    entityId = entry.m_Header.m_uEntityID
                });
            }
        }
    }
    
    public ICatchable ScanForCatch()
    {
        // GetCollidersInArc is a complex function but when the last argument is false, it scans for all objects whose
        // closest point lies in an arc in front of the chef. It returns position + 0.5 * forward * detectionRadius, so
        // 0.5 in front of the chef.
        // The 2f here is the distance, and 1.57f here is pi/2 so anywhere in front of the chef.
    	  Vector3 collidersInArc = InteractWithItemHelper.GetCollidersInArc(2f, 1.5707964f, this.m_Transform, this.m_colliders, this.m_catchMask, false);
        // This then gets whatever ServerCatchableItem we can find from these colliders, prioritizing the closest one to
        // that chef + 0.5 point.
    	  return InteractWithItemHelper.ScanForComponent<ServerCatchableItem>(this.m_colliders, collidersInArc, this.m_CatchCondition);
    }
    
    In other words, the chef scans for anything in front of it within radius 2 and initiates catching. Later we’ve seen above that there’s also a 1.8 minimum distance between the center of the chef and the center of the object, and a max 120 angle between chef forward and object velocity. The catching is done on the server side unlike most of the other chef controls.

Warping

States we need to warp:

  • m_flying (if changing, perform flight start/end actions)
  • m_flightTimer
  • m_thrower
  • m_ThrowStartColliders (if changing, perform physics collision changes)

Things to do when warping from non-flight to flight:

  • Set pickup referral, server & client
  • Register attach changed callback

3.8 - Plate Stacks

Multiple plates (clean or dirty) in one stack

Relevant classes:

  • ServerStack
  • ServerPlateStackBase
    • ServerCleanPlateStack
    • ServerDirtyPlateStack

and their client counterparts

Stack

ServerStack does not synchronize anything using messages; it only provides some functions to add or remove elements from the stack:

// ServerStack::AddToStack
public void AddToStack(GameObject _item)
{
    if (this.m_stackItems.Contains(_item))
    {
        return;
    }
    
    // IAttachment's only implementation is ServerPhysicalAttachment.
    // Clean plates are PhysicalAttachment's. Dirty plates aren't.
    // PhysicalAttachment objects have more things to do when attaching/detaching.
    IAttachment attachment = _item.RequestInterface<IAttachment>();
    if (attachment != null)
    {
        attachment.Attach(this.m_stack);
    }
    else
    {
        _item.transform.SetParent(this.m_stack.GetAttachPoint(_item));
    }
    this.m_stackItems.Add(_item);
    // Properly positions and aligns each item in the stack.
    this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
    // Properly calculates the height of the colliders for the stack.
    this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
}

// ServerStack::RemoveFromStack - does the reverse, and recalculates transforms and collider.
public GameObject RemoveFromStack()
{
    if (this.m_stackItems.Count > 0)
    {
        GameObject gameObject = this.m_stackItems[this.m_stackItems.Count - 1];
        this.m_stackItems.Remove(gameObject);
        IAttachment attachment = gameObject.RequestInterface<IAttachment>();
        if (attachment != null)
        {
            attachment.Detach();
        }
        else
        {
            gameObject.transform.SetParent(null);
        }
        this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
        this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
        return gameObject;
    }
    return null;
}

On the client side, the things to do are very similar:

// ServerStack::AddToStack
public void AddToStack(GameObject _item)
{
    if (this.m_stackItems.Contains(_item))
    {
        return;
    }
    if (_item.RequestInterface<IClientAttachment>() == null)
    {
        _item.transform.SetParent(this.m_stack.GetAttachPoint(_item));
    }
    this.m_stackItems.Add(_item);
    this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
    this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
}

// ServerStack::RemoveFromStack
public GameObject RemoveFromStack()
{
    if (this.m_stackItems.Count > 0)
    {
        GameObject gameObject = this.m_stackItems[this.m_stackItems.Count - 1];
        this.m_stackItems.Remove(gameObject);
        if (gameObject.RequestInterface<IClientAttachment>() == null)
        {
            gameObject.transform.SetParent(null);
        }
        this.m_stack.RefreshStackTransforms(ref this.m_stackItems);
        this.m_stack.RefreshStackCollider(ref this.m_boxCollider, this.m_stackItems.Count);
        return gameObject;
    }
    return null;
}

Note that AddToStack is NOT called by some server message propagated from ServerStack. We’ll later cover what calls ServerStack/ClientStack.

The functions on the client side fills in the non-synchronizing case where the object is not a PhysicalAttachment (which would automatically synchronize the attachment on the client side), as well as ensuring that the transforms/collider are refreshed.

PlateStackBase

Now we turn to the other component: ServerPlateStackBase, which is a separate component not directly related to Stack.

In the StartSynchronising function we add a spawnable prefab for the element in the stack:

// ServerPlateStackBase::StartSynchronising
public override void StartSynchronising(Component synchronisedObject)
{
    base.StartSynchronising(synchronisedObject);
    this.m_plateStack = (PlateStackBase)synchronisedObject;
    this.m_stack = base.gameObject.RequireComponent<ServerStack>();
    NetworkUtils.RegisterSpawnablePrefab(base.gameObject, this.m_plateStack.m_platePrefab);
}

Then the add/remove functions (AddToStack is triggered externally):

// ServerPlateStackBase::AddToStack
public virtual void AddToStack()
{
    // Adding to stack always involves spawning a prefab for the inner element.
    GameObject item = NetworkUtils.ServerSpawnPrefab(base.gameObject, this.m_plateStack.m_platePrefab);
    this.m_stack.AddToStack(item);
}

// ServerPlateStackBase::RemoveFromStack (this is virtual, overridden by ServerCleanPlateStack)
protected virtual GameObject RemoveFromStack()
{
    GameObject result = this.m_stack.RemoveFromStack();
    // This is an empty event message; the message is only used for removal from the stack.
    this.SendServerEvent(this.m_data);
    return result;
}

On the client side, adding to a plate is triggered by a spawning callback:

// ClientPlateStackBase::StartSynchronising
public override void StartSynchronising(Component synchronisedObject)
{
    base.StartSynchronising(synchronisedObject);
    this.m_plateStack = (PlateStackBase)synchronisedObject;
    this.m_stack = base.gameObject.RequireComponent<ClientStack>();
    // This callback is called when a prefab is spawned; the same does NOT happen on the server side.
    NetworkUtils.RegisterSpawnablePrefab(base.gameObject, this.m_plateStack.m_platePrefab, new VoidGeneric<GameObject>(this.PlateSpawned));
}
// ClientPlateStackBase::ApplyServerEvent
public override void ApplyServerEvent(Serialisable serialisable)
{
    this.PlateRemoved();
}

// ClientPlateStackBase::PlateSpawned (overridden later)
protected virtual void PlateSpawned(GameObject _object)
{
    this.m_plateAdded(_object);  // cosmetics
}

// ClientPlateStackBase::PlateRemoved (overridden later)
protected virtual void PlateRemoved()
{
    this.m_plateRemoved(null);  // cosmetics
}

DirtyPlateStack

ServerDirtyPlateStack registers two additional spawnable prefabs: a clean plate stack and a clean single plate, because washing with the water jet can turn the stack directly into a clean stack or a single plate.

Placing a dirty stack on top of another dirty stack triggers HandlePlacement:

// ServerDirtyPlateStack::HandlePlacement
public void HandlePlacement(ICarrier _carrier, Vector2 _directionXZ, PlacementContext _context)
{
    GameObject obj = _carrier.InspectCarriedItem();
    ServerStack serverStack = obj.RequireComponent<ServerStack>();
    for (int i = 0; i < serverStack.GetSize(); i++)
    {
        this.AddToStack();
    }
    _carrier.DestroyCarriedItem();
}

this just adds N new plates to the stack and destroys the original.

AddToStack is overridden to update washable properties. There’s also OnWashingFinished which I’ll skip here because it’s complicated and mostly deals with different cases of respawning the clean plate stack or single clean plate in the same place (chef’s hands or a counter).

// ServerDirtyPlateStack::AddToStack
public override void AddToStack()
{
    base.AddToStack();
    if (this.m_washable != null)
    {
        Washable washable = base.gameObject.RequireComponent<Washable>();
        this.m_washable.SetDuration((float)(washable.m_duration * this.m_stack.GetSize()));
    }
}

The other place where ServerDirtyPlateStack::AddToStack is called is in ServerPlateReturnStation, when a dirty plate is supposed to be returned after a plate was delivered for 7 seconds. (Note that the same logic is also used to respawn clean plate stacks.)

// ServerPlateReturnStation::ReturnPlate
public void ReturnPlate()
{
    if (this.m_stack == null)
    {
        this.CreateStack();
    }
    this.m_stack.AddToStack();
}

There isn’t a lot of useful things happening on ClientDirtyPlateStack.

CleanPlateStack

// ServerCleanPlateStack::AddToStack
public override void AddToStack()
{
    bool flag = false;
    if (this.m_stack.InspectTopOfStack() != null)
    {
        IIngredientContents ingredientContents = this.m_stack.InspectTopOfStack().RequestInterface<IIngredientContents>();
        flag = (ingredientContents != null && ingredientContents.HasContents());
    }
    if (flag)
    {
        // If the top plate has stuff on it, remove it first, add a new plate, then add it back.
        GameObject item = this.m_stack.RemoveFromStack();
        this.AddToBaseStack();
        this.m_stack.AddToStack(item);
    }
    else
    {
        // Otherwise just add one.
        this.AddToBaseStack();
    }
    // For the plate that was just added, set the pickup and placement referral to the stack.
    // (Isn't this wrong if the plate we added was the second item from the top??)
    // (But then maybe it doesn't matter - when would the plate actually be queried first?)
    GameObject obj = this.m_stack.InspectTopOfStack();
    ServerHandlePickupReferral serverHandlePickupReferral = obj.RequestComponent<ServerHandlePickupReferral>();
    if (serverHandlePickupReferral != null)
    {
        serverHandlePickupReferral.SetHandlePickupReferree(this);
    }
    ServerHandlePlacementReferral serverHandlePlacementReferral = obj.RequestComponent<ServerHandlePlacementReferral>();
    if (serverHandlePlacementReferral != null)
    {
        serverHandlePlacementReferral.SetHandlePlacementReferree(this);
    }
}

// ServerCleanPlateStack::AddToBaseStack
private void AddToBaseStack()
{
    // The base class's function wich spawns the plate and adds it to the stack.
    base.AddToStack();
    GameObject obj = this.m_stack.InspectTopOfStack();
    // This is for if the spawned plate would be destroyed, respawn it where the stack would respawn.
    ServerUtensilRespawnBehaviour serverUtensilRespawnBehaviour = base.gameObject.RequestComponent<ServerUtensilRespawnBehaviour>();
    ServerUtensilRespawnBehaviour serverUtensilRespawnBehaviour2 = obj.RequestComponent<ServerUtensilRespawnBehaviour>();
    if (serverUtensilRespawnBehaviour != null && serverUtensilRespawnBehaviour2 != null)
    {
        serverUtensilRespawnBehaviour2.SetIdealRespawnLocation(serverUtensilRespawnBehaviour.GetIdealRespawnLocation());
    }
}

// ServerCleanPlateStack::RemoveFromStack
protected override GameObject RemoveFromStack()
{
    GameObject gameObject = base.RemoveFromStack();
    // Reverses the referree settings.
    ServerHandlePickupReferral serverHandlePickupReferral = gameObject.RequestComponent<ServerHandlePickupReferral>();
    if (serverHandlePickupReferral != null && serverHandlePickupReferral.GetHandlePickupReferree() == this)
    {
        serverHandlePickupReferral.SetHandlePickupReferree(null);
    }
    ServerHandlePlacementReferral serverHandlePlacementReferral = gameObject.RequestComponent<ServerHandlePlacementReferral>();
    if (serverHandlePlacementReferral != null && serverHandlePlacementReferral.GetHandlePlacementReferree() == this)
    {
        serverHandlePlacementReferral.SetHandlePlacementReferree(null);
    }
    return gameObject;
}

Now the client side… is a bit strange.

// ClientCleanPlateStack::PlateSpawned
protected override void PlateSpawned(GameObject _object)
{
    bool flag = false;
    if (this.m_stack.InspectTopOfStack() != null)
    {
        IIngredientContents ingredientContents = this.m_stack.InspectTopOfStack().RequestInterface<IIngredientContents>();
        flag = (ingredientContents != null && ingredientContents.HasContents());
    }
    if (flag)
    {
        GameObject item = this.m_stack.RemoveFromStack();
        this.m_stack.AddToStack(_object);
        this.m_stack.AddToStack(item);
    }
    else
    {
        this.m_stack.AddToStack(_object);
    }
    this.m_delayedSetupPlates.Add(_object);
    base.NotifyPlateAdded(_object);
}

// ClientCleanPlateStack::DelayedPlateSetup
private void DelayedPlateSetup(GameObject _plate)
{
    if (_plate != null && _plate.gameObject != null)
    {
        ClientHandlePickupReferral clientHandlePickupReferral = _plate.RequestComponent<ClientHandlePickupReferral>();
        if (clientHandlePickupReferral != null)
        {
            clientHandlePickupReferral.SetHandlePickupReferree(this);
        }
        ClientHandlePlacementReferral clientHandlePlacementReferral = _plate.RequestComponent<ClientHandlePlacementReferral>();
        if (clientHandlePlacementReferral != null)
        {
            clientHandlePlacementReferral.SetHandlePlacementReferree(this);
        }
    }
}

// ClientCleanPlateStack::PlateRemoved
protected override void PlateRemoved()
{
    GameObject gameObject = this.m_stack.RemoveFromStack();
    ClientHandlePickupReferral clientHandlePickupReferral = gameObject.RequestComponent<ClientHandlePickupReferral>();
    if (clientHandlePickupReferral != null && clientHandlePickupReferral.GetHandlePickupReferree() == this)
    {
        clientHandlePickupReferral.SetHandlePickupReferree(null);
    }
    ClientHandlePlacementReferral clientHandlePlacementReferral = gameObject.RequestComponent<ClientHandlePlacementReferral>();
    if (clientHandlePlacementReferral != null && clientHandlePlacementReferral.GetHandlePlacementReferree() == this)
    {
        clientHandlePlacementReferral.SetHandlePlacementReferree(null);
    }
    base.NotifyPlateRemoved(gameObject);
}

// ClientCleanPlateStack::UpdateSynchronising
public override void UpdateSynchronising()
{
    for (int i = this.m_delayedSetupPlates.Count - 1; i >= 0; i--)
    {
        this.DelayedPlateSetup(this.m_delayedSetupPlates[i]);
        this.m_delayedSetupPlates.RemoveAt(i);
    }
}

So… no surprises except that the setting of the referrees on the client side is delayed by 1 frame. Why??

3.9 - Cannon

Detailed implementation of the cannon

Cannon structure

  • ClientCannon - handles most animations, e.g. the coroutine for the main flight
  • ServerCannon - ?
  • IClientCannonHandler - handler that can be used to launch different kinds of objects. Currently there’s only one implementation - ClientCannonPlayerHandler.

Launch process

LaunchProjectile is the main entry point to the coroutine. This is in response to receiving a server event to launch the projectile.

private IEnumerator LaunchProjectile(GameObject _objectToLaunch, Transform _target)
{
    IClientCannonHandler handler = this.GetHandler(_objectToLaunch);
    if (handler != null)
    {
        handler.Launch(_objectToLaunch);
    }
    _objectToLaunch.transform.SetParent(null, true);
    if (this.m_onLaunchedCallback != null)
    {
        this.m_onLaunchedCallback(_objectToLaunch);
    }
    IEnumerator animation = this.m_cannon.m_animation.Run(_objectToLaunch, _target);
    while (animation.MoveNext())
    {
        yield return null;
    }
    if (handler != null)
    {
        handler.Land(_objectToLaunch);
    }
    this.m_cannon.EndCannonRoutine(_objectToLaunch);
    if (handler != null)
    {
        IEnumerator exit = handler.ExitCannonRoutine(_objectToLaunch, _target.position, _target.rotation);
        while (exit.MoveNext())
        {
            yield return null;
        }
    }
    yield break;
}
  1. Call handler.Launch. This sets up the player - disabling controls, setting kinematic, disabling collider. See ClientCannonPlayerHandler.Launch:

    public void Launch(GameObject _obj)
     {
     	if (_obj != null)
     	{
     		this.m_inCannon = false;
     		this.m_controls.enabled = false;
     		this.m_controls.Motion.SetKinematic(true);
     		Collider collider = _obj.RequireComponent<Collider>();
     		collider.enabled = false;
     	}
     }
    
  2. Detach the transform of the object being launched from the cannon.

  3. Call the m_onLaunchedCallback if exists. This callback is only registered by ClientCannonCosmeticDecisions, so it’s cosmetics only (animations, particles, audio).

  4. Start the ProjectileAnimation animation coroutine. See ProjectileAnimation::Run; this manually animates the projectile by positioning it according to a parabola.

  5. Call the handler.Land function; see ClientCannonPlayerHandler.Land:

    public void Land(GameObject _obj)
     {
     	if (_obj != null)
     	{
     		this.m_controls.enabled = true;
     		if (this.m_controls.GetComponent<PlayerIDProvider>().IsLocallyControlled())
     		{
     			this.m_controls.Motion.SetKinematic(false);
     		}
     		Collider collider = _obj.RequireComponent<Collider>();
     		collider.enabled = true;
     		this.m_controls.GetComponent<ClientPlayerControlsImpl_Default>().ApplyImpact(this.m_controls.transform.forward.XZ() * 2f, 0.2f);
     	}
     }
    
  6. Call m_cannon.EndCannonRoutine (note this is on the Cannon, not ClientCannon). This is registered by only ServerCannon to call ServerCannon.EndCannonRoutine:

    public void EndCannonRoutine(GameObject _obj)
     {
     	this.m_flying = false;
     	IServerCannonHandler handler = this.GetHandler(_obj);
     	if (handler != null)
     	{
     		handler.ExitCannonRoutine(_obj);
     	}
     }
    

    This handler is the IServerCannonHandler, which also has only one implementation: ServerCannonPlayerHandler.ExitCannonRoutine:

    public void ExitCannonRoutine(GameObject _obj)
    {
    	_obj.GetComponent<Rigidbody>().isKinematic = false;
    	GroundCast groundCast = _obj.RequestComponent<GroundCast>();
    	if (groundCast != null)
    	{
    		groundCast.ForceUpdateNow();
    	}
    	ServerWorldObjectSynchroniser serverWorldObjectSynchroniser = _obj.RequestComponent<ServerWorldObjectSynchroniser>();
    	if (serverWorldObjectSynchroniser != null)
    	{
    		serverWorldObjectSynchroniser.ResumeAllClients(false);
    	}
    }
    
  7. Start the coroutine from handler.ExitCannonRoutine; see ClientCannonPlayerHandler.ExitCannonRoutine:

    public IEnumerator ExitCannonRoutine(GameObject _player, Vector3 _exitPosition, Quaternion _exitRotation)
     {
     	this.m_inCannon = false;
     	this.m_controls.AllowSwitchingWhenDisabled = false;
     	this.m_controls = null;
     	this.m_playerIdProvider = null;
     	if (_player != null)
     	{
     		DynamicLandscapeParenting dynamicParenting = _player.RequestComponent<DynamicLandscapeParenting>();
     		if (dynamicParenting != null)
     		{
     			dynamicParenting.enabled = true;
     		}
     		yield return null;
     	}
     	if (_player != null)
     	{
     		ClientWorldObjectSynchroniser synchroniser = _player.RequireComponent<ClientWorldObjectSynchroniser>();
     		while (synchroniser != null && !synchroniser.IsReadyToResume())
     		{
     			yield return null;
     		}
     		if (synchroniser != null)
     		{
     			synchroniser.Resume();
     		}
     	}
     	yield break;
     }
    

    Even though this is a coroutine, it’s really only necessary to resume the ClientWorldObjectSynchroniser. The first yield appears unnecessary.

What is GroundCast?

  • Seems to be responsible for handling sticking to the ground. Doesn’t seem all that important to understand it; just need to call ForceUpdateNow(). Also, the client part doesn’t call this, so maybe it’s not necessary here?

What is ServerWorldObjectSynchroniser and ClientWorldObjectSynchroniser?

  • It’s for physics synchronization. When the chef is in flight it doesn’t need to be synchronized. Afterwards we must re-enable it.
  • However, physics synchronization is disabled for local session anyway, so this is probably still not important.

What is DynamicLandscapeParenting?

  • It doesn’t matter what it is… we just need to enable it.

Load process

ServerCannon’s Load doesn’t really do anything except sending a message. We begin with ClientCannon.Load.

public void Load(GameObject _obj)
{
    this.m_loadedObject = _obj;
    this.m_exitPosition = _obj.transform.position;
    this.m_exitRotation = _obj.transform.rotation;
    IClientCannonHandler handler = this.GetHandler(_obj);
    if (handler != null)
    {
        handler.Load(_obj);
    }
    _obj.transform.position = this.m_cannon.m_attachPoint.position;
    _obj.transform.rotation = this.m_cannon.m_attachPoint.rotation;
    _obj.transform.SetParent(this.m_cannon.m_attachPoint, true);
    if (this.m_onLoadedCallback != null)
    {
        this.m_onLoadedCallback(_obj);
    }
}

Pretty simple here - sets all the transforms, parents, and calls ClientCannonPlayerHandler.Load:

public void Load(GameObject _player)
{
    this.m_controls = _player.RequireComponent<PlayerControls>();
    this.m_controls.AllowSwitchingWhenDisabled = true;
    this.m_controls.ThrowIndicator.Hide();
    this.m_playerIdProvider = _player.RequireComponent<PlayerIDProvider>();
    this.m_inCannon = true;
    DynamicLandscapeParenting dynamicLandscapeParenting = _player.RequestComponent<DynamicLandscapeParenting>();
    if (dynamicLandscapeParenting != null)
    {
        dynamicLandscapeParenting.enabled = false;
    }
    ClientWorldObjectSynchroniser clientWorldObjectSynchroniser = _player.RequestComponent<ClientWorldObjectSynchroniser>();
    if (clientWorldObjectSynchroniser != null)
    {
        clientWorldObjectSynchroniser.Pause();
    }
}

It’s mostly the inverse of the end of the launch process.

Unload process

ClientCannon.Unload:

public IEnumerator Unload(GameObject _obj, Vector3 _exitPosition, Quaternion _exitRotation)
{
    if (_obj != null)
    {
        _obj.transform.position = _exitPosition;
        _obj.transform.rotation = _exitRotation;
        _obj.transform.SetParent(null, true);
    }
    this.m_cannon.EndCannonRoutine(_obj);
    IClientCannonHandler handler = this.GetHandler(_obj);
    if (handler != null)
    {
        IEnumerator exit = handler.ExitCannonRoutine(_obj, _exitPosition, _exitRotation);
        while (exit.MoveNext())
        {
            yield return null;
        }
    }
    if (this.m_onUnloadedCallback != null)
    {
        this.m_onUnloadedCallback(_obj);
    }
    yield break;
}

Basically the same as the launch process except there’s no launching.

4 - Penguin Chess Engine

Engine for a simple game written in Rust WASM with React

Penguin Chess is a game that a friend of mine invented.

\(\rightarrow\) Click here to access the game engine! \(\leftarrow\)

The rules are very simple. The game is played on a 5x5 board where each player has 4 pawns and one king. Any piece can move in any of the 8 directions but must move until there is an obstacle (either a piece or the edge of the board). It’s disallowed for a pawn to move to the center square. The game is won when a player moves their king to the center square.

Starting board

Originally, the author had the kings and pawns on the same side, but it turns out there’s a 7-move winning strategy by the first player, so as a variant, the kings are swapped. My friend wondered if this is a more interesting game now, so I made an engine to search for any obvious winning moves.

Indeed there aren’t any winning moves in sight. But one must still be very careful because it’s very easy to make a wrong move and lose quickly.

Source Code

The project’s source code is at https://github.com/hpmv/penguin-chess.