This is the high-level steps that must be followed:
- Create the protobuf definition in Decentraland's SDK protocol
- Generate the new protobuf TypeScript code in the js-sdk-toolchain (serialization test + (optional) sdk helpers)
- Generate the new protobuf C# code in unity-explorer + implement the systems that handle the functionalities, cleanup, etc. for the component
- (Ideally) Create an example test-scene to show how the component is used from the SDK side
IMPORTANT This is the order in which PRs must be merged:
- Merge Protocol repo PR (if the change was on
main, thenexperimentalmust sync the new changes before step 2) - Update both
js-sdk-toolchainrepo PR andunity-explorerrepo PR to use the updated@dcl/protocol@experimentalpackage - Merge
js-sdk-toolchainandunity-explorerin any order
- Create the protobuf definition inside https://github.com/decentraland/protocol/tree/main/proto/decentraland/sdk/components
- Create a PR with the new changes
NOTE: After creating the PR, a GitHub Bot will comment with the package link to test the PR. You can use that link for testing in the following steps.
Things to take into account:
- We are using proto 3, so all the definition of the proto must compile with their syntax.
- Be VERY mindful of the fields and types you create in the Protobuf Message, due to retro-compatibility standards we can never rename or modify any field of an already released Protobuf message (AKA if you are still working on your PRs then you can keep changing stuff, although you will be forced to change the other 2 repo PRs as well of course), as that will break scenes that are already deployed and create conflicts in the SDK and Explorer... if at some point we really need to "update" an already released component field, we should create a new field and flag the old one as deprecated (example).
- We have some common types that cannot be recreated.
- The proto should have the basic definition.
- You must add the following code to enumerate the component with a UNIQUE ID:
import "common/id.proto";
option (ecs_component_id) = 1100;NOTE: With the repo cloned locally you can run
make list-components-idsandmake check-component-id ID=xxxto make sure your component is using a unique id. On Windows you can run this instead:bash -c "grep -rh 'option (common\.ecs_component_id)' proto/ --include='*.proto' | sed -E 's/.*= ([0-9]+);/\1/' | sort -n | nl"
IMPORTANT: New components going directly to main should have a 12xx ID (or cover the previous unused IDs). New components going to experimental should have a 14xx ID. DAO's Regenesis Labs (AKA Protocol Squad) experimental feature components will have a 16xx ID. This was defined to avoid ID conflicts when an experimental feature component is taken to main.
Example of .proto:
syntax = "proto3";
import "common/id.proto";
option (ecs_component_id) = 1020;
message PBAudioSource {
optional bool playing = 1;
optional float volume = 2; // default=1.0f
optional bool loop = 3;
optional float pitch = 4; // default=1.0f
string audio_clip_url = 5;
}Clone the sdk repo: https://github.com/decentraland/js-sdk-toolchain
Run the following command at the root of the js-sdk-toolchain project (use @experimental instead if you are working on an experimental feature):
npm install @dcl/protocol@nextOr the command generated by the GitHub Bot in your @dcl/protocol PR (this must be temporal for testing the PRs).
Then run the following commands at the root of the js-sdk-toolchain project:
make install
make buildAnd push the generated code to your branch.
For the branch PR to be approved and functional you will also need to:
- [Mandatory] Implement the new component serialization test (check the existent ones at https://github.com/decentraland/js-sdk-toolchain/tree/main/test/ecs/components)
- Afterwards you will have to run
make update-snapshotsto incorporate the new test into the test memory snapshots.
- Afterwards you will have to run
- [Optional] If it was defined that there would be SDK helpers, those also have to be implemented in the PR (Wiki page for helpers)
After that is done, the GitHub Bot comment in the PR will expose a test package so that you install that in your test scene and be able to use the new component from the SDK in the scene (see How to connect to a local scene on setting up a local scene and running it).
NOTE: If development was done and merged to
experimentalfollow these steps to port it tomain:
- Cherry pick the squash-merged commit without committing by running
cherry-pick --no-commit <HASH>- Discard the conflicted test snapshots and
playground-assets.api.md(the non-conflicted files will already be staged) withgit checkout .- Update the protocol package with the main-based PR in protocol for your changes with
npm install ....(PR-generated command)- Rebuild everything with
make installthenmake buildand thenmake update-snapshots- Add the new filechanges and commit to your main-based branch
In general, any component will be defined by default to be a LWW, so that every update to it through CRDT will overwrite the whole state of it for whoever reads it later. If not specified or needed, always go for a LWW Component approach.
In some cases, specially for some "result components" populated by the Explorer, instead of a LWW, a less-used kind of CRDT component called GrowOnly-Value-Set is needed. That kind of component always holds a collection of all the previous states of the component.
Deeper and more detailed info on those 2 versions of the CRDT components design can be found at the ADR-117. Here are their main differences:
- Last-Write-Wins Component (LWW)
- The component can be overwritten and the scene will read only its last state.
- GrowOnly-Value-Set Component (GOVS)
- This component holds the history of every write in an array (a SDK helper could be useful to implement in order to let the creators avoid the boilerplate. See
videoEventsorassetLoadas reference).
- This component holds the history of every write in an array (a SDK helper could be useful to implement in order to let the creators avoid the boilerplate. See
IMPORTANT: In order to implement a grow-only-value result component, you will need to list its name inside the file scripts/protocol-buffer-generation/generateIndex.ts in the GROWN_ONLY_COMPONENTS array.
To generate the C# code from the protobuf files that come from the protocol, go to the scripts path in the root of the unity-explorer repository. And execute the following commands:
npm install
npm run build-protocolPrerequisite: Node/npm only.
build-protocolruns theprotoc-gen-bitwiseplugin (for the quantized/bit-packed Pulse network state), a dependency-free Node script bundled in@dcl/protocol— no Python or extra packages required.
To upgrade to the latest version of the @dcl/protocol, we should update using:
npm install @dcl/protocol@experimental
npm run build-protocolNOTE: unity-explorer needs to always use the
@experimentalprotocol package to support all the experimental features, otherwise the Unity project won't even compile due to missing component files, etc.
To test a PR, we can use a URL generated by the GitHub Bot in the @dcl/protocol PR:
npm install "https://sdk-team-cdn.decentraland.org/@dcl/protocol/branch//dcl-protocol-1.0.0-3143233696.commit-45f1290.tgz"
npm run build-protocolIMPORTANT: After merging a @dcl/protocol PR, we must make unity-explorer point to the updated @dcl/protocol@experimental package (and NOT the PR test package) before merging the unity-explorer PR.
- If the protocol update happened at
mainthen theexperimentalbranch must sync those new changes first, and then update the unity-explorer PR with the latest@experimentalpackage. - If the protocol update happened at
experimentalthen the unity-explorer PR can just use the updated@experimentalpackage.
After the C# code for the protobuf component was generated, one or more systems have to be implemented to handle its functionality, following the Arch ECS architecture.
- Add the
partialclass corresponding to the new PB component at IDirtyMarker - Register the component at the ComponentsContainer, otherwise no system will recognize the component on the ECS entities.
NOTE: You'll see that some special components are registered as "result components". Those are some components designed mainly to be PUT by the Explorer and only READ by the scene (e.g. input result component, raycast result component).
- Create a folder dedicated to the new component at https://github.com/decentraland/unity-explorer/tree/dev/Explorer/Assets/DCL/SDKComponents and implement the handling systems, components, etc. in there.
- Assembly references: add a
<Feature>.Systems.asmrefpointing atDCL.Plugins(GUIDfc4fd35fb877e904d8cedee73b2256f6) for the systems folder, and a<Feature>.Tests.asmrefpointing atDCL.EditMode.Testsfor theTests/EditMode/folder. Only create a newasmdefif the feature genuinely needs isolated compilation or has conflicting dependencies. Prefer edit-mode tests over play-mode tests unless the feature requires scene lifecycle or physics simulation.
- Assembly references: add a
- Create a PLUGIN file at https://github.com/decentraland/unity-explorer/tree/dev/Explorer/Assets/DCL/PluginSystem/World to INJECT your Systems in that code.
- Your Plugin has to be instantiated at https://github.com/decentraland/unity-explorer/blob/dev/Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs#L254~L280 along with the other Scene Plugins (not GLOBAL).
If the Explorer needs to update the component through CRDT for the scene to be able to read its updated values, for LWW components the PUT command is used, but for GOVS components the APPEND command has to be used. Several "result component" systems can be checked as reference, like the WriteEngineInfoSystem for LWW and the VideoEventsSystem for GOVS.
The following must be implemented:
- Relevant functionalities for the new component
- Custom non-sdk
structcomponents (can be empty) are normally used to change the state of the entity. - If
IsDirtyneeds to be relied upon, for the case of the scene modifying the component values in runtime and reacting to that: prefer injectingResetDirtyFlagSystem<PBYourComponent>.InjectToWorld(ref builder)in your Plugin — it clears the flag automatically after all systems in the group have run. Only setIsDirty = falsemanually inside a query when you need granular control (e.g. a system that handles one sub-case and must leave the flag set for a downstream system).
- Custom non-sdk
- Cleanup of the component
- SDK component removal
- Scene unloading (
IFinalizeWorldSystemimplementation)
- Pausing of component functionality if the scene is not current or player leaves/re-enters the scene:
- Usage of
ISceneStateProvider.IsCurrentto escape theUpdate()of the system. - (more complex and powerful) Usage of
ISceneIsCurrentListenerto listen to the moments the user enters/exits the scene.
- Usage of
Several SDK components carry a collision_mask / collisionMask field of type ColliderLayer (defined in decentraland/sdk/components/common/mesh_collider.proto). The full enum is:
| Name | Value | Meaning |
|---|---|---|
CL_NONE |
0 | No collisions. |
CL_POINTER |
1 | Pointer-ray collisions (mouse hover, click). |
CL_PHYSICS |
2 | Scene-mesh walls / floors / platforms that affect the player's physics. Does NOT target the character itself — see "Unified main-player qualification rule" below. |
CL_PLAYER |
4 | Any player avatar — main player AND remote avatars. |
CL_MAIN_PLAYER |
8 | The local (main) player avatar only. |
CL_RESERVED3..6 |
16/32/64/128 | Reserved — do not use. |
CL_CUSTOM1..8 |
256..32768 | Scene-defined custom layers (8 of them). |
Additive avatar semantics. The main player capsule is "tagged" with both CL_PLAYER and CL_MAIN_PLAYER: a mask containing either bit matches it. Remote avatars are tagged only with CL_PLAYER. Consequently:
| Mask | Main player matches? | Remote avatar matches? |
|---|---|---|
CL_PLAYER |
yes | yes |
CL_MAIN_PLAYER |
yes | no |
CL_PLAYER | CL_MAIN_PLAYER |
yes | yes |
Unified main-player qualification rule. Both Raycast (ExecuteRaycastSystem.DoesHitColliderQualify) and TriggerArea (TriggerAreaHandlerSystem.PropagateResultComponent) qualify the main player using the same constant:
PhysicsLayers.PLAYER_QUALIFYING_BITS = CL_PLAYER | CL_MAIN_PLAYERA scene mask that contains either of those two bits qualifies the local player. Any other mask (CL_PHYSICS, CL_POINTER, CL_CUSTOM*, CL_NONE) does NOT qualify the main player on either system.
CL_PHYSICS is deliberately excluded from this set. Per the proto definition, CL_PHYSICS targets scene-mesh walls and floors that affect the player's physics — not the character itself. This matches the convention of every major game engine:
- Unreal:
WorldStatic/WorldDynamicvs the separatePawnchannel. - Unity (idiomatic): project layers like
EnvironmentvsPlayer/Character. - Godot: world geometry on its own layer; characters on a separate
Playerlayer. - Source / Half-Life 2:
MASK_NPCWORLDSTATIC(world without entities) vs masks that opt entities in.
Scenes that need to detect the local player on a raycast or trigger area must opt in via CL_PLAYER or CL_MAIN_PLAYER explicitly.
Remote avatars qualify only on CL_PLAYER. They have no client-side physics body either way.
TriggerArea targetOnlyMainPlayer optimisation. When the scene mask is exactly CL_MAIN_PLAYER (no other bits), TriggerAreaHandlerSystem.SetupTriggerArea sets targetOnlyMainPlayer = true. The underlying SDKEntityTriggerArea MonoBehaviour then short-circuits any OnTriggerEnter/OnTriggerExit whose collider transform isn't the main player's, dropping remote-avatar overlaps before they reach the per-frame query in PropagateResultComponent. Any other mask (including CL_PLAYER | CL_MAIN_PLAYER) must NOT enable this optimisation, because it would also reject the colliders the mask is meant to match.
Scene-mesh routing (MeshCollider / GltfContainer). PhysicsLayers.TryGetUnityLayerFromSDKLayer picks the destination Unity layer per SDK mask:
| SDK mask | Unity layer | Effect on player capsule |
|---|---|---|
CL_PHYSICS | CL_POINTER (Default mask) |
Default |
Solid (player blocks) |
CL_PHYSICS alone |
CharacterOnly |
Solid |
CL_POINTER alone |
OnPointerEvent |
Pass-through |
CL_PLAYER and/or CL_MAIN_PLAYER (no other bits) |
SDKAvatarHit |
Pass-through; only trigger areas / raycasts targeting avatar bits detect it |
Any mask containing CL_PHYSICS (mixed) |
CharacterOnly (physics wins) |
Solid |
| Custom layers | SDKCustomLayer |
Pass-through |
SDKAvatarHit is matrix-configured to only overlap with SDKEntityTriggerArea and SDKAvatarTriggerArea, so the player capsule walks straight through avatar-tagged scene meshes while trigger areas and avatar-targeting raycasts still pick them up.
Migration note for scenes pre-dating this rule. A scene that previously used CL_PHYSICS raycasts to detect the local player (e.g. line-of-sight checks) must now OR CL_MAIN_PLAYER (or CL_PLAYER) into the mask. Walls-and-floors checks that don't want to detect the player keep working unchanged.
Especially for big implementations:
- Systems separation if system is too big
- If using Promises: need
TryGet()or can just getResult? - Use
ListPoolorComponentPoolwhen possible - Less "intention" components as possible, try to infer from existence/lack of component on Entity
- Use
UpdateGroupand Systems Update Order (UpdateBefore&UpdateAfterattributes) - Make sure scene feature doesn't run when it's not the current scene
- Test coverage if possible
- Allocations optimization
This step is important because it allows creators (like the Content Team itself) to easily test the new component and know how it should be used, when the official documentation may still be pending.
- Clone the sdk7-test-scenes repo
- Create your test scene base
- Duplicate one of the existent test scenes that exist in
/scenes - Change the new folder name to your new test scene coordinates AND name (same format as the rest)
- Update the
README.mdfile - Update the
package.jsonfile ->"name"property - Update the
scene.jsonfile ->"name"property +"title"property +"base"and"parcels"coordinate properties
- Duplicate one of the existent test scenes that exist in
- Go to the root of the repo and run
npm iand thennpm run check-parcelsto confirm there is no coordinates conflict between your new test scene and a previously existent one (that also updates thedcl-workspace.jsonfile) - Return to the root of your test scene and install an SDK version that contains the new component, for example if you are still working on an SDK branch with the component, the package is the one exposed in the js-sdk-toolchain PR, like:
npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/trigger-area-components/dcl-sdk-7.10.1-17412673762.commit-72cc7b1.tgz"- After that, your IDE should recognize the new component when imported from
'@dcl/sdk/ecs'and you should be able to use it in your code without compilation errors. - NOTE: If the change you did to the protocol/sdk involves changing the API for global functions you may need to use the
--saveparameter when installing the test package like:npm install --save "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/move-player-to-duration-field/dcl-sdk-7.17.1-20757258908.commit-16f1a78.tgz"
- Run your scene to confirm there are no compilation errors (
npm run start -- --explorer-alpha) - Code your test scene making use of the new component (
index.tsand whatever extra scripts you need) - Make sure to remove unneeded files like
/models, etc. if your scene doesn't use them (/images/scene-thumbnail.pngmust be kept) - Create a branch in the test scenes repo and push your new scene to create the corresponding PR in that repo, you are responsible for merging it