
Galleon
Vehicles · by Brixborg
A big three-masted galleon, ready for a ship battle game. Sit at the helm: W/S set the sails, A/D steer, Q/E fire a broadside; or click a cannon. Each cannonball hit wears down her hull: a battered ship loses her fittings, then her planks, takes on water, sinks, and comes back. Put it on water.
Use it in your games: in the studio, open View > Archive, go to the Vehicles shelf and pick "Galleon".
The script
-- Copyright (c) 2026 Brixborg Owners. All rights reserved. See LICENSE.txt.
-- THE GALLEON'S WORKINGS: sailing, the cannons, taking on water, sinking,
-- and coming back. (A Script inside the Galleon model.)
--
-- SAILING Sit at the helm (the seat behind the wheel at the back).
-- W raises the sails a notch, S lowers them (3 notches = full
-- sail, 0 = stopped); hold the key to keep going. A and D steer: the faster she goes, the
-- better she turns.
-- CANNONS Sit on the seat behind a cannon and click to fire it; A and D
-- swing it left and right, W and S raise and lower it, slowly
-- and only so far (AIM_TURN, AIM_RAISE, AIM_DIP). Or click a
-- cannon you are standing near. From the helm, Q fires
-- every cannon on the left side, E every one on the right.
-- A cannonball bursts where it lands, deadly to anyone standing
-- right there (whoever fired it gets the kill).
-- DAMAGE Like the old pirate games, a ship is sturdy: each cannonball
-- that hits her takes HIT_DAMAGE off her Hull (twice that below
-- the waterline). Her great hull slabs never come off (as in
-- the classic Galleons game), but hits knock off her fittings
-- (rails, barrels, lanterns, guns), and once her Hull is below
-- RIGGING_FALLS_BELOW they shoot away her rigging too (yards,
-- sails, the crow's nest). Below LEAK_BELOW she takes on water
-- faster than the pumps clear it, and at no Hull at all, or a
-- full hold, she goes down.
-- SINKING A ship that sinks is cleared away after a while, and a new one
-- appears where she first was (turn RESPAWNS off to stop that,
-- or put a BoolValue called Respawns, set to false, in the ship:
-- then something else, like a game script, brings the new ships).
-- TEAMS Put a BrickColorValue called TeamColor in the ship: her flag and
-- sail stripes take that colour, her spawn (the square on the
-- main deck) is for that team only, and only that team can steer
-- her and fire her cannons.
--
-- While someone is at the helm they see the ship's state at the bottom of
-- the screen: that is the HelmControls LocalScript inside this script.
-- ===========================================================================
-- THE NUMBERS (change these)
-- ===========================================================================
local TOP_SPEED = 28 -- studs per second at full sail
local SAIL_NOTCHES = 3 -- how many notches from stopped to full sail
local SPEED_CHANGE = 6 -- how quickly she picks up or loses speed (studs per second, each second)
local NOTCH_EVERY = 0.5 -- holding W or S: one more notch this often (seconds)
local TURN_RATE = 0.45 -- radians per second at full speed...
local SLOWEST_TURN = 0.35 -- ...and this share of it when stopped
local PUSH = 40 -- how hard the sails can push, for each 1 of her weight
local CANNON_REACH = 14 -- how near a cannon you must be to click it
local RELOAD = 4 -- seconds between shots from one cannon
local BALL_SPEED = 170 -- studs per second out of the barrel
local BALL_RISE = 0.1 -- how much it is aimed upwards
local BALL_FLOAT = 0.6 -- share of gravity taken off the ball (it flies flatter and further)
local BALL_LIFE = 6 -- seconds before a cannonball that hit nothing is tidied away
local BLAST = 5 -- how wide a cannonball's burst is (studs)
local BLAST_PUSH = 60000 -- how hard it throws loose pieces
local AIM_SPEED = 20 -- how fast a gunner swings their cannon (degrees per second)
local AIM_TURN = 30 -- how far it swings left or right (degrees)
local AIM_RAISE = 15 -- how far it can be raised...
local AIM_DIP = 5 -- ...and lowered (degrees)
local HIT_DAMAGE = 0.04 -- Hull taken off by one cannonball (1 = all of it: 25 hits)
local LOW_HIT = 2 -- times as much for a hit below her waterline
local RIGGING_FALLS_BELOW = 0.4 -- below this much Hull, hits shoot away her rigging as well
local LEAK_BELOW = 0.5 -- below this much Hull, she takes on water...
local LEAK = 0.05 -- ...this fast for each 1 of Hull missing below that (whole holds per second)
local PUMPS = 0.004 -- how fast the pumps empty the hold (whole holds per second)
local FLOOD_WEIGHT = 1.6 -- a full hold weighs this many times the ship
local SINK_TIME = 14 -- seconds a sunk ship stays before it is cleared away
local RESPAWNS = true -- a new ship appears (where she first was) when the wreck is cleared
-- ===========================================================================
local ship = script.Parent
if ship:FindFirstChild("Respawns") then RESPAWNS = ship.Respawns.Value end
local spawnCopy = ship:Clone() -- (taken before anything happens: the new ship after a sinking)
local debris = game:GetService("Debris")
local players = game:GetService("Players")
local helm = ship:WaitForChild("Helm")
helm.HeadsUpDisplay = false -- (the helm has its own display)
helm.MaxSpeed = TOP_SPEED
-- The middle of the ship: the ballast block holding the stabilizer.
local middle, gyro
for _, part in ipairs(ship:GetChildren()) do
if part.Name == "Ballast" and part:FindFirstChild("Stabilizer") then
middle = part
gyro = part.Stabilizer
end
end
-- How heavy she is, altogether.
local weight = 0
for _, part in ipairs(ship:GetChildren()) do
if part:IsA("BasePart") then weight = weight + part:GetMass() end
end
-- Her lift: wood alone would float her with her decks awash, so (as the
-- old pirate games did) a BodyForce in her middle, called Lift, helps hold
-- her up. (How strong is set where she is built: FLOAT in makegalleon.py.)
-- It goes when she sinks.
local lift = middle:FindFirstChild("Lift")
-- The sails' push: a BodyVelocity that carries the whole ship (and, when
-- she is flooding, drags her down).
local sails = Instance.new("BodyVelocity")
sails.Name = "Sails"
sails.maxForce = Vector3.new(0, 0, 0)
sails.velocity = Vector3.new(0, 0, 0)
sails.Parent = middle
-- What the helm's display shows (and what other scripts can read).
local function value(class, name, start)
local v = Instance.new(class)
v.Name = name
v.Value = start
v.Parent = ship
return v
end
local sailLevel = value("IntValue", "SailLevel", 0)
local speedValue = value("NumberValue", "Speed", 0)
local hullValue = value("NumberValue", "Hull", 1) -- 1 = not a scratch, 0 = wrecked (other ships' cannonballs lower it)
local floodValue = value("NumberValue", "Flooding", 0) -- 1 = the hold is full
local sunkValue = value("BoolValue", "Sunk", false)
-- (RemoteEvents, because the helm's and gunners' controls are LocalScripts,
-- running on the players' own computers in a multiplayer game.)
local broadside = Instance.new("RemoteEvent")
broadside.Name = "Broadside" -- (the helm fires this: "left" or "right")
broadside.Parent = ship
local gunnerEvent = Instance.new("RemoteEvent")
gunnerEvent.Name = "Gunner" -- (a gunner's controls fire this: their seat, and what to do)
gunnerEvent.Parent = ship
value("NumberValue", "CannonReload", RELOAD)
-- ---------------------------------------------------------------------------
-- Team colours
-- ---------------------------------------------------------------------------
local teamColor = ship:FindFirstChild("TeamColor")
-- May this player steer her and fire her cannons?
local function crewman(player)
if not player then return false end
if not teamColor then return true end
return not player.Neutral and player.TeamColor == teamColor.Value
end
if teamColor then
for _, part in ipairs(ship:GetChildren()) do
if part.Name == "Flag" or part.Name:find("SailBand") then part.BrickColor = teamColor.Value end
if part:IsA("SpawnLocation") then
part.Neutral = false
part.TeamColor = teamColor.Value
end
end
end
-- ---------------------------------------------------------------------------
-- Is a piece still where it belongs on the ship? (Measured from the middle.)
-- ---------------------------------------------------------------------------
local function remembered(part)
return {part = part, offset = middle.CFrame:toObjectSpace(part.CFrame)}
end
local function stillOn(entry)
if not middle or not middle.Parent or entry.part.Parent ~= ship then return false end
return ((middle.CFrame * entry.offset).p - entry.part.Position).magnitude < 2
end
-- What a cannonball can knock off a ship: her fittings at once, and her
-- rigging too once she is badly battered. (Never the hull itself.)
local FITTINGS = {Rail = true, Barrel = true, Crate = true, Lantern = true, Window = true, Cannon = true}
local function rigging(name)
return name:find("Yard") or name:find("Sail") or name == "CrowsNest" or name == "Flag" or name == "Jib"
end
-- The ship a part belongs to (any galleon: one with a Hull), or nil.
local function shipOf(part)
local model = part and part.Parent
if model and model:FindFirstChild("Hull") and model:FindFirstChild("Sunk") then return model end
return nil
end
-- ---------------------------------------------------------------------------
-- The cannons
-- ---------------------------------------------------------------------------
local cannons = {}
local function tagKill(humanoid, player)
if not player then return end
local old = humanoid:FindFirstChild("creator")
if old then old:Destroy() end
local tag = Instance.new("ObjectValue")
tag.Name = "creator"
tag.Value = player
tag.Parent = humanoid
debris:AddItem(tag, 2)
end
local function burst(position, player)
local blast = Instance.new("Explosion")
blast.Position = position
blast.BlastRadius = BLAST
blast.BlastPressure = BLAST_PUSH
blast.DestroyJointRadiusPercent = 0 -- (it breaks nothing by itself: what breaks is decided here)
local killed = {}
blast.Hit:connect(function(part, distance)
local model = part.Parent
if model and model:IsA("Hat") then model = model.Parent end
local humanoid = model and model:FindFirstChild("Humanoid")
if humanoid then
if not killed[humanoid] and distance < BLAST * 0.6 and not model:FindFirstChildOfClass("ForceField") then
killed[humanoid] = true
tagKill(humanoid, player)
humanoid.Health = 0
end
return
end
local target = shipOf(part)
if not target then return end
local hull = target.Hull.Value
if FITTINGS[part.Name] or (rigging(part.Name) and hull < RIGGING_FALLS_BELOW) then
part:BreakJoints()
end
end)
blast.Parent = workspace
end
-- A cannonball has hit a ship: take it off her Hull.
local function damage(target, position)
local hull = target:FindFirstChild("Hull")
local sunk = target:FindFirstChild("Sunk")
if not hull or (sunk and sunk.Value) then return end
local below = false
for _, part in ipairs(target:GetChildren()) do
if part.Name == "Ballast" and part:FindFirstChild("Stabilizer") then
below = part.CFrame:toObjectSpace(CFrame.new(position)).p.Y < 3 -- (near her waterline or under it)
end
end
hull.Value = math.max(0, hull.Value - HIT_DAMAGE * (below and LOW_HIT or 1))
end
local function fire(entry, player)
if sunkValue.Value or not stillOn(entry) then return end
if player and not crewman(player) then return end
if tick() - entry.lastShot < RELOAD then return end
entry.lastShot = tick()
local cannon = entry.part
-- Out of the side it is on, aimed a little up.
local out = cannon.CFrame.rightVector
if out:Dot(cannon.Position - middle.Position) < 0 then out = -out end
local aim = (out + Vector3.new(0, BALL_RISE, 0)).unit
local ball = Instance.new("Part")
ball.Name = "Cannonball"
ball.Shape = Enum.PartType.Ball
ball.Size = Vector3.new(1.5, 1.5, 1.5)
ball.Material = Enum.Material.Metal
ball.BrickColor = BrickColor.new("Really black")
ball.TopSurface = Enum.SurfaceType.Smooth
ball.BottomSurface = Enum.SurfaceType.Smooth
ball.CFrame = CFrame.new(cannon.Position + out * (cannon.Size.X / 2 + 2.5)) -- (well clear of her own side)
ball.Velocity = aim * BALL_SPEED + middle.Velocity
local float = Instance.new("BodyForce")
float.force = Vector3.new(0, ball:GetMass() * workspace.Gravity * BALL_FLOAT, 0)
float.Parent = ball
ball.Parent = workspace
debris:AddItem(ball, BALL_LIFE)
local bang = Instance.new("Sound", cannon)
bang.SoundId = "brixborg:cannon"
bang.Volume = 0.7
bang.Pitch = 0.9 + math.random() * 0.2
bang:Play()
debris:AddItem(bang, 2)
local smoke = Instance.new("Smoke", cannon)
debris:AddItem(smoke, 0.6)
local landed = false
local splashed = false
ball.Touched:connect(function(hit)
if landed or hit:IsDescendantOf(ship) or hit.Name == "Cannonball" then return end
if hit.Material == Enum.Material.Water then
-- Into the sea: a splash (once). The ball carries on, so it can
-- still hole a hull below the waterline.
if not splashed then
splashed = true
local sound = Instance.new("Sound", ball)
sound.SoundId = "brixborg:splash_big"
sound.Volume = 0.7
sound:Play()
end
return
end
local character = player and player.Character
if character and hit:IsDescendantOf(character) then return end
landed = true
local target = shipOf(hit)
if target then damage(target, ball.Position) end
if hit.Material == Enum.Material.Wood or hit.Material == Enum.Material.WoodPlanks then
local crack = Instance.new("Sound", hit) -- (splintering planks)
crack.SoundId = "brixborg:crack"
crack.Volume = 0.8
crack:Play()
debris:AddItem(crack, 1)
end
burst(ball.Position, player)
ball:Destroy()
end)
end
for _, part in ipairs(ship:GetChildren()) do
if part.Name == "Cannon" then
local entry = remembered(part)
entry.lastShot = -RELOAD
-- For aiming: the weld that holds it to the deck, as it was built.
for _, weld in ipairs(ship:GetDescendants()) do
if weld:IsA("Weld") and weld.Part1 == part then entry.weld = weld end
end
entry.base = entry.weld and entry.weld.C0
entry.out = part.CFrame.rightVector:Dot(part.Position - middle.Position) > 0 and 1 or -1
entry.yaw, entry.pitch = 0, 0
table.insert(cannons, entry)
local click = Instance.new("ClickDetector")
click.MaxActivationDistance = CANNON_REACH
click.Parent = part
click.MouseClick:connect(function(player) fire(entry, player) end)
end
end
-- ---------------------------------------------------------------------------
-- Gunners: sit on the seat behind a cannon, and click to fire it
-- ---------------------------------------------------------------------------
local gunnerControls = script:WaitForChild("GunnerControls")
local gunFor = {} -- seat -> the cannon in front of it
for _, seat in ipairs(ship:GetChildren()) do
if seat.Name == "GunnerSeat" then
local best, bestDistance = nil, math.huge
for _, entry in ipairs(cannons) do
local distance = (entry.part.Position - seat.Position).magnitude
if distance < bestDistance then best, bestDistance = entry, distance end
end
gunFor[seat] = best
local screen, gunner = nil, nil
seat:GetPropertyChangedSignal("Occupant"):connect(function()
-- (Take the last gunner's controls off their screen.)
if screen then screen:Destroy() screen = nil end
local gui = gunner and gunner:FindFirstChild("PlayerGui")
local display = gui and gui:FindFirstChild("Gunner")
if display then display:Destroy() end
gunner = nil
local humanoid = seat.Occupant
local player = humanoid and players:GetPlayerFromCharacter(humanoid.Parent)
if not player or not crewman(player) then return end
gunner = player
screen = gunnerControls:Clone()
local link = Instance.new("ObjectValue")
link.Name = "Ship"
link.Value = ship
link.Parent = screen
local mine = Instance.new("ObjectValue")
mine.Name = "Seat"
mine.Value = seat
mine.Parent = screen
screen.Disabled = false
screen.Parent = player:FindFirstChild("PlayerGui") or Instance.new("PlayerGui", player)
end)
end
end
-- Swing a cannon round (it turns about its middle), within its limits.
local function aim(entry, turn, raise)
if not entry.weld then return end
entry.yaw = math.max(-AIM_TURN, math.min(AIM_TURN, entry.yaw + turn))
entry.pitch = math.max(-AIM_DIP, math.min(AIM_RAISE, entry.pitch + raise))
entry.weld.C0 = entry.base * CFrame.Angles(0, math.rad(entry.yaw), 0) * CFrame.Angles(0, 0, math.rad(entry.out * entry.pitch))
end
gunnerEvent.OnServerEvent:connect(function(player, seat, action, steer, lift, seconds)
local entry = gunFor[seat]
local humanoid = seat and seat.Occupant
if not entry or not humanoid or players:GetPlayerFromCharacter(humanoid.Parent) ~= player then return end
if action == "aim" then
-- (D swings it to the gunner's right, W raises it.)
local step = AIM_SPEED * math.min(seconds or 0.1, 0.25)
aim(entry, -steer * step, lift * step)
else
fire(entry, player)
end
end)
broadside.OnServerEvent:connect(function(player, side)
-- (Only whoever is at the helm can call for a broadside.)
local occupant = helm.Occupant
if not occupant or players:GetPlayerFromCharacter(occupant.Parent) ~= player then return end
local right = middle.CFrame.rightVector
for _, entry in ipairs(cannons) do
local onRight = (entry.part.Position - middle.Position):Dot(right) > 0
if (side == "right") == onRight then
fire(entry, player)
wait(0.15)
end
end
end)
-- ---------------------------------------------------------------------------
-- The helm: who is steering, and their display
-- ---------------------------------------------------------------------------
local helmControls = script:WaitForChild("HelmControls")
local captain, captainScreen = nil, nil
local removeHelmScreen
-- Take the display off the captain's screen.
removeHelmScreen = function()
if captainScreen then captainScreen:Destroy() end
local gui = captain and captain:FindFirstChild("PlayerGui")
local display = gui and gui:FindFirstChild("Helm")
if display then display:Destroy() end
end
local function changeCaptain()
local humanoid = helm.Occupant
local player = humanoid and players:GetPlayerFromCharacter(humanoid.Parent)
if player and not crewman(player) then player = nil end -- (sitting there, but not her crew)
if player == captain then return end
removeHelmScreen()
captain, captainScreen = player, nil
if player then
local controls = helmControls:Clone()
local link = Instance.new("ObjectValue")
link.Name = "Ship"
link.Value = ship
link.Parent = controls
controls.Disabled = false
controls.Parent = player:FindFirstChild("PlayerGui") or Instance.new("PlayerGui", player)
captainScreen = controls
end
end
-- Sails up and down: a notch for each press of W or S. (Nobody at the
-- helm: she keeps her sails as they were.)
-- Holding the key keeps going, a notch every NOTCH_EVERY seconds.
local holding = 0
helm:GetPropertyChangedSignal("Throttle"):connect(function()
local throttle = helm.Throttle
holding = holding + 1
local mine = holding
while throttle ~= 0 and helm.Throttle == throttle and holding == mine and not sunkValue.Value and captain do
sailLevel.Value = math.max(0, math.min(SAIL_NOTCHES, sailLevel.Value + throttle))
wait(NOTCH_EVERY)
end
end)
-- ---------------------------------------------------------------------------
-- Sinking, and the new ship
-- ---------------------------------------------------------------------------
local function sink()
sunkValue.Value = true
sailLevel.Value = 0
if lift then lift:Destroy() end
if gyro then gyro.maxTorque = Vector3.new(0, 0, 0) end -- (she can list and roll as she goes)
sails.maxForce = Vector3.new(0, weight * workspace.Gravity * FLOOD_WEIGHT, 0)
sails.velocity = Vector3.new(0, -40, 0)
helm.Disabled = true
local gurgle = Instance.new("Sound", middle) -- (bubbling as she goes down)
gurgle.SoundId = "brixborg:bubbles_loop"
gurgle.Looped = true
gurgle.Volume = 0.8
gurgle:Play()
wait(SINK_TIME)
removeHelmScreen()
-- (The new ship goes in first: this script goes with the wreck, and
-- stops. The new ship's own copy of it starts everything again.)
if RESPAWNS and spawnCopy then spawnCopy.Parent = ship.Parent end
ship:Destroy()
end
-- ---------------------------------------------------------------------------
-- Every moment
-- ---------------------------------------------------------------------------
local heading = math.atan2(-middle.CFrame.lookVector.X, -middle.CFrame.lookVector.Z)
local speed = 0
local water = 0
if gyro then gyro.maxTorque = Vector3.new(gyro.maxTorque.X, gyro.maxTorque.X, gyro.maxTorque.Z) end -- (it steers too)
while ship.Parent and not sunkValue.Value do
local passed = wait(0.05)
changeCaptain()
if not middle.Parent or middle.Parent ~= ship then
water = 1 -- (her keel is broken)
end
-- ---- Speed and steering ----
local inWater = workspace:FindWaterAt(middle.Position) ~= nil
local wanted = inWater and TOP_SPEED * sailLevel.Value / SAIL_NOTCHES or 0
if speed < wanted then speed = math.min(wanted, speed + SPEED_CHANGE * passed)
else speed = math.max(wanted, speed - SPEED_CHANGE * passed) end
local turning = TURN_RATE * (SLOWEST_TURN + (1 - SLOWEST_TURN) * speed / TOP_SPEED)
if captain then heading = heading - helm.Steer * turning * passed end
local facing = CFrame.Angles(0, heading, 0)
if gyro then gyro.cframe = facing end
speedValue.Value = speed
-- ---- Water in the hold ----
local leaking = math.max(0, LEAK_BELOW - hullValue.Value) * LEAK
water = math.max(0, math.min(1, water + (leaking - PUMPS) * passed))
if hullValue.Value <= 0 then water = 1 end
floodValue.Value = water
local push = inWater and weight * PUSH or 0
sails.maxForce = Vector3.new(push, weight * workspace.Gravity * FLOOD_WEIGHT * water, push)
sails.velocity = facing.lookVector * speed + Vector3.new(0, -40, 0)
if water >= 1 then sink() end
end