48 27 No Key8 Ball Duels Script (Delta Mobile) – Autoplay with Trick Shots
8 Ball Duels · RobloxDescription
Pool Duels Autoplay is a 8 Ball Duels script you can run in Delta on Android, iOS or PC. It includes autoplay, trick shots, shot lines and more. It waits for your turn and for the balls to settle before each shot. Created by finderisgone. No key.
Key Features
- Autoplay: plans and takes your shots
- Trick and bank shots
- Shot path lines
Pool Duels Autoplay, finderisgone’s 8 Ball Duels autoplay script, doesn’t guess. Before it shoots, it loads the game’s own pool physics and rules modules, runs candidate shots through a simulation, and only fires the one that comes out best. It waits for your turn and for every ball to stop rolling first.
How a turn plays out
- Planning. It builds direct pots, bank shots (object ball off a cushion) and kick shots for your legal targets. Then it re-tests the top 30 direct shots at 25, 50, 75 and 100 percent power, with some top and back spin too.
- Scoring. The status line counts through the candidates (“Testing every candidate”) and then reports the winner as “Best shot”, with its predicted pots and power percentage.
- Shooting. The cue swings round, charges and releases like a real input. With ball in hand it places the cue ball too.
If no candidate passes, the panel reads “No safe shot found. Waiting…” and it keeps looking.
Panel and controls
The real panel is small and draggable, with two buttons: Autoplay: OFF/ON and Lines: ON/OFF. F8 toggles autoplay on a keyboard, and on mobile you tap the button. With autoplay off and lines on, it previews the path of the aim you’re lining up yourself.
Setup notes
No key and no web requests. The full source sits in the code box. It needs an executor that can require the game’s client modules. If that fails you’ll see “Pool module missing” or “Unsupported PoolMatchClient interface”. Running it twice unloads the first copy, and getgenv().PoolDuelsToolsUnload() removes it completely. The tested list puts Delta first. Arceus X and Codex owners can try it too, as can Fluxus users and anyone on a desktop injector.
Questions
Does it shoot during my opponent’s turn? No. It holds at “Waiting for your turn” until the game hands you the cue.
Are there other 8 Ball Duels scripts? Two: Vitality’s Hub and Barrage, both loader-based. This one is fully readable.
Table tip: try a few frames with Lines on and Autoplay off before letting it play. Watching where your own aim actually sends the object ball off a cushion teaches bank angles quickly, and it makes the autoplay’s choices easier to follow.
local Players=game:GetService('Players')
local Run=game:GetService('RunService')
local UIS=game:GetService('UserInputService')
local RS=game:GetService('ReplicatedStorage')
local player=assert(Players.LocalPlayer,'Run on the game client')
local ENV=type(getgenv)=='function' and getgenv() or _G
if type(ENV.PoolDuelsToolsUnload)=='function' then pcall(ENV.PoolDuelsToolsUnload)end
local function child(parent,name)return assert(parent:WaitForChild(name,20),'Pool module missing: '..name)end
local pool=child(child(child(RS,'Libraries'),'GameSpecific'),'Pool')
local function module(name)return require(child(pool,name))end
local Physics,Geometry,Rules,C=module('PoolPhysics'),module('PoolGeometry'),module('PoolRules'),module('PoolConstants')
local Client,Input=module('PoolMatchClient'),module('PoolInputController')
local Renderer,View=module('PoolTableRenderer2D'),module('PoolViewController')
local Planner=(function()
local Planner={}
function Planner.new(Physics,Geometry,Rules,C)
local self={}
local R=C.BallRadius
local DT=1/120
local function copy(value,seen)
if type(value)~='table' then return value end
seen=seen or {};if seen[value] then return seen[value] end
local result={};seen[value]=result
for k,v in value do result[k]=copy(v,seen) end
return result
end
function self:copyRules(rules)return copy(rules)end
function self:validPlacement(sim,rules,pos)
if (Rules.ClampPlacement(rules,pos)-pos).Magnitude>.001 then return false end
if not Geometry.IsInsidePlayArea(pos,R,Physics.GetTable(sim)) then return false end
for _,id in sim.Order do
local b=sim.Balls[id]
if id~=Physics.CueNumber(sim) and not b.Pocketed and not b.Destroyed and
(pos-b.Position).Magnitude<R+(b.Radius or R)+.02 then return false end
end
return true
end
local function clear(sim,a,b,skip1,skip2)
local delta=b-a;local len=delta:Dot(delta)
if len<1e-8 then return false end
for _,id in sim.Order do
local ball=sim.Balls[id]
if id~=skip1 and id~=skip2 and not ball.Pocketed and not ball.Destroyed then
local t=math.clamp((ball.Position-a):Dot(delta)/len,0,1)
-- Reduced safety margin from .015 to .002 to allow tight straight-on shots
if (ball.Position-(a+delta*t)).Magnitude<R+(ball.Radius or R)+.002 then return false end
end
end
return true
end
function self:candidates(sim,rules,yieldWork)
local cueId=Physics.CueNumber(sim)
local cue=sim.Balls[cueId]
if not cue then return {} end
local shape=Physics.GetTable(sim)
local pockets=Geometry.GetPockets(shape)
local placements={cue.Position}
if rules.BallInHand then
for _,x in {-.62,-.25,.2,.6} do
for _,y in {-.55,0,.55} do
local pos=Rules.ClampPlacement(rules,Vector2.new(shape.HalfWidth*x,shape.HalfHeight*y))
if self:validPlacement(sim,rules,pos) then placements[#placements+1]=pos end
end
end
end
local proposals={}
local function propose(origin,dir,target,pocket,distance,rank,override)
if dir.Magnitude<1e-6 then return end
local speed=math.sqrt(2*C.Physics.RollingDeceleration*math.max(distance+5,1))
local force=override or math.clamp((speed-C.Physics.MinShotSpeed)/(C.Physics.MaxShotSpeed-C.Physics.MinShotSpeed),0,1)^(1/C.Physics.PowerCurve)
proposals[#proposals+1]={direction=dir.Unit,power=math.clamp(force,.08,1),spin=Vector2.zero,
target=target,call=pocket,placement=rules.BallInHand and origin or nil,rank=rank}
end
for _,origin in placements do
if not rules.BallInHand or self:validPlacement(sim,rules,origin) then
for _,id in sim.Order do
local b=sim.Balls[id]
if not b.Pocketed and not b.Destroyed then
local isLegalTarget = Rules.IsLegalFirstContact(rules,sim,id)
if isLegalTarget then
-- PLAN A: Legal Targets (Direct, Banks, and Kicks)
for _,pocket in pockets do
if yieldWork then yieldWork()end
local point=pocket.BowlCentre or pocket.MouthCentre
local out=point-b.Position
if out.Magnitude>.01 then
local ghost=b.Position-out.Unit*(R+(b.Radius or R))
local delta=ghost-origin
local cut=delta.Magnitude>.01 and delta.Unit:Dot(out.Unit) or 0
-- 1. Direct cut shots (Relaxed IsInsidePlayArea check to R*0.1 for frozen balls)
if cut>.12 and Geometry.IsInsidePlayArea(ghost,R*0.1,shape) and clear(sim,origin,ghost,cueId,id)
and clear(sim,b.Position,point,cueId,id) then
propose(origin,delta,id,pocket.Id,delta.Magnitude+out.Magnitude/(cut*cut),200*cut)
end
local wx,wy=shape.HalfWidth-(b.Radius or R),shape.HalfHeight-(b.Radius or R)
-- 2. Targeted Kick Shots (Cue ball off wall to hit target into pocket)
for _,image in {Vector2.new(2*wx-ghost.X,ghost.Y),Vector2.new(-2*wx-ghost.X,ghost.Y),
Vector2.new(ghost.X,2*wy-ghost.Y),Vector2.new(ghost.X,-2*wy-ghost.Y)}do
local kick=image-origin
if kick.Magnitude>.01 then
propose(origin,kick,id,pocket.Id,kick.Magnitude+out.Magnitude, 90)
end
end
-- 3. Targeted Bank Shots (Object ball off wall into pocket)
for _,image in {Vector2.new(2*wx-point.X,point.Y),Vector2.new(-2*wx-point.X,point.Y),
Vector2.new(point.X,2*wy-point.Y),Vector2.new(point.X,-2*wy-point.Y)}do
local bank=image-b.Position
if bank.Magnitude>.01 then
local contact=b.Position-bank.Unit*(R+(b.Radius or R))
local approach=contact-origin
local angle=approach.Magnitude>.01 and approach.Unit:Dot(bank.Unit)or 0
if angle>.2 and Geometry.IsInsidePlayArea(contact,R*0.1,shape) and clear(sim,origin,contact,cueId,id)then
propose(origin,approach,id,pocket.Id,approach.Magnitude+bank.Magnitude/(angle*angle),80*angle)
end
end
end
end
end
local direct=b.Position-origin
if clear(sim,origin,b.Position,cueId,id) then
-- Safe hits to avoid scratching if nothing goes in
local tangent=Vector2.new(-direct.Y,direct.X).Unit
propose(origin,direct,id,nil,direct.Magnitude,-10,0.15)
propose(origin,direct,id,nil,direct.Magnitude,-20,0.5)
propose(origin,direct+tangent*(R*0.8),id,nil,direct.Magnitude,-30,0.2)
propose(origin,direct-tangent*(R*0.8),id,nil,direct.Magnitude,-30,0.2)
else
-- PLAN B: Blocked smash (Just to try and break out the ball)
propose(origin,direct,id,nil,direct.Magnitude,-150,0.8)
propose(origin,direct,id,nil,direct.Magnitude,-150,1.0)
end
else
-- PLAN C: Enemy balls. ONLY if completely desperate, rank lowest.
local direct=b.Position-origin
if clear(sim,origin,b.Position,cueId,id) then
propose(origin,direct,id,nil,direct.Magnitude,-200,0.6)
propose(origin,direct,id,nil,direct.Magnitude,-200,1.0)
end
end
end
end
end
-- PLAN D: Generic blind wall kicks to avoid fouls if heavily snookered
for i=1, 36 do
local angle = (i/36) * math.pi * 2
local dir = Vector2.new(math.cos(angle), math.sin(angle))
proposals[#proposals+1]={direction=dir, power=0.5, spin=Vector2.zero, placement=rules.BallInHand and origin or nil, rank=-100}
proposals[#proposals+1]={direction=dir, power=0.85, spin=Vector2.zero, placement=rules.BallInHand and origin or nil, rank=-110}
end
end
-- Sort by best potential rank first
table.sort(proposals,function(a,b)return a.rank>b.rank end)
local candidates={}
local seen={}
local function add(p,power,angle,spin)
angle=angle or 0
local co,si=math.cos(angle),math.sin(angle)
local dir=Vector2.new(p.direction.X*co-p.direction.Y*si,p.direction.X*si+p.direction.Y*co)
local v={direction=dir,power=math.clamp(power,C.Input.MinimumPower,1),spin=spin or p.spin,
target=p.target,call=p.call,placement=p.placement}
local pos=v.placement or cue.Position
local key=string.format('%.4f:%.4f:%.3f:%.2f:%.2f:%.2f:%.2f',dir.X,dir.Y,v.power,v.spin.X,v.spin.Y,pos.X,pos.Y)
if not seen[key] then seen[key]=true;candidates[#candidates+1]=v end
end
if rules.Phase=='Break' then
local nearest,dist
for _,id in sim.Order do
local b=sim.Balls[id]
if id~=cueId and not b.Pocketed then
local d=(b.Position-cue.Position).Magnitude
if not dist or d<dist then nearest,dist=b,d end
end
end
if nearest then
local p={direction=(nearest.Position-cue.Position).Unit,spin=Vector2.zero,
target=nearest.Number,placement=rules.BallInHand and cue.Position or nil}
for _,angle in {0,.01,-.01,.03,-.03} do add(p,1,angle)end
end
end
-- Test up to 350 top proposals
for i=1, math.min(350,#proposals) do
local p=proposals[i]
if p.call then
add(p,p.power)
add(p,math.clamp(p.power*1.2, 0.1, 1))
add(p,math.clamp(p.power*0.8, 0.1, 1))
-- Power Bracketing: For the top 30 direct shots, test at multiple fixed powers to avoid pocket rattle
if i <= 30 then
for _,power in ipairs({0.25, 0.5, 0.75, 1.0}) do add(p,power) end
for _,spin in ipairs({Vector2.new(0,.4),Vector2.new(0,-.4)}) do add(p,p.power,0,spin) end
end
else
add(p,p.power)
end
end
return candidates
end
function self:simulate(base,rules,shot,yieldWork,trace)
local sim=Physics.Clone(base)
local match=copy(rules)
local cueId=Physics.CueNumber(sim)
if shot.placement then
if not rules.BallInHand or not self:validPlacement(sim,rules,shot.placement)then return nil end
sim.Balls[cueId].Position=shot.placement
sim.Balls[cueId].Pocketed=false;sim.Balls[cueId].Sinking=false
end
local context=Rules.BeginShot(match,sim)
context.CalledPocket=shot.call
local paths={}
local function record()
for _,id in sim.Order do
local b=sim.Balls[id]
if not b.Pocketed then
local line=paths[id]or {};paths[id]=line
local pos=b.Position
if #line==0 or (line[#line]-pos).Magnitude>.04 then
if #line>=2 then
local a=line[#line]-line[#line-1];local d=pos-line[#line]
local cross=math.abs(a.X*d.Y-a.Y*d.X)
if a:Dot(d)>0 and cross<.008*a.Magnitude*d.Magnitude then line[#line]=pos
elseif #line<110 then line[#line+1]=pos end
else line[#line+1]=pos end
end
end
end
end
if trace then record() end
if not Physics.Shoot(sim,shot.direction,shot.power,shot.spin)then return nil end
local events=0
for tick=1,3600 do
Physics.Step(sim,DT)
if trace and (tick%4==0 or #sim.Events~=events)then record();events=#sim.Events end
if yieldWork and tick%16==0 then yieldWork()end
if sim.Settled then break end
end
if not sim.Settled then return nil end
local result=Rules.ResolveShot(match,sim,context)
local cue=sim.Balls[cueId]
local pots,wrong=0,0
local group=Rules.GetGroup(rules,rules.Turn)or Rules.GetGroup(match,rules.Turn)
for _,id in result.Potted or {} do
if id~=cueId then
if not Rules.IsNineBall(rules) and group and id~=Rules.MoneyBall(rules) and C.GetGroup(id)~=group then wrong+=1
else pots+=1 end
end
end
local score
if result.Winner and result.Winner~=rules.Turn then score=-100000
elseif result.Winner==rules.Turn then score=100000
elseif result.Legal~=true then
score=-10000
if pots > 0 then
score = -5000 + pots*500
else
if cue and not cue.Pocketed then
local distance
for _,id in sim.Order do
local b=sim.Balls[id]
if not b.Pocketed and Rules.IsLegalFirstContact(match,sim,id) then
local d=(b.Position-cue.Position).Magnitude
if not distance or d<distance then distance=d end
end
end
if distance then score-=distance*10 end
end
end
else
score=pots*1200-wrong*700+(result.Continues and 500 or 0)
score=score-shot.power*15
end
if cue and cue.Pocketed then score-=15000 end
if result.WrongPocket then score-=2000 end
return {shot=shot,score=score,result=result,paths=paths,sim=sim,pots=pots}
end
function self:search(base,rules,yieldWork,expired,onProgress)
local candidates=self:candidates(base,rules,yieldWork)
local best
local evaluated,total=0,#candidates
if onProgress then onProgress(0,total,nil)end
for i,shot in candidates do
if expired()then break end
local result=self:simulate(base,rules,shot,yieldWork,false)
evaluated+=1
if result and (not best or result.score>best.score)then
best=result
-- EARLY EXIT: Stop calculating immediately if we found a perfect legal pot
if best.pots > 0 and best.result.Legal == true and best.score > 500 then
break
end
end
if onProgress then onProgress(evaluated,total,best)end
end
return best
end
return self
end
return Planner
end)()
local planner=Planner.new(Physics,Geometry,Rules,C)
-- Hardcoded calcSpeed to 0.03734 (equivalent to 93% power on old slider)
local state={closed=false,auto=false,lines=true,calcSpeed=0.03734,quality=2,current=nil,seen=0,job=nil,best=nil,paths={},pathResult=nil,
key=nil,previewKey=nil,failedKey=nil,lastShot=nil,requestPlan=false,showBest=false,scan=0,drawAge=0,uiAge=0,
tested=0,total=0,fired=0,execution=nil}
local connections={}
local gui=Instance.new('ScreenGui');gui.Name='PoolDuelsClientTools';gui.ResetOnSpawn=false;gui.IgnoreGuiInset=true;gui.DisplayOrder=10000;gui.ZIndexBehavior=Enum.ZIndexBehavior.Global
gui.Parent=child(player,'PlayerGui')
local function make(kind,parent,props)
local o=Instance.new(kind);for key,value in props do o[key]=value end;o.Parent=parent;return o
end
-- Reduced panel size to fit just the essential buttons
local panel=make('Frame',gui,{Size=UDim2.fromOffset(300,130),Position=UDim2.new(0,18,.5,-65),BackgroundColor3=Color3.fromRGB(16,18,25),BorderSizePixel=0,Active=true,ZIndex=10})
make('UICorner',panel,{CornerRadius=UDim.new(0,12)})
local function button(value,y)
local b=make('TextButton',panel,{Text=value,Position=UDim2.fromOffset(12,y),Size=UDim2.new(1,-24,0,30),BackgroundColor3=Color3.fromRGB(33,43,62),TextColor3=Color3.fromRGB(238,243,255),Font=Enum.Font.GothamBold,TextSize=13,BorderSizePixel=0,ZIndex=11,Visible=true,Active=true,Selectable=true})
make('UICorner',b,{CornerRadius=UDim.new(0,6)});return b
end
local autoButton=button('Autoplay: OFF',8)
local linesButton=button('Lines: ON',46)
local progress=make('TextLabel',panel,{Text='Autoplay off',Position=UDim2.fromOffset(12,84),Size=UDim2.new(1,-24,0,40),
BackgroundTransparency=1,TextColor3=Color3.fromRGB(198,213,235),Font=Enum.Font.Gotham,TextSize=12,TextWrapped=true,ZIndex=12,Visible=true})
local status={Text='Waiting for your pool match'}
local lineObjects={}
local function hideLines()
for _,line in lineObjects do line.Visible=false end
end
local function matchState(c)
return type(c)=='table' and c.Replica and c.Replica.Data and c.Replica.Data.poolMatch
end
local function ready(c)
local m=matchState(c)
return m and m.Phase=='Playing' and c.Seat and c.Seat>0 and m.Turn==c.Seat and
type(c.Simulation)=='table' and c.Simulation.Settled and not c.AwaitingShot and not c.Covered and
type(c.Input)=='table' and c.Input.ShotBindable and c.Input.State~='Camera' and c.Input.State~='GameOver' and type(c.Rules)=='table' and c.Rules.Turn==c.Seat and c.Rules.Phase~='GameOver' and
not c.Pending and #(c.ShotQueue or {})==0 and #(c.PatchQueue or {})==0
end
local function fingerprint(c)
if not c or not c.Simulation or not c.Rules then return nil end
local parts={tostring(c),tostring(c.Simulation),tostring(c.Seat),tostring(c.Rules.Turn),tostring(c.Rules.ShotCount),tostring(c.AppliedShot),
tostring(c.Rules.Phase),tostring(c.Rules.BallInHand),tostring(c.Rules.BehindHeadString),tostring(c.Rules.GameType),tostring(c.Rules.CallPocket),
tostring(c.Rules.Groups and c.Rules.Groups[1]),tostring(c.Rules.Groups and c.Rules.Groups[2]),tostring(c.Simulation.Table),tostring(c.Simulation.Modifiers)}
for _,id in c.Simulation.Order do
local b=c.Simulation.Balls[id]
parts[#parts+1]=string.format('%s:%.5f:%.5f:%s:%s',tostring(id),b.Position.X,b.Position.Y,tostring(b.Pocketed),tostring(b.Destroyed))
end
return table.concat(parts,'|')
end
local function cancelExecution()
local e=state.execution
state.execution=nil
if not e then return end
local c=e.client
if ready(c) then
if e.oldPosition and c.Rules.BallInHand then
local ball=c.Simulation.Balls[Physics.CueNumber(c.Simulation)]
if ball and (ball.Position-e.best.shot.placement).Magnitude<.001 then
ball.Position=e.oldPosition
View.SetBall(c.View,ball.Number,ball.Position)
end
end
c.Input.ChargeMode=e.saved.ChargeMode;c.Input.Dial=e.saved.Dial
c.Input.Dragging=e.saved.Dragging;c.Input.AimLocked=e.saved.AimLocked
c.Input.Direction=e.saved.Direction;c.Hud.Spin=e.saved.Spin
Input.SetState(c.Input,e.saved.State or 'Aiming')
Input.SetPower(c.Input,e.saved.Power or 0)
end
end
local function clearWork()
cancelExecution()
state.job=nil;state.best=nil;state.paths={};state.pathResult=nil;state.previewKey=nil;state.showBest=false;hideLines()
end
local function fail(err)
cancelExecution()
state.auto=false;autoButton.Text='Autoplay: OFF';state.job=nil
status.Text='Stopped: '..string.sub(tostring(err),1,130)
warn('[Pool tools] '..tostring(err))
end
local function jobFor(kind,c,key,shot)
local sim=Physics.Clone(c.Simulation)
local rules=planner:copyRules(c.Rules)
local started=os.clock()
local job={kind=kind,client=c,key=key,best=nil,checked=0,total=0,started=started}
job.expires=kind=='search' and math.huge or started+5
if kind=='search' then state.tested=0;state.total=0 end
local function yieldWork()
if os.clock()>=job.expires then coroutine.yield('expired')end
if os.clock()>=job.sliceEnd then coroutine.yield()end
end
job.work=coroutine.create(function()
if kind=='search' then
return planner:search(sim,rules,yieldWork,function()return os.clock()>=job.expires end,
function(i,total,best)job.checked=i;job.total=total;job.best=best;state.tested=i;state.total=total end)
end
return planner:simulate(sim,rules,shot,yieldWork,true)
end)
state.job=job
end
local function safeBest(c,best)
return ready(c) and not Physics.HasPatrol(c.Simulation) and best and best.key==fingerprint(c) and best.result and
(not best.result.Winner or best.result.Winner==c.Seat)
end
local function fireBest(verified)
local c,best=state.current,verified or state.best
if not safeBest(c,best)then status.Text='No current verified legal shot. Find a shot first.';return false end
if state.lastShot==best.key then status.Text='Shot already requested; waiting for the game';return false end
local shot=best.shot
if shot.placement then
if not c.Rules.BallInHand or not c.Input.CanPlaceCueBall or not planner:validPlacement(c.Simulation,c.Rules,shot.placement)then return false end
local ball=c.Simulation.Balls[Physics.CueNumber(c.Simulation)]
ball.Position=shot.placement;ball.Pocketed=false;ball.Sinking=false
View.SetBall(c.View,ball.Number,shot.placement)
c.Input.Dragging=false
Input.SetState(c.Input,'Aiming')
end
c.Call=shot.call;c.CallLocked=shot.call~=nil
c.Hud.Spin=shot.spin
Input.SetAimDirection(c.Input,shot.direction)
Input.SetPower(c.Input,shot.power)
c.Input.CanPlaceCueBall=false
state.lastShot=best.key
state.job=nil;hideLines()
local e=state.execution
state.execution=nil
if e then
c.Input.ChargeMode=e.saved.ChargeMode;c.Input.Dial=e.saved.Dial
c.Input.AimLocked=e.saved.AimLocked
end
Input.SetState(c.Input,'Simulating')
c.Input.ShotBindable:Fire(shot.direction,shot.power,shot.spin)
state.fired+=1
status.Text='Shot requested · waiting for the result'
return true
end
local function beginExecution(c,best)
local direction=c.Input.Direction or best.shot.direction
if direction.Magnitude<.001 then direction=best.shot.direction end
local startAngle=math.atan2(direction.Y,direction.X)
local targetAngle=math.atan2(best.shot.direction.Y,best.shot.direction.X)
local delta=(targetAngle-startAngle+math.pi)%(2*math.pi)-math.pi
local e={client=c,best=best,started=os.clock(),startAngle=startAngle,delta=delta,
aimSeconds=math.clamp(.35+math.abs(delta)*.22,.35,1),chargeSeconds=.55+best.shot.power*.3,holdSeconds=.15,
saved={State=c.Input.State,ChargeMode=c.Input.ChargeMode,Dial=c.Input.Dial,Dragging=c.Input.Dragging,
AimLocked=c.Input.AimLocked,Direction=c.Input.Direction,Power=c.Input.Power,Spin=c.Hud.Spin}}
if best.shot.placement then
if not c.Rules.BallInHand or not c.Input.CanPlaceCueBall or not planner:validPlacement(c.Simulation,c.Rules,best.shot.placement)then return false end
local ball=c.Simulation.Balls[Physics.CueNumber(c.Simulation)]
e.oldPosition=ball.Position;ball.Position=best.shot.placement
View.SetBall(c.View,ball.Number,ball.Position)
best.key=fingerprint(c);state.key=best.key;state.job=nil;state.previewKey=nil
end
c.Input.Dragging=false
Input.SetState(c.Input,'Aiming')
c.Hud.Spin=best.shot.spin
state.execution=e
return true
end
local function advanceExecution()
local e=state.execution
if not e then return end
local c=e.client
if not state.auto or c~=state.current or not safeBest(c,e.best)then cancelExecution();return end
local age=os.clock()-e.started
local remaining=math.max(0,e.aimSeconds+e.chargeSeconds+e.holdSeconds-age)
local phase
if age<e.aimSeconds then
local t=math.clamp(age/e.aimSeconds,0,1);t=t*t*(3-2*t)
local angle=e.startAngle+e.delta*t
Input.SetAimDirection(c.Input,Vector2.new(math.cos(angle),math.sin(angle)))
Input.SetPower(c.Input,0)
phase='Aiming'
else
if not e.charging then
e.charging=true;c.Input.ChargeMode='Dial';c.Input.Dial=0
Input.SetState(c.Input,'Charging')
end
Input.SetAimDirection(c.Input,e.best.shot.direction)
local t=math.clamp((age-e.aimSeconds)/e.chargeSeconds,0,1)
Input.SetPower(c.Input,e.best.shot.power*t*t*(3-2*t))
phase=t<1 and 'Charging' or 'Ready'
end
View.SetCueStick(c.View,c.Simulation.Balls[Physics.CueNumber(c.Simulation)].Position,c.Input.Direction,c.Input.Power)
progress.Text=string.format('%s - shot in %.1fs\nTested %d/%d | Shots sent: %d',phase,remaining,state.tested,state.total,state.fired)
if remaining<=0 then fireBest(e.best)end
end
local function toggleAuto()
cancelExecution()
state.auto=not state.auto;state.requestPlan=state.auto and not safeBest(state.current,state.best);state.job=nil
autoButton.Text=state.auto and 'Autoplay: ON' or 'Autoplay: OFF'
if not state.auto then status.Text='Autoplay off';progress.Text=string.format('Autoplay off\nTested %d/%d | Shots sent: %d',state.tested,state.total,state.fired)end
end
connections[#connections+1]=linesButton.Activated:Connect(function()
state.lines=not state.lines;linesButton.Text=state.lines and 'Lines: ON' or 'Lines: OFF'
if not state.lines then hideLines();if state.job and state.job.kind~='search' then state.job=nil end
else state.previewKey=nil end
end)
connections[#connections+1]=autoButton.Activated:Connect(toggleAuto)
connections[#connections+1]=UIS.InputBegan:Connect(function(input,processed)
if not processed and not UIS:GetFocusedTextBox() and input.KeyCode==Enum.KeyCode.F8 then toggleAuto()end
end)
local dragging,dragStart,panelStart,dragTouch
connections[#connections+1]=panel.InputBegan:Connect(function(input)
if input.UserInputType==Enum.UserInputType.MouseButton1 or input.UserInputType==Enum.UserInputType.Touch then
dragging=true;dragStart=input.Position;panelStart=panel.Position;dragTouch=input
end
end)
connections[#connections+1]=UIS.InputChanged:Connect(function(input)
if dragging and (input==dragTouch or input.UserInputType==Enum.UserInputType.MouseMovement)then
local delta=input.Position-dragStart
panel.Position=UDim2.new(panelStart.X.Scale,panelStart.X.Offset+delta.X,panelStart.Y.Scale,panelStart.Y.Offset+delta.Y)
end
end)
connections[#connections+1]=UIS.InputEnded:Connect(function(input)
if input==dragTouch or input.UserInputType==Enum.UserInputType.MouseButton1 then dragging=false end
end)
local originalUpdate=Client.Update
assert(type(originalUpdate)=='function','Unsupported PoolMatchClient interface')
local wrappedUpdate
wrappedUpdate=function(c,...)
if not state.closed then state.current=c;state.seen=os.clock()end
return originalUpdate(c,...)
end
assert(not table.isfrozen or not table.isfrozen(Client),'PoolMatchClient is read-only in this runner')
Client.Update=wrappedUpdate
local function unload()
if state.closed then return end
cancelExecution()
state.closed=true;state.auto=false;state.job=nil
if Client.Update==wrappedUpdate then Client.Update=originalUpdate end
for _,connection in connections do connection:Disconnect()end
ENV.PoolDuelsToolsUnload=nil
gui:Destroy()
end
ENV.PoolDuelsToolsUnload=unload
connections[#connections+1]=gui.Destroying:Connect(unload)
local function project(c,point)
if c.View.Mode=='2D' then
local origin,size=Renderer.GetScreenRect(c.View.Renderer2D)
local scale=Renderer.TableToScale(point)
return origin+Vector2.new(scale.X.Scale*size.X,scale.Y.Scale*size.Y),size.X>0 and size.Y>0
end
local camera=workspace.CurrentCamera
if not camera then return Vector2.zero,false end
local p,on=camera:WorldToViewportPoint(View.ToWorld(point,C.BallRadius))
return Vector2.new(p.X,p.Y),on and p.Z>0
end
local function draw()
local c=state.current
if not state.lines or not ready(c) or not state.pathResult or state.pathResult.key~=state.key then hideLines();return end
local n=0
for id,path in state.paths do
local color=id==Physics.CueNumber(c.Simulation) and Color3.fromRGB(95,210,255)or Color3.fromRGB(255,206,95)
local ball=state.pathResult.sim.Balls[id]
if ball and ball.Pocketed then color=id==Physics.CueNumber(c.Simulation)and Color3.fromRGB(255,90,100)or Color3.fromRGB(90,240,145)end
for i=2,#path do
if n>=600 then break end
local a,visibleA=project(c,path[i-1]);local b,visibleB=project(c,path[i])
local delta=b-a
if visibleA and visibleB and delta.Magnitude>.4 then
n+=1
local line=lineObjects[n]
if not line then
line=make('Frame',gui,{BorderSizePixel=0,AnchorPoint=Vector2.new(.5,.5),ZIndex=2,BackgroundTransparency=.12})
lineObjects[n]=line
end
line.Size=UDim2.fromOffset(delta.Magnitude,2)
line.Position=UDim2.fromOffset((a.X+b.X)*.5,(a.Y+b.Y)*.5)
line.Rotation=math.deg(math.atan2(delta.Y,delta.X));line.BackgroundColor3=color;line.Visible=true
end
end
end
for i=n+1,#lineObjects do lineObjects[i].Visible=false end
end
local function tick(dt)
local c=state.current
state.scan+=dt
if (not c or os.clock()-state.seen>2)and state.scan>2 and type(getgc)=='function'then
state.scan=0
for _,candidate in getgc(true)do
if type(candidate)=='table' and rawget(candidate,'Simulation')and rawget(candidate,'Input')and rawget(candidate,'Replica') and rawget(candidate,'Rules')then
if ready(candidate)then state.current=candidate;state.seen=os.clock();c=candidate;break end
end
end
end
if not ready(c) then
clearWork();state.key=nil
if not c then status.Text='Waiting for your pool match'
elseif c.AwaitingShot or not c.Simulation.Settled then status.Text='Waiting for the balls to settle'
else status.Text='Waiting for your turn'end
progress.Text=(state.auto and status.Text or 'Autoplay off')..' | Shots sent: '..state.fired
return
end
if Physics.HasPatrol(c.Simulation)then
clearWork();status.Text='Moving table objects: autoplay paused';progress.Text=status.Text;return
end
local key=fingerprint(c)
if key~=state.key then clearWork();state.key=key;state.lastShot=nil;state.failedKey=nil end
if state.requestPlan then cancelExecution();state.best=nil;state.failedKey=nil end
if (state.requestPlan or (state.auto and not state.best and state.lastShot~=key and state.failedKey~=key))and(not state.job or state.job.kind~='search')then
state.requestPlan=false;state.showBest=false
jobFor('search',c,key)
end
local job=state.job
if job then
if job.key~=key or job.client~=c then state.job=nil;return end
job.sliceEnd=os.clock()+(job.kind=='search' and state.calcSpeed or 0.004)
local ok,result=coroutine.resume(job.work)
if not ok then fail(result);return end
local done=coroutine.status(job.work)=='dead' or result=='expired'
if done then
state.job=nil
if result=='expired'then result=job.kind=='search'and job.best or nil end
if job.kind=='search' then
state.best=result;state.showBest=result~=nil;state.planFinished=os.clock();state.previewKey=nil
if result then result.key=key end
if not result or not safeBest(c,result)then
state.failedKey = key
status.Text='No safe shot found. Waiting...'
else status.Text='Best shot: '..result.pots..' predicted pot(s) · '..math.floor(result.shot.power*100)..'% power' end
elseif result then
result.key=key;state.paths=result.paths;state.pathResult=result
end
elseif job.kind=='search'then
status.Text='Planning · '..job.checked..'/'..job.total..' shots tested'
end
end
if state.auto and safeBest(c,state.best)and state.lastShot~=state.best.key and not state.execution then beginExecution(c,state.best)end
if state.execution then advanceExecution()
elseif state.auto then
if state.job and state.job.kind=='search' then
if state.total==0 then progress.Text=string.format('Preparing shots\nShots sent: %d',state.fired)
else progress.Text=string.format('Testing every candidate: %d/%d\nShots sent: %d',state.tested,state.total,state.fired)end
elseif state.lastShot==key then progress.Text='Waiting for shot result | Shots sent: '..state.fired
else progress.Text='Tested '..state.tested..'/'..state.total..' | Shots sent: '..state.fired end
else progress.Text=string.format('Autoplay off\nTested %d/%d | Shots sent: %d',state.tested,state.total,state.fired)end
if state.lines and not state.job then
local shot
if state.showBest and state.best then shot=state.best.shot
else
local direction=c.Input.Direction
if not direction or direction.Magnitude<.001 then return end
local power=c.Input.Power or 0
shot={direction=direction.Unit,power=power>=C.Input.MinimumPower and power or .5,spin=c.Hud.Spin or Vector2.zero,call=c.Call}
end
local previewKey=string.format('%.4f:%.4f:%.3f:%.2f:%.2f:%s',shot.direction.X,shot.direction.Y,shot.power,shot.spin.X,shot.spin.Y,tostring(state.showBest))
if state.previewKey~=previewKey then state.previewKey=previewKey;jobFor('trace',c,key,shot)end
if not state.showBest then status.Text='Your aim · preview at '..math.floor(shot.power*100)..'% power' end
end
end
connections[#connections+1]=Run.Heartbeat:Connect(function(dt)
if state.closed then return end
local ok,err=pcall(tick,dt);if not ok then fail(err)end
end)
connections[#connections+1]=Run.RenderStepped:Connect(function(dt)
if state.closed then return end
state.drawAge+=dt;if state.drawAge<1/30 then return end;state.drawAge=0
local ok,err=pcall(draw);if not ok then hideLines();status.Text='Prediction display: '..string.sub(tostring(err),1,100)end
end)
return {Unload=unload,State=state,Plan=function()state.requestPlan=true end}


