[SOLVED] Is pathfinding possible for dynamic levels

For some background
I saw a game called “lethal company”, and I thought I should try and remake it for a long term project.
It’s important to note that everything is inspired by lethal company, but I want my own aspects + performance will also shape the project.

  1. I have simple creatures in the game, using nodes on the floors (since levels are procedurally generated) so we can’t pre calculate, nodes will appear and disappear as the player moves through the levels.
  2. Creatures need to have some type of algorithm (weather BSF or A* etc will depend on performance, and amount)
  3. Creatures need each a unique ability, as for the brakken (sneaks up behind you, if you see it it runs away, has an anger meter, which at 100% will chase you even when looking at it, looking at it to make it run away adds to the anger meter) (see this channel for details on specifics for each AI)

What I want to achieve
Some performance friendly way to have a creature move around objects, walls to get the player in dynamically generated environments.

I have the creature setup for the coil head (wheeping angel) set up already, though it doesn’t use any pathfinding.
current pathfinding code here:

var DynamicPathfinding = pc.createScript('dynamicPathfinding');

// --- Attributes --- //
DynamicPathfinding.attributes.add('target', {
    type: 'entity',
    title: 'Target Entity',
    description: 'Entity to navigate toward'
});

DynamicPathfinding.attributes.add('margin', {
    type: 'number',
    default: 2,
    title: 'Grid Margin',
    description: 'Extra margin around the min/max bounding box of the entity + target'
});

DynamicPathfinding.attributes.add('moveSpeed', {
    type: 'number',
    default: 2,
    title: 'Move Speed',
    description: 'Movement speed of the entity'
});

DynamicPathfinding.attributes.add('updateInterval', {
    type: 'number',
    default: 0.5,
    title: 'Re-path Interval',
    description: 'Seconds between BFS recalculations'
});

DynamicPathfinding.attributes.add('useDiagonals', {
    type: 'boolean',
    default: false,
    title: 'Use Diagonal Neighbors',
    description: 'If true, BFS checks 8 directions instead of 4'
});

// --- Internal ---
DynamicPathfinding.prototype.initialize = function () {
    if (!this.app.systems.rigidbody) {
        console.error("No rigidbody system in the project.");
        return;
    }

    this.path = [];
    this.currentNodeIndex = 0;
    this.timer = 0;
    if (!this.target) {
        console.error("No target assigned!");
    }
};

DynamicPathfinding.prototype.update = function (dt) {
    this.timer += dt;

    // Recalculate path after each interval
    if (this.timer >= this.updateInterval && this.target) {
        this.timer = 0;
        this.calculatePath();
    }

    // Move if we have a path
    if (this.path.length > 0) {
        this.followPath(dt);
    }
};

// --------------------------------------------------
// 1) Calculate BFS grid dynamically
// --------------------------------------------------
DynamicPathfinding.prototype.calculatePath = function () {
    // 1. Determine bounding box that covers the entity + target
    let entityPos = this.entity.getPosition();
    let targetPos = this.target.getPosition();

    let ex = Math.floor(entityPos.x);
    let ez = Math.floor(entityPos.z);
    let tx = Math.floor(targetPos.x);
    let tz = Math.floor(targetPos.z);

    let minX = Math.min(ex, tx) - this.margin;
    let maxX = Math.max(ex, tx) + this.margin;
    let minZ = Math.min(ez, tz) - this.margin;
    let maxZ = Math.max(ez, tz) + this.margin;

    this.width  = maxX - minX + 1;  // # cells in X
    this.height = maxZ - minZ + 1;  // # cells in Z

    // Store offset so we can map from "grid space" back to "world space"
    this.offsetX = minX;
    this.offsetZ = minZ;

    // Build grid array
    this.grid = new Array(this.width);
    for (let x = 0; x < this.width; x++) {
        this.grid[x] = new Array(this.height);
        for (let z = 0; z < this.height; z++) {
            this.grid[x][z] = {
                moveable: true,
                visited: false,
                parent: null
            };
        }
    }

    // 3. Raycast each cell to detect obstacles
    for (let gx = 0; gx < this.width; gx++) {
        for (let gz = 0; gz < this.height; gz++) {
            // Convert grid coords => world coords
            let worldX = gx + this.offsetX + 0.5;
            let worldZ = gz + this.offsetZ + 0.5;

            let start = new pc.Vec3(worldX, 2, worldZ);
            let end   = new pc.Vec3(worldX, -1, worldZ);

            let moveable = true;
            try {
                let result = this.app.systems.rigidbody.raycastFirst(start, end);
                if (result) {
                    let hitEntity = result.entity;
                    if (hitEntity) {
                        // If we hit ourselves or the target, ignore
                        if (hitEntity === this.entity || hitEntity === this.target) {
                            // do nothing, keep moveable = true
                        } else {
                            let name = (hitEntity.name || "").toLowerCase();
                            // If it's not a "floor" or "door," consider it blocked
                            // (Adjust if you want doors to block movement, etc.)
                            if (name !== "floor" && name !== "door" && hitEntity.collision) {
                                moveable = false;
                            }
                        }
                    }
                }
            } catch (err) {
                console.error("Raycast error:", err);
            }
            this.grid[gx][gz].moveable = moveable;
        }
    }

    // 4. Convert entity/target world coords => local grid coords
    let startGrid = {
        x: ex - this.offsetX,
        z: ez - this.offsetZ
    };
    let targetGrid = {
        x: tx - this.offsetX,
        z: tz - this.offsetZ
    };

    // Check bounds again (margin might not be enough if entity is way outside)
    if (!this.inBounds(startGrid.x, startGrid.z)) {
        console.warn("Entity grid position out of range after margin expansion:", startGrid);
        this.path = [];
        return;
    }
    if (!this.inBounds(targetGrid.x, targetGrid.z)) {
        console.warn("Target grid position out of range after margin expansion:", targetGrid);
        this.path = [];
        return;
    }

    // 5. BFS from startGrid => targetGrid
    for (let x = 0; x < this.width; x++) {
        for (let z = 0; z < this.height; z++) {
            this.grid[x][z].visited = false;
            this.grid[x][z].parent = null;
        }
    }
    this.path = this.bfsPath(startGrid, targetGrid);

    // If BFS fails, path = []
    if (this.path.length === 0) {
        // BFS couldn't find a route
        // (obstacles or not enough margin, etc.)
        // console.log("No path found from", startGrid, "to", targetGrid);
    } else {
        this.currentNodeIndex = 0;
    }
};

// --------------------------------------------------
// 2) BFS to find path in local grid
// --------------------------------------------------
DynamicPathfinding.prototype.bfsPath = function (start, target) {
    let queue = [start];
    this.grid[start.x][start.z].visited = true;

    while (queue.length > 0) {
        let current = queue.shift();
        // Found target?
        if (current.x === target.x && current.z === target.z) {
            // Reconstruct
            return this.reconstructPath(target);
        }

        // Enqueue neighbors
        let neighbors = this.getNeighbors(current);
        for (let i = 0; i < neighbors.length; i++) {
            let n = neighbors[i];
            if (this.inBounds(n.x, n.z) &&
                !this.grid[n.x][n.z].visited &&
                this.grid[n.x][n.z].moveable) {
                this.grid[n.x][n.z].visited = true;
                this.grid[n.x][n.z].parent = current;
                queue.push(n);
            }
        }
    }

    return [];
};

DynamicPathfinding.prototype.getNeighbors = function (node) {
    // 4 or 8 directions
    let directions;
    if (this.useDiagonals) {
        directions = [
            { x: -1, z:  0 },
            { x:  1, z:  0 },
            { x:  0, z: -1 },
            { x:  0, z:  1 },
            { x: -1, z: -1 },
            { x: -1, z:  1 },
            { x:  1, z: -1 },
            { x:  1, z:  1 }
        ];
    } else {
        directions = [
            { x: -1, z:  0 },
            { x:  1, z:  0 },
            { x:  0, z: -1 },
            { x:  0, z:  1 }
        ];
    }

    let result = [];
    for (let i = 0; i < directions.length; i++) {
        let nx = node.x + directions[i].x;
        let nz = node.z + directions[i].z;
        result.push({ x: nx, z: nz });
    }
    return result;
};

DynamicPathfinding.prototype.inBounds = function (x, z) {
    return (x >= 0 && x < this.width && z >= 0 && z < this.height);
};

// BFS: build path from target->start
DynamicPathfinding.prototype.reconstructPath = function (end) {
    let path = [];
    let current = end;
    while (current) {
        path.push(current);
        let parent = this.grid[current.x][current.z].parent;
        current = parent || null;
    }
    path.reverse();
    return path;
};

// --------------------------------------------------
// 3) Follow the path with physics
// --------------------------------------------------
DynamicPathfinding.prototype.followPath = function (dt) {
    if (this.currentNodeIndex >= this.path.length) {
        return; // done
    }

    let node = this.path[this.currentNodeIndex];
    // Convert local grid coords back to world space
    let worldX = node.x + this.offsetX + 0.5;
    let worldZ = node.z + this.offsetZ + 0.5;

    let pos = this.entity.getPosition();
    let targetPos = new pc.Vec3(worldX, pos.y, worldZ);

    let direction = targetPos.clone().sub(pos);
    let distance = direction.length();

    // If close, go to next node
    if (distance < 0.1) {
        this.entity.rigidbody.linearVelocity = pc.Vec3.ZERO;
        this.currentNodeIndex++;
    } else {
        // Move toward the node
        direction.normalize();
        let velocity = direction.scale(this.moveSpeed);
        this.entity.rigidbody.linearVelocity = velocity;
    }
};

This one is pretty simple, and does have some errors, jitter issues etc.

Any help to improvements, better my AI agents etc, will be greatly appreciated. I know it’s difficult given my constraints. Thank you for reading.

I used @Albertos 's enemy AI script, and updated it for dynamic entities. Works very well, and it looks very nice. Big shout out to him for this amazing script!

I’ve been working on a version that also supports dynamic rigidbodies, but I never finished it. Please let me know if you have any problems!

Thank you very much! :pray:

1 Like

It wasn’t very hard, I use forces to move it etc, but I do have an issue with detecting smaller obstacles. It seems to work well for big ones, but not small ones (1 by 1 render primitive cubes) makes it a bit iffy in certain places, for example I have a walking lattice with handrails, they are pretty thin, and the AI doesn’t seem to notice them. If you have any insights that would be very appreciated! Again, thank you for the script!!

Did you check if the sensors have to correct length and height for your scene? You can enable the Show Sensors attribute to see the sensors. If they have the correct length and height, you can make the size of the collision a little bigger than the visual entity maybe?

1 Like

Yeah I have the debug sensors turned on for this as I knew this could help fix it. So, setting the height higher, and the length smaller, (1.2, and 0.2) actually fixed the issue. I have corners of the rooms, where it used to get stuck on, but after messing with the settings I got it working pretty well! I appreciate your help with this as well, and it will definitely make the game more scary when a creature chases you through hallways, around objects etc just to kill you :laughing:

Great! It should be unable to get stuck in a corner, but maybe the combination of settings is causing this anyway unfortunately. If you are be able to share your project (can be in a private message) I can see if there is a way to optimize it.

1 Like

That would be very much appreciated! Yes, it still will get stuck in corners etc… even after the changes. I will privately DM you with the editor link.

1 Like