Skip to main content

Movement & the Movement Tick

Everything that moves the local player goes through the movement_tick event. It is the game's own movement tick, hooked in native from LocalPlayer::aiStep and fired before the client travels, so position and motion written from it are what the game actually moves with.

ctx.on("movement_tick", (state) => {
state.strafe(ctx.options.speed.value, 1);
});
movement_tick is performance-critical

The listener runs on the game thread, synchronously, with the thread blocked, and the runtime gives up on the whole hook after 20 ms. Work done here costs frames directly, and anything that finishes late has already missed the tick it was computed for.

Keep the handler to reading a few fields and writing motion. Defer heavy calculations — pathfinding, raytracing, entity scans, block searches — to the packet tick event, cache the result, and have movement_tick only apply it. See Deferring heavy work.

Why not the packet?

The old way to move the player was to rewrite the auth input packet after the fact. That desynced the client from the server, so the client had to be snapped back with correct_player_move_prediction every time.

Writing through movement_tick happens before travel and collision: the game moves from the value you wrote and reports it in its own auth input packet. There is nothing to correct afterwards, and nothing to keep in sync by hand.

Where it sits in a tick

OrderWhat happens
1movement_tick fires. The local player refreshes position, motion, rotation and the on-ground flag from memory before any module listener runs.
2Your movement_tick listeners run — read the state, write position/motion, request a sprint intent, schedule an aim.
3The rotation override is committed (latched from rotationScheduler) and the sprint intent is resolved.
4The game travels and resolves collision with whatever you wrote.
5The resulting player_auth_input packet reaches the proxy — tick fires (then post_tick).

So movement_tick is the earliest point in a tick, and tick is the point at which that tick's outcome is on the wire. A rotation and a sprint intent decided in the same movement_tick end up on the same packet.

What is fresh and what is one tick old

MovementState reads game memory, so its fields describe this tick. Some of the local player's fields have no memory source and still come off the last packet, so during movement_tick they describe the previous tick:

Fresh (on state)One tick behind (on localPlayer)
state.position, state.motionlocalPlayer.inputData
state.rotation, state.onGroundlocalPlayer.moveVector, localPlayer.isMoving
state.moveInput, state.isMovinglocalPlayer.verticalCollision / horizontalCollision
state.isJumping, state.sprintinglocalPlayer.serverSideRotation (the previous commit)

localPlayer.position / motion / rotation are refreshed from this event before your listener runs, so they agree with state — but they are a snapshot. They do not follow writes you make; read the value back off state if you need it live.

MovementState

The object handed to movement_tick. Positions are in the same feet-relative frame as localPlayer.position.

Reading

MemberTypeDescription
sessionGameSessionThe session this tick was dispatched to.
get positionVector3Position at the start of this tick.
get motionVector3Velocity at the start of this tick.
get previousPositionVector3Position at the start of the previous tick.
get rotationVector2Real client rotation in degrees — x = pitch, y = yaw. Not what is being sent to the server, so motion derived from it is unaffected by an active aim override.
get onGroundbooleanWhether the player was on the ground going into this tick. The game's own OnGroundFlagComponent, not a guess from collision flags. One tick behind by construction — collision resolves after this event — which is the same age as the packet's vertical_collision it replaces.
get moveInputVector2The raw move input for this tick — x = strafe, y = forward, in the player's own frame. This is the live input out of memory, unlike localPlayer.moveVector.
get isMovingbooleanWhether the player is asking to move at all this tick.
get isJumpingbooleanWhether a jump off the ground runs this tick — the game's own decision, not the jump input.
get sprintingbooleanThe game's sprint flag going into this tick (not what a script asked for).
get isSprintJumpingbooleanisJumping && sprinting — the one tick where motion depends on yaw.

Rotation is read only here: writing it would need the head yaw kept in sync, so rotation spoofing stays on the packet side via localPlayer.rotationScheduler.

Writing

MethodDescription
setPosition(position)Teleport for this tick. Travel and collision still run from the new position.
setMotion(motion)Set velocity for this tick. Treated the way vanilla treats lerpMotion — gravity and friction still apply on top.
addMotion(motion)Add to the current motion.
strafe(speed?, strength?)Apply strafe motion in the movement direction. speed defaults to the current horizontal speed; strength (0–1, default 1) blends between current and new motion.
jump(jumpVelocity?)Apply a jump. Defaults to 0.42 plus jump-boost, and queues the jump flag on the next packet.
requestSprint(intent)Ask for a sprint state, see below.
compensateSprintJump(sentYaw, sprinting?)Fix up a sprint jump under an aim override, see below.

setPosition / setMotion / addMotion take any IVector3 — a plain { x, y, z } is fine.

strafe() uses the packet-side input

state.strafe(...) builds its direction from localPlayer.moveVector / localPlayer.isMoving, which are the previous tick's input. If you want this tick's live input, read state.moveInput and write the motion yourself with utils.computeStrafe(...).

Sprint — SprintIntent

Sprinting is not a boolean you set; you declare an intent and the strongest one wins, so two modules disagreeing always resolve the same way regardless of listener order.

import { SprintIntent } from "@protohax/userscript";

ctx.on("movement_tick", (state) => {
state.requestSprint(SprintIntent.Sprint);
});
ValueMeaning
SprintIntent.AsIsLeave the game's own sprint logic alone.
SprintIntent.SprintHold sprinting on. The game's "stop sprinting" edge is swallowed, so sprint survives rotation, low hunger and item use.
SprintIntent.NotSprintHold sprinting off. The game can never engage sprint.

NotSprint beats Sprint beats AsIs. Prefer requestSprint(...) over assigning state.sprint directly — a raw assignment can lower another module's request.

Note that state.sprinting reports the game's flag going into this tick, not what anyone requested during it.

Sprint jumps under an aim override

A sprint jump is the only tick where the player's motion depends on their yaw: Mob::jumpFromGround adds (-sin(yaw), cos(yaw)) * 0.2 when the sprint flag is set. With an aim override active, the client jumps along its real yaw while the server recomputes the boost from the yaw in the packet — a position mismatch nothing downstream can explain away.

compensateSprintJump(sentYaw, sprinting?) adds the difference between the two boosts, so the total matches what the server calculated. It is a no-op unless this tick is a sprint jump and the two yaws differ.

ProtoHax already calls it for you at the end of every movement tick, with the rotation just committed from rotationScheduler. You only need to call it yourself if you build a rotation path the scheduler does not know about.

Deferring heavy work

The pattern: decide in tick, apply in movement_tick.

import { utils } from "@protohax/userscript";

(ctx) => {
let target: { x: number; y: number; z: number } | null = null;

// `tick` runs on the packet path — heavy work here does not stall the game.
ctx.on("tick", () => {
target = expensiveSearch(ctx.session); // raytrace, entity scan, pathfind…
});

// `movement_tick` only applies the cached decision.
ctx.on("movement_tick", (state) => {
if (!target) return;

const dir = new utils.Vector3(
target.x - state.position.x,
0,
target.z - state.position.z,
).normalize();

state.setMotion(new utils.Vector3(dir.x * 0.3, state.motion.y, dir.z * 0.3));
});
};

Rules of thumb for the handler:

  • No allocation-heavy loops, no JSON work, no scanning the entity map or chunk data.
  • Nothing asynchronous — a promise resolves long after the tick is gone.
  • No console.log on every tick; logging is not free.
  • Cache anything derived from options or world state and refresh it from tick.

Examples

Speed — apply a flat horizontal speed while moving:

ctx.on("movement_tick", (state) => {
if (state.isMoving) state.strafe(ctx.options.speed.value, 1);
});

Air jump / long jump — jump off the ground and keep momentum:

ctx.on("movement_tick", (state) => {
if (state.onGround && state.isMoving) {
state.jump();
state.strafe(0.5, 1);
}
});

Velocity (anti-knockback) — cancel incoming motion for a few ticks:

let ticksLeft = 0;

ctx.onPacket("set_entity_motion", (packet) => {
if (packet.runtime_entity_id === ctx.session.entityState.localPlayer.runtimeId) {
packet.isCancelled = true;
ticksLeft = ctx.options.ticks.value;
}
});

ctx.on("movement_tick", (state) => {
if (ticksLeft > 0) {
ticksLeft--;
state.setMotion({ x: 0, y: state.motion.y, z: 0 });
}
});

Hold sprint off while using an item:

import { SprintIntent } from "@protohax/userscript";

ctx.on("movement_tick", (state) => {
if (usingItem) state.requestSprint(SprintIntent.NotSprint);
});

Migrating from the old local-player verbs

setPosition, setMotion, addMotion, strafe and jump are gone from localPlayer — they lived on the packet path and needed a client correction. They are now on MovementState:

BeforeNow
ctx.on("tick", () => player.strafe(s, 1))ctx.on("movement_tick", (state) => state.strafe(s, 1))
player.jump()state.jump()
player.setMotion(v) / addMotion(v)state.setMotion(v) / state.addMotion(v)
player.setPosition(v, takeover)state.setPosition(v) — there is no takeover flag; the write is the movement.

The movementTakeover argument has no successor: nothing has to be faked back to the client anymore, because the client moved for real.