Skip to content

Instantly share code, notes, and snippets.

@magicoal-nerb
Last active July 20, 2025 10:02
Show Gist options
  • Save magicoal-nerb/49c3831fd63004debf6ab774f42331be to your computer and use it in GitHub Desktop.
Save magicoal-nerb/49c3831fd63004debf6ab774f42331be to your computer and use it in GitHub Desktop.
really simple quake movement in roblx
--!strict
-- poopbarrel/magical_noob
-- @submodule quakec
-- @description another quake movement reimplementation ig
-- Physics
local XZ: Vector3 = Vector3.new(1.0, 0.0, 1.0)
local CAPSULE_FLOOR_ANGLE: number = 0.7
-- Convars
local PL_HITBOX_SIZE: Vector3 = Vector3.new(4, 6, 4)
local PL_GROUND_ACCELERATION: number = 500
local PL_AIR_ACCELERATION: number = 500
local PL_GROUND_FRICTION: number = 10
local PL_GRAVITY: number = 100
local PL_MAX_AIR: number = 2
-- Keymap
local QUAKEC_KEYMAP: { Enum.KeyCode } = {
Enum.KeyCode.W,
Enum.KeyCode.A,
Enum.KeyCode.S,
Enum.KeyCode.D,
}
local QUAKEC_KEYMAP_VEC: { Vector3 } = {
Vector3.new(0, 0, -1),
Vector3.new(-1, 0, 0),
Vector3.new(0, 0, 1),
Vector3.new(1, 0, 0),
}
-- Services
local ContextActionService = game:GetService("ContextActionService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local CurrentCamera = workspace.CurrentCamera
local quakec = {}
quakec.__index = quakec
local function quakeAccelerate(
input: Vector3,
velocity: Vector3,
accelerate: number,
maxVelocity: number,
dt: number
): Vector3
-- Constrain min(dot(v, i + i*dt), maxVel)
local projection: number = velocity:Dot(input)
local accel: number = math.max(math.min(accelerate * dt, maxVelocity - projection), 0.0)
return velocity + accel * input
end
local function utilClosestPointOnLines(
a: Vector3,
dirA: Vector3,
b: Vector3,
dirB: Vector3
): (Vector3, Vector3)
local ba = a - b
local axb = dirA:Cross(dirB)
-- For our line, our space is within 0<=t<=1, so clamp the result.
if axb.Magnitude < 1e-3 then
-- Degenerate case we have to get the closest point assuming b is a point.
-- dot(a + dirA*lambda - b, dirA) = 0
-- dot(a - b, dirA) + dot(dirA, dirA)lambda = 0
-- lambda = dot(b - a, dirA)/dot(dirA, dirA)
local t = math.clamp((b-a):Dot(dirA) / dirA:Dot(dirA), 0.0, 1.0)
return a + dirA * t, b
else
-- Consider two lines: a + dirA*lambda_1 = b + dirB*lambda_2
-- ba = dirB*lambda_2 - dirA*lambda_1 let ba = a - b
-- A trick we can do to eliminate one of our unknowns is to cross both sides
-- as a line perpendicular to both a and b is the closest line.
-- cross(ba, dirB) = -cross(dirA,dirB)*lambda_1
-- dot(cross(ba, dirA), cross(dirA, dirB)) = -|cross(dirA, dirB)|^2 * lambda_1
-- -dot(cross(ba, dirA), cross(dirA, dirB)) / |cross(dirA, dirB)|^2 = lambda_1
-- cross(ba, dirA) = cross(dirB,dirA)*lambda_2
-- dot(cross(ba, dirA), cross(dirB, dirA)) = |cross(dirB, dirA)|^2 * lambda_2
-- dot(cross(ba, dirA), cross(dirB, dirA)) / |cross(dirB, dirA)|^2 = lambda_2
-- => -dot(cross(ba, dirA), cross(dirA, dirB)) / |cross(dirA, dirB)|^2 = lambda_2
local invAxb = 1.0 / axb:Dot(axb)
local t1 = math.clamp(-ba:Cross(dirB):Dot(axb) * invAxb, 0.0, 1.0)
local t2 = math.clamp(-ba:Cross(dirA):Dot(axb) * invAxb, 0.0, 1.0)
return a + dirA * t1,
b + dirB * t2
end
end
local function utilQuaternionXZ(quat: CFrame): CFrame
-- Project the quaternion onto the XZ plane
-- making it only the y rotation
local up = quat.UpVector
return CFrame.new(
0, 0, 0,
-up.Z, 0, up.X, up.Y+1
) * quat
end
local function utilSphereRayPlane(
a: Vector3,
rayDir: Vector3,
radius: number,
origin: Vector3,
normal: Vector3
): (Vector3, number)
-- dot(a + rayDir*s - origin, normal)^2 = radius^2
-- dot(a - origin, normal) + dot(rayDir, normal)s = radius
-- s = (radius - dot(a - origin, normal))/dot(rayDir, normal)
local ao = a - origin
local s = (radius - ao:Dot(normal)) / rayDir:Dot(normal)
local sClamped = math.clamp(s, 0.0, 1.0)
return a + rayDir * sClamped, sClamped
end
local function utilConstrain(disp: Vector3, normal: Vector3): Vector3
-- Minimize dot(disp, normal), given velocity + normal*lambda_1
local dot = math.min(disp:Dot(normal), 0.0)
return disp - normal * dot
end
export type quakec = typeof(setmetatable({} :: {
position: Vector3,
velocity: Vector3,
jumping: boolean,
height: Vector3,
size: Vector3,
radius: number,
input: number,
floor: Vector3?,
rootPart: BasePart,
character: Model,
overlap: OverlapParams,
}, quakec))
function quakec.new(character: Model): quakec
local overlap = OverlapParams.new()
overlap.FilterDescendantsInstances = { character }
overlap.FilterType = Enum.RaycastFilterType.Exclude
local rootPart = character:WaitForChild("HumanoidRootPart") :: BasePart
assert(rootPart, "Could not find a rootpart! You have to specify it yourself.")
rootPart.Transparency = 1.0
return setmetatable({
velocity = rootPart.AssemblyLinearVelocity,
position = rootPart.Position,
-- parameters
height = PL_HITBOX_SIZE * Vector3.yAxis * 0.5,
size = PL_HITBOX_SIZE,
radius = 1,
input = 0,
jumping = false,
rootPart = rootPart,
character = character,
overlap = overlap,
}, quakec)
end
function quakec.move(self: quakec, delta: Vector3)
-- Handles collision logic
local position = self.position
local velocity = self.velocity
local radius = self.radius
local height = self.height - Vector3.yAxis * radius
local contacts = {}
local parts = workspace:GetPartBoundsInBox(
CFrame.new(position + delta*0.5),
self.size + delta:Abs() + Vector3.one,
self.overlap
)
local floor
for _, part in parts do
if not part:CanCollideWith(self.rootPart) then
-- Don't bother trying colliding with this.
continue
end
-- Do a quick collision check
local point = part:GetClosestPointOnSurface(position)
local normal = (position - point).Unit
if normal ~= normal then
-- NaN
continue
end
local newPosition, t = utilSphereRayPlane(
position,
delta,
radius,
point,
normal
)
if t < 1.0 then
-- We have a speculative contact!
position = newPosition
velocity = utilConstrain(velocity, normal)
delta = utilConstrain(delta, normal)
end
table.insert(contacts, {
-- We'll extrapolate this for the plane
-- pushing step.
part = part,
p = point,
n = normal,
})
end
local headHeight = height * 0.5
position += delta
for _, contact in contacts do
-- We can get a rough approximation of our collision through
-- 2 points. It's not pretty, but it works, especially since we don't have
-- algorithms like GJK here.
local contactPoint0 = contact.part:GetClosestPointOnSurface(position + headHeight)
local contactPoint1 = contact.part:GetClosestPointOnSurface(position - height)
local p, contactPoint = utilClosestPointOnLines(
position + headHeight,
-height - headHeight,
contactPoint0,
contactPoint1 - contactPoint0
)
-- Plane push step just make sure
-- that we do some discrete checks.
local delta = p - contactPoint
local diff = radius - delta.Magnitude
if diff >= -1e-3 then
local normal = delta.Unit
if normal ~= normal then
-- Prevent NaN
continue
end
position += math.max(diff, 0.0) * normal
if normal.Y > CAPSULE_FLOOR_ANGLE then
-- ! Could have a weird floor angle bug here,
-- but I think this should be fine.
floor = normal
else
velocity = utilConstrain(velocity, normal)
end
end
end
self.position = position
self.velocity = velocity
self.floor = floor
end
function quakec.getWishDirection(self: quakec)
-- Loop through our keymap for the raw input
-- direction.
local rawDir = Vector3.zero
local input = self.input
for i, keyVec in QUAKEC_KEYMAP_VEC do
local mask = bit32.lshift(1, i - 1)
if bit32.btest(input, mask) then
rawDir += keyVec
end
end
local plane = utilQuaternionXZ(CurrentCamera.CFrame.Rotation)
if rawDir:FuzzyEq(Vector3.zero, 1e-3) then
-- No input.
return Vector3.zero, plane
else
-- Use our projection !!
local unit = rawDir.Unit
return plane * unit, plane
end
end
function quakec.getJumpVelocity(self: quakec)
-- Custom I guess
return Vector3.yAxis * 25
end
function quakec.update(self: quakec, dt: number)
dt = math.min(dt, 1/30)
self:move(self.velocity * dt)
local wishDir, plane = self:getWishDirection()
local velocity = self.velocity
velocity -= PL_GRAVITY * Vector3.yAxis * dt
if self.floor and self.jumping then
-- Player is jumping
velocity *= XZ
velocity += self:getJumpVelocity()
elseif self.floor then
-- Apply friction
local speed = velocity.Magnitude
if speed > 0.0 then
local drop = speed * PL_GROUND_FRICTION * dt
velocity = velocity * math.max(speed - drop, 0.0) / speed
end
velocity = quakeAccelerate(
wishDir,
velocity,
PL_GROUND_ACCELERATION,
16,
dt
) * XZ
velocity = velocity - self.floor:Dot(velocity) * Vector3.yAxis
else
-- Apply air physics
local projection = quakeAccelerate(
wishDir,
velocity,
PL_AIR_ACCELERATION,
PL_MAX_AIR,
dt
) * XZ
velocity = (velocity * Vector3.yAxis) + projection
end
self.velocity = velocity
self.rootPart.AssemblyLinearVelocity *= 0
self.rootPart.AssemblyAngularVelocity *= 0
self.rootPart.CFrame = CFrame.new(self.position) * plane
end
function quakec.bind(self: quakec): quakec
-- Creates our own quick input system
local map = {}
for i, key in QUAKEC_KEYMAP do
map[key] = bit32.lshift(1, i - 1)
end
ContextActionService:BindActionAtPriority(
"rbx-quakec-move",
function(_, state, input)
if state == Enum.UserInputState.Begin then
-- Begin user input, just add it to the bitmask.
self.input = bit32.bor(self.input, map[input.KeyCode])
elseif state == Enum.UserInputState.End then
-- Stop user input, remove it from the bitmask.
self.input = bit32.band(self.input, 0xFF - map[input.KeyCode])
end
end,
false,
math.huge,
unpack(QUAKEC_KEYMAP)
)
ContextActionService:BindActionAtPriority(
"rbx-quakec-jump",
function(_, state, input)
if state == Enum.UserInputState.Begin then
-- Begin jumping input
self.jumping = true
elseif state == Enum.UserInputState.End then
-- Stop jumping input
self.jumping = false
end
end,
false,
math.huge,
Enum.KeyCode.Space
)
return self
end
function quakec.hook(self: quakec): quakec
-- Update physics before the camera does
-- so it doesn't jitter.
RunService:BindToRenderStep(
"rbx-quakec-update",
Enum.RenderPriority.Camera.Value - 1,
function(dt)
self:update(dt)
end
)
return self
end
-- Create player
quakec.new(Players.LocalPlayer.Character)
:bind()
:hook()
return quakec
@Nobonet
Copy link

Nobonet commented Apr 17, 2025

sans

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment