Skip to content

Prediction

TSUKI has a built-in prediction engine for projectile leading and motion tracking.

Units

All spatial values are in game units (Source inches). Speeds are game-units/second (u/s). Multiply by 0.0254 to convert to meters / m/s.

Projectile Intercept

prediction.solve_linear(target, [source_pos,] speed, [bone], [extra_time])

Solves straight-line projectile intercept: where to aim so a projectile of speed hits target, accounting for velocity and travel time.

ParamTypeDescription
targetplayerPlayer object to intercept
source_posvec3Projectile origin. Defaults to local player's eye position if omitted
speednumberProjectile speed in u/s
bonestringAim bone (e.g. "head"). Default = origin
extra_timenumberExtra lead time in seconds for cast windup + latency

bone and extra_time are order-flexible: a string is read as the bone, a number as extra_time.

Returns aim_pos: vec3, flight_time: number, or nil, nil. Both solvers return nil, nil when:

  • the prediction engine or world context is unavailable;
  • the player handle is stale;
  • the target is not alive, an explicit gate. prediction.predict has no such gate and will happily extrapolate a dead player, so the two disagree here;
  • speed <= 0. This is the common one: local_player():get_active_projectile_speed() reads 0 until the local weapon speed resolves, and that resolve reopens on a hero change. Keep the if speed <= 0 then return end guard;
  • the solve does not converge to a positive, finite time (solve_ballistic also bails as soon as its arc solver reports no solution).

flight_time is the travel time only. It deliberately excludes extra_time, even though the target is predicted at travel + extra_time. Do not add extra_time back when timing a shot against it.

lua
local me = local_player()
local speed = me:get_active_projectile_speed()
if speed <= 0 then return end

local aim, flight = prediction.solve_linear(target, speed, "head", 0.06)
if aim then
    -- aim = world position to put the crosshair on
    -- flight = seconds of travel
end

With explicit source position:

lua
local origin = me:get_head_world()
local aim, flight = prediction.solve_linear(target, origin, speed, "head")

prediction.solve_ballistic(target, [source_pos,] speed, gravity, up_speed, [bone], [extra_time])

Work in progress

Arced/gravity projectile variant. Currently untested.

No Deadlock projectile has been found that this solver is the right tool for, so it is untested against the game. solve_linear is what the aim path and every shipped example use.

Solves an arced projectile intercept. Takes the same parameters as solve_linear plus two required numbers:

ParamTypeDescription
gravitynumberDownward acceleration on the projectile, u/s². The engine's own world-gravity constant is 800
up_speednumberInitial upward launch velocity, u/s. 0 for a flat throw

Neither has a default. Omitting them raises rather than falling back.

Returns aim_pos: vec3, flight_time: number, or nil, nil. aim_pos is the aim point raised by the arc compensation 0.5 * gravity * t² - up_speed * t, so it sits above where the target will be. Do not use it as a predicted position; use prediction.predict for that. flight_time is travel time only.

On top of the shared nil, nil cases, solve_ballistic also returns nil, nil the moment its arc solver reports no solution. The target is out of reach for that speed/gravity combination.

Raw Prediction

prediction.predict(target, time, [bone])

Raw position extrapolation: where target will be in time seconds. Returns a vec3. Returns (0,0,0) if unavailable. It never returns nil, so check for a zero vector before you feed the result to an aim call.

lua
local future_pos = prediction.predict(target, 0.5, "chest")

prediction.get_velocity(target)

Returns the target's tracked velocity and acceleration as two vec3 values (u/s and u/s²).

lua
local vel, accel = prediction.get_velocity(target)
local speed = vel:distance(vec3(0, 0, 0))  -- scalar speed

prediction.get_state(target)

Movement-state snapshot. Returns a table:

FieldTypeDescription
groundedboolOn the ground
airborneboolNot grounded (jumping / falling / in the air)
stationaryboolGrounded and barely moving
speednumberTotal velocity magnitude (u/s)
speed2dnumberHorizontal velocity magnitude (u/s)
vel_znumberVertical velocity (u/s, negative = falling)

Confidence

get_confidence tells you how predictable a target's motion is.

prediction.get_confidence(target, [ramp_seconds])

Returns a table:

FieldTypeDescription
stability0-1Time since the target last changed its commitment, divided by ramp_seconds (1 = committed). This is recency, not smoothness. The clock restarts when the smoothed heading turns more than ~45° (dot < 0.70) against a sample under 0.25 s old, or when the target crosses from moving to stopped (horizontal speed under 80 u/s). A target braking hard in a straight line stays at 1.0 until it actually halts. A target that stops drops to 0 even though its velocity is now perfectly steady
straightness0-1How straight the path is. Low = turning / strafing
decel0-1Current horizontal speed as a fraction of a recent peak that decays with a 0.45 s time constant. Both sides are floored at 80 u/s, so a target standing still reads 1.0, not 0. A parked target is maximally predictable. Low means "slowing down from a recent high speed", and only for about a second: once the peak memory decays the value walks smoothly back to 1.0 even though the target never moved. Gate fire on decel (or overall) with that in mind, or you will read a parked target as unpredictable
overall0-1min(stability, straightness) * decel
centervec3Horizontal anchor for the target's recent motion; z is always a literal 0, so it is not a usable world aim point on its own. With a real window (3+ samples spanning at least 0.35 s and more than 1 unit of path) it is the centroid of the sampled x/y positions over the last ~0.9 s, the oscillation centre of a strafer. Otherwise it is the target's most recent sampled position, with swing_radius left at 0; and vec3(0, 0, 0) when the target has no tracking at all. A non-zero center is not evidence of an oscillation. Test swing_radius > 0 for that
swing_radiusnumberMax distance from the centroid (orbit radius, in game units)

ramp_seconds (optional): the window that stability ramps over. Default 0.30 s. Any value <= 0, including omitting the argument, selects that engine default rather than meaning "instant". It scales stability only. straightness, decel, center and swing_radius are unaffected and always use the engine's fixed ~0.9 s window.

get_confidence never returns nil, so if c then guards nothing. Its failure value is 1.0 on every factor, which reads as maximum confidence, so a script that gates on overall fires freely when the prediction engine is not up.

When there is no history. A target the engine is not tracking returns stability = straightness = decel = 1.0, overall = 1.0, center = vec3(0, 0, 0) and swing_radius = 0. That is the case for a stale handle, a target never observed, a tracker evicted after 2 s without updates (including a target dead that long) or past the 14 target cap, and after prediction.reset(), a map change or a hero swap.

A target that has been seen is barely better. On its first tracked tick, and again immediately after prediction.reset_confidence(target), the history is re-seeded rather than discarded. overall is 1.0 again, and center is the target's last sampled position.

So c.overall >= 0.90 is not evidence that the target has been observed. Before committing a full lead, gate on something that only exists with real history: c.swing_radius > 0, or your own "seen for N ticks" counter.

lua
local c = prediction.get_confidence(target)

if c.overall >= 0.90 then
    aim = predicted_pos       -- confident: full lead
elseif c.overall < 0.55 then
    aim = current_pos         -- uncertain: aim where they are
end

prediction.reset_confidence(target)

Resets one target's motion history (keeps the velocity tracker). Useful after displacing a target (e.g. a hook reel).

prediction.reset()

Clears all tracking state for every target.

Example

lua
function on_tick()
    local me = local_player()
    if not me then return end

    local speed = me:get_active_projectile_speed()
    if speed <= 0 then return end

    local target = targeting.find_closest_by_distance(60)
    if not target or not target:is_alive() then return end

    local origin = me:get_head_world()
    local aim, flight = prediction.solve_linear(target, origin, speed, "head", 0.06)
    if not aim then return end

    local c = prediction.get_confidence(target)
    local final
    if c.overall >= 0.90 then
        final = aim
    elseif c.overall < 0.55 then
        final = target:bone_pos("head") or target:get_position()
    else
        return  -- hysteresis gap: hold
    end

    -- convert final (world vec3) to a view angle and apply
end

With the internal loaded, aim.shoot leads the point with this same solver and queues the press, so you hand it the target instead of a solved position.

For ability projectiles (e.g. Haze sleep dagger):

lua
local dagger = me:get_ability(slot.ability1)
if dagger and dagger.projectile_speed > 0 then
    local aim, t = prediction.solve_linear(target, dagger.projectile_speed, "chest")
end

Function Index

FunctionReturns
prediction.solve_linear(target, [source_pos,] speed, [bone], [extra])vec3, number | nil, nil
prediction.solve_ballistic(target, [source_pos,] speed, gravity, up_speed, [bone], [extra])vec3, number | nil, nil
prediction.predict(target, time, [bone])vec3
prediction.get_velocity(target)vec3 vel, vec3 accel
prediction.get_state(target)table
prediction.get_confidence(target, [ramp_seconds])table
prediction.reset_confidence(target)-
prediction.reset()-