@dcl-regenesislabs/opendcl 0.1.4 → 0.1.5-23161709858.commit-828c176
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/context/sdk7-cheat-sheet.md +3 -3
- package/extensions/permissions/index.ts +1 -12
- package/package.json +2 -2
- package/skills/add-3d-models/SKILL.md +16 -1
- package/skills/add-interactivity/SKILL.md +146 -37
- package/skills/add-interactivity/references/input-reference.md +148 -0
- package/skills/advanced-input/SKILL.md +1 -1
- package/skills/advanced-rendering/SKILL.md +27 -11
- package/skills/animations-tweens/SKILL.md +54 -24
- package/skills/audio-video/SKILL.md +16 -1
- package/skills/audio-video/references/media-reference.md +184 -0
- package/skills/authoritative-server/SKILL.md +6 -4
- package/skills/authoritative-server/references/server-patterns.md +251 -0
- package/skills/build-ui/SKILL.md +27 -1
- package/skills/build-ui/references/ui-components.md +228 -0
- package/skills/camera-control/SKILL.md +3 -1
- package/skills/create-scene/SKILL.md +65 -1
- package/skills/deploy-scene/SKILL.md +26 -9
- package/skills/deploy-worlds/SKILL.md +13 -1
- package/skills/game-design/SKILL.md +230 -0
- package/skills/lighting-environment/SKILL.md +23 -15
- package/skills/multiplayer-sync/SKILL.md +198 -72
- package/skills/multiplayer-sync/references/networking-patterns.md +238 -0
- package/skills/nft-blockchain/SKILL.md +18 -31
- package/skills/optimize-scene/SKILL.md +55 -12
- package/skills/player-avatar/SKILL.md +18 -1
- package/skills/player-avatar/references/avatar-apis.md +152 -0
- package/skills/scene-runtime/SKILL.md +4 -2
- package/skills/scene-runtime/references/runtime-apis.md +206 -0
- package/skills/visual-feedback/SKILL.md +16 -1
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: animations-tweens
|
|
3
|
-
description: Animate objects in Decentraland scenes
|
|
3
|
+
description: Animate objects in Decentraland scenes. Play GLTF model animations with Animator, create procedural motion with Tween (move/rotate/scale), and chain sequences with TweenSequence. Use when the user wants to animate, move, rotate, spin, slide, bob, or create motion effects. Do NOT use for audio/video playback (see audio-video).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Animations and Tweens in Decentraland
|
|
7
7
|
|
|
8
|
+
## When to Use Which Animation Approach
|
|
9
|
+
|
|
10
|
+
| Need | Approach | When |
|
|
11
|
+
|------|----------|------|
|
|
12
|
+
| Play animation baked into a .glb model | `Animator` | Character walks, door opens, flag waves — any animation created in Blender/Maya |
|
|
13
|
+
| Move/rotate/scale an entity smoothly | `Tween` | Sliding doors, floating platforms, growing objects — procedural A-to-B motion |
|
|
14
|
+
| Chain multiple animations in sequence | `TweenSequence` | Patrol paths, multi-step doors, complex choreography |
|
|
15
|
+
| Continuous per-frame control | `engine.addSystem()` | Physics-like motion, following a target, custom easing |
|
|
16
|
+
|
|
17
|
+
**Decision flow:**
|
|
18
|
+
1. Does the .glb model already have the animation? → `Animator`
|
|
19
|
+
2. Is it a simple move/rotate/scale between two values? → `Tween`
|
|
20
|
+
3. Do you need frame-by-frame control or custom math? → System with `dt`
|
|
21
|
+
|
|
8
22
|
## GLTF Animations (Animator)
|
|
9
23
|
|
|
10
24
|
Play animations embedded in .glb models:
|
|
@@ -67,7 +81,7 @@ Tween.create(box, {
|
|
|
67
81
|
end: Vector3.create(14, 1, 8)
|
|
68
82
|
}),
|
|
69
83
|
duration: 2000, // milliseconds
|
|
70
|
-
easingFunction: EasingFunction.
|
|
84
|
+
easingFunction: EasingFunction.EF_EASESINE
|
|
71
85
|
})
|
|
72
86
|
```
|
|
73
87
|
|
|
@@ -100,7 +114,7 @@ Tween.create(box, {
|
|
|
100
114
|
Chain multiple tweens to play one after another:
|
|
101
115
|
|
|
102
116
|
```typescript
|
|
103
|
-
import { TweenSequence } from '@dcl/sdk/ecs'
|
|
117
|
+
import { TweenSequence, TweenLoop } from '@dcl/sdk/ecs'
|
|
104
118
|
|
|
105
119
|
// First tween
|
|
106
120
|
Tween.create(box, {
|
|
@@ -109,7 +123,7 @@ Tween.create(box, {
|
|
|
109
123
|
end: Vector3.create(14, 1, 8)
|
|
110
124
|
}),
|
|
111
125
|
duration: 2000,
|
|
112
|
-
easingFunction: EasingFunction.
|
|
126
|
+
easingFunction: EasingFunction.EF_EASESINE
|
|
113
127
|
})
|
|
114
128
|
|
|
115
129
|
// Chain sequence
|
|
@@ -122,7 +136,7 @@ TweenSequence.create(box, {
|
|
|
122
136
|
end: Vector3.create(2, 1, 8)
|
|
123
137
|
}),
|
|
124
138
|
duration: 2000,
|
|
125
|
-
easingFunction: EasingFunction.
|
|
139
|
+
easingFunction: EasingFunction.EF_EASESINE
|
|
126
140
|
}
|
|
127
141
|
],
|
|
128
142
|
loop: TweenLoop.TL_RESTART // Loop the entire sequence
|
|
@@ -133,12 +147,16 @@ TweenSequence.create(box, {
|
|
|
133
147
|
|
|
134
148
|
Available easing functions from `EasingFunction`:
|
|
135
149
|
- `EF_LINEAR` — Constant speed
|
|
136
|
-
- `EF_EASEINQUAD` / `EF_EASEOUTQUAD` / `
|
|
137
|
-
- `EF_EASEINSINE` / `EF_EASEOUTSINE` / `
|
|
138
|
-
- `EF_EASEINEXPO` / `EF_EASEOUTEXPO` / `
|
|
139
|
-
- `EF_EASEINELASTIC` / `EF_EASEOUTELASTIC` / `
|
|
140
|
-
- `EF_EASEOUTBOUNCE` / `EF_EASEINBOUNCE` / `
|
|
141
|
-
- `EF_EASEINBACK` / `EF_EASEOUTBACK` / `
|
|
150
|
+
- `EF_EASEINQUAD` / `EF_EASEOUTQUAD` / `EF_EASEQUAD` — Quadratic
|
|
151
|
+
- `EF_EASEINSINE` / `EF_EASEOUTSINE` / `EF_EASESINE` — Sinusoidal (smooth)
|
|
152
|
+
- `EF_EASEINEXPO` / `EF_EASEOUTEXPO` / `EF_EASEEXPO` — Exponential
|
|
153
|
+
- `EF_EASEINELASTIC` / `EF_EASEOUTELASTIC` / `EF_EASEELASTIC` — Elastic bounce
|
|
154
|
+
- `EF_EASEOUTBOUNCE` / `EF_EASEINBOUNCE` / `EF_EASEBOUNCE` — Bounce effect
|
|
155
|
+
- `EF_EASEINBACK` / `EF_EASEOUTBACK` / `EF_EASEBACK` — Overshoot
|
|
156
|
+
- `EF_EASEINCUBIC` / `EF_EASEOUTCUBIC` / `EF_EASECUBIC` — Cubic
|
|
157
|
+
- `EF_EASEINQUART` / `EF_EASEOUTQUART` / `EF_EASEQUART` — Quartic
|
|
158
|
+
- `EF_EASEINQUINT` / `EF_EASEOUTQUINT` / `EF_EASEQUINT` — Quintic
|
|
159
|
+
- `EF_EASEINCIRC` / `EF_EASEOUTCIRC` / `EF_EASECIRC` — Circular
|
|
142
160
|
|
|
143
161
|
## Custom Animation Systems
|
|
144
162
|
|
|
@@ -165,28 +183,28 @@ engine.addSystem(spinSystem)
|
|
|
165
183
|
|
|
166
184
|
### Tween Helper Methods
|
|
167
185
|
|
|
168
|
-
Use shorthand helpers
|
|
186
|
+
Use shorthand helpers that create or replace the Tween component directly on the entity:
|
|
169
187
|
|
|
170
188
|
```typescript
|
|
171
189
|
import { Tween, EasingFunction } from '@dcl/sdk/ecs'
|
|
172
190
|
|
|
173
|
-
// Move
|
|
174
|
-
Tween.
|
|
191
|
+
// Move — signature: Tween.setMove(entity, start, end, duration, easingFunction?)
|
|
192
|
+
Tween.setMove(entity,
|
|
175
193
|
Vector3.create(0, 1, 0), Vector3.create(0, 3, 0),
|
|
176
|
-
|
|
177
|
-
)
|
|
194
|
+
1500, EasingFunction.EF_EASEINBOUNCE
|
|
195
|
+
)
|
|
178
196
|
|
|
179
|
-
// Rotate
|
|
180
|
-
Tween.
|
|
197
|
+
// Rotate — signature: Tween.setRotate(entity, start, end, duration, easingFunction?)
|
|
198
|
+
Tween.setRotate(entity,
|
|
181
199
|
Quaternion.fromEulerDegrees(0, 0, 0), Quaternion.fromEulerDegrees(0, 180, 0),
|
|
182
|
-
|
|
183
|
-
)
|
|
200
|
+
2000, EasingFunction.EF_EASEOUTQUAD
|
|
201
|
+
)
|
|
184
202
|
|
|
185
|
-
// Scale
|
|
186
|
-
Tween.
|
|
203
|
+
// Scale — signature: Tween.setScale(entity, start, end, duration, easingFunction?)
|
|
204
|
+
Tween.setScale(entity,
|
|
187
205
|
Vector3.One(), Vector3.create(2, 2, 2),
|
|
188
|
-
|
|
189
|
-
)
|
|
206
|
+
1000, EasingFunction.EF_LINEAR
|
|
207
|
+
)
|
|
190
208
|
```
|
|
191
209
|
|
|
192
210
|
### Yoyo Loop Mode
|
|
@@ -229,6 +247,18 @@ anim.states[0].weight = 0.5 // blend walk at 50%
|
|
|
229
247
|
anim.states[1].weight = 0.5 // blend idle at 50%
|
|
230
248
|
```
|
|
231
249
|
|
|
250
|
+
## Troubleshooting
|
|
251
|
+
|
|
252
|
+
| Problem | Cause | Solution |
|
|
253
|
+
|---------|-------|----------|
|
|
254
|
+
| GLTF animation not playing | Wrong clip name in `Animator.states` | Open the .glb in a viewer (e.g., Blender) to find exact clip names — they are case-sensitive |
|
|
255
|
+
| Animator component has no effect | Entity missing `GltfContainer` | `Animator` only works on entities that have a loaded GLTF model |
|
|
256
|
+
| Tween doesn't move | Start and end positions are the same | Verify `start` and `end` values differ in `Tween.Mode.Move()` |
|
|
257
|
+
| Tween plays once then stops | No `TweenSequence` with loop | Add `TweenSequence.create(entity, { sequence: [], loop: TweenLoop.TL_YOYO })` for back-and-forth |
|
|
258
|
+
| Animation jitters or stutters | Creating new Tween every frame | Only create Tween once, not inside a system — use `tweenSystem.tweenCompleted()` to chain |
|
|
259
|
+
|
|
260
|
+
> **Need 3D models to animate?** See the **add-3d-models** skill for loading GLTF models that contain animation clips.
|
|
261
|
+
|
|
232
262
|
## Best Practices
|
|
233
263
|
|
|
234
264
|
- Use Tweens for simple A-to-B animations (doors, platforms, UI elements)
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audio-video
|
|
3
|
-
description: Add
|
|
3
|
+
description: Add sound effects, music, audio streaming, and video players to Decentraland scenes. Covers AudioSource (local files), AudioStream (streaming URLs), VideoPlayer (video surfaces), video events, and media permissions. Use when the user wants sound, music, audio, video screens, radio, or media playback. Do NOT use for 3D model animations (see animations-tweens).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Audio and Video in Decentraland
|
|
7
7
|
|
|
8
|
+
## When to Use Which Media Component
|
|
9
|
+
|
|
10
|
+
| Need | Component | Key Difference |
|
|
11
|
+
|------|-----------|---------------|
|
|
12
|
+
| Sound effect from a file (click, explosion, footstep) | `AudioSource` | Local file, spatial, one-shot or looping |
|
|
13
|
+
| Background music or radio stream | `AudioStream` | External URL, non-spatial, continuous |
|
|
14
|
+
| Video on a surface (screen, billboard) | `VideoPlayer` + `Material.Texture.Video` | Requires a mesh to display on |
|
|
15
|
+
|
|
16
|
+
**Decision flow:**
|
|
17
|
+
1. Is it a local audio file? → `AudioSource`
|
|
18
|
+
2. Is it a streaming URL (radio, live audio)? → `AudioStream`
|
|
19
|
+
3. Is it video content? → `VideoPlayer` on a plane/mesh
|
|
20
|
+
|
|
8
21
|
## Audio Source (Sound Effects & Music)
|
|
9
22
|
|
|
10
23
|
Play audio clips from files:
|
|
@@ -289,6 +302,8 @@ Material.setPbrMaterial(screen2, {
|
|
|
289
302
|
- **Supported formats**: `.mp4` (H.264), `.webm`, HLS (`.m3u8`) for live streaming
|
|
290
303
|
- **Live streaming**: Use HLS (`.m3u8`) URLs — most reliable across clients
|
|
291
304
|
|
|
305
|
+
For full component field details, supported formats, and advanced patterns, see `{baseDir}/references/media-reference.md`.
|
|
306
|
+
|
|
292
307
|
## Important Notes
|
|
293
308
|
|
|
294
309
|
- Audio files must be in the project's directory (relative paths from project root)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Media Components Reference
|
|
2
|
+
|
|
3
|
+
## AudioSource — Full Fields
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { AudioSource } from '@dcl/sdk/ecs'
|
|
7
|
+
|
|
8
|
+
AudioSource.create(entity, {
|
|
9
|
+
audioClipUrl: 'sounds/effect.mp3', // Path to local audio file (required)
|
|
10
|
+
playing: false, // Start/stop playback
|
|
11
|
+
loop: false, // Loop when finished
|
|
12
|
+
volume: 1.0, // Volume 0.0 to 1.0
|
|
13
|
+
pitch: 1.0 // Playback speed (0.5 = half, 2.0 = double)
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Supported formats:** `.mp3` (recommended), `.ogg`, `.wav`
|
|
18
|
+
|
|
19
|
+
Audio is spatial by default — volume decreases with distance from the entity. Place the entity where the sound should originate.
|
|
20
|
+
|
|
21
|
+
### Playback Control
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
const audio = AudioSource.getMutable(entity)
|
|
25
|
+
audio.playing = true // Play
|
|
26
|
+
audio.playing = false // Stop
|
|
27
|
+
audio.volume = 0.5 // Adjust volume
|
|
28
|
+
audio.pitch = 1.5 // Speed up
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Reset and Replay
|
|
32
|
+
|
|
33
|
+
To replay a sound effect from the beginning:
|
|
34
|
+
```typescript
|
|
35
|
+
const audio = AudioSource.getMutable(entity)
|
|
36
|
+
audio.playing = false
|
|
37
|
+
audio.playing = true // Restarts from beginning
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## AudioStream — Full Fields
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { AudioStream } from '@dcl/sdk/ecs'
|
|
44
|
+
|
|
45
|
+
AudioStream.create(entity, {
|
|
46
|
+
url: 'https://stream.example.com/radio.mp3', // Streaming URL (required)
|
|
47
|
+
playing: true, // Start/stop stream
|
|
48
|
+
volume: 0.5 // Volume 0.0 to 1.0
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Supported stream formats:** HTTP/HTTPS audio streams (`.mp3`, `.ogg`, `.aac`)
|
|
53
|
+
|
|
54
|
+
AudioStream is NOT spatial — it plays at the same volume regardless of player distance. Best for background music or radio.
|
|
55
|
+
|
|
56
|
+
## VideoPlayer — Full Fields
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { VideoPlayer } from '@dcl/sdk/ecs'
|
|
60
|
+
|
|
61
|
+
VideoPlayer.create(entity, {
|
|
62
|
+
src: 'videos/clip.mp4', // Local file or external URL (required)
|
|
63
|
+
playing: true, // Start/stop playback
|
|
64
|
+
loop: false, // Loop when finished
|
|
65
|
+
volume: 1.0, // Volume 0.0 to 1.0
|
|
66
|
+
playbackRate: 1.0, // Playback speed
|
|
67
|
+
position: 0 // Start time in seconds
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Supported formats:**
|
|
72
|
+
- `.mp4` (H.264) — most compatible
|
|
73
|
+
- `.webm` — good quality, smaller files
|
|
74
|
+
- `.ogg` — open format
|
|
75
|
+
- `.m3u8` (HLS) — live streaming, most reliable for streams
|
|
76
|
+
|
|
77
|
+
### Video Texture Setup
|
|
78
|
+
|
|
79
|
+
VideoPlayer alone doesn't display video. You must create a video texture and apply it to a mesh:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// 1. Create mesh surface
|
|
83
|
+
MeshRenderer.setPlane(entity)
|
|
84
|
+
|
|
85
|
+
// 2. Create video texture referencing the VideoPlayer entity
|
|
86
|
+
const videoTexture = Material.Texture.Video({ videoPlayerEntity: entity })
|
|
87
|
+
|
|
88
|
+
// 3. Apply as basic material (best performance)
|
|
89
|
+
Material.setBasicMaterial(entity, { texture: videoTexture })
|
|
90
|
+
|
|
91
|
+
// OR as PBR material with emissive (self-lit screen)
|
|
92
|
+
Material.setPbrMaterial(entity, {
|
|
93
|
+
texture: videoTexture,
|
|
94
|
+
roughness: 1.0,
|
|
95
|
+
specularIntensity: 0,
|
|
96
|
+
metallic: 0,
|
|
97
|
+
emissiveTexture: videoTexture,
|
|
98
|
+
emissiveIntensity: 0.6,
|
|
99
|
+
emissiveColor: Color3.White()
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Live Streaming
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// HLS stream
|
|
107
|
+
VideoPlayer.create(entity, {
|
|
108
|
+
src: 'https://example.com/stream.m3u8',
|
|
109
|
+
playing: true
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// LiveKit video stream
|
|
113
|
+
VideoPlayer.create(entity, {
|
|
114
|
+
src: 'livekit-video://current-stream',
|
|
115
|
+
playing: true
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Video Events
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { videoEventsSystem, VideoState } from '@dcl/sdk/ecs'
|
|
123
|
+
|
|
124
|
+
videoEventsSystem.registerVideoEventsEntity(entity, (event) => {
|
|
125
|
+
console.log('State:', event.state) // VideoState enum
|
|
126
|
+
console.log('Time:', event.currentOffset) // Current playback time
|
|
127
|
+
console.log('Length:', event.videoLength) // Total duration
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Poll current state
|
|
131
|
+
const state = videoEventsSystem.getVideoState(entity)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**VideoState values:** `VS_READY`, `VS_PLAYING`, `VS_PAUSED`, `VS_ERROR`, `VS_BUFFERING`, `VS_SEEKING`, `VS_NONE`
|
|
135
|
+
|
|
136
|
+
### Multiple Screens, One Video
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// One VideoPlayer, shared across screens
|
|
140
|
+
VideoPlayer.create(screen1, { src: 'videos/shared.mp4', playing: true })
|
|
141
|
+
const tex = Material.Texture.Video({ videoPlayerEntity: screen1 })
|
|
142
|
+
Material.setBasicMaterial(screen1, { texture: tex })
|
|
143
|
+
Material.setBasicMaterial(screen2, { texture: tex })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Video Limits
|
|
147
|
+
|
|
148
|
+
| Quality Setting | Max Simultaneous Videos |
|
|
149
|
+
|----------------|------------------------|
|
|
150
|
+
| Low | 1 |
|
|
151
|
+
| Medium | 5 |
|
|
152
|
+
| High | 10 |
|
|
153
|
+
|
|
154
|
+
### Media Permissions in scene.json
|
|
155
|
+
|
|
156
|
+
External audio/video URLs require permissions:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"requiredPermissions": ["ALLOW_MEDIA_HOSTNAMES"],
|
|
161
|
+
"allowedMediaHostnames": ["stream.example.com", "cdn.example.com"]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## AudioAnalysis (Advanced)
|
|
166
|
+
|
|
167
|
+
Real-time frequency and amplitude data from audio sources:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { AudioAnalysis, AudioAnalysisView } from '@dcl/sdk/ecs'
|
|
171
|
+
|
|
172
|
+
// Enable analysis on an audio source entity
|
|
173
|
+
AudioAnalysis.create(audioEntity, {})
|
|
174
|
+
|
|
175
|
+
// Read analysis data in a system
|
|
176
|
+
engine.addSystem(() => {
|
|
177
|
+
const view = AudioAnalysisView.getOrNull(audioEntity)
|
|
178
|
+
if (view) {
|
|
179
|
+
// Use frequency/amplitude data for visualizers, beat detection, etc.
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Used for music visualizers, reactive environments, and beat-synced animations.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: authoritative-server
|
|
3
|
-
description: Build multiplayer scenes with a headless authoritative server
|
|
3
|
+
description: Build multiplayer Decentraland scenes with a headless authoritative server. Covers isServer() branching, registerMessages() for client-server communication, validateBeforeChange() for server-only state, Storage (world and player persistence), EnvVar (environment variables), and project structure. Use when the user wants authoritative multiplayer, anti-cheat, server-side validation, persistent storage, or server messages. Do NOT use for basic CRDT multiplayer without a server (see multiplayer-sync).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Authoritative Server Pattern
|
|
@@ -181,10 +181,10 @@ room.onMessage('gameEvent', (data) => {
|
|
|
181
181
|
Before sending messages from the client, wait until state is synchronized:
|
|
182
182
|
|
|
183
183
|
```typescript
|
|
184
|
-
import {
|
|
184
|
+
import { isStateSyncronized } from '@dcl/sdk/network'
|
|
185
185
|
|
|
186
186
|
engine.addSystem(() => {
|
|
187
|
-
if (!
|
|
187
|
+
if (!isStateSyncronized()) return
|
|
188
188
|
|
|
189
189
|
// Safe to send messages now
|
|
190
190
|
room.send('playerJoin', { displayName: 'Player' })
|
|
@@ -323,10 +323,12 @@ Put synced components and messages in `shared/` so both server and client import
|
|
|
323
323
|
## Important Notes
|
|
324
324
|
|
|
325
325
|
- **Use `Schemas.Int64` for timestamps**: `Schemas.Number` corrupts large numbers (13+ digits). Always use `Schemas.Int64` for values like `Date.now()`.
|
|
326
|
-
- **State sync readiness**: Clients must wait for `
|
|
326
|
+
- **State sync readiness**: Clients must wait for `isStateSyncronized()` (from `@dcl/sdk/network`) to return `true` before sending messages. Note the intentional SDK typo: "Syncronized" not "Synchronized".
|
|
327
327
|
- **Custom vs built-in validation**: Custom components use global `validateBeforeChange((value) => ...)`. Built-in components (Transform, GltfContainer) use per-entity `validateBeforeChange(entity, (value) => ...)`.
|
|
328
328
|
- **Single codebase**: Both server and client run the same `index.ts` entry point. Use `isServer()` to branch.
|
|
329
329
|
- **No Node.js APIs**: The DCL runtime uses sandboxed QuickJS — no `fs`, `http`, etc. `setTimeout`/`setInterval` are supported. Use SDK-provided APIs (Storage, EnvVar, engine systems) for server-side operations.
|
|
330
330
|
- **SDK branch (MANDATORY)**: The auth-server pattern requires `npm install @dcl/sdk@auth-server`, not the standard `@dcl/sdk`. Without it, `isServer()`, `registerMessages()`, `Storage`, and `EnvVar` are unavailable.
|
|
331
331
|
- **scene.json required fields**: `authoritativeMultiplayer: true` must be set, and `logsPermissions: ["0xWalletAddress"]` must list wallet addresses that should see server logs.
|
|
332
332
|
- For basic CRDT multiplayer without a server, see the `multiplayer-sync` skill.
|
|
333
|
+
|
|
334
|
+
For complete server setup examples, authentication flow, state reconciliation, Storage patterns, and EnvVar usage, see `{baseDir}/references/server-patterns.md`.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Authoritative Server Patterns Reference
|
|
2
|
+
|
|
3
|
+
## Complete Server Setup
|
|
4
|
+
|
|
5
|
+
### Project Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/
|
|
9
|
+
├── index.ts # Entry point — isServer() branching
|
|
10
|
+
├── client/
|
|
11
|
+
│ ├── setup.ts # Client init, input handlers, message senders
|
|
12
|
+
│ └── ui.tsx # React ECS UI (reads synced state, sends messages)
|
|
13
|
+
├── server/
|
|
14
|
+
│ ├── server.ts # Server init, game loop, message handlers
|
|
15
|
+
│ └── gameState.ts # Server state management
|
|
16
|
+
└── shared/
|
|
17
|
+
├── schemas.ts # Custom component definitions + validateBeforeChange
|
|
18
|
+
└── messages.ts # Message definitions via registerMessages()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Entry Point (index.ts)
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { isServer } from '@dcl/sdk/network'
|
|
25
|
+
|
|
26
|
+
export async function main() {
|
|
27
|
+
if (isServer()) {
|
|
28
|
+
const { initServer } = await import('./server/server')
|
|
29
|
+
initServer()
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { initClient } = await import('./client/setup')
|
|
34
|
+
const { setupUi } = await import('./client/ui')
|
|
35
|
+
initClient()
|
|
36
|
+
setupUi()
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Shared Schemas (shared/schemas.ts)
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { engine, Schemas, Entity } from '@dcl/sdk/ecs'
|
|
44
|
+
import { AUTH_SERVER_PEER_ID } from '@dcl/sdk/network/message-bus-sync'
|
|
45
|
+
|
|
46
|
+
// Custom synced component
|
|
47
|
+
export const GameState = engine.defineComponent('game:State', {
|
|
48
|
+
phase: Schemas.String,
|
|
49
|
+
score: Schemas.Int,
|
|
50
|
+
timeRemaining: Schemas.Int
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Global validation — only server can modify
|
|
54
|
+
GameState.validateBeforeChange((value) => {
|
|
55
|
+
return value.senderAddress === AUTH_SERVER_PEER_ID
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// For built-in components, use per-entity validation
|
|
59
|
+
type ComponentWithValidation = {
|
|
60
|
+
validateBeforeChange: (entity: Entity, cb: (value: { senderAddress: string }) => boolean) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function protectServerEntity(entity: Entity, components: ComponentWithValidation[]) {
|
|
64
|
+
for (const component of components) {
|
|
65
|
+
component.validateBeforeChange(entity, (value) => {
|
|
66
|
+
return value.senderAddress === AUTH_SERVER_PEER_ID
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Shared Messages (shared/messages.ts)
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { Schemas } from '@dcl/sdk/ecs'
|
|
76
|
+
import { registerMessages } from '@dcl/sdk/network'
|
|
77
|
+
|
|
78
|
+
export const Messages = {
|
|
79
|
+
// Client → Server
|
|
80
|
+
playerReady: Schemas.Map({ displayName: Schemas.String }),
|
|
81
|
+
playerAction: Schemas.Map({ action: Schemas.String, targetId: Schemas.Int }),
|
|
82
|
+
|
|
83
|
+
// Server → Client
|
|
84
|
+
gameStarted: Schemas.Map({ roundNumber: Schemas.Int }),
|
|
85
|
+
playerScored: Schemas.Map({ playerName: Schemas.String, points: Schemas.Int }),
|
|
86
|
+
gameEnded: Schemas.Map({ winnerId: Schemas.String })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const room = registerMessages(Messages)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Server Logic (server/server.ts)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { engine, PlayerIdentityData, Transform } from '@dcl/sdk/ecs'
|
|
96
|
+
import { syncEntity } from '@dcl/sdk/network'
|
|
97
|
+
import { room } from '../shared/messages'
|
|
98
|
+
import { GameState, protectServerEntity } from '../shared/schemas'
|
|
99
|
+
|
|
100
|
+
export function initServer() {
|
|
101
|
+
// Create server-managed entities
|
|
102
|
+
const stateEntity = engine.addEntity()
|
|
103
|
+
GameState.create(stateEntity, { phase: 'lobby', score: 0, timeRemaining: 60 })
|
|
104
|
+
protectServerEntity(stateEntity, [Transform])
|
|
105
|
+
syncEntity(stateEntity, [GameState.componentId], 1)
|
|
106
|
+
|
|
107
|
+
// Handle client messages
|
|
108
|
+
room.onMessage('playerReady', (data, context) => {
|
|
109
|
+
if (!context) return
|
|
110
|
+
console.log(`[Server] ${data.displayName} ready (${context.from})`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
room.onMessage('playerAction', (data, context) => {
|
|
114
|
+
if (!context) return
|
|
115
|
+
// Validate action on server
|
|
116
|
+
const playerPos = getPlayerPosition(context.from)
|
|
117
|
+
if (isValidAction(data.action, playerPos)) {
|
|
118
|
+
applyAction(data)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Game loop
|
|
123
|
+
engine.addSystem(gameLoopSystem)
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Authentication Flow
|
|
128
|
+
|
|
129
|
+
The auth server automatically provides player identity via `PlayerIdentityData`:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// Server reads actual player positions
|
|
133
|
+
engine.addSystem(() => {
|
|
134
|
+
for (const [entity, identity] of engine.getEntitiesWith(PlayerIdentityData)) {
|
|
135
|
+
const transform = Transform.getOrNull(entity)
|
|
136
|
+
if (!transform) continue
|
|
137
|
+
|
|
138
|
+
// identity.address = wallet address (verified by server)
|
|
139
|
+
// transform.position = actual player position (not client-reported)
|
|
140
|
+
console.log(`[Server] ${identity.address} at`, transform.position)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Never trust client-reported positions. The server sees real positions via `PlayerIdentityData` + `Transform`.
|
|
146
|
+
|
|
147
|
+
## State Reconciliation
|
|
148
|
+
|
|
149
|
+
When server state diverges from client state, the server always wins:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// Server-side: apply authoritative state
|
|
153
|
+
function reconcileState() {
|
|
154
|
+
const state = GameState.getMutable(stateEntity)
|
|
155
|
+
|
|
156
|
+
// Server calculates correct state
|
|
157
|
+
state.timeRemaining = Math.max(0, state.timeRemaining - 1)
|
|
158
|
+
|
|
159
|
+
if (state.timeRemaining <= 0 && state.phase === 'active') {
|
|
160
|
+
state.phase = 'ended'
|
|
161
|
+
room.send('gameEnded', { winnerId: findWinner() })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Because `validateBeforeChange` blocks client writes, clients can only read the state and send messages. The server is the single source of truth.
|
|
167
|
+
|
|
168
|
+
## Storage Patterns
|
|
169
|
+
|
|
170
|
+
### World Storage (Global Data)
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { Storage } from '@dcl/sdk/server'
|
|
174
|
+
|
|
175
|
+
// Save leaderboard
|
|
176
|
+
await Storage.world.set('leaderboard', JSON.stringify([
|
|
177
|
+
{ name: 'Alice', score: 100 },
|
|
178
|
+
{ name: 'Bob', score: 85 }
|
|
179
|
+
]))
|
|
180
|
+
|
|
181
|
+
// Load leaderboard
|
|
182
|
+
const data = await Storage.world.get<string>('leaderboard')
|
|
183
|
+
const leaderboard = data ? JSON.parse(data) : []
|
|
184
|
+
|
|
185
|
+
// Delete
|
|
186
|
+
await Storage.world.delete('leaderboard')
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Player Storage (Per-Player Data)
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
import { Storage } from '@dcl/sdk/server'
|
|
193
|
+
|
|
194
|
+
// Save player progress
|
|
195
|
+
await Storage.player.set(playerAddress, 'progress', JSON.stringify({
|
|
196
|
+
level: 5,
|
|
197
|
+
coins: 250,
|
|
198
|
+
achievements: ['first_kill', 'speedrun']
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
// Load player progress
|
|
202
|
+
const saved = await Storage.player.get<string>(playerAddress, 'progress')
|
|
203
|
+
const progress = saved ? JSON.parse(saved) : { level: 1, coins: 0, achievements: [] }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Note:** Storage only accepts strings. Always `JSON.stringify()` objects and `String()` numbers.
|
|
207
|
+
|
|
208
|
+
**Local dev storage location:** `node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json`
|
|
209
|
+
|
|
210
|
+
## Environment Variables
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { EnvVar } from '@dcl/sdk/server'
|
|
214
|
+
|
|
215
|
+
// Read with defaults
|
|
216
|
+
const maxPlayers = parseInt((await EnvVar.get('MAX_PLAYERS')) || '4')
|
|
217
|
+
const gameDuration = parseInt((await EnvVar.get('GAME_DURATION')) || '300')
|
|
218
|
+
const debugMode = ((await EnvVar.get('DEBUG')) || 'false') === 'true'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Local Development (.env file)
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
MAX_PLAYERS=8
|
|
225
|
+
GAME_DURATION=300
|
|
226
|
+
DEBUG=true
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Production Deployment
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
npx sdk-commands deploy-env MAX_PLAYERS --value 8
|
|
233
|
+
npx sdk-commands deploy-env GAME_DURATION --value 300
|
|
234
|
+
npx sdk-commands deploy-env OLD_VAR --delete
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## scene.json Required Fields
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"authoritativeMultiplayer": true,
|
|
242
|
+
"worldConfiguration": {
|
|
243
|
+
"name": "my-world.dcl.eth"
|
|
244
|
+
},
|
|
245
|
+
"logsPermissions": ["0xYourWalletAddress"]
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- `authoritativeMultiplayer: true` — enables the headless server runtime
|
|
250
|
+
- `worldConfiguration.name` — identifies the world (required for Storage and deploy)
|
|
251
|
+
- `logsPermissions` — wallet addresses that can see `console.log()` from the server
|