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.
| Param | Type | Description |
|---|---|---|
target | player | Player object to intercept |
source_pos | vec3 | Projectile origin. Defaults to the camera position if omitted |
speed | number | Projectile speed in u/s |
bone | string | Aim bone (e.g. "head"). Defaults to the origin |
extra_time | number | Extra 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.predictstill 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 theif speed <= 0 then return endguard- 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.
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
endWith an explicit source position:
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:
| Param | Type | Description |
|---|---|---|
gravity | number | Downward acceleration on the projectile, u/s². The engine's world gravity is 800 |
up_speed | number | Initial 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.
| Param | Type | Description |
|---|---|---|
slot | integer | Your 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.yaw | number | View angles to cast from. Give both, or your current view is used |
opts.heroes | boolean | false 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
| Key | Type | Meaning |
|---|---|---|
points | table | Up to 64 vec3 positions along the flight, the launch point first |
hit | string | What stopped it: "world" (the map), "hero" (an enemy hero), "range" (it reached its maximum range) or "none" (still flying after 2.5 seconds) |
impact | vec3 | Where it stops |
time | number | Seconds until it stops |
hero | player | The enemy hero it hits. Only set when hit is "hero" |
speed | number | Launch speed, u/s |
gravity | number | Downward acceleration, u/s² |
radius | number | Its radius when it hits heroes |
range | number | Maximum range. 0 means none |
aoe | number | Radius of its area effect. 0 means unknown |
bullet | boolean | True when the ability fires a bullet instead of a projectile |
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
endRaw 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.
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.
local vel, accel = prediction.get_velocity(target)
local speed = vel:distance(vec3(0, 0, 0)) -- scalar speedprediction.get_state(target)
Movement-state snapshot. Returns a table:
| Field | Type | Description |
|---|---|---|
grounded | bool | On the ground |
airborne | bool | Not grounded (jumping / falling / in the air) |
stationary | bool | Grounded and barely moving (horizontal speed under 12 u/s) |
speed | number | Total velocity magnitude (u/s) |
speed2d | number | Horizontal velocity magnitude (u/s) |
vel_z | number | Vertical 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:
| Field | Type | Description |
|---|---|---|
stability | 0-1 | Seconds 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 |
straightness | 0-1 | How straight the path is. Low = turning / strafing |
decel | 0-1 | Current 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 |
overall | 0-1 | min(stability, straightness) * decel |
center | vec3 | The 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_radius | number | Max 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.
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
endprediction.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
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
endWith 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):
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")
endFunction Index
| Function | Returns |
|---|---|
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() | - |