跳到主要内容

The Simulation

This is the core of CherryGrove. We read input, update the state, and render output here.

Do Some Research

Let's start by first taking a look at Minecraft Java (oversimplified):

  1. Update TPS manager
  2. Run datapack functions with #tick
  3. Per-dimension tick (overworld -> nether -> end -> custom)
    1. Update world border
    2. Update time, weather and daylight
    3. Run scheduled commands
    4. Run scheduled block updates
    5. Run scheduled fluid updates
    6. Update chunk load level
    7. For all chunks that are loaded, execute at every applicable position pseudo-randomly (oversimplified again):
      1. Run mob spawn logic
      2. Update ice and snow
      3. Execute random tick
    8. Send block updates to clients
    9. Update POI
    10. Unload chunks
    11. Execute block events
    12. For all non-passenger entities:
      1. Execute despawn logic
      2. Update entity
      3. Update its passengers
    13. Update block entities
  4. Send updates to clients
  5. Handle clients' incoming packets
  6. Send player info to clients
  7. Autosave every 6000 ticks

The most important thing we take from this is not from any of the steps, but they are sequential and single-threaded, for everything in the world. So we might need to look at Folia and some truly multi-threaded games for a multi-threading approach.

And we found out that we cannot just multithread everything unless we want Bedrock level bugs everywhere. As far as I'm concerned, that best way has been implemented and it's right in front of our eyes: Regional ticking. It's based on the fact that most of the interactions are local, so distant events are not affected by asynchrony. There is only one precaution though: Minecraft 1.21.x finally implemented true interdimension interactions like ender pearls travelling through portals, making it officially the goal for CherryGrove to support them as well. So the region in CherryGrove should be able to be multidimensional. Since the dimensions is essentially cut in many halves, there should not be much difficuly in implementing this.

But we can still learn some things from Minecraft Java:

  1. We probably should determine the chunks that are loaded first, then update them, then unload some of them.
  2. We should use a neighborhood update system like Java instead of a global update system like Bedrock. It will save a lot of performance and enable more complex in-game contraptions to be made.

After a lot of thinking, I came up with a design like this:

  1. Update global state
  2. Send start signal to all region threads
  3. In every region thread:
    1. Update chunk load levels (start loading new chunks, unloading old chunks, etc.)
    2. For all loaded chunks, execute at every applicable position with deterministic order based on pack priorities and types:
      1. Run scheduled before events
      2. Regular update blocks
      3. Regular update entities
      4. Regular update structures that is alive
      5. Run scheduled after events
      6. Write state diffs to the main registry
  4. Manage regions (load, unload, merge, split, etc.)
  5. Save to disk every several ticks
  6. Execute player input functions from InputHandler
  7. Render current state

Surprisingly it's not "read input, update state, render output" but "update state, read input, render output". This is because multiplayer fairness. If we read input at the beginning, players with low latency will be able to modify the world state one tick earlier than players with high latency. Yes we're actually caring for the future multiplayer era.

Adoptive Ticking

The question for every video game is: which reference should we align the ticking system to? Some just use turn-based system where there is no concept like time in the game, some use real-time system where the game state is updated according to real-world time, and some use a fixed-tick system where the game state is updated at a fixed rate regardless of real-world time.

CherryGrove uses a fixed-tick system just like Minecraft, but the tick rate itself is not fixed. It's meant to solve the biggest problem of fixed-tick system: time resolution. The engine uses a separate and dynamic ticking system: We first demand an argument for every pack for its simulation resolution intentions, and merge them with user settings and hardware specs to decide a "planck time", which is the smallest resolution for time in the simulation.

In simulation, we run the loop at "planck time" periods, and update packs that require higher time resolution at a faster rate more frequently and packs that require lower time resolution at a slower rate less frequently. For example, we deduced that the "planck time" is 1ms, and we have two packs: one is a redstone implementation that requires high time resolution, and the other is a pack that adds animal movements and player actions that requires low time resolution. So we run redstone packs' onTick function every one loop but the other pack's onTick function every 20 loops. This way we can save a lot of CPU resource while still maintaining the simulation accuracy for different packs.

When an interaction that crosses time resolution boundary occurs, we will 1) run the low resolution pack immediately and prematurely, 2) queue up the interaction and wait for the next time the low resolution pack is supposed to run, essentially slowing down the high resolution pack's ticking at this and only this particular interaction while maintaining the exact ratio between them. Pack creators can choose from these two options.

When a lag occurs, we will simply adjust the "planck time" to a higher value, so everything slows down while maintaining the exact ratio.