diff --git a/src/baserunning.lua b/src/baserunning.lua
new file mode 100644
index 0000000..6e5c00e
--- /dev/null
+++ b/src/baserunning.lua
@@ -0,0 +1,209 @@
+--- @alias Runner {
+---   x: number,
+---   y: number,
+---   nextBase: Base,
+---   prevBase: Base | nil,
+---   forcedTo: Base | nil,
+--- }
+
+-- selene: allow(unscoped_variables)
+baserunning = {
+    ---@type Runner[]
+    runners = {},
+
+    ---@type Runner[]
+    outRunners = {},
+
+    ---@type Runner[]
+    scoredRunners = {},
+
+    ---@type Runner | nil
+    batter = nil,
+
+    --- Since this object is what ultimately *mutates* the out count,
+    --- it seems sensible to store the value here.
+    outs = 0
+}
+
+---@param runner integer | Runner
+---@param message string | nil
+function baserunning.outRunner(self, runner, message)
+    self.outs = self.outs + 1
+    if type(runner) ~= "number" then
+        for i, maybe in ipairs(self.runners) do
+            if runner == maybe then
+                runner = i
+            end
+        end
+    end
+    if type(runner) ~= "number" then
+        error("Expected runner to have type 'number', but was: " .. type(runner))
+    end
+    self.outRunners[#self.outRunners + 1] = self.runners[runner]
+    table.remove(self.runners, runner)
+
+    self:updateForcedRunners()
+
+    announcer:say(message or "YOU'RE OUT!")
+    if self.outs < 3 then
+        return
+    end
+
+    -- TODO: outRunners/scoredRunners split
+    while #self.runners > 0 do
+        self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
+    end
+end
+
+function baserunning.outEligibleRunners(self, fielder)
+    local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
+    local didOutRunner = false
+    for i, runner in pairs(self.runners) do
+        local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
+        if -- Force out
+            touchedBase
+                and runner.prevBase -- Make sure the runner is not standing at home
+                and runner.forcedTo == touchedBase
+                and touchedBase ~= runnerOnBase
+            -- Tag out
+            or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < C.TagDistance
+        then
+            self:outRunner(i)
+            didOutRunner = true
+        end
+    end
+
+    return didOutRunner
+end
+
+function baserunning.updateForcedRunners(self)
+    local stillForced = true
+    for _, base in ipairs(C.Bases) do
+        local runnerTargetingBase = utils.getRunnerWithNextBase(self.runners, base)
+        if runnerTargetingBase then
+            if stillForced then
+                runnerTargetingBase.forcedTo = base
+            else
+                runnerTargetingBase.forcedTo = nil
+            end
+        else
+            stillForced = false
+        end
+    end
+end
+
+---@param deltaSeconds number
+function baserunning.walkAwayOutRunners(self, deltaSeconds)
+    for i, runner in ipairs(self.outRunners) do
+        if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
+            runner.x = runner.x + (deltaSeconds * 25)
+            runner.y = runner.y + (deltaSeconds * 25)
+        else
+            table.remove(self.outRunners, i)
+        end
+    end
+end
+
+---@return Runner
+function baserunning.newRunner(self)
+    local new = {
+        x = C.RightHandedBattersBox.x - 60,
+        y = C.RightHandedBattersBox.y + 60,
+        nextBase = C.RightHandedBattersBox,
+        prevBase = nil,
+        forcedTo = C.Bases[C.First],
+    }
+    self.runners[#self.runners + 1] = new
+    return new
+end
+
+---@param self table
+---@param runnerIndex integer
+function baserunning.runnerScored(self, runnerIndex)
+    -- TODO: outRunners/scoredRunners split
+    self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex]
+    table.remove(self.runners, runnerIndex)
+end
+
+--- Returns true only if the given runner moved during this update.
+---@param runner Runner | nil
+---@param runnerIndex integer | nil May only be nil if runner == batter
+---@param appliedSpeed number
+---@param deltaSeconds number
+---@return boolean runnerMoved, boolean runnerScored
+function baserunning.updateRunner(self, runner, runnerIndex, appliedSpeed, deltaSeconds)
+    local autoRunSpeed = 20 * deltaSeconds
+
+    if not runner or not runner.nextBase then
+        return false, false
+    end
+
+    local nearestBase, nearestBaseDistance = utils.getNearestOf(C.Bases, runner.x, runner.y)
+
+    if
+        nearestBaseDistance < 5
+        and runnerIndex ~= nil
+        and runner ~= self.batter --runner.prevBase
+        and runner.nextBase == C.Bases[C.Home]
+        and nearestBase == C.Bases[C.Home]
+    then
+        self:runnerScored(runnerIndex)
+        return true, true
+    end
+
+    local nb = runner.nextBase
+    local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)
+
+    if distance < 2 then
+        runner.nextBase = C.NextBaseMap[runner.nextBase]
+        runner.forcedTo = nil
+        return false, false
+    end
+
+    local prevX, prevY = runner.x, runner.y
+    local mult = 1
+    if appliedSpeed < 0 then
+        if runner.prevBase then
+            mult = -1
+        else
+            -- Don't allow running backwards when approaching the plate
+            appliedSpeed = 0
+        end
+    end
+
+    local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
+        or nearestBaseDistance < 5 and 0
+        or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
+
+    mult = autoRun + (appliedSpeed / 20)
+    runner.x = runner.x - (x * mult)
+    runner.y = runner.y - (y * mult)
+
+    return prevX ~= runner.x or prevY ~= runner.y, false
+end
+
+--- Update non-batter runners.
+--- Returns true only if at least one of the given runners moved during this update
+---@param appliedSpeed number
+---@return boolean someRunnerMoved, number runnersScored
+function baserunning.updateRunning(self, appliedSpeed, forcedOnly, deltaSeconds)
+    local someRunnerMoved = false
+    local runnersScored = 0
+
+    -- TODO: Filter for the runner closest to the currently-held direction button
+    for runnerIndex, runner in ipairs(self.runners) do
+        if runner ~= self.batter and (not forcedOnly or runner.forcedTo) then
+            local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds)
+            someRunnerMoved = someRunnerMoved or thisRunnerMoved
+            if thisRunnerScored then
+                runnersScored = runnersScored + 1
+            end
+        end
+    end
+
+    if someRunnerMoved then
+        self:updateForcedRunners()
+    end
+
+    return someRunnerMoved, runnersScored
+end
diff --git a/src/constants.lua b/src/constants.lua
index 703fe69..ceb0257 100644
--- a/src/constants.lua
+++ b/src/constants.lua
@@ -71,13 +71,19 @@ C.SmallestBallRadius = 6
 
 C.BatLength = 50
 
+---@alias OffenseState table
+
 --- An enum for what state the offense is in
+---@type table<string, OffenseState>
 C.Offense = {
     batting = {},
     running = {},
     walking = {},
 }
 
+---@alias Side table
+
+---@type table<string, Side>
 --- An enum for which side (offense or defense) a team is on.
 C.Sides = {
     offense = {},
diff --git a/src/dbg.lua b/src/dbg.lua
index 37b754e..4a4a79d 100644
--- a/src/dbg.lua
+++ b/src/dbg.lua
@@ -20,21 +20,22 @@ end
 
 -- Only works if called with the bases empty (i.e. the only runner should be the batter.
 -- selene: allow(unused_variable)
-function dbg.loadTheBases(runners)
-    utils.newRunner()
-    utils.newRunner()
-    utils.newRunner()
-    runners[2].x = C.Bases[C.First].x
-    runners[2].y = C.Bases[C.First].y
-    runners[2].nextBase = C.Bases[C.Second]
+function dbg.loadTheBases(br)
+    br:newRunner()
+    br:newRunner()
+    br:newRunner()
 
-    runners[3].x = C.Bases[C.Second].x
-    runners[3].y = C.Bases[C.Second].y
-    runners[3].nextBase = C.Bases[C.Third]
+    br.runners[2].x = C.Bases[C.First].x
+    br.runners[2].y = C.Bases[C.First].y
+    br.runners[2].nextBase = C.Bases[C.Second]
 
-    runners[4].x = C.Bases[C.Third].x
-    runners[4].y = C.Bases[C.Third].y
-    runners[4].nextBase = C.Bases[C.Home]
+    br.runners[3].x = C.Bases[C.Second].x
+    br.runners[3].y = C.Bases[C.Second].y
+    br.runners[3].nextBase = C.Bases[C.Third]
+
+    br.runners[4].x = C.Bases[C.Third].x
+    br.runners[4].y = C.Bases[C.Third].y
+    br.runners[4].nextBase = C.Bases[C.Home]
 end
 
 if not playdate then
diff --git a/src/field.lua b/src/fielding.lua
similarity index 92%
rename from src/field.lua
rename to src/fielding.lua
index 3e7cb8d..fc34662 100644
--- a/src/field.lua
+++ b/src/fielding.lua
@@ -57,6 +57,7 @@ function Field.resetFielderPositions(self, fromOffTheField)
     self.fielders.right.target = utils.xy(C.Screen.W * 2, self.fielders.left.target.y)
 end
 
+---@param deltaSeconds number
 ---@param fielder Fielder
 ---@param ballPos XYPair
 ---@return boolean isTouchingBall
@@ -72,6 +73,9 @@ end
 
 --- Selects the nearest fielder to move toward the given coordinates.
 --- Other fielders should attempt to cover their bases
+---@param self table
+---@param ballDestX number
+---@param ballDestY number
 function Field.haveSomeoneChase(self, ballDestX, ballDestY)
     local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
     chasingFielder.target = { x = ballDestX, y = ballDestY }
@@ -86,6 +90,7 @@ function Field.haveSomeoneChase(self, ballDestX, ballDestY)
 end
 
 ---@param ball XYPair
+---@param deltaSeconds number
 ---@return Fielder | nil fielderTouchingBall nil if no fielder is currently touching the ball
 function Field.updateFielderPositions(self, ball, deltaSeconds)
     local fielderTouchingBall = nil
@@ -100,6 +105,11 @@ function Field.updateFielderPositions(self, ball, deltaSeconds)
 end
 
 -- TODO? Start moving target fielders close sooner?
+---@param field table
+---@param targetBase Base
+---@param throwBall ThrowBall
+---@param throwFlyMs number
+---@return ActionResult
 local function playerThrowToImpl(field, targetBase, throwBall, throwFlyMs)
     if field.fielderTouchingBall == nil then
         return ActionResult.NeedsMoreTime
@@ -113,6 +123,10 @@ local function playerThrowToImpl(field, targetBase, throwBall, throwFlyMs)
 end
 
 --- Buffer in a fielder throw action.
+---@param self table
+---@param targetBase Base
+---@param throwBall ThrowBall
+---@param throwFlyMs number
 function Field.playerThrowTo(self, targetBase, throwBall, throwFlyMs)
     local maxTryTimeMs = 5000
     actionQueue:upsert('playerThrowTo', maxTryTimeMs, function()
diff --git a/src/main.lua b/src/main.lua
index a5e0b2d..f9e75e4 100644
--- a/src/main.lua
+++ b/src/main.lua
@@ -7,14 +7,6 @@ import 'CoreLibs/object.lua'
 import 'CoreLibs/timer.lua'
 import 'CoreLibs/ui.lua'
 
---- @alias Runner {
----   x: number,
----   y: number,
----   nextBase: Base,
----   prevBase: Base | nil,
----   forcedTo: Base | nil,
---- }
-
 --- @alias Fielder {
 ---   x: number,
 ---   y: number,
@@ -24,14 +16,17 @@ import 'CoreLibs/ui.lua'
 
 --- @alias EasingFunc fun(number, number, number, number): number
 
+---@alias ThrowBall fun(destX: number, destY: number, easingFunc: EasingFunc, flyTimeMs: number | nil, floaty: boolean | nil, customBallScaler: pd_animator | nil)
+
 import 'utils.lua'
 import 'constants.lua'
 import 'assets.lua'
 
 import 'action-queue.lua'
 import 'announcer.lua'
+import 'baserunning.lua'
 import 'dbg.lua'
-import 'field.lua'
+import 'fielding.lua'
 import 'graphics.lua'
 import 'npc.lua'
 import 'draw/overlay.lua'
@@ -86,20 +81,14 @@ local teams <const> = {
     },
 }
 
-local PlayerTeam <const> = teams.home
+local PlayerTeam <const> = teams.away
 local battingTeam = teams.away
 local outs = 0
 local inning = 1
-local offenseMode = C.Offense.batting
-
---- @type Runner[]
-local runners <const> = {}
-
---- @type Runner[]
-local outRunners <const> = {}
+local offenseState = C.Offense.batting
 
 ---@type Runner | nil
-local batter = utils.newRunner(runners)
+local batter = baserunning:newRunner()
 
 local throwMeter = 0
 
@@ -184,7 +173,7 @@ end
 ---@param pitchTypeIndex number | nil
 local function pitch(pitchFlyTimeMs, pitchTypeIndex)
     catcherThrownBall = false
-    offenseMode = C.Offense.batting
+    offenseState = C.Offense.batting
 
     local current = Pitches[pitchTypeIndex]
     ballAnimatorX = current.x
@@ -207,42 +196,11 @@ local function pitch(pitchFlyTimeMs, pitchTypeIndex)
     secondsSincePitchAllowed = 0
 end
 
-local function updateForcedRunners()
-    local stillForced = true
-    for _, base in ipairs(C.Bases) do
-        local runnerTargetingBase = utils.getRunnerWithNextBase(runners, base)
-        if runnerTargetingBase then
-            if stillForced then
-                runnerTargetingBase.forcedTo = base
-            else
-                runnerTargetingBase.forcedTo = nil
-            end
-        else
-            stillForced = false
-        end
-    end
-end
-
 ---@param runner integer | Runner
+---@param message string | nil
 local function outRunner(runner, message)
-    if type(runner) ~= "number" then
-        for i, maybe in ipairs(runners) do
-            if runner == maybe then
-                runner = i
-            end
-        end
-    end
-    if type(runner) ~= "number" then
-        error("Expected runner to have type 'number', but was: " .. type(runner))
-    end
-    outRunners[#outRunners + 1] = runners[runner]
-    table.remove(runners, runner)
-
-    outs = outs + 1
-    updateForcedRunners()
-
-    announcer:say(message or "YOU'RE OUT!")
-    if outs < 3 then
+    baserunning:outRunner(runner, message)
+    if baserunning.outs < 3 then
         return
     end
 
@@ -254,9 +212,6 @@ local function outRunner(runner, message)
         Field:benchTo(currentlyFieldingTeam.benchPosition)
         announcer:say("SWITCHING SIDES...")
     end
-    while #runners > 0 do
-        outRunners[#outRunners + 1] = table.remove(runners, #runners)
-    end
     -- Delay to keep end-of-inning on the scoreboard for a few seconds
     playdate.timer.new(3000, function()
         outs = 0
@@ -270,13 +225,12 @@ local function outRunner(runner, message)
             end
         end
     end)
+
 end
 
----@param runnerIndex number
-local function score(runnerIndex)
-    outRunners[#outRunners + 1] = runners[runnerIndex]
-    table.remove(runners, runnerIndex)
-    battingTeam.score = battingTeam.score + 1
+---@param scoredRunCount number
+local function score(scoredRunCount)
+    battingTeam.score = battingTeam.score + scoredRunCount
     announcer:say("SCORE!")
 end
 
@@ -308,108 +262,17 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
 
     Field:playerThrowTo(targetBase, throwBall, throwFlyMs)
     secondsSinceLastRunnerMove = 0
-    offenseMode = C.Offense.running
+    offenseState = C.Offense.running
 
     return true
 end
 
-local function outEligibleRunners(fielder)
-    local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
-    local didOutRunner = false
-    for i, runner in pairs(runners) do
-        local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
-        if -- Force out
-            touchedBase
-                and runner.prevBase -- Make sure the runner is not standing at home
-                and runner.forcedTo == touchedBase
-                and touchedBase ~= runnerOnBase
-            -- Tag out
-            or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < C.TagDistance
-        then
-            outRunner(i)
-            didOutRunner = true
-        end
-    end
-
-    return didOutRunner
-end
-
-local function npcFielderAction(fielder, outedSomeRunner)
-    if offenseMode ~= C.Offense.running then
-        return
-    end
-    if outedSomeRunner then
-        -- Delay a little before the next play
-        playdate.timer.new(750, function()
-            npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
-        end)
-    else
-        npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
-    end
-end
-
---- Returns true only if the given runner moved during this update.
----@param runner Runner | nil
----@param runnerIndex integer | nil May only be nil if runner == batter
----@param appliedSpeed number
----@return boolean
-local function updateRunner(runner, runnerIndex, appliedSpeed)
-    local autoRunSpeed = 20 * deltaSeconds
-    --autoRunSpeed = 140
-
-    if not runner or not runner.nextBase then
-        return false
-    end
-
-    local nearestBase, nearestBaseDistance = utils.getNearestOf(C.Bases, runner.x, runner.y)
-
-    if
-        nearestBaseDistance < 5
-        and runnerIndex ~= nil
-        and runner ~= batter --runner.prevBase
-        and runner.nextBase == C.Bases[C.Home]
-        and nearestBase == C.Bases[C.Home]
-    then
-        score(runnerIndex)
-    end
-
-    local nb = runner.nextBase
-    local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)
-
-    if distance < 2 then
-        runner.nextBase = C.NextBaseMap[runner.nextBase]
-        runner.forcedTo = nil
-        return false
-    end
-
-    local prevX, prevY = runner.x, runner.y
-    local mult = 1
-    if appliedSpeed < 0 then
-        if runner.prevBase then
-            mult = -1
-        else
-            -- Don't allow running backwards when approaching the plate
-            appliedSpeed = 0
-        end
-    end
-
-    local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
-        or nearestBaseDistance < 5 and 0
-        or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
-
-    mult = autoRun + (appliedSpeed / 20)
-    runner.x = runner.x - (x * mult)
-    runner.y = runner.y - (y * mult)
-
-    return prevX ~= runner.x or prevY ~= runner.y
-end
-
 local function nextBatter()
     batter = nil
     playdate.timer.new(2000, function()
         pitchTracker:reset()
         if not batter then
-            batter = utils.newRunner(runners)
+            batter = baserunning:newRunner()
         end
     end)
 end
@@ -418,9 +281,9 @@ local function walk()
     announcer:say("Walk!")
     batter.nextBase = C.Bases[C.First]
     batter.prevBase = C.Bases[C.Home]
-    offenseMode = C.Offense.walking
+    offenseState = C.Offense.walking
     batter = nil
-    updateForcedRunners()
+    baserunning:updateForcedRunners()
     nextBatter()
 end
 
@@ -446,7 +309,7 @@ local function updateBatting(batDeg, batSpeed)
         and ball.y < 232
     then
         BatCrackReverb:play()
-        offenseMode = C.Offense.running
+        offenseState = C.Offense.running
         local ballAngle = batAngle + math.rad(90)
 
         local mult = math.abs(batSpeed / 15)
@@ -464,7 +327,7 @@ local function updateBatting(batDeg, batSpeed)
 
         batter.nextBase = C.Bases[C.First]
         batter.prevBase = C.Bases[C.Home]
-        updateForcedRunners()
+        baserunning:updateForcedRunners()
         batter.forcedTo = C.Bases[C.First]
         batter = nil -- Demote batter to a mere runner
 
@@ -472,34 +335,16 @@ local function updateBatting(batDeg, batSpeed)
     end
 end
 
---- Update non-batter runners.
---- Returns true only if at least one of the given runners moved during this update
 ---@param appliedSpeed number
----@return boolean
-local function updateRunning(appliedSpeed, forcedOnly)
-    local runnerMoved = false
-
-    -- TODO: Filter for the runner closest to the currently-held direction button
-    for runnerIndex, runner in ipairs(runners) do
-        if runner ~= batter and (not forcedOnly or runner.forcedTo) then
-            runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved
-        end
+---@return boolean someRunnerMoved
+local function updateNonBatterRunners(appliedSpeed, forcedOnly)
+    local runnerMoved, runnersScored = baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds)
+    if runnersScored ~= 0 then
+        score(runnersScored)
     end
-
     return runnerMoved
 end
 
-local function walkAwayOutRunners()
-    for i, runner in ipairs(outRunners) do
-        if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
-            runner.x = runner.x + (deltaSeconds * 25)
-            runner.y = runner.y + (deltaSeconds * 25)
-        else
-            table.remove(outRunners, i)
-        end
-    end
-end
-
 local function playerPitch(throwFly)
     local aButton = playdate.buttonIsPressed(playdate.kButtonA)
     local bButton = playdate.buttonIsPressed(playdate.kButtonB)
@@ -541,7 +386,7 @@ local function updateGameState()
         throwMeter = throwMeter + math.abs(crankLimited * C.PitchPower)
     end
 
-    if offenseMode == C.Offense.batting then
+    if offenseState == C.Offense.batting then
         if ball.y < C.StrikeZoneStartY then
             pitchTracker.recordedPitchX = nil
         elseif not pitchTracker.recordedPitchX then
@@ -575,8 +420,9 @@ local function updateGameState()
 
         updateBatting(batAngleDeg, batSpeed)
 
+        -- Walk batter to the plate
         -- TODO: Ensure batter can't be nil, here
-        updateRunner(batter, nil, crankLimited)
+        baserunning:updateRunner(batter, nil, crankLimited, deltaSeconds)
 
         if secondsSincePitchAllowed > C.PitchAfterSeconds then
             if playerOnDefense then
@@ -588,25 +434,24 @@ local function updateGameState()
                 pitch(C.PitchFlyMs, math.random(#Pitches))
             end
         end
-    elseif offenseMode == C.Offense.running then
-        local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(runners)
-        if updateRunning(appliedSpeed) then
+    elseif offenseState == C.Offense.running then
+        local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(baserunning.runners)
+        if updateNonBatterRunners(appliedSpeed) then
             secondsSinceLastRunnerMove = 0
         else
             secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
             if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
                 throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
                 Field:resetFielderPositions()
-                offenseMode = C.Offense.batting
+                offenseState = C.Offense.batting
                 if not batter then
-                    batter = utils.newRunner(runners)
+                    batter = baserunning:newRunner()
                 end
             end
         end
-    elseif offenseMode == C.Offense.walking then
-        updateForcedRunners()
-        if not updateRunning(C.WalkedRunnerSpeed, true) then
-            offenseMode = C.Offense.batting
+    elseif offenseState == C.Offense.walking then
+        if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
+            offenseState = C.Offense.batting
         end
     end
 
@@ -619,13 +464,13 @@ local function updateGameState()
         end
     end
     if fielderHoldingBall then
-        local outedSomeRunner = outEligibleRunners(fielderHoldingBall)
+        local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
         if playerOnOffense then
-            npcFielderAction(fielderHoldingBall, outedSomeRunner)
+            npc.fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, throwBall)
         end
     end
 
-    walkAwayOutRunners()
+    baserunning:walkAwayOutRunners(deltaSeconds)
     actionQueue:runWaiting(deltaSeconds)
 end
 
@@ -648,7 +493,7 @@ function playdate.update()
         ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
     end
 
-    if offenseMode == C.Offense.batting then
+    if offenseState == C.Offense.batting then
         gfx.setLineWidth(5)
         gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
     end
@@ -658,7 +503,7 @@ function playdate.update()
     end
 
     -- TODO? Scale sprites down as y increases
-    for _, runner in pairs(runners) do
+    for _, runner in pairs(baserunning.runners) do
         if runner == batter then
             if batAngleDeg > 50 and batAngleDeg < 200 then
                 PlayerBack:draw(runner.x, runner.y)
@@ -671,7 +516,7 @@ function playdate.update()
         end
     end
 
-    for _, runner in pairs(outRunners) do
+    for _, runner in pairs(baserunning.outRunners) do
         PlayerFrown:draw(runner.x, runner.y)
     end
 
@@ -687,7 +532,7 @@ function playdate.update()
 
     gfx.setDrawOffset(0, 0)
     if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
-        drawMinimap(runners, Field.fielders)
+        drawMinimap(baserunning.runners, Field.fielders)
     end
     drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning)
     drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
diff --git a/src/npc.lua b/src/npc.lua
index 33cfca3..9bef3f5 100644
--- a/src/npc.lua
+++ b/src/npc.lua
@@ -5,6 +5,10 @@ local npcBatSpeed = 1500
 -- selene: allow(unscoped_variables)
 npc = {}
 
+---@param ball XYPair
+---@param catcherThrownBall boolean
+---@param deltaSec number
+---@return number
 function npc.updateBatAngle(ball, catcherThrownBall, deltaSec)
     if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
         npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
@@ -19,6 +23,8 @@ function npc.batSpeed()
     return npcBatSpeed
 end
 
+---@param runners Runner[]
+---@return number
 function npc.runningSpeed(runners)
     if #runners == 0 then
         return 0
@@ -30,6 +36,7 @@ function npc.runningSpeed(runners)
     return 0
 end
 
+---@param runners Runner[]
 ---@return Base[]
 local function getForcedOutTargets(runners)
     local targets = {}
@@ -45,6 +52,7 @@ local function getForcedOutTargets(runners)
 end
 
 --- Returns the position,distance of the base closest to the runner who is *furthest* from a base
+---@param runners Runner[]
 ---@return Base | nil, number | nil
 local function getBaseOfStrandedRunner(runners)
     local farRunnersBase, farDistance
@@ -62,6 +70,7 @@ local function getBaseOfStrandedRunner(runners)
 end
 
 --- Returns x,y of the out target
+---@param runners Runner[]
 ---@return number|nil, number|nil
 function npc.getNextOutTarget(runners)
     -- TODO: Handle missed throws, check for fielders at target, etc.
@@ -77,6 +86,8 @@ function npc.getNextOutTarget(runners)
 end
 
 ---@param fielder Fielder
+---@param runners Runner[]
+---@param throwBall ThrowBall
 function npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
     local targetX, targetY = npc.getNextOutTarget(runners)
     if targetX ~= nil and targetY ~= nil then
@@ -90,6 +101,25 @@ function npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
     end
 end
 
+---@param offenseState OffenseState
+---@param fielder Fielder
+---@param outedSomeRunner boolean
+---@param ball { x: number, y: number, heldBy: Fielder | nil }
+---@param throwBall ThrowBall
+function npc.fielderAction(offenseState, fielder, outedSomeRunner, ball, throwBall)
+    if offenseState ~= C.Offense.running then
+        return
+    end
+    if outedSomeRunner then
+        -- Delay a little before the next play
+        playdate.timer.new(750, function()
+            npc.tryToMakeAPlay(fielder, baserunning.runners, ball, throwBall)
+        end)
+    else
+        npc.tryToMakeAPlay(fielder, baserunning.runners, ball, throwBall)
+    end
+end
+
 if not playdate then
     return npc
 end
diff --git a/src/utils.lua b/src/utils.lua
index e68b68b..763fdeb 100644
--- a/src/utils.lua
+++ b/src/utils.lua
@@ -119,19 +119,6 @@ function utils.distanceBetweenZ(x1, y1, z1, x2, y2, z2)
     return sqrt((x * x) + (y * y) + (z * z)), x, y, z
 end
 
----@return Runner
-function utils.newRunner(runners)
-    local new = {
-        x = C.RightHandedBattersBox.x - 60,
-        y = C.RightHandedBattersBox.y + 60,
-        nextBase = C.RightHandedBattersBox,
-        prevBase = nil,
-        forcedTo = C.Bases[C.First],
-    }
-    runners[#runners + 1] = new
-    return new
-end
-
 --- Returns the base being touched by the player at (x,y), or nil, if no base is being touched
 ---@param x number
 ---@param y number