Object Animations Engine

How a database objects row becomes an animated sprite on the client, what each animation parameter does and how to reuse one spritesheet across many objects.

Overview

Every animated room object (doors, NPCs, enemies, chests, etc.) is created on the client by the AnimationEngine class (lib/objects/client/animation-engine.js), using the data of its objects row and its objects_assets rows.

The Two Keys: client_key and asset_key

An object row has two keys that do completely different jobs. Mixing them up is the usual source of bugs.

  • client_key: the object INSTANCE identity. Keys the sprite in the scene, the hit routing, the life bars, the targeting and the battle lookups. MUST be unique per object.
  • asset_key: the loaded TEXTURE (spritesheet) name, it just points at a PNG. Many objects can share it.

asset_key lives in client_params. If you omit it, it falls back to client_key.

Rule of thumb:

  • Want two objects to be separate things in the game: give them a different client_key.
  • Want them to draw the same sprite: give them the same asset_key.

Reuse One Spritesheet Across Many Objects

Give every object a UNIQUE client_key, point them all at the SAME asset_key, and create only ONE assets row for that texture. The others reuse the already loaded texture, no assets row of their own is needed.

Database example, three doors that look identical but are three real, independent doors:

-- objects: unique client_key each, same asset_key in client_params
INSERT INTO `objects` (`room_id`,`layer_name`,`tile_index`,`class_type`,`object_class_key`,`client_key`,`private_params`,`client_params`,`enabled`) VALUES
(21,'merge-collisions',3522,2,'door_3','door_house_3','{"runOnHit":true,"roomVisible":true,"yFix":6}','{"positionFix":{"y":-18},"frameStart":0,"frameEnd":3,"repeat":0,"hideOnComplete":false,"autoStart":false,"restartTime":2000,"asset_key":"door_house_3"}',1),
(21,'merge-collisions',3534,2,'door_4','door_house_4','{"runOnHit":true,"roomVisible":true,"yFix":6}','{"positionFix":{"y":-18},"frameStart":0,"frameEnd":3,"repeat":0,"hideOnComplete":false,"autoStart":false,"restartTime":2000,"asset_key":"door_house_3"}',1),
(21,'merge-collisions',4800,2,'door_5','door_house_5','{"runOnHit":true,"roomVisible":true,"yFix":6}','{"positionFix":{"y":-18},"frameStart":0,"frameEnd":3,"repeat":0,"hideOnComplete":false,"autoStart":false,"restartTime":2000,"asset_key":"door_house_3"}',1);

-- objects_assets: ONE row for the shared texture (attach it to the first door only)
INSERT INTO `objects_assets` (`object_id`,`asset_type`,`asset_key`,`asset_file`,`extra_params`) VALUES
(<door_3 id>,'spritesheet','door_house_3','door-a-x3.png','{"frameWidth":32,"frameHeight":64}');

Result: the door_house_3 texture loads once, and each door is its own instance, so a hit animates the exact door you hit.

Do NOT give the three doors the same client_key. They then overwrite each other in the client scene registry (only one sprite survives) and the life bar and targeting lookups resolve to the wrong or a missing instance and crash. Sharing the texture is done with asset_key, never by sharing client_key.

The same assets row can be created in the administration panel, under the object assets section:

Admin Panel - Create object asset form with the object, asset type, asset key, asset file and extra params fields

Parameters

The values shown are the ones from the door example above.

private_params (server side, control WHEN the animation fires)

  • runOnHit: play when the player collides with the object body. Door: true.
  • roomVisible: broadcast the animation to everyone in the room. Door: true.
  • playerVisible: send it only to the player who triggered it, instead of the room. Door: unset.
  • yFix (and xFix): shift the SERVER body and anchor by N pixels, moves the collision AND the render. Door: 6.

client_params (client side, control HOW it looks and plays)

  • asset_key: the texture to draw, shared across objects, falls back to client_key. Door: door_house_3.
  • frameStart / frameEnd: first and last spritesheet frame, 0..3 = 4 frames. Door: 0 / 3.
  • repeat: loop count, 0 = play once, -1 = loop forever. AnimationEngine falls back to -1 when the value is not a number, but AnimationObject already defaults it to 0 server side, so a database animation object plays once unless you set it. Door: 0.
  • autoStart: play immediately on creation (needs more than 1 frame). Door: false.
  • hideOnComplete: Phaser hides the sprite when the animation finishes. Door: false.
  • restartTime: milliseconds after finishing to reset to the first frame and pause. Door: 2000.
  • positionFix {x,y}: shift ONLY the rendered sprite, not the collision body. Door: {y:-18}.
  • enabled: if false, no sprite is created (logs Animation disabled), default true. Door: inherits true.

Note: hideOnComplete (hide) is different from destroyOnComplete (remove entirely).

Object Position

server body / anchor = tilePixel + yFix
rendered sprite      = tilePixel + yFix + positionFix
  • The collision uses the server body / anchor position.
  • yFix moves the real object (collision and render).
  • positionFix nudges only the drawing, to line the art up over the body.

Door: body at tileY + 6, sprite drawn at tileY + 6 - 18 = tileY - 12.

Aligning a sprite taller than the tile

The sprite has no setOrigin, so Phaser uses the origin 0.5, 0.5: the sprite is drawn CENTERED on the tile center. A sprite taller than the tile therefore spills equally above and below the tile, and its foot does not rest on the tile.

To make the foot sit on the tile, raise it by approximately half the overshoot:

positionFix.y = -(frameHeight - tileHeight) / 2

Then nudge it a few pixels for empty art.

positionFix is a FIXED offset, it does not scale with the sprite. Each sprite HEIGHT needs its own positionFix.y. If the sprite height changes by N pixels, adjust positionFix.y by -N/2 (taller sprite = more negative).

Examples on a 32px tile:

  • 64px sprite: -(64-32)/2 = -16 (the doors use -18, 2px extra for art padding).
  • 68px sprite (same art, 4px taller): -18 - (68-64)/2 = -20.

Lifecycle

  1. The server sends each object's client_params to the client as animation data.
  2. AnimationEngine is constructed from those params.
  3. createAnimation():
    • if enabled is false, stop (logs Animation disabled);
    • if the asset_key texture is not loaded yet (for example an object created after the scene preload), load it once from the room preload assets (the objects_assets row asset_file and extra_params, the same data ScenePreloader.preloadValidAssets() uses) and retry createAnimation() after the loader completes;
    • register a Phaser animation named after client_key, using the asset_key frames;
    • create the sprite at the computed position and store it in the scene under client_key;
    • if autoStart, play now.
  4. On hit, the server broadcasts the animation message, the client finds the sprite by client_key and calls runAnimation() to play it. restartTime resets it afterwards.

Code Map

Key resolution, in the AnimationEngine constructor (lib/objects/client/animation-engine.js):

this.asset_key = sc.get(props, 'asset_key', props.key);

Instance registry (keyed by client_key), in AnimationEngine.createAnimation():

currentScene.objectsAnimations[this.key] = this;

Hit routing (by client_key), in ObjectsPlugin.startObjectAnimation() (lib/objects/client/plugin.js):

if(!sc.hasOwn(currentScene.objectsAnimations, message.key)){
    return false;
}
currentScene.objectsAnimations[message.key].runAnimation();

Texture load (by asset_key, one per assets row), in ScenePreloader.preloadValidAssets() (lib/game/client/scene-preloader.js):

this.load.spritesheet(asset.asset_key, `/assets/custom/sprites/${asset.asset_file}`, assetParams);

Missing texture fallback: AnimationEngine.createAnimation() loads the texture once from the room preload assets and retries when the loader completes (see Lifecycle above).

Position math (server), in P2world.createWorldObject() (lib/world/server/p2world.js):

posX += sc.get(roomObject, 'xFix', 0);
posY += sc.get(roomObject, 'yFix', 0);

Position math (client), AnimationEngine.calculateAnimPosition() and the sprite placement in AnimationEngine.createAnimation():

let spriteX = this.positionFix ? this.animPos.x : this.x;
let spriteY = this.positionFix ? this.animPos.y : this.y;

Other references:

  • Server params: the defaults in the AnimationObject constructor (this.clientParams) in lib/objects/server/object/type/animation-object.js. The client_key and params mapping in the BaseObject constructor (this.key = props.client_key;) and BaseObject.mapClientParams() in lib/objects/server/object/type/base-object.js.
  • client_key is also the lookup key for the life bars, the battle and the targeting (lib/actions/client/receiver-wrapper.js, lib/game/client/scene-dynamic.js), which is why it must be unique.

Related Documentation

Go Up