Writing an entity class

An entity is a thing placed in a map that is not world geometry: a light, a door, a pickup, a guard's post. It is two halves:

  • authored - a HammeriteEntity in the map: a type name and a dictionary of properties, saved with the map;
  • running - a node the game builds from it, found by the classname property. That is your script.

This page builds ExamplePickup from the minimal example - a box that spins while the game runs and vanishes when triggered - and then the hooks it does not need: gizmos, handles, brushwork and triggers of your own.

1. Pick a base class

Extend the one matching what the node must be. Two methods are @abstract - set_editor_active() and apply_entity_property() - so forgetting either is a compile error. Every other hook has a default that does nothing, and you write only the ones your entity uses.

Base class For
HammeritePointEntityNode3D something that only has to be somewhere: a light, a marker, a pickup
HammeritePointEntityAnimatableBody3D something that moves and is collided with: a door leaf, a lift
HammeritePointEntityRigidBody3D something physics moves
HammeriteBrushEntityNode3D brushwork with no physics
HammeriteBrushEntityStaticBody3D brushwork the player walks into
HammeriteBrushEntityArea3D brushwork that notices what is inside it: water, triggers
class_name ExamplePickup
extends HammeritePointEntityNode3D

The class_name is the classname the map names. It is your game's, so it needs no prefix.

2. Register the type

A type says what its entities carry and the default of each. Register it with core (HammeriteEntityTypeRegistry), before the map is built, so the game knows it with or without the editor:

HammeriteEntityTypeRegistry.register_point(&"pickup", {
    &"classname": &"ExamplePickup",
    &"color": Color.GOLD,
    &"spin_degrees": 90.0,
})

This is the only place a default is written. An entity in a map saved before spin_degrees existed has no value for it, and its node is told 90.0 from here, so the script needs no default of its own.

The editor offers every registered type in the entity tool's list (Cmd E), and map_editor.register_point_entity_type() registers here too. A game that only wants to add a property to one of core's types uses extend_point() (extend_point_entity_type() in the editor) instead: registering replaces.

A property whose default is null holds a value of any type, or none, and the editor shows it with a type to choose beside the value. For a property whose legal values are a list - a sound, another entity's name - the editor can offer a dropdown: register_property_choices(type, property, provider). The editor README lists the rest of its seams.

3. Authored properties

Every property the type declares reaches apply_entity_property() - when the entity is built, when the map loads, and each time the author changes one in the editor:

func apply_entity_property(prop_name: StringName, value: Variant) -> void:
    match prop_name:
        &"origin":
            position = value
        &"color":
            _material.albedo_color = value
        &"spin_degrees":
            _spin_degrees = float(value)

origin is where it stands, in the map's space: set position, not global_position, and the entity goes wherever the HammeriteMap3D goes. rotation, when the type has one, is applied for you before this is called.

4. Drawing for the author

set_editor_active(active) is told whether the editor is up over the world - as the node is built and on every change. It governs what the entity draws for the author, and says nothing about whether the game is running. A light shows a bulb icon; a spawn point with no mesh shows a marker:

func set_editor_active(active: bool) -> void:
    _marker_mesh.visible = active

The pickup's own box is all it needs, so its set_editor_active() does nothing.

Gizmos are lines over the level for what a mesh cannot show - how far something reaches, which way it looks. draw_editor_gizmos() is asked every frame the editor is up, and writes into a HammeriteGizmoBuffer in world space. Say which views a shape is for: the ortho views already outline the entity's box, so a second box there is noise.

func draws_editor_gizmos_only_when_picked() -> bool:
    return true

func draw_editor_gizmos(gizmos: HammeriteGizmoBuffer) -> void:
    var color: Color = Color.GREEN if entity.selected else Color.WHITE_SMOKE
    gizmos.box(get_aabb(), color, HammeriteGizmoBuffer.View.PERSPECTIVE)
    gizmos.sphere(global_position, _watch_radius, color)
    gizmos.line(global_position, to_global(_look_at), color)

draws_editor_gizmos_only_when_picked() returning true promises the drawing is empty unless the entity is selected or hovered, so the editor skips asking on every other frame - a level's props are thousands. Keep the promise by checking entity.selected and entity.hovered yourself, or draw something always and leave it false.

Handles are points the author drags to set a vector property - where a guard looks, how far a door travels - instead of typing numbers. Offer them with get_editor_handles(), positioned in world space; the editor picks, drags and snaps, and hands each new position to move_editor_handle(). What the position means is yours to say. Here it is a point the entity looks at, stored relative to it:

func get_editor_handles() -> Array[HammeriteEditorHandle]:
    return [HammeriteEditorHandle.new(&"look_at", to_global(_look_at), Color.ORANGE)]

func move_editor_handle(handle_id: StringName, to: Vector3) -> void:
    if handle_id == &"look_at":
        entity.set_property(&"look_at", to_local(to))

Write the value through entity.set_property(), not into the node: the authored property is what is saved, and the change comes back to apply_entity_property() like any other. The editor records the whole drag as one undo step by calling move_editor_handle() with the old position, so the method must work from any position, not only the next one along.

5. Simulation

The world runs from the moment HammeriteMap3D builds it, unless the editor is up over it. The editor can stop it, run it again, or run just the selection, and put it back. An entity that does anything while running says so with can_simulate():

func can_simulate() -> bool:
    return true

func start_simulation() -> void:
    _simulating = true

func stop_simulation() -> void:
    _simulating = false

func reset_simulation() -> void:
    _simulating = false
    _taken = false
    _mesh.show()

stop_simulation() is a pause: keep the state reached, so start_simulation() resumes. reset_simulation() puts the node back as the map built it. Gate _process and _physics_process on your own flag, as the pickup does with _simulating - the node exists, and is processed, while the author is editing.

6. Runtime properties and actions

What the inspector shows of the running thing, beside the authored properties (which are entity.get_property()). The author can watch them and set them while the world runs; none is saved.

func get_runtime_properties() -> Dictionary[StringName, Variant]:
    return {&"taken": _taken}

func set_runtime_property(key: StringName, value: Variant) -> void:
    if key == &"taken":
        _taken = bool(value)
        _mesh.visible = !_taken

func reset_runtime_properties() -> void:
    set_runtime_property(&"taken", false)

get_runtime_property(key, default) reads one out of get_runtime_properties().

An action is a button in the same panel: something done to the running entity, rather than a value set on it, and not undoable. Call super() to keep the Trigger every triggerable entity gets:

func get_actions() -> Dictionary[StringName, String]:
    var actions: Dictionary[StringName, String] = super()
    actions[&"respawn"] = "Respawn"
    return actions

func run_action(action_id: StringName) -> void:
    if action_id == &"respawn":
        reset_runtime_properties()
    else:
        super(action_id)

7. Triggering

An entity that can be triggered says so, and says what triggering it does:

func can_trigger() -> bool:
    return true

func trigger(_impulse: Variant = null) -> void:
    set_runtime_property(&"taken", true)

Three things trigger it:

  • the author, from Trigger on the editor's right-click menu - with a null impulse;
  • a trigger brush entity, when a body walks into it: it triggers every entity named in its target property, handing over its target_impulse;
  • your game, by name, through the map:
var how_many: int = map_3d.trigger_named("vault_door", true)

A name is not an id: every entity with that name that can be triggered is triggered, so one switch can light a whole corridor. impulse means what the entity says it does - Hammerite's lights read true and false as on and off and anything else as switch over; a lift might read a floor number.

The editor warns, once per script, about a hook that can never run because the one that allows it is not written - trigger() without can_trigger(), start_simulation() without can_simulate() - and about a hook still under a name it used to have, such as get_properties().

8. Brushwork entities

A door, a lift, a pool or a volume that notices who walks in is made of brushes rather than standing at a point. Register it with register_brush(), and extend one of the HammeriteBrushEntity* classes. The same hooks apply, with these differences:

  • The node is built with a mesh per brush, kept up to date as the author edits them (get_brush_meshes()), and - on a body or an area - a convex collision shape per brush (get_brush_shapes()). Their geometry is in map space, so the node stays at the map's origin.
  • Nothing moves it for you: a brush entity's origin, where its type has one, moves nothing unless apply_entity_property() moves something.
  • A brush entity is lightmapped where it stands. One that moves must hand its faces to the probes first; see Moving brush entities.

A volume that does something to whoever is inside it - a pressure plate, a pool of shadow that makes the player harder to see - is an Area3D. Listen in _init, and watch only while running, so an author dragging it through a guard is not the guard walking in:

class_name ShadowVolume
extends HammeriteBrushEntityArea3D

func _init() -> void:
    monitoring = false
    body_entered.connect(func(body: Node3D) -> void: body.add_to_group(&"in_shadow"))
    body_exited.connect(func(body: Node3D) -> void: body.remove_from_group(&"in_shadow"))

func can_simulate() -> bool:
    return true

func start_simulation() -> void:
    set_deferred(&"monitoring", true)

func stop_simulation() -> void:
    set_deferred(&"monitoring", false)

func apply_entity_property(_prop_name: StringName, _value: Variant) -> void:
    pass

set_deferred, because a simulation can be started from inside a physics callback, where monitoring cannot change. HammeriteBrushEntityArea3D already writes set_editor_active(): it draws the brushes only while the editor is up, so the volume is invisible to whoever plays.

9. In the game

Nothing more: HammeriteMap3D builds the node from classname wherever the map has one of these, with or without the editor, and starts it. A map saved with a type your game no longer registers still loads; an entity whose classname no class answers to is reported and left out.

Hammerite 1.0 · built from a6dd634, 2026-10-10