Enemy and Object Respawn Flow
How to set up a respawning object (enemy, resource node, chest, etc.) from scratch, and the complete server and client flow that spawns, hides and respawns the enemy and rock instances.
Configuring respawn areas in the admin panel
A respawn area keeps a group of enemies or resources alive on a map: the area object says what spawns, the respawn rule says how many instances and how often, and a map layer says where. Everything except the map layer is created from the admin panel.
Before you start: the map layer
The room map needs a tile layer whose name contains respawn-area (for example respawn-area-monsters), with tiles painted wherever an instance can appear. Paint at least as many tiles as the instances you want, each instance uses its own tile. See Create a Room / Map and Required Map Layer below.
1. Create the area object
- Log in to the admin panel at /reldens-admin with an admin user (the path can be changed with the RELDENS_ADMIN_ROUTE_PATH environment variable).
- Open Game Objects > Objects in the sidebar (/reldens-admin/objects) and click Create New.
- Select the Room ID. The Layer Name field turns into a list of the room map layers: pick the respawn area layer.
- Leave the Tile Index empty (the area covers the whole layer) and select multiple as Class Type.
- Set a unique Object Class Key (for example enemy_3), the Client Key (the sprite asset key, for example enemy_forest_1) and the Title shown to the players.
- In Private Params set what spawns, for an enemy: {"shouldRespawn":true,"childObjectType":4,"isAggressive":true}. Without "shouldRespawn":true the area is ignored. Interactive objects like the mining rocks need more keys, see Configuration reference below.
- In Client Params set the animation, for example {"autoStart":true,"frameStart":12,"frameEnd":26,"repeat":-1}.
- Check Enabled and click Save.
2. Add the sprite
- Open Game Objects > Assets and click Create New.
- Select the area object as Object ID, set the Asset Type to spritesheet, the Asset Key (the same key used in the Client Key) and the Asset File (the image file name, the file goes in the theme assets/custom/sprites/ folder, see Required Asset File below).
- Set the frame size in Extra Params, for example {"frameWidth":47,"frameHeight":50}, and click Save.
- Optionally add the walk animations in Game Objects > Animations, and for enemies their stats in Game Objects > Objects - Stats and their skills in Game Objects > Objects - Skills (see Battle System).
3. Create the respawn rule
- Open Respawn > Respawn Areas (/reldens-admin/respawn) and click Create New.
- Select the area object as Object ID.
- Set the Respawn Time in milliseconds (the time before a defeated or depleted instance comes back) and the Instances Limit (how many instances are alive at the same time).
- Set the Layer to exactly the same layer name used in the object Layer Name, then click Save.
- Restart the server and enter the room: the instances appear on random tiles of the layer, and each one comes back on a new random tile after the respawn time.
Configuration reference
- Objects Layer Name and Respawn Areas Layer - must be identical and must contain respawn-area.
- Objects Class Type - always multiple (ID 7) for an area, the spawned instances get their class from the Private Params.
- Objects Private Params keys:
- shouldRespawn - required, true.
- childObjectType - class type of the spawned instances, 4 for enemies.
- childObjectClassKey - custom class of the spawned instances (registered by a theme plugin), used instead of childObjectType.
- hasState, runOnAction, collisionType and respawnStateTime - needed by interactive objects like the mining rocks.
- isAggressive, interactionRadio, randomMovement and battleTimeOff - enemy behavior.
- Objects Enabled - a disabled area object is skipped.
- Respawn Time - milliseconds, the sample forest uses 20000 for the trees and 30000 for the rocks.
- Instances Limit - instances alive at the same time, 12 trees and 10 rocks in the sample forest.
The complete field descriptions are in Required DB Records below, together with the SQL alternative and the code a custom object class needs.
Code integration (advanced)
The sections below describe the database records behind the admin forms, the SQL alternative, the code required by custom object classes, and the complete server and client flow that spawns, hides and respawns the instances.
Required DB Records
1. objects table - the template / area object
This is the parent container. It defines the respawn area and resolves the child class.
- room_id - ID of the scene room where the object lives.
- layer_name - MUST match the map layer name exactly (e.g. 'merge-respawn-area-monsters'). Used by the respawn system to find this object. Map layer names MUST contain 'respawn-area' (the import merges spot layers as merge-<name>, which still contains it).
- tile_index - set to NULL for area objects.
- class_type - set to 7 (MultipleObject). This is the container type for all respawning objects.
- object_class_key - set to any string (e.g. 'enemy_1'). Not required to match a registered custom class; used only as a fallback lookup attempt. The class_type=7 fallback to MultipleObject is what actually runs.
- client_key - set to the asset key of the child (e.g. 'enemy_forest_1'). This is only used as the template key and is overridden at spawn time; it does not affect spawned instances.
- private_params - JSON. Controls the child class resolution and the spawn behavior:
- "shouldRespawn": true - required. Without this the respawn system silently skips this object.
- "childObjectClassKey": "rock_forest_1" - use this (string) to resolve the child class from customClasses.objects. Takes priority over childObjectType.
- "childObjectType": 4 - use this (number) instead of childObjectClassKey to resolve by type from the objects_types DB table through the objectsClassTypes config list (e.g. 4 = EnemyObject). Use one or the other, not both.
- "hasState": true - required if the child needs a Colyseus body state (needed for position sync and visibility control).
- "runOnAction": true - set if the child responds to player click interactions.
- "collisionType": 2 - set to enable collision bodies (see Collision Configuration).
- "respawnStateTime": 100 - required for correct client rendering. Sets the delay (ms) between the position update patch (inState=AVOID_INTERPOLATION) and the activation patch (inState=ACTIVE). Without this (defaults to 0), both state changes collapse into the same Colyseus patch, the client never sees AVOID_INTERPOLATION, and the position change triggers client-side interpolation. Since static objects have mov=false, the interpolation runs exactly one step then stops, leaving the sprite permanently stuck at a partially interpolated position. Any value above one Colyseus patch interval (>= 50ms) is sufficient; 100ms is safe.
- Any other properties are merged onto both the template and the child instances via Object.assign.
- client_params - JSON. Merged into clientParams on both the template and the child instances. Key fields:
- "classKey": "rock_forest_1" - tells the client which custom class to use (customClasses.objects[classKey]). Without this the client falls back to the base AnimationEngine (the sprite still shows but custom client features won't run).
- "ui": false - set to false for non-dialog objects (enemies, rocks, chests). Omitting this causes the NPC dialog UI to be created for the object.
- "autoStart": true - used for enemies to auto-play the walk animation.
- "timingDuration": 5000 - used by TimingObject children for the action timer (ms).
- "frameStart": 0, "frameEnd": 0 - animation frame range.
- enabled - set to 1. Setting it to 0 causes the respawn system to skip this object with a warning.
2. respawn table - spawn rules
One row per object-layer combination.
- object_id - FK to objects.id of the template object above.
- respawn_time - milliseconds before a dead / depleted instance respawns at a new tile.
- instances_limit - how many child instances are spawned simultaneously.
- layer - MUST exactly match objects.layer_name.
3. objects_assets table - the spritesheet
One row per asset for the object. The primary key column is object_asset_id.
- object_id - FK to objects.id.
- asset_type - 'spritesheet' for animated sprites.
- asset_key - the Phaser texture key (e.g. 'enemy_forest_1'). The respawn system sets childInstance.clientParams.asset_key to this value. Without this, the client cannot create a sprite.
- asset_file - filename under assets/custom/sprites/ (e.g. 'monster-treant.png').
- extra_params - JSON: {"frameWidth": N, "frameHeight": N}.
SQL template
-- objects table (parent/area object)
INSERT INTO `objects` (`room_id`, `layer_name`, `tile_index`, `class_type`, `object_class_key`, `client_key`, `private_params`, `client_params`, `enabled`) VALUES
(<room_id>, 'respawn-area-your-layer', NULL, 7, 'your_object_area_key', 'your_asset_key',
'{"shouldRespawn":true,"childObjectClassKey":"your_child_key","hasState":true,"runOnAction":true,"collisionType":2,"respawnStateTime":100}',
'{"classKey":"your_child_key","ui":false}',
1);
-- respawn table (use the object id inserted above)
INSERT INTO `respawn` (`object_id`, `respawn_time`, `instances_limit`, `layer`) VALUES
(<object_id>, 30000, 1, 'respawn-area-your-layer');
-- objects_assets table
INSERT INTO `objects_assets` (`object_id`, `asset_type`, `asset_key`, `asset_file`, `extra_params`) VALUES
(<object_id>, 'spritesheet', 'your_asset_key', 'your-sprite.png', '{"frameWidth":32,"frameHeight":32}');
All three records can also be created through the admin panel at /reldens-admin - use Objects for the objects table, Objects Assets for objects_assets, and Respawn for the respawn table (see Respawn Areas Entity).
Required Map Layer
In the room's Tiled JSON map:
- Add a layer whose name contains 'respawn-area' (e.g. 'merge-respawn-area-monsters').
- Paint non-zero tile values on any tiles where instances should be allowed to spawn. The respawn system picks random non-zero tiles from this layer for positioning.
- The layer name must exactly match objects.layer_name and respawn.layer.
Required Server-Side Code
Register the child class
In theme/plugins/server-plugin.js:
customClasses.objects['your_object_key'] = YourObjectClass;
Where 'your_object_key' matches private_params.childObjectClassKey.
Child class requirements
- Extend AnimationObject, NpcObject, EnemyObject, TimingObject, or any class in that chain.
- Must NOT extend MultipleObject or BaseObject directly (those lack isAnimation / hasAnimation and won't register client animations).
- If the child needs to respond to messages (clicks), implement executeMessageActions AND register itself in room.messageActions via runAdditionalRespawnSetup:
async runAdditionalRespawnSetup() { this.events.onWithKey( 'reldens.sceneRoomOnCreate', (room) => { room.messageActions[this.key] = this; }, this.eventUniqueKey('registerMessageAction'), this.uid ); } - Every child instance created by the respawn system gets an ObjectRespawnBehavior assigned to objInstance.respawnBehavior. ObjectRespawnBehavior handles the respawn cycle when respawnBehavior.execute(room) is called; objects can hook into it with the optional onBeforeRestore(room), onAfterRestore(room) and onSetActive(room) methods.
- hasState: true in private_params is required for the body state (Colyseus sync, visibility control). Without it the body exists in the physics world but clients cannot track its position or state changes.
Required Client-Side Code
Register the client class
In theme/plugins/client-plugin.js:
customClasses.objects['your_object_key'] = YourClientClass;
Where 'your_object_key' matches client_params.classKey.
Client class requirements
- Extend AnimationEngine or the client TimingObject (lib/objects/client/object/type/timing-object.js, which extends AnimationEngine).
- The client TimingObject handles the timingStart / timingCancel / timingComplete messages and renders a progress bar.
- Without a registered client class (or with a missing classKey in client_params), the base AnimationEngine is used - the sprite appears correctly but custom client features won't run.
Required Asset File
Place the sprite PNG under:
theme/default/assets/custom/sprites/your-sprite.png
After the theme is built / deployed, it must also exist in the project root at:
dist/assets/custom/sprites/your-sprite.png
The client preloader loads it from /assets/custom/sprites/<asset_file>.
Enemy Spawn and Respawn Flow
Example DB records
These are the actual values from the default Reldens installation, not templates. For the field descriptions and the SQL template, see Required DB Records above.
objects table row id=6:
- room_id = 114 (reldens-forest-level-1 scene)
- layer_name = 'merge-respawn-area-monsters'
- tile_index = NULL
- class_type = 7 (MultipleObject)
- object_class_key = 'enemy_1' (not in customClasses, used only to attempt the lookup)
- client_key = 'enemy_forest_1'
- private_params = '{"shouldRespawn":true,"childObjectType":4,"isAggressive":true,"interactionRadio":170,"randomMovement":{"maxTiles":3}}'
- client_params = '{"autoStart":true,"frameStart":12,"frameEnd":26,"repeat":-1}'
- enabled = 1
respawn table row id=3:
- object_id = 6
- respawn_time = 20000
- instances_limit = 12
- layer = 'merge-respawn-area-monsters'
objects_assets table row object_asset_id=5:
- object_id = 6
- asset_type = 'spritesheet'
- asset_key = 'enemy_forest_1'
- asset_file = 'monster-treant.png'
- extra_params = '{"frameWidth":47,"frameHeight":50,"spacing":2}'
objects_animations rows id=5 to id=8 also exist for object_id = 6 (the directional walk animations).
Step 1 - ObjectsManager.generateObjectFromObjectData (lib/objects/server/manager.js)
Reads the objects row for the area, instantiates MultipleObject as the container, resolves EnemyObject as the child class via childObjectType=4, and stores the template in roomObjectsByLayer keyed by the map layer name. No child instances are created yet - this just prepares the template.
Called from RoomScene.onCreate (through ObjectsManager.generateObjects) after loading all the object rows for the room.
- let objClass = this.config.getWithoutLogs('server/customClasses/objects/'+objectData.object_class_key, false) - returns false for 'enemy_1' (not in customClasses).
- objClass = this.resolveClassFromTypes(objectClassTypes, objectData.class_type) - class type 7 returns MultipleObject.
- Builds objProps merging config / events / dataServer with all the DB row fields.
- this.prepareInitialStats(objProps) - object id=6 has ten related_objects_stats rows (objects_stats ids 21-30), so objProps.initialStats is filled with them keyed by stat key, and the spawned enemies use them.
- let objectInstance = new objClass(objProps), a MultipleObject.
Inside the MultipleObject constructor (lib/objects/server/object/type/multiple-object.js):
- super(props) calls the BaseObject constructor.
- BaseObject: Object.assign(this, props) - assigns ALL props including all the DB fields.
- BaseObject: this.appendIndex = sc.get(props, 'tile_index', null), the NULL tile_index of the area row stays null.
- BaseObject: this.objectIndex = props.layer_name + (this.appendIndex || '-idx-'+props.id), with a null append index it is 'merge-respawn-area-monsters-idx-6'.
- BaseObject: this.key = props.client_key = 'enemy_forest_1'.
- BaseObject: calls mapClientParams(props) - parses client_params, sets this.clientParams.key = 'enemy_forest_1', this.clientParams.id = 6.
- BaseObject: calls mapPrivateParams(props) - parses private_params, sets this.shouldRespawn = true, this.childObjectType = 4, this.isAggressive = true.
- MultipleObject: this.multiple = true.
- MultipleObject: this.classInstance = false.
Back in ObjectsManager.generateObjectFromObjectData:
- this.attachToAnimations(objectInstance) - checks sc.hasOwn(objectInstance, 'isAnimation') and sc.hasOwn(objectInstance, 'hasAnimation'). MultipleObject has neither, so it is NOT added to objectsAnimationsData.
- if(objectInstance.multiple) - true.
- objectInstance.objProps = objProps.
- let childClassKey = sc.get(objectInstance, 'childObjectClassKey', false) = false (not set on the enemy), so subObjClass starts as false.
- subObjClass = this.resolveClassFromTypes(objectClassTypes, objectInstance.childObjectType) = this.resolveClassFromTypes(objectClassTypes, 4) = EnemyObject.
- objectInstance.classInstance = subObjClass, so EnemyObject.
- this.enrichWithMultipleAnimationsData(objectData, objectInstance) - object id=6 has four related_objects_animations rows, so objectInstance.multipleAnimations is filled with {'merge-respawn-area-monsters_6_right': {...}, ..._down, ..._left, ..._up}.
- this.attachToMessagesListeners(objectInstance, objectData) - sc.hasOwn(objectInstance, 'listenMessages') = false (MultipleObject has no listenMessages). Returns false. NOT added to listenMessagesObjects.
- this.prepareAssetsPreload(objectData) - adds the objects_assets row (object_asset_id=5) to preloadAssets. This causes the client to preload the 'enemy_forest_1' spritesheet.
- this.roomObjects[objectInstance.objectIndex] = objectInstance, so roomObjects['merge-respawn-area-monsters-idx-6']. The Tree Punch area (object id=7) is on the same layer with a NULL tile_index too, its own id keeps it apart as roomObjects['merge-respawn-area-monsters-idx-7'].
- this.roomObjectsByLayer[objectData.layer_name][objectData.id] = objectInstance - the layer list is keyed by the object id, so roomObjectsByLayer['merge-respawn-area-monsters'] keeps both areas: {6: treeArea, 7: treePunchArea}.
Step 2 - Map parsing and respawn layer detection (lib/world/server/p2world.js)
Scans the Tiled map layers during the world creation. When a respawn area layer is found, an event triggers RespawnPlugin to create a RoomRespawn instance for that layer, which will own all the spawn tile tracking and the instance creation.
During RoomScene.createWorld, P2world.createWorldContent parses the map layers. After the bodies queue is processed it loops the map layers again:
for(let layer of mapLayers) {
let eventData = {layer, world: this};
await this.events.emit('reldens.parsingMapLayersAfterBodiesQueue', eventData);
}
In RespawnPlugin.listenEvents (lib/respawn/server/plugin.js) the listener validates the layer and the world from the event data and then runs:
await this.createRoomRespawnArea(layer, world);
RespawnPlugin.createRoomRespawnArea:
- Checks the layer name contains 'respawn-area'. True for 'merge-respawn-area-monsters'.
- Creates new RoomRespawn({layer, world, events, dataServer, config}).
- await respawnArea.activateObjectsRespawn().
- world.respawnAreas['merge-respawn-area-monsters'] = respawnArea.
Step 3 - RoomRespawn.activateObjectsRespawn (lib/respawn/server/room-respawn.js)
Parses the map layer to build the pool of valid spawn tiles, queries the respawn table for the matching definitions, and calls createNewObjectInstance once per slot up to instances_limit.
- this.parseMapForRespawnTiles() - reads the layer data array. For 'merge-respawn-area-monsters', iterates all the map tiles. Tiles with value != 0 are pushed to this.respawnTiles and this.respawnTilesData[tileIndex] = {x, y, tile, tile_index, row, column}.
- this.layerObjects = this.world.objectsManager.roomObjectsByLayer[this.layer.name] = {6: treeArea, 7: treePunchArea} (both area objects of the layer).
- Queries the respawn entity with {layer: 'merge-respawn-area-monsters', object_id: {operator: 'IN', value: ['6', '7']}} - returns rows id=3 (object 6) and id=4 (object 7).
- Iterates this.respawnDefinitions, for row id=3:
- sc.hasOwn(this.layerObjects, respawnArea.object_id) = sc.hasOwn(this.layerObjects, 6) = true.
- sc.hasOwn(this.layerObjects[6], 'shouldRespawn') = true (set by mapPrivateParams).
- let multipleObj = this.layerObjects[respawnArea.object_id], the Tree area.
- if(!multipleObj.objProps.enabled) - objProps.enabled = 1 (from the DB row) - truthy, continues.
- let objClass = multipleObj.classInstance = EnemyObject.
- Loops qty = 0; qty < 12 (instances_limit=12) and calls createNewObjectInstance(respawnArea, multipleObj, objClass, tilewidth, tileheight, qty) for every qty from 0 to 11.
- Row id=4 (object 7, Tree Punch, instances_limit=18) runs the same checks and loop, 18 times.
Step 4 - RoomRespawn.createNewObjectInstance
Picks a random valid tile, clones the parent's props, instantiates EnemyObject at that position with a full physics body and Colyseus body state, and registers the live instance with the room's object manager. Runs once per instances_limit slot.
For qty=0:
- this.instancesCreated[3] = [] (first instance of the respawn row id=3).
- generateObjectIndex(respawnArea) - instancesCreated[3].length = 0, returns 'merge-respawn-area-monsters_3_0'.
- let clonedObjProps = Object.assign({}, multipleObj.objProps) - copies all the DB fields + config / events / dataServer.
- clonedObjProps.client_key = objectIndex, so 'merge-respawn-area-monsters_3_0'.
- clonedObjProps.events = this.events.
- let {randomTileIndex, tileData} = this.getRandomTile(objectIndex) - picks a random non-zero tile not used by another instance, returns {x, y, tile, tile_index, row, column} as tileData.
- Object.assign(clonedObjProps, tileData) - sets x, y on clonedObjProps.
- let objInstance = new objClass(clonedObjProps), an EnemyObject.
Inside the EnemyObject constructor (lib/objects/server/object/type/enemy-object.js):
- super(props) calls NpcObject -> AnimationObject -> BaseObject.
- BaseObject: Object.assign(this, props) - sets all the props from clonedObjProps.
- BaseObject: this.key = props.client_key = 'merge-respawn-area-monsters_3_0'.
- mapClientParams: this.clientParams.key = 'merge-respawn-area-monsters_3_0', this.clientParams.id = 6.
- mapPrivateParams: sets shouldRespawn=true, childObjectType=4, isAggressive=true.
- AnimationObject and NpcObject call mapClientParams and mapPrivateParams again at the end of their constructors.
- EnemyObject: this.hasState = true.
- EnemyObject: this.runOnHit = sc.get(props, 'runOnHit', true) = true (default).
- EnemyObject: this.isAggressive = sc.get(this, 'isAggressive', false) = true (from mapPrivateParams).
- EnemyObject: this.respawnTime = false, this.respawnStateTime = sc.get(props, 'battleTimeOff', 1000) (1000 for this enemy), this.respawnLayer = false.
- EnemyObject calls this.mapClientParams(props) and this.mapPrivateParams(props) again at the end of its constructor, so the private_params values win over the constructor assignments (for example randomMovement becomes {"maxTiles":3}); this row has no respawnStateTime in its private_params, so it stays 1000.
Back in createNewObjectInstance:
- if(sc.isObjectFunction(objInstance, 'runAdditionalRespawnSetup')) = true.
- await objInstance.runAdditionalRespawnSetup() - sets up the actions (skills), the aggressive behavior event listener and the battle end event listener.
- Emits reldens.afterRunAdditionalRespawnSetup.
- let assetsArr = this.getObjectAssets(multipleObj) - iterates multipleObj.objProps.related_objects_assets, returns ['enemy_forest_1'].
- objInstance.clientParams.asset_key = assetsArr[0], so 'enemy_forest_1'.
- objInstance.clientParams.enabled = true.
- objInstance.clientParams.animations = multipleObj.multipleAnimations (the four directional animations from objects_animations).
- this.world.objectsManager.objectsAnimationsData[objectIndex] = objInstance.clientParams.
- this.world.objectsManager.roomObjects[objectIndex] = objInstance.
- await this.world.createWorldObject(objInstance, objectIndex, tilewidth, tileheight, tileData.x, tileData.y, this.pathFinder).
Inside P2world.createWorldObject (lib/world/server/p2world.js) hasState is resolved and passed to createCollisionBody, which builds a PhysicalBody with an ObjectBodyState schema:
let hasState = this.allowBodiesWithState ? sc.get(roomObject, 'hasState', false) : false;
Then the body and its state are set on the object:
roomObject.state = bodyObject.bodyState;
roomObject.objectBody = bodyObject;
Back in createNewObjectInstance:
- objInstance.respawnTime = respawnArea.respawn_time = 20000.
- objInstance.respawnLayer = this.layer.name = 'merge-respawn-area-monsters'.
- objInstance.objectIndex = objectIndex = 'merge-respawn-area-monsters_3_0'.
- objInstance.randomTileIndex = randomTileIndex.
- this.instancesCreated[respawnArea.id].push(objInstance).
- objInstance.respawnBehavior = new ObjectRespawnBehavior(objInstance).
The same process is repeated up to instances_limit, creating 'merge-respawn-area-monsters_3_1' to 'merge-respawn-area-monsters_3_11', then the 18 'merge-respawn-area-monsters_4_<n>' instances of the Tree Punch (object id=7, respawn row id=4) on the same layer.
Step 5 - sceneRoomOnCreate (lib/respawn/server/plugin.js)
After the room and the world are fully initialized, adds each child instance's body state to the Colyseus bodies MapSchema. From this point on, any change to bodyState is synced to all the connected clients.
RoomScene.onCreate (lib/rooms/server/scene.js) emits reldens.sceneRoomOnCreate at its end. This is AFTER the world is created, after this.roomData.objectsAnimationsData = this.objectsManager.objectsAnimationsData and after the room State is created.
RespawnPlugin.createRespawnAreasObjectsInstances(room) runs:
- let respawnAreasKeys = Object.keys(room.roomWorld.respawnAreas) = ['merge-respawn-area-monsters', 'merge-respawn-area-mining-rocks'].
- For each area, calls this.createRespawnObjectsInstances(area, room).
- createRespawnObjectsInstances iterates area.instancesCreated = {3: [enemyInstance0, ..., enemyInstance11], 4: [...18 instances]}.
- Calls this.createRespawnObjectsInstancesInState(instanceObjects, room) for each respawn row.
createRespawnObjectsInstancesInState:
- For enemyInstance0: objInstance.hasState = true -> room.state.addBodyToState(objInstance.state, objInstance.client_key) with client_key = 'merge-respawn-area-monsters_3_0'. The body state is now synced to all clients.
- Every other instance: the same with its own client_key.
Step 6 - Client receives the room data
The client receives the serialized objectsAnimationsData, creates a Phaser sprite for the enemy at its starting position, and registers Colyseus body state listeners so the server-side inState, x and y changes drive the sprite's visibility and position in real time.
room.state.roomData.objectsAnimationsData contains:
{
'merge-respawn-area-monsters_3_0': {
key: 'merge-respawn-area-monsters_3_0',
id: 6,
asset_key: 'enemy_forest_1',
enabled: true,
autoStart: true,
...
},
'merge-respawn-area-monsters_3_1': { ... }
}
The 12 Tree enemies share the asset_key 'enemy_forest_1' (and the 18 Tree Punch enemies share 'enemy_forest_2'), so the server SceneDataFilter moves the properties with the same value in every enemy of the group to animationsDefaults['enemy_forest_1'], and the client AnimationsDefaultsMerger.mergeDefaults merges them back into each entry before the objects are created (see Step 4 and Step 5 of the rock flow below, and Room Data Optimization).
ObjectAnimationFactory.createDynamicAnimations (lib/objects/client/object-animation-factory.js), invoked from the reldens.afterSceneDynamicCreate listener of ObjectsPlugin.listenEvents (lib/objects/client/plugin.js):
- Iterates sceneDynamic.objectsAnimationsData.
- For the key 'merge-respawn-area-monsters_3_0': calls this.createAnimationFromAnimData(animProps, sceneDynamic).
- if(!animProps.key) - animProps.key = 'merge-respawn-area-monsters_3_0' - OK.
- let classKey = sc.get(animProps, 'classKey', animProps.key) is the object index (no classKey in this client_params), then config.getWithoutLogs('client/customClasses/objects/'+classKey, AnimationEngine) - no match, returns AnimationEngine.
- let animationEngine = new animationClass(sceneDynamic.gameManager, animProps, sceneDynamic), an AnimationEngine.
Inside the AnimationEngine constructor: this.key = 'merge-respawn-area-monsters_3_0', this.asset_key = 'enemy_forest_1', this.enabled = true.
The factory then creates the sprite and checks its visibility in one call:
this.updateAnimationVisibility(existentBody, animationEngine.createAnimation());
- AnimationEngine.createAnimation() - this.enabled is true, so it passes the if(!this.enabled) gate. Creates the sprite at (x, y) with asset_key = 'enemy_forest_1'. The enemy IS VISIBLE.
- updateAnimationVisibility(existentBody, sprite) - the sprite is only hidden when the body's inState is DEATH or DISABLED, so an ACTIVE enemy stays visible.
- setOnChangeBodyCallback registers a Colyseus listener on the body state. When inState changes: setVisibility(currentBody, ACTIVE === body.inState) - shows / hides the sprite.
Step 7 - Interaction (collision-based)
The player walks into the enemy area. P2world detects the collision. CollisionsManager resolves the collision and calls objectInstance.onHit(props).
EnemyObject.onHit(props):
- this.startBattleOnHit = true - proceeds.
- Calls startBattleWithPlayer(props).
- Gets playerBody, playerSchema.
- Calls this.battle.startBattleWith(playerSchema, room).
The enemy uses the Pve battle system - NO click message, NO executeMessageActions, NO messageActions routing needed (see Battle System).
Step 8 - Enemy dies (death / respawn cycle)
When the enemy's HP hits zero, Pve.battleEnded sets inState=DEATH to hide the sprite, then calls EnemyObject.respawn(room), which disables the collision, freezes the body as STATIC and hands the cycle over to ObjectRespawnBehavior. After respawnTime ms the behavior restores the object: EnemyObject.onBeforeRestore resets the HP, makes the body DYNAMIC again and sets AVOID_INTERPOLATION to suppress the client interpolation, the behavior moves the body to a new random tile, then sets ACTIVE after respawnStateTime ms so the client shows the sprite at the correct new position.
When HP reaches 0, the battle system itself triggers the respawn - NOT a collision.
Pve.battleEnded(playerSchema, room) (lib/actions/server/pve.js):
- this.targetObject.objectBody.bodyState.inState = GameConst.STATUS.DEATH. Colyseus syncs it to the client. Client setVisibility(ACTIVE === DEATH) = false. The enemy is HIDDEN.
- if(sc.isObjectFunction(this.targetObject, 'respawn')) - checks if the method exists on the enemy instance.
- await this.targetObject.respawn(room) - calls EnemyObject.respawn(room) directly.
EnemyObject.onBattleEnd is NOT what triggers the respawn - it only logs 'BattleEnd method not implemented for EnemyObject.'. The respawn is called directly by Pve.battleEnded before the battle end event fires:
if(sc.isObjectFunction(this.targetObject, 'respawn')){
await this.targetObject.respawn(room);
}
this.sendBattleEndedActionData(room, playerSchema, actionData);
let event = new BattleEndedEvent({playerSchema, pve: this, actionData, room});
await this.events.emit(this.targetObject.getBattleEndEvent(), event);
EnemyObject.runAdditionalRespawnSetup registers:
- setupActions() - loads the skills from the DB.
- this.aggression.setup() - EnemyAggression.setup() listens to the reldens.sceneRoomOnCreate event to attach the aggression post-broadphase listener to the room world.
- events.onWithKey(getBattleEndEvent(), onBattleEnd.bind(this), ...) - registers the battle end listener (currently only logs).
The sceneRoomOnCreate event fires AFTER runAdditionalRespawnSetup runs (the respawn system creates the instances during the world creation, sceneRoomOnCreate fires after). This pattern allows the child instances to register room-level behavior after the room is fully created.
EnemyObject.respawn(room):
- this.objectBody.resetAuto() - stops the movement.
- this.objectBody.stopMove().
- this.objectBody.collisionResponse = false - disables the collisions.
- this.originalType = this.objectBody.type.
- this.objectBody.type = this.objectBody.world.bodyTypes.STATIC - stops the physics.
- return this.respawnBehavior.execute(room).
ObjectRespawnBehavior.execute(room) (lib/respawn/server/object-respawn-behavior.js):
- this.respawnTimer = await this.scheduleWithTimer(async () => { await this.restore(room); }, this.objInstance.respawnTime) with respawnTime = 20000.
After 20 seconds, ObjectRespawnBehavior.restore(room) runs:
- obj.onBeforeRestore(room) -> EnemyObject.onBeforeRestore sets this.objectBody.collisionResponse = true, this.objectBody.type = DYNAMIC, this.stats = Object.assign({}, this.initialStats) (restores the full HP) and this.objectBody.bodyState.inState = GameConst.STATUS.AVOID_INTERPOLATION. Colyseus syncs it to the client, setVisibility(ACTIVE === AVOID_INTERPOLATION) = setVisibility(false). The enemy is HIDDEN briefly.
- Picks a new random tile from respawnAreas['merge-respawn-area-monsters'] and repositions the body and its bodyState.x / bodyState.y.
- obj.onAfterRestore(room) -> EnemyObject.onAfterRestore emits reldens.restoreObjectAfter.
- this.respawnStateTimer = await this.scheduleWithTimer(() => { this.setActive(room); }, sc.get(obj, 'respawnStateTime', 0)) (respawnStateTime = sc.get(props, 'battleTimeOff', 1000), so 1000ms for this enemy).
ObjectRespawnBehavior.setActive(room):
- this.objInstance.isActive = false.
- this.objInstance.objectBody.bodyState.inState = GameConst.STATUS.ACTIVE.
- Calls the optional onSetActive(room) hook (EnemyObject.onSetActive).
- Colyseus syncs it to the client. setVisibility(ACTIVE === ACTIVE) = setVisibility(true). The enemy is VISIBLE again.
Step 9 - Client visibility summary
How the Colyseus inState value drives the sprite visibility throughout the enemy lifecycle. The same callback handles all the state transitions.
Client ObjectsPlugin.setOnChangeBodyCallback (lib/objects/client/plugin.js):
- Registers a listener on every property of the body state.
- On ANY property change: this.setVisibility(currentBody, GameConst.STATUS.ACTIVE === body.inState).
- currentBody = currentScene.objectsAnimations['merge-respawn-area-monsters_3_0'] = the AnimationEngine instance.
- ObjectsPlugin.setVisibility calls currentBody.sceneSprite.setVisible(isActive).
Resulting states:
- Enemy ACTIVE (inState=1) -> sprite visible.
- Enemy AVOID_INTERPOLATION (inState=4) -> sprite hidden.
- Enemy DEATH (inState=3) -> sprite hidden.
Rock (TimingObject) Spawn and Respawn Flow
Key differences vs the enemy flow
- Rocks use childObjectClassKey (string) instead of childObjectType (number) to resolve the sub-object class.
- Rocks use RockObject -> TimingObject -> NpcObject -> AnimationObject -> BaseObject.
- Rocks are interactive objects (click to start a timed action) rather than collision-based enemies.
- Rocks do NOT have their own respawn method; they rely entirely on ObjectRespawnBehavior.
Example DB records
These are the actual values from the default Reldens installation (migrations/production/reldens-sample-data-v4.0.0.sql). For the field descriptions and the SQL template, see Required DB Records above.
objects table row id=16:
- room_id = 114 (reldens-forest-level-1 scene)
- layer_name = 'merge-respawn-area-mining-rocks'
- tile_index = NULL
- class_type = 7 (MultipleObject)
- object_class_key = 'rock_forest_1_area' (not in customClasses, falls back to class_type)
- client_key = 'rock_forest_1' (template key, irrelevant for the spawned instances)
- private_params = '{"shouldRespawn":true,"childObjectClassKey":"rock_forest_1","itemKey":"ore","cancelOnMove":true,"cancelOnHit":true,"cancelOnOutOfRange":false,"runOnAction":true,"collisionType":2,"hasState":true,"interactionArea":48}'
- client_params = '{"timingDuration":5000,"isInteractive":true,"frameStart":0,"frameEnd":0,"classKey":"rock_forest_1","ui":false}'
- enabled = 1
respawn table row id=7:
- object_id = 16
- respawn_time = 30000
- instances_limit = 10
- layer = 'merge-respawn-area-mining-rocks'
objects_assets table row object_asset_id=14:
- object_id = 16
- asset_type = 'spritesheet'
- asset_key = 'rock_forest_1'
- asset_file = 'rock.png'
- extra_params = '{"frameWidth":32,"frameHeight":32}'
Sprite file: theme/default/assets/custom/sprites/rock.png.
Step 1 - ObjectsManager.generateObjectFromObjectData
Identical to the enemy flow but resolves the child class via childObjectClassKey='rock_forest_1' (string lookup in customClasses) rather than childObjectType (numeric lookup in objectsClassTypes). Called from RoomScene.onCreate for object id=16.
- config.getWithoutLogs('server/customClasses/objects/rock_forest_1_area', false) -> false (not registered).
- resolveClassFromTypes(objectClassTypes, 7) -> MultipleObject.
new MultipleObject(objProps):
- Object.assign(this, props) sets all the DB fields.
- this.key = props.client_key = 'rock_forest_1' (template key).
- this.objectIndex = props.layer_name + (this.appendIndex || '-idx-'+props.id) = 'merge-respawn-area-mining-rocks-idx-16' (the NULL tile_index stays null, see the enemy flow Step 1).
- mapPrivateParams: Object.assign(this, privateParamsObject) sets this.shouldRespawn = true, this.childObjectClassKey = 'rock_forest_1', this.itemKey = 'ore', this.cancelOnMove = true, this.cancelOnHit = true, this.hasState = true, this.collisionType = 2, this.runOnAction = true, this.interactionArea = 48.
- this.multiple = true, this.classInstance = false.
Then in the manager:
- attachToAnimations(objectInstance): MultipleObject has no isAnimation or hasAnimation -> NOT added to objectsAnimationsData.
- if(objectInstance.multiple) -> true.
- objectInstance.objProps = objProps.
- childClassKey = sc.get(objectInstance, 'childObjectClassKey', false) = 'rock_forest_1'.
- subObjClass = config.getWithoutLogs('server/customClasses/objects/rock_forest_1', false) = RockObject (registered by ServerPlugin.defineCustomClasses in theme/plugins/server-plugin.js).
- objectInstance.classInstance = RockObject.
- enrichWithMultipleAnimationsData(objectData, objectInstance): object id=16 has no objects_animations rows, so objectInstance.multipleAnimations stays empty.
- attachToMessagesListeners: MultipleObject has no listenMessages -> skipped. The template is NOT added to messageActions.
- prepareAssetsPreload(objectData) adds object_asset_id=14 to preloadAssets:
- key = '1614' (object_id=16 + object_asset_id=14)
- value = {asset_type:'spritesheet', asset_key:'rock_forest_1', asset_file:'rock.png', extra_params:'{"frameWidth":32,"frameHeight":32}'}
- roomObjects['merge-respawn-area-mining-rocks-idx-16'] = multipleObjInstance.
- roomObjectsByLayer['merge-respawn-area-mining-rocks'][16] = multipleObjInstance.
Step 2 - Map parsing
Same as the enemy flow. The merge-respawn-area-mining-rocks layer is detected and a RoomRespawn instance is created to manage the rock spawn tile tracking and the instance creation.
- reldens-forest-level-1.json contains the layer 'merge-respawn-area-mining-rocks' (id=10, width=72, height=100).
- Its data array has 46 non-zero tiles (the clearing tile values 14, 15, 16, 21, 22 and others) at rows 23-28, columns 56-63 (columns 59 and 60 of row 23 are empty).
- parseMapForRespawnTiles() finds those tiles and adds them to this.respawnTiles.
- this.layerObjects = roomObjectsByLayer['merge-respawn-area-mining-rocks'] = {16: multipleObjInstance}.
- The DB query for respawn with {layer: 'merge-respawn-area-mining-rocks', object_id: IN [16]} returns row id=7.
- Check: sc.hasOwn(layerObjects[16], 'shouldRespawn') = true.
- Check: multipleObj.objProps.enabled = 1 -> truthy.
- Check: multipleObj.classInstance = RockObject.
- Loops qty = 0; qty < 10 (instances_limit=10) -> calls createNewObjectInstance ten times.
Step 3 - RoomRespawn.createNewObjectInstance
Instantiates one RockObject child at a random tile per slot, ten in total ('merge-respawn-area-mining-rocks_7_0' to 'merge-respawn-area-mining-rocks_7_9'); the steps below follow the first one. The key difference from enemies: runAdditionalRespawnSetup registers the rock instance in room.messageActions, enabling the server-side routing of the player click messages to the correct instance.
- objectIndex = generateObjectIndex(respawnArea) = 'merge-respawn-area-mining-rocks_7_0' (layer + respawnArea.id + instances created so far).
- clonedObjProps = Object.assign({}, multipleObj.objProps) clones all the DB fields.
- clonedObjProps.client_key = 'merge-respawn-area-mining-rocks_7_0' overrides the template key.
- {randomTileIndex, tileData} = getRandomTile(objectIndex) picks a random non-zero tile of the respawn layer that no other instance uses.
- Object.assign(clonedObjProps, tileData) sets x, y, tile, tile_index, row, column.
new RockObject(clonedObjProps) runs the chain RockObject -> TimingObject -> NpcObject -> AnimationObject -> BaseObject:
- BaseObject:
- Object.assign(this, props) assigns all the cloned props.
- this.key = 'merge-respawn-area-mining-rocks_7_0'.
- this.uid = 'merge-respawn-area-mining-rocks_7_0-<timestamp>'.
- mapClientParams(props): sc.toJson(props.client_params, {}) = {timingDuration:5000, isInteractive:true, frameStart:0, frameEnd:0, classKey:'rock_forest_1', ui:false}, merged into this.clientParams. Then this.clientParams.key = this.key, this.clientParams.id = 16.
- mapPrivateParams(props): applies private_params again via Object.assign(this, ...), setting shouldRespawn, childObjectClassKey, itemKey, hasState, etc.
- AnimationObject:
- this.isAnimation = true.
- Reinitializes the this.clientParams object then calls mapClientParams / mapPrivateParams again.
- NpcObject:
- this.hasAnimation = true, this.listenMessages = true, this.collisionResponse = true.
- Sets this.clientParams.isInteractive = true.
- Sets this.interactionArea from the server/objects/actions/interactionsDistance config.
- Calls mapClientParams / mapPrivateParams again, so the "interactionArea":48 of private_params replaces the config value.
- TimingObject:
- this.isActive = false, this.timingTimer = null, this.timingCheckInterval = null.
- RockObject:
- Class field respawnStateTime = 100.
RockObject.runAdditionalRespawnSetup():
- Registers this.events.onWithKey('reldens.sceneRoomOnCreate', (room) => { room.messageActions[this.key] = this; }, ...).
- When reldens.sceneRoomOnCreate fires: room.messageActions['merge-respawn-area-mining-rocks_7_0'] = rockInstance.
Then:
- objInstance.clientParams.asset_key = 'rock_forest_1' (from getObjectAssets).
- objInstance.clientParams.enabled = true.
- world.objectsManager.objectsAnimationsData['merge-respawn-area-mining-rocks_7_0'] = rockInstance.clientParams.
- world.objectsManager.roomObjects['merge-respawn-area-mining-rocks_7_0'] = rockInstance.
createWorldObject(rockInstance, 'merge-respawn-area-mining-rocks_7_0', tileW, tileH, x, y, pathFinder):
- rockInstance.interactionArea is 48, so roomObject.setupInteractionArea() builds the interaction area around the rock position.
- hasState = this.allowBodiesWithState ? sc.get(roomObject, 'hasState', false) : false = true.
- Creates a PhysicalBody with an ObjectBodyState schema object.
- rockInstance.state = bodyObject.bodyState.
- rockInstance.objectBody = bodyObject.
Finally: rockInstance.respawnTime = 30000 and rockInstance.respawnBehavior = new ObjectRespawnBehavior(rockInstance).
Step 4 - State synchronization (reldens.sceneRoomOnCreate)
Adds the rock's body state to the Colyseus bodies MapSchema and serializes objectsAnimationsData into the room's sceneData, making the position and asset info available to any client that joins.
In RoomScene.onCreate (lib/rooms/server/scene.js): this.roomData.objectsAnimationsData = this.objectsManager.objectsAnimationsData. At this point objectsAnimationsData['merge-respawn-area-mining-rocks_7_0'] already exists (added in Step 3 before the room state is created).
new State(this.roomData, this.sceneDataFilter) -> mapRoomData() -> SceneDataFilter.filterRoomData(roomData) -> buildFilteredData(roomData) (lib/rooms/server/scene-data-filter.js):
- optimizeData(objectsAnimationsData, 'asset_key', false): the ten rock entries are grouped by asset_key='rock_forest_1' (and the enemies by 'enemy_forest_1' and 'enemy_forest_2'). The properties with the same value in every rock of the group (for example classKey, timingDuration, isInteractive, id and layerName) are moved to animationsDefaults['rock_forest_1']; each rock entry keeps its key, its asset_key (the grouping field) and the properties that differ, like its position.
- filteredData.animationsDefaults holds one entry per group with the shared properties.
- filteredData.preloadAssets: the rock asset entry '1614' is preserved with all its fields including asset_type.
this.state = roomState: the sceneData JSON now includes the rock animation data, the animations defaults and the preload assets.
reldens.sceneRoomOnCreate fires -> RespawnPlugin.createRespawnAreasObjectsInstances(room):
- room.state.addBodyToState(rockInstance.state, 'merge-respawn-area-mining-rocks_7_0').
- The rock body state is now in the Colyseus bodies MapSchema -> synced to all clients.
- The rockInstance.runAdditionalRespawnSetup listener fires -> room.messageActions['merge-respawn-area-mining-rocks_7_0'] = rockInstance.
Step 5 - Client receives the room data
Loads the rock_forest_1 spritesheet, creates the Phaser sprite with the pointerdown interaction enabled, and registers the Colyseus body state change listeners. The rock is visible and clickable from this point.
Client RoomEvents.checkAndCreateScene (lib/game/client/room-events.js):
if(0 === Object.keys(this.roomData).length){
this.roomData = AnimationsDefaultsMerger.mergeDefaults(sc.toJson(this.room.state.sceneData));
}
AnimationsDefaultsMerger.mergeDefaults (lib/game/client/animations-defaults-merger.js):
- preloadAssetsDefaults is merged first into preloadAssets (grouped by asset_type), so the shared spritesheet extra_params are restored before the preloader reads them.
- animationsDefaults is merged into objectsAnimationsData grouped by asset_key: the rock entry resolves the group value 'rock_forest_1' and becomes Object.assign({}, animationsDefaults['rock_forest_1'], rockEntry), so it gets back every shared property (the enemies are restored the same way from their own entries).
- roomData.preloadAssetsDefaults and roomData.animationsDefaults are deleted before the data is returned.
ScenePreloader.preloadValidAssets processes preloadAssets['1614']: asset_type='spritesheet' -> this.load.spritesheet('rock_forest_1', '/assets/custom/sprites/rock.png', {frameWidth:32, frameHeight:32}).
ObjectAnimationFactory.createDynamicAnimations(sceneDynamic) iterates objectsAnimationsData:
- Key 'merge-respawn-area-mining-rocks_7_0': animProps.key is set.
- classKey = sc.get(animProps, 'classKey', animProps.key) = 'rock_forest_1' (classKey is set in client_params).
- animationClass = config.getWithoutLogs('client/customClasses/objects/rock_forest_1', AnimationEngine) -> Rock (registered by ClientPlugin.defineCustomClasses in theme/plugins/client-plugin.js), which extends ToolTimingObject (theme/plugins/objects/client/tool-timing-object.js), a client TimingObject that also plays the pickaxe tool animation while the timing runs.
- new Rock(gameManager, animProps, sceneDynamic).
AnimationEngine.createAnimation():
- this.enabled = true.
- Checks this.currentPreloader.anims.textureManager.list['rock_forest_1'], the texture loaded by the preloader.
- Creates the sprite via currentScene.physics.add.sprite(x, y, 'rock_forest_1').
- this.isInteractive = true -> enableInteraction(currentScene) registers the pointerdown listener (overridden by the client TimingObject).
- currentScene.objectsAnimations['merge-respawn-area-mining-rocks_7_0'] = this.
The rock sprite is NOW VISIBLE in the scene.
ObjectsPlugin.setOnChangeBodyCallback registers the Colyseus body listeners:
- On any body property change: calls animationFactory.updateObjectsAnimations('merge-respawn-area-mining-rocks_7_0', body, currentScene).
- setVisibility(currentBody, ACTIVE === body.inState) controls the sprite visibility.
Step 6 - Player clicks the rock
A player click sends an OBJECT_INTERACTION message to the server, routed via room.messageActions to RockObject.executeMessageActions, which validates the player is within range then starts the TimingObject countdown.
Client TimingObject.enableInteraction click handler (lib/objects/client/object/type/timing-object.js):
- (this.key === this.asset_key) ? this.id : this.key -> the keys differ, so the id sent is 'merge-respawn-area-mining-rocks_7_0'.
- Sends {act: ObjectsConst.OBJECT_INTERACTION, id: 'merge-respawn-area-mining-rocks_7_0', type: this.type} (type is TYPE_NPC for this object).
Server RoomScene.executeSceneMessageActions (lib/rooms/server/scene.js) iterates messageActions:
- messageActions['merge-respawn-area-mining-rocks_7_0'] = rockInstance.
- Calls rockInstance.executeMessageActions(client, data, room, playerSchema).
TimingObject.executeMessageActions:
- isValidId(data): RockObject overrides it as this.key === data?.id || false || Number(this.id) === Number(data?.id || false), so 'merge-respawn-area-mining-rocks_7_0' === 'merge-respawn-area-mining-rocks_7_0' passes.
- isObjectInteractionMessage(data): data.act === OBJECT_INTERACTION.
- isValidInteraction(playerSchema.state.x, playerSchema.state.y): validates the player is within the interaction area.
- if(this.isActive) -> false (the rock is idle) -> proceeds.
- startTiming(client, room, playerSchema).
TimingObject.startTiming:
- this.isActive = true.
- client.send('*', {act: 'timingStart', id: 16, key: 'merge-respawn-area-mining-rocks_7_0'}), sending id = this.id = 16 (DB id, shared by every rock instance) and the instance key.
- Reads the affected property (client/actions/skills/affectedProperty, hp) and keeps its value at the start.
- Starts timingCheckInterval every 100ms: checks if the player moved (cancelOnMove=true) or if the affected property is lower than its value at the previous check, a hit from an enemy or another player (cancelOnHit=true). Either one -> cancelTiming(client).
- Starts timingTimer = setTimeout(completeTiming, this.clientParams.timingDuration) (5000ms).
The client receives {act: 'timingStart', id: 16, key: 'merge-respawn-area-mining-rocks_7_0'}. The registered Rock class (a ToolTimingObject, so a client TimingObject) matches on message.key === this.key and shows the progress bar only on that instance (matching on the shared id showed the bar on every rock). The base AnimationEngine does NOT handle timingStart, so without classKey: 'rock_forest_1' in client_params no progress bar would be rendered.
Step 7 - Timing completes
After timingDuration ms without the player moving, completeTiming credits the player with an ore item, disables the rock's collision group so the player can walk through it, sets inState=DISABLED to hide the sprite, then triggers ObjectRespawnBehavior.execute to start the respawn timer.
After 5000ms (if the player did not move), RockObject.completeTiming(client, room, playerSchema):
- let newItem = playerSchema.inventory.manager.createItemInstance(this.itemKey) with this.itemKey = 'ore'.
- let addResult = await playerSchema.inventory.manager.addItem(newItem).
- If addResult === false (inventory full, etc.) -> this.cancelTiming(client) and return.
- this.isActive = false.
- this.objectBody.setShapesCollisionGroup(0).
- this.objectBody.bodyState.inState = GameConst.STATUS.DISABLED.
- client.send('*', {act: 'timingComplete', id: 16, key: 'merge-respawn-area-mining-rocks_7_0', rewarded: true, itemKey: 'ore'}).
- if(!this.respawnBehavior) { return; } guard (respawnBehavior is set in Step 3).
- this.respawnBehavior.execute(room).
The rock sprite becomes invisible: Colyseus syncs inState = DISABLED -> setVisibility(false).
Step 8 - ObjectRespawnBehavior.execute (lib/respawn/server/object-respawn-behavior.js)
Schedules restore() after respawnTime ms. On restore, onBeforeRestore sets AVOID_INTERPOLATION, the body moves to a new random tile, the body state x / y are updated, then respawnStateTime ms later setActive sets ACTIVE. The client receives the position change while the sprite is hidden (AVOID_INTERPOLATION suppresses the interpolation), then shows it at the correct position when ACTIVE arrives.
execute(room):
- this.respawnTimer = await this.scheduleWithTimer(async () => { await this.restore(room); }, this.objInstance.respawnTime) with respawnTime = 30000.
After 30 seconds, restore(room):
- obj.onBeforeRestore(room) -> RockObject.onBeforeRestore sets this.objectBody.bodyState.inState = GameConst.STATUS.AVOID_INTERPOLATION.
- Gets respawnArea = world.respawnAreas['merge-respawn-area-mining-rocks'].
- Picks a new random tile via respawnArea.getRandomTile(obj.objectIndex).
- Updates the body position: obj.objectBody.position = [newX, newY], obj.objectBody.bodyState.x = newX, obj.objectBody.bodyState.y = newY.
- Calls updateBodyPositionInitialData(room, newX, newY), which (when obj.updateInitialPosition is set) updates room.state.roomData.objectsAnimationsData['merge-respawn-area-mining-rocks_7_0'].x/y and calls room.state.mapRoomData() to refresh the serialized sceneData.
- this.respawnStateTimer = await this.scheduleWithTimer(() => { this.setActive(room); }, sc.get(obj, 'respawnStateTime', 0)) (100 for RockObject).
setActive(room):
- obj.isActive = false.
- obj.objectBody.bodyState.inState = GameConst.STATUS.ACTIVE.
- Calls RockObject.onSetActive, which restores the collision group with this.objectBody.setShapesCollisionGroup(this.objectBody.originalCollisionGroup).
- Colyseus syncs inState = ACTIVE to the client.
- Client setVisibility(ACTIVE === ACTIVE) = setVisibility(true).
- The rock sprite reappears at the new position.
Step 9 - Timing cancelled (player moved or was hit)
If the player moves or is hit during the mining countdown, all the timers are cleared and the client is notified. The rock remains active and immediately clickable again, no respawn is triggered.
timingCheckInterval fires every 100ms. The timing is cancelled if playerSchema.state.x !== startX || playerSchema.state.y !== startY (and cancelOnMove=true), or if the affected property is lower than its value at the previous 100ms check (and cancelOnHit=true, a heal never cancels). lastAffectedValue starts with the value when the timing started and is updated on every check:
let currentAffectedValue = sc.get(playerSchema.stats, affectedProperty, 0);
if(this.cancelOnHit && currentAffectedValue < lastAffectedValue){
this.cancelTiming(client);
return;
}
lastAffectedValue = currentAffectedValue;
Then cancelTiming(client):
- clearInterval(timingCheckInterval).
- clearTimeout(timingTimer).
- this.isActive = false.
- client.send('*', {act: 'timingCancel', id: 16, key: 'merge-respawn-area-mining-rocks_7_0'}).
The rock remains active and clickable. No respawn is triggered.
Critical Design Notes
- childObjectClassKey in private_params takes priority over childObjectType for the sub-object class resolution. If childObjectClassKey is set, it looks up customClasses.objects[childObjectClassKey] first.
- Spawned rock instances get client_key = objectIndex (pattern: layerName_respawnAreaId_instanceNumber), overriding the template's DB client_key.
- clientParams.id = 16 (DB id of the template) but clientParams.key = objectIndex. The client sends id = key for the interaction (since key !== asset_key). RockObject.isValidId accepts either this.key === data.id or Number(this.id) === Number(data.id).
- hasState = true in private_params is required for createWorldObject to create the PhysicalBody with ObjectBodyState, enabling the Colyseus sync.
- runAdditionalRespawnSetup MUST register room.messageActions[this.key] for the click interaction to be routed to the rock instance. This fires via the reldens.sceneRoomOnCreate listener.
- ObjectRespawnBehavior handles the respawn lifecycle for every instance created by the respawn system. Rocks call it directly from completeTiming; enemies call it from EnemyObject.respawn() after freezing the body, and hook into it with onBeforeRestore, onAfterRestore and onSetActive.
- RockObject.completeTiming overrides TimingObject.completeTiming to use this.itemKey directly instead of rollReward(). The respawnBehavior guard (if(!this.respawnBehavior){ return; }) prevents a crash when completeTiming is accidentally called on the template MultipleObject.
- The reldens-forest-level-1.json map layer merge-respawn-area-mining-rocks (id=10) has its data array with non-zero tile values in the clearing at rows 23-28, columns 56-63, defining where the rocks can spawn.
Related Documentation
- Respawn Areas Entity - respawn table admin and field reference.
- Objects Entity - objects table, private_params, client_params.
- Collision Configuration - collisionType and physics body types.
- Battle System - the PvE battle that kills and respawns the enemies.
- Room Data Optimization - animationsDefaults and the scene data filter.
- Object Animations Engine - the client AnimationEngine used by the spawned objects.
- Create a Room / Map - map layers and the respawn area layer naming.
- Feature Modules - lib/respawn/, lib/actions/, lib/objects/ internals.
reldens