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.
| Param | Type | Description |
|---|---|---|
target | player | Player object to intercept |
source_pos | vec3 | Projectile origin. Defaults to local player's eye position if omitted |
speed | number | Projectile speed in u/s |
bone | string | Aim bone (e.g. "head"). Default = origin |
extra_time | number | Extra 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.predicthas 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 theif speed <= 0 then return endguard;- the solve does not converge to a positive, finite time (
solve_ballisticalso 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.
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 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])
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:
| Param | Type | Description |
|---|---|---|
gravity | number | Downward acceleration on the projectile, u/s². The engine's own world-gravity constant is 800 |
up_speed | number | Initial 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.
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²).
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 |
speed | number | Total velocity magnitude (u/s) |
speed2d | number | Horizontal velocity magnitude (u/s) |
vel_z | number | Vertical 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:
| Field | Type | Description |
|---|---|---|
stability | 0-1 | Time 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 |
straightness | 0-1 | How straight the path is. Low = turning / strafing |
decel | 0-1 | Current 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 |
overall | 0-1 | min(stability, straightness) * decel |
center | vec3 | Horizontal 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_radius | number | Max 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.
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.
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 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):
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.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() | - |