Skip to content

Instantly share code, notes, and snippets.

@magicoal-nerb
Last active April 17, 2025 22:50
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
-- poopbarrel/magical_noob
-- @submodule quakec
-- @description another quake movement reimplementation ig
-- Physics
local XZ = Vector3.new(1.0, 0.0, 1.0);
local CAPSULE_FLOOR_ANGLE = 0.7;
-- Convars
local PL_HITBOX_SIZE = Vector3.new(4, 6, 4);
local PL_GROUND_ACCELERATION = 500;
local PL_GROUND_FRICTION = 10;
local PL_AIR_ACCELERATION = 500;
local PL_GRAVITY = 100;
local PL_MAX_AIR = 2;
-- Keymap
local QUAKEC_KEYMAP = {
Enum.KeyCode.W,
Enum.KeyCode.A,
Enum.KeyCode.S,
Enum.KeyCode.D
};
local QUAKEC_KEYMAP_VEC = {
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,
velocity,
accelerate,
maxVelocity,
dt
)
-- Constrain min(dot(v, i + i*dt), maxVel)
local projection = velocity:Dot(input);
local accel = math.max(math.min(accelerate * dt, maxVelocity - projection), 0.0);
return velocity + accel * input;
end
local function utilClosestPointOnLines(a, dirA, b, dirB)
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)
-- 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,
rayDir,
radius,
origin,
normal
)
-- 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, normal)
-- Minimize dot(disp, normal), given velocity + normal*lambda_1
local dot = math.min(disp:Dot(normal), 0.0);
return disp - normal * dot;
end
function quakec.new(character)
local overlap = OverlapParams.new();
overlap.FilterDescendantsInstances = { character };
overlap.FilterType = Enum.RaycastFilterType.Exclude;
local rootPart = character:WaitForChild("HumanoidRootPart");
assert(rootPart, "Could not find a rootpart! You have to specify it yourself.");
rootPart.Transparency = 1.0;
rootPart.Anchored = true;
return setmetatable({
position = rootPart.Position;
velocity = rootPart.Velocity;
-- parameters
height = PL_HITBOX_SIZE * Vector3.yAxis * 0.5;
size = PL_HITBOX_SIZE;
radius = 1;
input = 0;
rootPart = rootPart;
character = character;
overlap = overlap;
}, quakec);
end
function quakec:move(delta)
-- 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()
-- 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()
-- Custom I guess
return Vector3.yAxis * 25;
end
function quakec:update(dt)
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 *= math.max(speed - drop, 0.0) / speed;
end
velocity = quakeAccelerate(
wishDir,
velocity,
PL_GROUND_ACCELERATION,
16,
dt
) * XZ;
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.CFrame = CFrame.new(self.position) * plane;
end
function quakec:bind()
-- 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()
-- 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