Basic enemy AI



var Enemy = pc.createScript('enemy');

// === ATTRIBUTES ===
Enemy.attributes.add('viewDistance', { type: 'number', default: 20 });
Enemy.attributes.add('searchDistance', { type: 'number', default: 10 });
Enemy.attributes.add('attackDistance', { type: 'number', default: 3 });
Enemy.attributes.add('stopDistance', { type: 'number', default: 2 });
Enemy.attributes.add('moveSpeed', { type: 'number', default: 2 });
Enemy.attributes.add('rotateSpeed', { type: 'number', default: 3 });
Enemy.attributes.add('maxHealth', { type: 'number', default: 100 });
Enemy.attributes.add('groundOffset', { type: 'number', default: 0.3, title: 'Vertical Offset (prevent sinking)' });

// Facing fix (for models facing wrong direction)
Enemy.attributes.add('facingDirection', {
    type: 'number',
    enum: [
        { 'Forward (+Z)': 0 },
        { 'Backward (-Z)': 180 },
        { 'Right (+X)': -90 },
        { 'Left (-X)': 90 }
    ],
    default: 0,
    title: 'Facing Direction'
});

// === INIT ===
Enemy.prototype.initialize = function () {
    this.enemy = this.entity;
    this.enemyState = 'Search';
    this.targets = this.app.root.findByTag('Target');
    this.target = null;

    this.currentHealth = this.maxHealth;
    this.isMoving = false;
    this.isAttacking = false;
    this.isAlive = true;
    this.timer = 0;

    // Lock rigidbody rotation on X/Z but allow Y
    if (this.enemy.rigidbody) {
        this.enemy.rigidbody.angularFactor = new pc.Vec3(0, 1, 0);
    }

    // Raise slightly above terrain
    const pos = this.enemy.getPosition();
    this.enemy.setPosition(pos.x, pos.y + this.groundOffset, pos.z);

    // Ensure upright posture (X=90)
    const euler = this.enemy.getEulerAngles();
    this.enemy.setEulerAngles(90, euler.y, 0);

    // Collision detection
    if (this.enemy.collision) {
        this.enemy.collision.on('collisionstart', this.onCollisionStart, this);
    }
};

// === UPDATE ===
Enemy.prototype.update = function (dt) {
    if (!this.isAlive) return;

    this.findTarget();
    this.updateState(dt);
    this.updateAnimation();

    // Maintain upright rotation
    const euler = this.enemy.getEulerAngles();
    this.enemy.setEulerAngles(90, euler.y, 0);
};

// === FIND TARGET ===
Enemy.prototype.findTarget = function () {
    const myPos = this.enemy.getPosition();
    let closest = null;
    let closestDist = Infinity;

    for (let i = 0; i < this.targets.length; i++) {
        const t = this.targets[i];
        const dist = myPos.distance(t.getPosition());
        if (dist < closestDist && dist < this.viewDistance) {
            closest = t;
            closestDist = dist;
        }
    }

    if (closest) {
        this.target = closest;
        this.distanceToTarget = closestDist;
    } else {
        this.target = null;
    }
};

// === STATE MACHINE ===
Enemy.prototype.updateState = function (dt) {
    switch (this.enemyState) {
        case 'Search': this.search(dt); break;
        case 'Chase': this.chase(dt); break;
        case 'Attack': this.attack(dt); break;
    }
};

// === SEARCH ===
Enemy.prototype.search = function (dt) {
    this.timer -= dt;
    if (this.timer <= 0) {
        this.timer = pc.math.random(2, 4);
        this.setRandomDestination();
    }

    this.moveTowards(this.randomDest, dt, this.moveSpeed);

    if (this.target) {
        this.enemyState = 'Chase';
    }
};

// === CHASE ===
Enemy.prototype.chase = function (dt) {
    if (!this.target) {
        this.enemyState = 'Search';
        return;
    }

    const dist = this.moveTowards(this.target.getPosition(), dt, this.moveSpeed * 1.5);

    if (dist < this.attackDistance) {
        this.enemyState = 'Attack';
    }
};

// === ATTACK ===
Enemy.prototype.attack = function (dt) {
    if (!this.target) {
        this.enemyState = 'Search';
        return;
    }

    const dist = this.enemy.getPosition().distance(this.target.getPosition());
    if (dist > this.attackDistance + 0.5) {
        this.enemyState = 'Chase';
        return;
    }

    this.faceTarget(dt);

    if (!this.isAttacking) {
        this.isAttacking = true;
        const attackAnim = 'Attack' + pc.math.randomInt(1, 3);
        this.setAnimation(attackAnim);

        setTimeout(() => {
            this.isAttacking = false;
        }, 1000);
    }
};

// === MOVE TOWARD FUNCTION ===
Enemy.prototype.moveTowards = function (targetPos, dt, speed) {
    if (!targetPos) return 0;

    const pos = this.enemy.getPosition();
    const dir = targetPos.clone().sub(pos);
    dir.y = 0;

    const dist = dir.length();
    if (dist < 0.05) return dist;

    dir.normalize();
    this.faceTarget(dt, dir);

    // Move with safe physics teleport
    const move = dir.clone().scale(speed * dt);
    const newPos = pos.clone().add(move);

    if (this.enemy.rigidbody) {
        this.enemy.rigidbody.teleport(newPos, this.enemy.getRotation());
    } else {
        this.enemy.setPosition(newPos);
    }

    this.isMoving = true;
    return dist;
};

// === FACE TARGET ===
Enemy.prototype.faceTarget = function (dt, dir) {
    if (!dir && this.target) {
        dir = this.target.getPosition().clone().sub(this.enemy.getPosition());
        dir.y = 0;
        dir.normalize();
    }
    if (!dir) return;

    const targetAngle = Math.atan2(dir.x, dir.z) * pc.math.RAD_TO_DEG + this.facingDirection;
    const euler = this.enemy.getEulerAngles();
    const smoothY = pc.math.lerpAngle(euler.y, targetAngle, dt * this.rotateSpeed);

    this.enemy.setEulerAngles(90, smoothY, 0); // Keep upright X=90
};

// === RANDOM DESTINATION ===
Enemy.prototype.setRandomDestination = function () {
    const pos = this.enemy.getPosition();
    const range = this.searchDistance || 10;
    const x = pc.math.random(pos.x - range, pos.x + range);
    const z = pc.math.random(pos.z - range, pos.z + range);
    this.randomDest = new pc.Vec3(x, pos.y, z);
};

// === ANIMATION HANDLER ===
Enemy.prototype.setAnimation = function (name) {
    if (this.enemy.anim && this.enemy.anim.baseLayer) {
        const layer = this.enemy.anim.baseLayer;
        if (layer.activeState !== name) layer.transition(name, 0.2);
    }
};

Enemy.prototype.updateAnimation = function () {
    if (!this.isAlive) return;
    if (this.isAttacking) return;
    if (this.isMoving) this.setAnimation('Walk');
    else this.setAnimation('Idle');
    this.isMoving = false;
};

// === DAMAGE / DEATH ===
Enemy.prototype.takeDamage = function (amount) {
    if (!this.isAlive) return;
    this.currentHealth -= amount;

    const hitAnim = 'Hit' + pc.math.randomInt(1, 2);
    this.setAnimation(hitAnim);

    if (this.currentHealth <= 0) this.die();
};

Enemy.prototype.die = function () {
    this.isAlive = false;
    const deathAnim = 'Death' + pc.math.randomInt(1, 2);
    this.setAnimation(deathAnim);

    setTimeout(() => this.enemy.destroy(), 3000);
};

// === COLLISION ===
Enemy.prototype.onCollisionStart = function (result) {
    if (result.other.tags.has('bullet')) {
        this.takeDamage(25);
        result.other.destroy();
    }
};
// This is the fix i tried but i still cant get it to follow the player let alone attack the player could you help me out

Since PlayCanvas uses the negative Z axis as forward, you need to rotate the model entity 180 degrees relative to its parent.

With the script i sent you just click whether your editor has a -Z balance and i fixed it but the problem comes in at chasing the player

So the enemy moves correctly until it has a target? If that’s the case, you might need to check your faceTarget function.

May I ask why you changed the default script so much?

Because in the new editor version the script you wrote brought up allot of errors so with every error i had to apply a new fix and i also needed it to work with my game structure

But thanks because it worked as a powerful foundation

That’s strange. I will check this and update the project to the new editor version. Thanks!

1 Like

No problem at all but i actually would like to ask you a few more questions (Im new to this engine just came in last month on 23 ) so could you please try to explain to me where the error might be in my code because i dont see any errors

If you share the editor link of your project, I can take a look later today.

Ok sure

https://playcanvas.com/editor/scene/2347747

Alright, I just switched my Basic Enemy AI to the latest editor version, and there are no errors. Everything seems to works fine.

I checked your project and found a few rotation issues in your character setup. After fixing those, my Basic Enemy AI worked as expected, at least from what I’ve seen so far.

  • Make sure the X-axis of the parent entity is set to 0° (instead of 90°).

    image

  • Make sure the X-axis of the model entity is set to -90° and the Y-axis to 180°.

    image

Thanks ill make sure i try that

The player doesn’t have a Target tag here. If you want the enemy to chase the player, you need to add a Target tag to the player entity.

For those who haven’t seen the latest version in action yet, here is a short video showing the enemies chasing the mouse and getting killed by it too. I think it’s pretty cool! :sunglasses:

1 Like

Wow thanks allot you saved me buddy

1 Like

Im currently fixing the attack logic (After i added the target tag this popped up (( [Enemy%20script.js?id=261340969&branchId=82dd648b-2650-46c6-af48-c8c3227c1a5b:152]: pc.math.randomInt is not a function TypeError: pc.math.randomInt is not a function at Enemy.attack (https://launch.playcanvas.com/api/assets/files/Scripts/Enemy%20script.js?id=261340969&branchId=82dd648b-2650-46c6-af48-c8c3227c1a5b:152:47) at scriptType.updateState (https://launch.playcanvas.com/api/assets/files/Scripts/Enemy%20script.js?id=261340969&branchId=82dd648b-2650-46c6-af48-c8c3227c1a5b:102:29) at scriptType.update (https://launch.playcanvas.com/api/assets/files/Scripts/Enemy%20script.js?id=261340969&branchId=82dd648b-2650-46c6-af48-c8c3227c1a5b:66:10) at ScriptComponent._scriptMethod (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:109859:28) at ScriptComponent._onUpdate (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:109895:23) at ScriptComponentSystem._callComponentMethod (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:110680:58) at ScriptComponentSystem._onUpdate (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:110696:15) at ComponentSystemRegistry.fire (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:1732:27) at AppBase.update (https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:71961:23) at https://code.playcanvas.com/playcanvas-2.12.4.dbg.js:72920:26) The)) im currently working on it but thanks for the update

When I tried the project you shared, I first got a lot of animation errors that prevented the game from running. They come from animation-controller.js.

1 Like

But i dont have a script that is called that

Maybe it’s from PlayCanvas itself.

You added the code below to the attack state:

// FIXED RANDOM ANIMATION CHOICE
const attackIndex = Math.floor(pc.math.random(1, 3)); // replaces randomInt
const attackAnim = 'Attack' + attackIndex;
this.setAnimation(attackAnim);

Do all the possible animations exist?

Also, since the attack state runs every frame, I guess you don’t want a new animation triggered every frame?

1 Like