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]) ​

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

ParamTypeDescription
targetplayerPlayer object to intercept
source_posvec3Projectile origin. Defaults to the camera position if omitted
speednumberProjectile speed in u/s
bonestringAim bone (e.g. "head"). Defaults to the origin
extra_timenumberExtra lead time in seconds, for cast windup and latency

bone and extra_time can be passed in either order. A string is read as the bone and a number as extra_time.

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

  • there is no game, or the prediction engine is not running
  • the player handle is stale
  • the target is dead. prediction.predict still extrapolates a dead player
  • speed <= 0, the most common case. local_player():get_active_projectile_speed() reads 0 until your weapon speed is known, and again after a hero change, so keep the if speed <= 0 then return end guard
  • the solve does not reach a positive, finite time

flight_time is the travel time only. It does not include 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 an 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]) ​

TIP

No Deadlock projectile needs this solver so far. Use solve_linear unless the projectile arcs.

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 world gravity is 800
up_speednumberInitial upward launch velocity, u/s. 0 for a flat throw

Neither has a default. Omitting them raises an error.

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.

Besides the shared nil, nil cases, solve_ballistic also returns nil, nil as soon as its arc solver reports no solution. That happens when the target is out of reach for that speed and gravity.

Ability Arc ​

prediction.ability_arc(slot, [opts]) ​

Works out where one of your own abilities would fly if you cast it now. It aims along your crosshair, starts just in front of your hero where the game launches it, and uses the ability's own speed and gravity. It stops at the map, at the first enemy hero in its way, or at its maximum range.

ParamTypeDescription
slotintegerYour ability, 1 to 4. Anything else raises an error. The slot constants and get_ability_damage number the same abilities 0 to 3, so add 1: prediction.ability_arc(slot.ability1 + 1)
opts.pitch, opts.yawnumberView angles to cast from. Give both, or your current view is used
opts.heroesbooleanfalse to ignore enemy heroes, so only the map and its range stop it. Default true

Returns a table, or nil when:

  • you are dead
  • the slot is empty, or the ability does not fire a projectile or a bullet
  • the ability's data has not been read yet. Keep calling
KeyTypeMeaning
pointstableUp to 64 vec3 positions along the flight, the launch point first
hitstringWhat stopped it: "world" (the map), "hero" (an enemy hero), "range" (it reached its maximum range) or "none" (still flying after 2.5 seconds)
impactvec3Where it stops
timenumberSeconds until it stops
heroplayerThe enemy hero it hits. Only set when hit is "hero"
speednumberLaunch speed, u/s
gravitynumberDownward acceleration, u/s²
radiusnumberIts radius when it hits heroes
rangenumberMaximum range. 0 means none
aoenumberRadius of its area effect. 0 means unknown
bulletbooleanTrue when the ability fires a bullet instead of a projectile
lua
function on_tick()
    local arc = prediction.ability_arc(1)
    if not arc then return end
    for i = 2, #arc.points do
        draw.line3d(arc.points[i - 1], arc.points[i], 0xFFFFFFFF, 2)
    end
    if arc.hit == "hero" then
        local s = draw.world_to_screen(arc.impact)
        if s then draw.text(s.x, s.y, arc.hero:hero_name(), 0xFFFFFFFF) end
    end
end

Raw Prediction ​

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

Extrapolates where target will be in time seconds. Returns a vec3, or (0,0,0) if unavailable. It never returns nil, so check for a zero vector before you pass 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²).

The acceleration is smoothed over about 0.1 s and capped at 1200 u/s². It is near 0 at a steady speed and 0 when the target is nearly still, so a non-zero value means the target is speeding up, braking or turning. In the air, z also carries gravity (about -800). A jump, a dash, a teleport or any other sudden change in velocity resets it to 0, so a burst of speed shows in the velocity, not here.

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 (horizontal speed under 12 u/s)
speednumberTotal velocity magnitude (u/s)
speed2dnumberHorizontal velocity magnitude (u/s)
vel_znumberVertical velocity (u/s, negative = falling)

Never nil. For a stale handle or a target that is not tracked, it returns grounded = true, airborne = false, stationary = true and 0 for the speeds, the same as a target standing still.

Confidence ​

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

prediction.get_confidence(target, [ramp_seconds]) ​

Returns a table:

FieldTypeDescription
stability0-1Seconds since the target last turned more than ~45° or came to a stop (horizontal speed under 80 u/s), divided by ramp_seconds, up to 1. A target braking hard in a straight line stays at 1.0 until it halts, then drops to 0
straightness0-1How straight the path is. Low = turning / strafing
decel0-1Current horizontal speed as a fraction of a recent peak, both floored at 80 u/s, so a target standing still reads 1.0, not 0. A low value means the target is slowing from a recent high speed. It stays low for about a second, then climbs back to 1.0 even if the target stays stopped. A target that just stopped reads low for that second, so allow for it when you gate fire on decel or overall
overall0-1min(stability, straightness) * decel
centervec3The centre of the target's x/y positions over the last ~0.9 s, the point a strafer swings around. z is always 0, so it is not an aim point by itself. Until the target has moved enough, it is the target's latest position and swing_radius is 0. vec3(0, 0, 0) when the target is not tracked. A non-zero center does not mean the target is strafing. Test swing_radius > 0 for that
swing_radiusnumberMax distance from the centroid (orbit radius, in game units)

ramp_seconds (optional) is the window that stability ramps over. The default is 0.30 s. Omitting it, or passing any value <= 0, uses that default, not "instant". It only scales stability. straightness, decel, center and swing_radius are not affected and always use the engine's fixed ~0.9 s window.

get_confidence never returns nil. With no history it returns stability = straightness = decel = 1.0, overall = 1.0, center = vec3(0, 0, 0) and swing_radius = 0, which reads as full confidence. That happens when the prediction engine is not available, for a stale handle, a target never seen, a target not updated for 2 s (including one dead that long) or past the 14 target cap, and after prediction.reset(), a map change or a hero swap. On a target's first tracked tick, and right after prediction.reset_confidence(target), overall is also 1.0 and center is its last sampled position.

So c.overall >= 0.90 does not prove the target has been seen. Before a full lead, also check something that needs real history, such as c.swing_radius > 0 or your own count of ticks seen.

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. Every script shares the same tracking, so this resets it for them too.

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 the same solver and queues the press, so you pass 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.ability_arc(slot, [opts])table | 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()-