Custom Entities, Triggers and Stylegrounds - EverestAPI/Resources GitHub Wiki

Important

This wiki page assumes that you have set up your code mod via the template.

Don't know what the template is all about? Check out the Code Mod Setup wiki page to get started.

Note

This is a guide which explains how to define a custom entity, trigger, or styleground.
For information on how to integrate an entity, trigger or styleground with Lönn for use in maps, please see the Lönn Wiki 🔗.

Table of Contents

Custom Entities

To create a custom entity (an object which you can place in a map), create a class that extends Monocle.Entity and add using Celeste.Mod.Entities; to the top of the file.
Then, add the [CustomEntity] attribute, so that Everest can detect it when loading .bin map data.

The ID passed to [CustomEntity] must be unique. The most common format is [CustomEntity("ModName/EntityName")].

The class should look like so:

// ExampleEntity.cs
using Celeste.Mod.Entities;
using Monocle;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/ExampleEntity")]
public class ExampleEntity : Entity
{
    // ...
}

Important

To be able to place your entity in Lönn, you will also have to create a Lönn plugin 🔗 for it.

When spawning your entity from map data, Everest looks for a special constructor and invokes it, if it finds one.
It checks for, in this exact order:

  1. public ExampleEntity(EntityData data, Vector2 offset, EntityID id)
    Useful when making an entity which doesn't respawn.
  2. public ExampleEntity(EntityData data, Vector2 offset)
    The most common constructor signature.
  3. public ExampleEntity(Vector2 offset)
    Not very commonly used, due to its lack of the EntityData parameter.
  4. public ExampleEntity()
    Common for "controller" entities.
    (entities which are invisible and intangible, but which do stuff behind the scenes)

Let's explain what each parameter is:

  • data - Contains the room-relative position of the entity and its attributes assigned in Lönn
  • offset - The offset of the top-left corner of the room
  • id - The entity's unique ID, commonly used to prevent an entity from loading again

Your constructor needs to call base with a given absolute position (or none when making a "controller" entity), else it will spawn at the wrong place.
The absolute position can be calculated with data.Position + offset.

Here is an example with a constructor defined:

// ExampleEntity.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/ExampleEntity")]
public class ExampleEntity : Entity
{
    // Used when loading the entity from a map
    // Remember to call base(data.Position + offset)!
    public ExampleEntity(EntityData data, Vector2 offset)
        : base(data.Position + offset)
    {
        // ...
    }
}

Important

If Everest cannot find a constructor with one of these signatures, it won't be able to spawn your entity.
Mind the access level - the constructor must be public!

Now you can read information from Lönn in the constructor, using various members of the data parameter.
Here are a few:

  • data.Bool(...) - Read a bool value
  • data.Float(...) - Read a float value
  • data.String(...) - Read a string value
  • data.HexColor(...) - Read a hexadecimal color value
  • data.Nodes - An array of node positions relative to the entity

The Entity class has two important methods you can override:

  • Update - Called every frame while the entity is Active
  • Render - Called every frame while the entity is Visible

There are also some lifecycle methods:

  • Added - Called once when the entity is first added to the level
  • Awake - Called once when all entities are added to the level on this frame
  • Removed - Called once when the entity is being removed from the level (like after a transition, or when respawning)

The entity can define a custom collider, via the Collider field. For a rectangular hitbox, you can assign a Hitbox to Collider:

// ExampleEntity.cs
public ExampleEntity(EntityData data, Vector2 offset)
    : base(data.Position + offset)
{
    // Create an 8x8 collider, offset 4 pixels left and 4 pixels up
    // (which makes it centered)
    Collider = new Hitbox(8, 8, -4, -4);
}

You can change the order in which an entity is rendered/updated by changing its Depth.
Lower values mean that the entity is closer in front and updates later. Higher values mean that the entity is further back and updates earlier. Madeline uses depth 0.
Some general depth constants can be found in the Depths class.

Important

Update/render order for multiple entities at the same depth is not consistent and may change depending on the order the entities are added and/or removed in.

This is especially problematic at depth 0, since Madeline is at that depth.
This can cause slight (but important) behavior differences seemingly randomly.

For example, if we want our entity to appear behind and update before background decals:

// ExampleEntity.cs
public ExampleEntity(EntityData data, Vector2 offset)
    : base(data.Position + offset)
{
    // Place us slightly behind background decals
    Depth = Depths.BGDecals + 50;
}

Entities can have Components, which are reusable bits of code that can be attached to multiple entities independently.
This helps prevent code duplication and simplifies logic.

Tip

Some useful components include:

  • Image - Renders a static image
  • Sprite - Renders an animated sprite defined in Sprites.xml (create a sprite with GFX.SpriteBank.Create("sprite_name"))
  • PlayerCollider - Runs code when Madeline collides with the entity (can accept non-default colliders)
  • DashListener - Runs code when Madeline dashes anywhere in the level
  • Holdable - Allows Madeline to pick up the entity just like Theo or a Jelly
  • SoundSource - Adds a localized source of sound*(wow!)*
  • WindMover - Runs code when your entity is being pushed by wind
  • Coroutine - Run a method which can be paused

Components can be added at any time by calling the Add method on the entity.
This is commonly done directly in the constructor, but it can be done at any time.

Components are updated and rendered based on the order they have been added in.

Important

Remember to call base.Update() and base.Render() in Update and Render respectively!
These methods are responsible for running logic for the components you've added to your entities.

Here is an example which adds a torch sprite, lights it up if the player dashes, and extinguishes it if the player touches it:

// ExampleEntity.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/ExampleEntity")]
public class ExampleEntity : Entity
{
    private readonly Sprite TorchSprite;

    // Used when loading the entity from a map
    // Remember to call base(data.Position + offset)!
    public ExampleEntity(EntityData data, Vector2 offset)
        : base(data.Position + offset)
    {
        // Define a centered 32x32px (4x4 tile) hitbox
        Collider = new Hitbox(32f, 32f, -16f, -16f);

        // Create a Sprite based on the Sprites.xml name
        // Store its reference so that we can do stuff with it
        Add(TorchSprite = GFX.SpriteBank.Create("torch"));

        // Add a DashListener which runs OnDash when Madeline dashes
        Add(new DashListener(OnDash));

        // Add a PlayerCollider which runs OnPlayer when Madeline touches it
        Add(new PlayerCollider(OnPlayer))
    }

    // Called when Madeline dashes
    private void OnDash(Vector2 dashDirection)
    {
        // Play the "turnOn" animation defined in Sprites.xml
        TorchSprite.Play("turnOn");
    }

    // Called when Madeline touches the entity
    private void OnPlayer(Player player)
    {
        // Play the "off" animation defined in Sprites.xml
        TorchSprite.Play("off");
    }
}

Extra [CustomEntity] functionality

[CustomEntity] accepts multiple IDs, which is useful if you need backwards compatibility:

// ExampleEntity.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/ExampleEntityNew", "ExampleMod/ExampleEntity")]
public class ExampleEntity : Entity
{
    // ...
}

Alternatively, you can provide generator methods, which can give you more control over how an entity is spawned.
By default, Everest looks for a generator named Load. You can specify a different generator name by putting an equals sign after the ID in [CustomEntity].

Generator methods must be static, return Entity (or a type which extends from it), and accept 4 parameters:

  • Level - The level scene to which the entity should be spawned
    If necessary, data related to the current session can be found in level.Session
  • LevelData - The data about the level straight from the .bin file
  • Vector2 - The offset of the top-left corner of the room (same as in the constructor)
  • EntityData - Contains the room-relative position of the entity and its attributes assigned in Lönn (same as in the constructor)

Important

A generator method, if provided, takes precedence over the special constructors.

Here's an example of an entity which uses two different entity IDs and needs two different ways of loading:

// ExampleEntity.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity(
    "ExampleMod/ExampleEntityNew",
    "ExampleMod/ExampleEntity = LoadLegacy")]
public class ExampleEntity : Entity
{
    // Called by Everest when loading a "ExampleMod/ExampleEntityNew" entity
    // No generator name specified in CustomEntity, but it defaults to Load (if present)
    public static ExampleEntity Load(
        Level level,
        LevelData levelData,
        Vector2 offset,
        EntityData entityData)
        => new ExampleEntity(entityData, offset, isLegacy: false);

    // Called by Everest when loading a "ExampleMod/ExampleEntity" entity
    // The generator name is specified in CustomEntity
    public static ExampleEntity LoadLegacy(
        Level level,
        LevelData levelData,
        Vector2 offset,
        EntityData entityData)
        => new ExampleEntity(entityData, offset, isLegacy: true);

    // This constructor won't be used by Everest!
    // - The signature is different
    // - We have defined generator methods, so Everest will use those
    public ExampleEntity(EntityData data, Vector2 offset, bool isLegacy)
        : base(data.Position + offset)
    {
        // ...
    }
}

Custom Triggers

Custom triggers are done almost identically to Custom Entities.
The only difference is that they extend from Celeste.Trigger, and not Monocle.Entity.
The base constructor also two parameters instead of one: EntityData and Vector2.

Then, you can override one (or more) of these methods:

  • OnEnter - Called once when the player enters the trigger
  • OnStay - Called every frame while the player stays in the trigger
  • OnLeave - Called once when the player exits the trigger

Here's an example trigger which counts how long the player has been in the trigger:

// ExampleTrigger.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/ExampleTrigger")]
public class ExampleTrigger : Trigger
{
    private float Timer;

    // Used when loading the trigger from a map
    // Remember to call base(data, offset)!
    public ExampleTrigger(EntityData data, Vector2 offset)
        : base(data, offset)
    {
        // ...
    }

    // Log when Madeline enters the trigger
    public override void OnEnter(Player player)
    {
        Logger.Info("ExampleMod/ExampleTrigger",
            "The trigger has been entered.");
    }

    // Accumulate time while Madeline stays in the trigger
    public override void OnStay(Player player)
    {
        Timer += Engine.DeltaTime;
    }

    // Log how long Madeline was inside when she exits the trigger
    // Round the time to 2 decimal places
    public override void OnEnter(Player player)
    {
        Logger.Info("ExampleMod/ExampleTrigger",
            $"The trigger has been exited. Time: {Timer:F2} sec.");
        Timer = 0;
    }
}

Extra Attributes

There are also some extra attributes which you might find useful.

[Tracked]

The [Tracked] attribute registers your entity to the tracker. This allows you to find your entity/entities in a more efficient way.

Entity firstEntity;

// Slow - iterates through *every* entity in the scene
firstEntity = Scene.Entities.FindFirst<ExampleEntity>();

// Fast - the tracker keeps track of every entity type in a list
// Requires the [Tracked] attribute on ExampleEntity
firstEntity = Scene.Tracker.GetEntity<ExampleEntity>();
// Slow:
// - iterates through *every* entity in the scene
// - creates a new list every time, using up memory
foreach (ExampleEntity entity in Scene.Entities.FindAll<ExampleEntity>())
{
    // ...
}

// Fast:
// - the tracker keeps track of every entity type in a list
// - returns the same list every time
// Requires the [Tracked] attribute on ExampleEntity
foreach (ExampleEntity entity in Scene.Tracker.GetEntities<ExampleEntity>())
{
    // ...
}

Important

GetEntities<T>() returns a reference to the list used internally by the tracker, not a copy.

As long as you use it in a read-only fashion (like iterating through it), everything is alright.
However, do not modify it. If you need a copy of the list of entities, use GetEntitiesCopy<T>() instead.

It also allows you to use CollideCheck<T>() (and overloads), where T is an Entity.
CollideCheck expects that T is marked with [Tracked], and will violently explo- erm, crash if it isn't.

This attribute has a parameter which determines whether child classes are included in the search as well. By default it's set to false.

This means that if you have an entity which extends from your tracked entity...

// DerivedEntity.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[CustomEntity("ExampleMod/DerivedEntity")]
public class DerivedEntity : ExampleEntity
{
    // ...
}

...then Scene.Tracker.GetEntities<MyEntity>() will return:

  • all ExampleEntity objects in the scene if ExampleEntity is annotated with [Tracked]
  • all ExampleEntity and DerivedEntity objects if ExampleEntity is annotated with [Tracked(true)]

[TrackedAs]

If you annotate your entity with [TrackedAs(type)], it will be tracked in the exact same way as the type specified.

For example...

// ExampleCustomWater.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Entities;

[TrackedAs(typeof(Water))]
[CustomEntity("ExampleMod/ExampleCustomWater")]
public class ExampleCustomWater : Water
{
    // ...
}

...means "ExampleCustomWater should be tracked in exactly the same way as Water is".

This is beneficial for several reasons:

  • CollideCheck<Water>() will also check collisions with ExampleCustomWater, making Madeline able to swim in your custom water with no extra code
  • Scene.Tracker.GetEntities<Water>() also returns ExampleCustomWater entities
  • ...et cetera.

Used here in Spring Collab 2020 🔗.

This is useful when developing an entity extending a tracked vanilla one, when the vanilla one has [Tracked(false)] making children not tracked by default.

Important

When using [TrackedAs], it is important that your entity extends from the type you provide.

Celeste and every mod expects that entities returned by Scene.Tracker.GetEntities<T>() (where T is an Entity) are instances of T, and will combus- uh, crash if there is an entity which does not extend from T.

[RegisterStrawberry]

This attribute can be placed on any class that extends Strawberry or implements IStrawberry.
It allows custom strawberries to be taken into account correctly in the total strawberry count, or in the strawberry tracker in the pause menu for example.

Here's an example from Spring Collab 2020:

[RegisterStrawberry(isTracked: true, blocksNormalCollection: false)]
[CustomEntity("SpringCollab2020/CassetteFriendlyStrawberry")]
public class CassetteFriendlyStrawberry : Strawberry
{
    // ...
}

This attribute has two parameters:

  • isTracked - Whether the strawberry should be counted in the maximum berry count, and should show up on the checkpoint card / the pause menu tracker.
    Its checkpoint ID and order will be auto-assigned by Everest in this case.

  • blocksNormalCollection - Whether the berry has specific collection rules, similarly to golden berries.
    In this case, it will allow berries behind it in the "berry train" to be collected.

For reference, in vanilla:

  • red berries are tracked and do not block normal collection
  • golden berries are untracked and block normal collection
  • the moon berry is untracked and does not block normal collection

If your custom berry doesn't extend Strawberry and you want to have seeds behaving normally, you can have your custom berry implement IStrawberrySeeded, then use the GenericStrawberrySeed class instead of vanilla strawberry seeds.
See Spring Collab 2020's Glass Berry 🔗 for an example.

Custom Stylegrounds

Custom stylegrounds are done in a very similar way to entities, with several key differences:

  • The class must use [CustomBackdrop], not [CustomEntity]
  • The class must extend from Backdrop
  • The constructor/generator must accept one parameter of type BinaryPacker.Element
  • The generator (if any) must return Backdrop (or a type which extends from it)

Tip

BinaryPacker.Element behaves very similarly to EntityData.

Here's an example:

// ExampleStyleground.cs
using Celeste.Mod.Entities;
using Monocle;
using Microsoft.Xna.Framework;

namespace Celeste.Mod.ExampleMod.Stylegrounds;

// Note the [CustomBackdrop] attribute
[CustomBackdrop("ExampleMod/ExampleStyleground")]
public class ExampleStyleground : Backdrop
{
    // No base() call needed, as the base constructor accepts 0 parameters
    public ExampleStyleground(BinaryPacker.Element data)
    {
        // ...
    }
}

It provides two extra methods on top of Update and Render:

  • BeforeRender - Do stuff before rendering
  • Ended - Do cleanup after the level ends (like upon completion, Save and Quit, Return to Map, etc.)
⚠️ **GitHub.com Fallback** ⚠️