function [obj] = constrainMotion(obj)
    arguments (Input)
        obj (1, 1) {mustBeA(obj, "miSim")};
    end
    arguments (Output)
        obj (1, 1) {mustBeA(obj, "miSim")};
    end

    nAgents = size(obj.agents, 1);

    if nAgents < 2
        nAAPairs = 0;
    else
        nAAPairs = nchoosek(nAgents, 2); % unique agent/agent pairs
    end

    % Compute velocity matrix from unconstrained gradient-ascent step
    v = zeros(nAgents, 3);
    for ii = 1:nAgents
        v(ii, :) = (obj.agents{ii}.pos - obj.agents{ii}.lastPos) ./ obj.timestep;
    end
    if all(isnan(v), "all") || all(v == zeros(nAgents, 3), "all")
        % Agents are not attempting to move, so there is no motion to be
        % constrained
        return;
    end

    % Initialize QP based on number of agents and obstacles
    nAOPairs = nAgents * size(obj.obstacles, 1); % unique agent/obstacle pairs
    nADPairs = nAgents * 6; % agents x (4 walls + 1 floor + 1 ceiling)
    nLNAPairs = sum(obj.constraintAdjacencyMatrix, "all") - nAgents;
    total = nAAPairs + nAOPairs + nADPairs + nLNAPairs;
    kk = 1;
    A = zeros(total, 3 * nAgents);
    b = zeros(total, 1);

    % Set up collision avoidance constraints
    h = NaN(nAgents, nAgents);
    h(logical(eye(nAgents))) = 0; % self value is 0
    for ii = 1:(nAgents - 1)
        for jj = (ii + 1):nAgents
            h(ii, jj) = norm(obj.agents{ii}.pos - obj.agents{jj}.pos)^2 - (obj.agents{ii}.collisionGeometry.radius + obj.agents{jj}.collisionGeometry.radius)^2;
            h(jj, ii) = h(ii, jj);

            A(kk, (3 * ii - 2):(3 * ii)) =  -2 * (obj.agents{ii}.pos - obj.agents{jj}.pos);
            A(kk, (3 * jj - 2):(3 * jj)) = -A(kk, (3 * ii - 2):(3 * ii));
            % Slack derived from existing params: recovery velocity = max gradient approach velocity.
            % Correction splits between 2 agents, so |A| = 2*r_sum
            r_sum_ij = obj.agents{ii}.collisionGeometry.radius + obj.agents{jj}.collisionGeometry.radius;
            v_max_ij = max(obj.agents{ii}.initialStepSize, obj.agents{jj}.initialStepSize) / obj.timestep;
            hMin = -(4 * r_sum_ij * v_max_ij / obj.barrierGain)^(1 / obj.barrierExponent);
            if norm(A(kk, :)) < 1e-9
                % Agents are coincident: A-row is zero, so b < 0 would make
                % 0 ≤ b unsatisfiable. Fall back to b = 0 (no correction possible).
                b(kk) = 0;
            else
                b(kk) = obj.barrierGain * max(hMin, h(ii, jj))^obj.barrierExponent;
            end
            kk = kk + 1;
        end
    end

    hObs = NaN(nAgents, size(obj.obstacles, 1));
    % Set up obstacle avoidance constraints
    for ii = 1:nAgents
        for jj = 1:size(obj.obstacles, 1)
            % find closest position to agent on/in obstacle
            cPos = obj.obstacles{jj}.closestToPoint(obj.agents{ii}.pos);

            hObs(ii, jj) = dot(obj.agents{ii}.pos - cPos, obj.agents{ii}.pos - cPos) - obj.agents{ii}.collisionGeometry.radius^2;

            A(kk, (3 * ii - 2):(3 * ii)) = -2 * (obj.agents{ii}.pos - cPos);
            % Floor for single-agent constraint: full correction on one agent, |A| = 2*r_i
            r_i = obj.agents{ii}.collisionGeometry.radius;
            v_max_i = obj.agents{ii}.initialStepSize / obj.timestep;
            hMin = -(2 * r_i * v_max_i / obj.barrierGain)^(1 / obj.barrierExponent);
            b(kk) = obj.barrierGain * max(hMin, hObs(ii, jj))^obj.barrierExponent;

            kk = kk + 1;
        end
    end

    % Set up domain constraints (walls and ceiling only)
    % Floor constraint is implicit with an obstacle corresponding to the
    % minimum allowed altitude, but I included it anyways
    h_xMin = 0.0; h_xMax = 0.0; h_yMin = 0.0; h_yMax = 0.0; h_zMin = 0.0; h_zMax = 0.0;
    for ii = 1:nAgents
        % X minimum
        h_xMin = (obj.agents{ii}.pos(1) - obj.domain.minCorner(1)) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [-1, 0, 0];
        b(kk) = obj.barrierGain * max(0, h_xMin)^obj.barrierExponent;
        kk = kk + 1;

        % X maximum
        h_xMax = (obj.domain.maxCorner(1) - obj.agents{ii}.pos(1)) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [1, 0, 0];
        b(kk) = obj.barrierGain * max(0, h_xMax)^obj.barrierExponent;
        kk = kk + 1;

        % Y minimum
        h_yMin = (obj.agents{ii}.pos(2) - obj.domain.minCorner(2)) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [0, -1, 0];
        b(kk) = obj.barrierGain * max(0, h_yMin)^obj.barrierExponent;
        kk = kk + 1;

        % Y maximum
        h_yMax = (obj.domain.maxCorner(2) - obj.agents{ii}.pos(2)) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [0, 1, 0];
        b(kk) = obj.barrierGain * max(0, h_yMax)^obj.barrierExponent;
        kk = kk + 1;

        % Z minimum — enforce z >= minAlt + radius (not just z >= domain floor + radius)
        h_zMin = (obj.agents{ii}.pos(3) - obj.minAlt) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [0, 0, -1];
        b(kk) = obj.barrierGain * max(0, h_zMin)^obj.barrierExponent;
        kk = kk + 1;

        % Z maximum
        h_zMax = (obj.domain.maxCorner(3) - obj.agents{ii}.pos(3)) - obj.agents{ii}.collisionGeometry.radius;
        A(kk, (3 * ii - 2):(3 * ii)) = [0, 0, 1];
        b(kk) = obj.barrierGain * max(0, h_zMax)^obj.barrierExponent;
        kk = kk + 1;
    end

    if coder.target('MATLAB')
        % Save off h function values (logging only — not needed in compiled mode)
        obj.h(:, obj.timestepIndex) = [h(triu(true(nAgents), 1)); reshape(hObs, [], 1); h_xMin; h_xMax; h_yMin; h_yMax; h_zMin; h_zMax;];
    end

    % Add communication network constraints
    hComms = NaN(nAgents, nAgents);
    hComms(logical(eye(nAgents))) = 0;
    for ii = 1:(nAgents - 1)
        for jj = (ii + 1):nAgents
            if obj.constraintAdjacencyMatrix(ii, jj)
                hComms(ii, jj) = min([obj.agents{ii}.commsGeometry.radius, obj.agents{jj}.commsGeometry.radius])^2 - norm(obj.agents{ii}.pos - obj.agents{jj}.pos)^2;

                A(kk, (3 * ii - 2):(3 * ii)) =  2 * (obj.agents{ii}.pos - obj.agents{jj}.pos);
                A(kk, (3 * jj - 2):(3 * jj)) = -A(kk, (3 * ii - 2):(3 * ii));
                b(kk) = obj.barrierGain * max(0, hComms(ii, jj))^obj.barrierExponent;

                kk = kk + 1;
            end
        end
    end

    % Solve QP program generated earlier
    vhat = reshape(v', 3 * nAgents, 1);
    H = 2 * eye(3 * nAgents);
    f = -2 * vhat;

    % Update solution based on constraints
    if coder.target('MATLAB')
        assert(size(A,2) == size(H,1))
        assert(size(A,1) == size(b,1))
        assert(size(H,1) == length(f))
    end
    opt = optimoptions("quadprog", "Display", "off", "Algorithm", "active-set", "UseCodegenSolver", true);
    x0 = zeros(size(H, 1), 1);
    [vNew, ~, exitflag] = quadprog(H, double(f), A, b, [], [], [], [], x0, opt);
    vNew = reshape(vNew, 3, nAgents)';

    if exitflag < 0
        % Infeasible or other hard failure: hold all agents at current positions
        if coder.target('MATLAB')
            warning("QP infeasible (exitflag=%d), holding positions.", int16(exitflag));
        else
            fprintf("[constrainMotion] QP infeasible (exitflag=%d), holding positions\n", int16(exitflag));
        end
        vNew = zeros(nAgents, 3);
    elseif exitflag == 0
        % Max iterations exceeded: use suboptimal solution already in vNew
        if coder.target('MATLAB')
            warning("QP max iterations exceeded, using suboptimal solution.");
        else
            fprintf("[constrainMotion] QP max iterations exceeded, using suboptimal solution\n");
        end
    end

    % Update the "next position" that was previously set by unconstrained
    % GA using the constrained solution produced here
    for ii = 1:size(vNew, 1)
        obj.agents{ii}.pos = obj.agents{ii}.lastPos + vNew(ii, :) * obj.timestep;
    end

    % Here we run this at the simulation level, but in reality there is no
    % parent level, so this would be run independently on each agent.
    % Running at the simulation level is just meant to simplify the
    % simulation

end