Skip to content

Instantly share code, notes, and snippets.

@clo4
Last active November 21, 2024 01:22
Show Gist options
  • Select an option

  • Save clo4/c18a86eac47eb6b3e7b291f8c7cc7b2f to your computer and use it in GitHub Desktop.

Select an option

Save clo4/c18a86eac47eb6b3e7b291f8c7cc7b2f to your computer and use it in GitHub Desktop.
Experiment for a Minecraft data pack to get the player's head rotation without using NBT.

I wanted to eliminate the single NBT check from my Minecraft data pack, Detect AFK Players. It uses the player's head rotation, and currently, there isn't a command like /rotate query to get that information without using NBT.

NBT is the save format, and requires the game to convert all the player data to that format from the in-memory data structures, serialize it, parse the lookup, and finally return the data. That's pretty slow! Naturally, the community's motto is to stay away from NBT unless you absolutely have to. I tried a few different things to detect activity, including:

  • Spawn a marker entity where the player is looking
    • Ties rotation to position, which is not good
  • Spawn an entity where the player is looking, "positioned at" a particular point
    • Requires either force loading chunks or using known loaded chunks
  • Use a predicate to detect input directly
    • Runs into edge cases such as player-operated farms where you use the mouse but not any keyboard inputs, and being a boat/camel passenger

Overall, head rotation truly is the best at detecting activity.

After giving up, I had an idea: what about binary searching for the player's angle, since selectors can use y_rotation to select players? Well, it turns out this works super well! But after benchmarking a binary search, I found that while the execution time of each command was significantly faster than using /data get entity @s ..., the game was spending a lot of time in unspecified... whatever that is. My assumption was overhead of loading different functions. To test this, I added more parameters to the generation script, allowing it to do more than 2 checks per function file, and specifying the number of iterations of search that should be done. It doesn't seem to be entirely down to function overhead, but I don't know enough about the game's internals to know exactly what was doing that.

Ultimately, I ended up with something that's fairly optimized. With 4 searches and 3 iterations, that's 360/4^3 = 5.625 degrees of precision (good enough for me!) with a best case scenario of 3 checks, and a worst case scenario of 4x3 checks per player.

Now, the speed. This is, usually, slightly faster than NBT - on my system, that's about 0.05ms faster. But it can also be worse. Sometimes noticeably better. It's highly variable depending on exactly what angle the player is facing. My conclusion from this is that while yes, there is probably extra optimization that could be done, maybe finding an even better configuration than 4x3, it's not worth it for a tiny performance bump and a way more complicated build system.

Maybe someone else would like this, so I'm making it public. Use this if you want!

The functions all use the new return and return run function syntax, so minimum Minecraft 1.20.3 is required. You can use it like so:

execute store result score @s rotation run function MY_NAMESPACE:head_rotation/1__-180_to_180
import os
import shutil
from pathlib import Path
def create_mcfunction(path: str | Path, commands: list[str]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write('\n'.join(commands) + "\n")
def generate_binary_search(min_angle: float, max_angle: float, *, searches: int, depth: int, max_depth: int, namespace: str) -> list[str]:
assert depth <= max_depth
range_size = (max_angle - min_angle) / searches
ranges = (
(i == searches - 1, min_angle + i * range_size, min_angle + (i + 1) * range_size)
for i in range(searches)
)
commands = []
for is_last, range_min, range_max in ranges:
if depth == max_depth:
if not is_last:
commands.append(
f"execute if entity @s[y_rotation={range_min}..{range_max}] run return {int(range_min * 1000)}"
)
else:
commands.append(f"return {int(range_min * 1000)}")
else:
next_depth = depth + 1
func_name = f"{next_depth}__{range_min}_to_{range_max}".replace('.', '_')
if not is_last:
commands.append(
f"execute if entity @s[y_rotation={range_min}..{range_max}] run return run function {namespace}:head_rotation/{func_name}"
)
else:
commands.append(f"return run function {namespace}:head_rotation/{func_name}")
return commands
def generate_range(base_path: str | Path, min_angle: float, max_angle: float, *, searches: int, depth: int = 1, max_depth: int, namespace: str) -> None:
assert depth > 0
assert min_angle < max_angle
if depth > max_depth:
return
commands = generate_binary_search(min_angle, max_angle, searches=searches, depth=depth, max_depth=max_depth, namespace=namespace)
filename = f"{depth}__{min_angle}_to_{max_angle}".replace('.', '_')
create_mcfunction(base_path / f"{filename}.mcfunction", commands)
if depth < max_depth:
range_size = (max_angle - min_angle) / searches
for i in range(searches):
min_r = min_angle + i * range_size
max_r = min_angle + (i + 1) * range_size
generate_range(base_path, min_r, max_r, searches=searches, depth=depth + 1, max_depth=max_depth, namespace=namespace)
def main():
namespace = "afk"
base_dir = Path(__file__).parent / "data" / namespace / "function" / "head_rotation"
shutil.rmtree(base_dir, ignore_errors=True)
# Best found so far: searches=4, max_depth=3
generate_range(
base_dir,
-180,
180,
searches=4,
max_depth=3,
namespace=namespace,
)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment