How to improve frame rate with Phaser 3 (with game objects)

Hi there. So I’ve been working on this prototype in phaser 3. I’m documenting my progress and am posting here to get some feedback, and maybe get some help or suggestions.

The first prototype I worked on uses state machines which are each declared in the player and npc class, respectively. I was also creating animations inside the entities classes, and playing the animations from within the state machines.

Heres what some of the npc and state code looks like…

export class NPC extends Entity {
  constructor(scene, x, y, texture, key) {
    super(scene, x, y, texture, key)
    this.scene = scene

    let walkSfxConfig = {
      mute: false,
      volume: 1,
      rate: 1,
      detune: 0,
      seek: 0,
      loop: true,
      delay: 0
    }
    this.footstepsSFX = scene.sound.add("walk1", walkSfxConfig);

    this.healthBar = new Bar(this.scene, 0, 2, 100, 16, 12);

    let idleAnimConfig = {
      key: 'minotaur-idle',
      frames: this.anims.generateFrameNames(texture, {
        start: 0,
        end: 4
      }),
      frameRate: 8,
      yoyo: false,
      repeat: -1,
    };
    let runAnimConfig = {
      key: 'minotaur-run',
      frames: this.anims.generateFrameNames(texture, {
        start: 10,
        end: 17
      }),
      frameRate: 8,
      yoyo: false,
      repeat: -1,
    };

    let weakAttackAnimConfig = {
      key: 'minotaur-weakattack',
      frames: this.anims.generateFrameNames(texture, {
        start: 40,
        end: 44
      }),
      frameRate: 8,
      yoyo: false,
      repeat: 0,
    };

    let strongAttackAnimConfig = {
      key: 'minotaur-strongattack',
      frames: this.anims.generateFrameNames(texture, {
        start: 30,
        end: 38
      }),
      frameRate: 8,
      yoyo: false,
      repeat: 0,
    };

    let deathAnimConfig = {
      key: 'minotaur-death',
      frames: this.anims.generateFrameNames(texture, {
        start: 90,
        end: 95
      }),
      frameRate: 10,
      yoyo: false,
      repeat: 0,
    };

    this.anims.create(idleAnimConfig)
    this.anims.create(runAnimConfig)
    this.anims.create(weakAttackAnimConfig)
    this.anims.create(strongAttackAnimConfig)
    this.anims.create(deathAnimConfig)

    this.isNPCDead = false

    this.npcStateMachine = new StateMachine('idle', {
      idle: new NpcIdleState(),
      hostile: new NpcHostileState(),
      move: new NpcMoveState(),
      attack: new NpcAttackState(),
      death: new NpcDeathState(),
      dead: new NpcDeadState()

    }, [this, scene])

    this.setInteractive(new Phaser.Geom.Rectangle(28, 20, 40, 40), Phaser.Geom.Rectangle.Contains);

    this.body.setSize(20, 20)

    // this.setActive(true)

  }

  injure(damageAmount) {
    this.healthBar.decrease(damageAmount)
  }
  handleLook(player) {
    if (player.body.x < this.x) {
      this.flipX = true
    } else {
      this.flipX = false
    }
  }

  update(delta) {
    this.body.x = this.body.x + this.body.velocity.x * delta;
    this.body.y = this.body.y + this.body.velocity.y * delta;
    
    // INSTANTIATE THE NPCS HEALTH BAR
    this.healthBar.bar.x = this.x - 10
    this.healthBar.bar.y = this.y - 35
    this.healthBar.bar.setScale(0.3, 0.3)
    
    // SET STATE MACHINE UPDATE
    this.npcStateMachine.step()

  }
}
export class NpcIdleState extends State {
  enter(npc, scene) {
    this.player = scene.players.getChildren()[0]

    npc.play("minotaur-idle", true)
    npc.isNPCDead = false
    npc.footstepsSFX.stop()

  }
  execute(npc, scene) {
    let distance = Phaser.Math.Distance.Between(npc.x, npc.y, this.player.x, this.player.y);
    if (distance < 140) {
      this.stateMachine.transition("hostile")
    }
  }
}

export class NpcHostileState extends State {
  enter(npc, scene) {
    this.player = scene.players.getChildren()[0]
    npc.play("minotaur-idle", true)
    npc.isNPCDead = false
    npc.footstepsSFX.stop()

  }
  execute(npc, scene) {
    let distance = Phaser.Math.Distance.Between(npc.x, npc.y, this.player.x, this.player.y);
    if (distance < 140) {
      this.stateMachine.transition("move")
    }
  }
}

export class NpcMoveState extends State {
  enter(npc, scene) {
    npc.play("minotaur-run", true)
    npc.footstepsSFX.play()

    scene.sound.play("bullsound1")
    npc.isNPCDead = false
    this.player = scene.players.getChildren()[0]

    this.randomSpeedValue = Phaser.Math.Between(38, 69);

    if (npc.healthBar.value == 0) {
      this.stateMachine.transition("death")
    }
  }
  execute(npc, scene) {
    scene.physics.moveTo(npc, this.player.x, this.player.y, this.randomSpeedValue)

    let distance = Phaser.Math.Distance.Between(npc.x, npc.y, this.player.x, this.player.y);

    if (npc.body.velocity.x < 0.1) {
      npc.setFlip(true)
    }
    if (npc.body.velocity.x > 0.1) {
      npc.setFlip(false)
    }
    if (npc.body.speed > 0) {
      //  4 is our distance tolerance, i.e. how close the source can get to the target
      //  before it is considered as being there. The faster it moves, the more tolerance is required.
      if (distance < 30) {
        npc.body.velocity.x = 0
        npc.body.velocity.y = 0
        this.stateMachine.transition("attack")

      }
    }

  }
}
export class NpcAttackState extends State {
  enter(npc, scene) {
    npc.footstepsSFX.stop()

    npc.isNPCDead = false
    this.player = scene.players.getChildren()[0]
    this.gameScene = scene.scene.get("GameUIScene")
    this.attackTime = 0
    this.amountAttack = 0

    npc.on("animationupdate", (currentAnim, currentFrame, sprite) => {
      if (currentFrame.textureFrame == 43) {
        scene.sound.play("attack1")
      }
      if (currentFrame.textureFrame == 31) {
        scene.sound.play("attack2")
      }
    })

    this.randomAttackValue = Phaser.Math.Between(1, 4);

  }
  execute(npc, scene) {
    if (this.attackTime == 30) {
      this.amountAttack = this.amountAttack + 1
    }
    this.attackTime++
    if (this.attackTime == 30) {
      if (this.amountAttack > this.randomAttackValue) {
        npc.play("minotaur-strongattack").on("animationcomplete", (anim) => {
        })

        // handle npc attack points
        let ap = 4
        let newAP = ap * 10 //level of npc
        scene.players.getChildren()[0].health = scene.players.getChildren()[0].health - newAP
        this.gameScene.bar.decrease(newAP)

      } else {
        npc.play("minotaur-weakattack").on("animationcomplete", (anim) => {
        })

        // handle npc attack points
        let ap = 1
        let newAP = ap * 11 //level of npc
        scene.players.getChildren()[0].health = scene.players.getChildren()[0].health - newAP
        this.gameScene.bar.decrease(newAP)

      }
    }

    if (this.attackTime == 140) {
      this.attackTime = 0
      if (this.amountAttack > 3) {
        this.amountAttack = 0
      }
    }

    if (npc.healthBar.value == 0) {
      this.stateMachine.transition("death")
    }
    if (this.gameScene.bar.value == 0) {
      this.stateMachine.transition("idle")
      this.attackTime = 0
    }
    let distance = Phaser.Math.Distance.Between(npc.x, npc.y, this.player.x, this.player.y);
    if (distance > 30) {
      this.stateMachine.transition("hostile")
    }

  }
}

export class NpcDeathState extends State {
  enter(npc, scene) {
    npc.isNPCDead = true
    npc.healthBar.destroy()
    let gameScene = scene.scene.get("GameScene")
    gameScene.dropItem(npc.x, npc.y, npc, this.randomNumber)
    npc.disableInteractive()
    npc.body.destroy()
    scene.sound.play("bullsound2")
    npc.play("minotaur-death").on("animationupdate", (anim, frame, sprite, a) => {
      if(a == 95) {
        npc.anims.stop("minotaur-idle")
        this.stateMachine.transition("dead")
      }
    })

  }
  execute(npc, scene) {

  }
}

export class NpcDeadState extends State {
  enter(npc, scene) {
    console.log("is really dead")
    // npc.anims.pause(npc.anims.currentAnim.frames[4]);
    this.timer = scene.time.delayedCall(9000, () => {
      npc.destroy()
    });

  }
  execute() {

  }
}

This was all going very well but…

I started to notice the frame rate drop, and decided to use in-browser fps counter in chrome. It initially ran from 30 fps, the npc would die (death state) and then destroy the npc. The frame rate would progressively get worse and worse as I keep killing the npcs. If I roam the game world without killing npcs, it seems fine. (or at least I think so?)

I changed the render option in the config from WEBGL to CANVAS. It seemed even worse in canvas.

I decided to go back to square one and watch a video tutorial which uses a simpler state manager. In this prototype, I added a boot and preload scene, with a loading bar, which I had borrowed from the tutorial. The animations are also initiated in the preload scene. This all works fine except the frame rate still decreases, particularly when I’m destroying npcs. I was hoping the animation change would ensure a steady frame.

Heres what my npc class currently looks like.


export class NPCMinotaur extends Entity {
    constructor(ctx, x, y, texture) {
        super(ctx, x, y, texture)

        this.ctx = ctx

        this.states = {
            idle: true,
            run: false,
            attack: false,
            death: false
        }

        // experience system
        if (this.states.idle) {
            this.startNewAnim("idle")

        }

        this.healthBar = new Bar(this.ctx, 0, 2, 100, 16, 12);


        this.gameUiScene = this.ctx.scene.get("GameUIScene")

        this.attackTime = 0
        this.randomAttackValue = Phaser.Math.Between(1, 4);
        this.randomSpeedValue = Phaser.Math.Between(3, 12);


        this.body.setSize(20, 23)
        this.startNewAnim('idle')
        this.player = this.ctx.players.getChildren()[0]

        this.setInteractive(new Phaser.Geom.Rectangle(28, 20, 40, 40), Phaser.Geom.Rectangle.Contains);


    }

    update(time, delta) {

        this.updateSpriteDirection()

        // INSTANTIATE THE NPCS HEALTH BAR
        this.healthBar.bar.x = this.x - 10
        this.healthBar.bar.y = this.y - 35
        this.healthBar.bar.setScale(0.3, 0.3)

        // if (this.states.death) return;

        if (this.states.idle) {
            // check if dead
            if (this.healthBar.value == 0) {
                this.startNewAnim('npc-death')

                this.setState('death')
            }
            this.isAttacking = false

            // this.ctx.physics.moveTo(this, this.ctx.players.getChildren()[0].x, this.ctx.players.getChildren()[0].y, 90)
            let distance = Phaser.Math.Distance.Between(this.ctx.players.getChildren()[0].x, this.ctx.players.getChildren()[0].y, this.x, this.y);
            if (distance < 120) {
                this.startNewAnim('run')

                this.body.velocity.x = 0
                this.body.velocity.y = 0
                this.setState('run')


            }
        }
        if (this.states.run) {
            this.isAttacking = false

            target = { x: this.ctx.players.getChildren()[0].x, y: this.ctx.players.getChildren()[0].y }
            this.moveSprite(target, delta)
        }

        if (this.states.attack) {
            // check if dead
            if (this.healthBar.value == 0) {
                this.death()
                this.startNewAnim('npc-death')
                this.on('animationupdate', (anim, frame, sprite, frameKey) => {
                    if (frame.index == 4) {
                        this.stopAnim()
                        this.ctx.players.getChildren()[0].setState('idle')
                    }
                })
                this.setState('death')
            }
            this.isAttacking = true
            this.body.velocity.x = 0
            this.body.velocity.y = 0
            this.attack()
        }

    }

    moveSprite(target, delta) {
        this.isAttacking = false

        this.ctx.physics.moveToObject(this, target, this.randomSpeedValue)
        let distance = Phaser.Math.Distance.Between(this.x, this.y, target.x, target.y);
        if (this.body.speed > 0) {

            //  4 is our distance tolerance, i.e. how close the source can get to the target
            //  before it is considered as being there. The faster it moves, the more tolerance is required.
            if (distance < 30) {
                this.setState('attack')
            }
        }
    }

    attack() {
        this.isAttacking = true
        if (this.attackTime == 30) {
            this.amountAttack = this.amountAttack + 1
        }
        if (this.isAttacking) {
            this.attackTime++
            if (this.attackTime == 6) {
                if (this.amountAttack > this.randomAttackValue) {
                } else {
                    this.startNewAnim('weakattack')
                    this.on('animationupdate', (anim, frame, sprite, frameKey) => {
                        if (frame.index == 5) {
                            this.startNewAnim('idle')
                        }
                    })
                    let ap = 1
                    let newAP = ap * 11 // level of npc (str?)
                    // this.ctx.players.getChildren()[0].health = this.ctx.players.getChildren()[0] - newAP
                    this.gameUiScene.bar.decrease(newAP)
                }
            }

            if (this.attackTime == 190) {
                this.attackTime = 0
                if (this.amountAttack > 3) {
                    this.amountAttack = 0
                }
            }
        }
        let distance = Phaser.Math.Distance.Between(this.x, this.y, this.ctx.players.getChildren()[0].x, this.ctx.players.getChildren()[0].y);

        // let distance = Phaser.Math.Distance.Between(this.x, this.y, this.ctx.players.getChildren()[0].x, this.ctx.players,getChildren()[0].y);
        if (distance > 30) {
            // this.timer.paused = true
            this.off('animationupdate')
            this.startNewAnim('run')

            this.setState('run')
            //   this.stateMachine.transition("hostile")
        }
    }

    death() {
        this.body.destroy()
        this.healthBar.bar.destroy()
        this.timer = this.ctx.time.delayedCall(9000, () => {
            this.destroy()
        });
    }
    updateSpriteDirection() {
        if (this.body.angle > 0 && this.body.angle < 1.5) {
            this.setFlip(false)
        }

        if (this.body.angle < 0 && this.body.angle > -1.5) {
            this.setFlip(false)
        }

        if (this.body.angle > 1.5 && this.body.angle < 3.15) {
            this.setFlip(true)
        }

        if (this.body.angle < -1.5 && this.body.angle > -3.15) {
            this.setFlip(true)
        }

    }

    setState(key) {
        if (!this.states.hasOwnProperty(key)) {
            console.log(this.key + "invalid STATE key" + key)
            return
        }
        if (!this.states.last === key) {
            return
        }
        this.resetStates()
        this.states[key] = true
        this.states.last = key
    }

    resetStates() {
        for (let key in this.states) {
            this.states[key] = false
        }
    }
}

I turn on the fps counter in chrome, and was using a second screen, and suddenly the framerate has jumped to 60 fps. I dont think I did anything for that to happen (edit: but after running the prototype I notice it has changed back to 30 when I’m using the laptopscreen), seeing as the previous prototype ran at 60 fps. I wasnt sure why the frame rate increase happened. Anyway, the frame rate continues to decrease. I guess I would expect the frame rate to decrease, but not in a way that affects gameplay. It starts at 60 fps, and as I destroy npcs, it reaches lows at 40 fps and further down to 30 fps.

I’ve looked at some old posts about how to better your chances at improving framerate. I would assume there are many different aspects that would contribute to a decreasing framerate. Things that I’m not aware of.

I tried using delta in the update functions. I’m actually unsure and have no idea how to use delta. I had come across using delta in the past, but hadn’t used delta with phaser. I’m unsure if this is a problem or not.

I’m hoping someone here can lend me suggestions. I’m thinking of uploading the code to github, so anyone can have a better look.

I have a lot to learn, and have come close to giving up on this, but am willing to push along to get something a bit more playable. Excuse the messy code, I’m still learning and willing to improve. Thanks.

Any suggestions or comments will be much appreciated.

Hi,
Try to use group of enemies, don’t destroy them, make them inactive and not visible.
I don’t see where your problem comes from, i use state only for my main character, enemies are much simpler.
But something i try to do is to not call something if not needed, an example:

updateSpriteDirection() {
    if (this.body.angle > 0 && this.body.angle < 1.5 && this.flipX) { // <-- check if it is already done
                this.setFlip(false)
    }
...
}

Another thing:
let distance = Phaser.Math.Distance.Between(npc.x, npc.y, this.player.x, this.player.y);
I try to avoid to use Distance.Between at every frame, and use it inside a timer.

update (time, delta) ← the first argument is time, not the delta

and i usually just use the setVelocity method instead of this:
this.body.x = this.body.x + this.body.velocity.x * delta;
this.body.y = this.body.y + this.body.velocity.y * delta;

1 Like

If you’re getting frame rates well below 60 you should be able to take a Performance timeline in dev tools to see where the time is spent. As a test you could create and destroy 100 NPCs in a row.

Add to update():

if (this.listenerCount('animationupdate') >= 2) {
  throw new Error('Too many listeners');
}

Same for 'animationcomplete'.

This sound should be removed when the entity is destroyed.

1 Like

Hi everyone, thanks for respond and looking.

The framerate starts at 30 fps. I suspect it was 60 due to using a second monitor, could be due to the refresh rate or the graphics card, i’m unsure. At two attempts, I destroyed about 20 enemies until I noticed the framerate dropping. Its not as unplayable as it was before, but im hoping it can be better. I only have one enemy type so far.

Hi there. So I wasn’t sure what you meant. Is it that I could be using an conditional ternary operator instead of what I’m in my example.

I made some changes to the code based on your suggestions.

Thanks for the response.

So I decided to check the performance timeline but to be honest I don’t know what I’m looking at. I havent had much practice with the dev tools.

I added the listener count like so…

for (let index = 0; index < this.npcs.getChildren().length; index++) {
    if (this.npcs.getChildren()[index].listenerCount('animationupdate') >= 2) {
    throw new Error('Too many listeners in npc animationupdate ' + index);
    }  
}

Would it be a good idea to turn off the listener when an animationcomplete or animationupdate is used, like this:

npc.play("minotaur-strongattack").on("animationcomplete", (anim) => {
    npc.off('animationcomplete')
    npc.play("minotaur-idle", true)
})

I can see how the error log would be useful.

The framerate has improved somewhat. I spawned 100 enemies and changed their states to dead. No errors or framerate drops.
Again thanks for the response.

@BlunT76 Oh I forgot about asking what you meant about using a timer when using distance to handle the interaction.

    let distance = Phaser.Math.Distance.Between(player.x, player.y, target.x, target.y);

    if (player.body.speed > 0) {
      //  4 is our distance tolerance, i.e. how close the source can get to the target
      //  before it is considered as being there. The faster it moves, the more tolerance is required.
      if (distance < 5) {
        player.body.velocity.x = 0
        player.body.velocity.y = 0

        this.stateMachine.transition("idle")
      }
    }

I’m unsure how to approach it with using a timer. Would you use an addEvent timer to get this to work? Sorry about my dumbfoundedness. This code is in the execute method in the state class.

@samme

Oh you were right, there was more than one listener happening. I put the listeners in the enter() method instead, and turn it off where I needed to. The error was showing, didn’t realise this was happening… Anyway, thanks for the information.

This is fine. With animationcomplete you can also use once():

npc.play("minotaur-strongattack").once("animationcomplete", (anim) => {
    npc.play("minotaur-idle", true)
})

And an alternative is chain().

Can you avoid patterns like this (above)? Here it seems the animation should be adjusted so it ends on the desired frame.

1 Like