create-playcanvas 0.4.0 → 0.5.1

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.
Files changed (46) hide show
  1. package/README.md +5 -0
  2. package/dist/index.mjs +42 -41
  3. package/package.json +2 -1
  4. package/skills/add-effects/SKILL.md +81 -0
  5. package/skills/apply-conventions/SKILL.md +73 -0
  6. package/skills/assemble-scene/SKILL.md +53 -0
  7. package/skills/assemble-scene/references/direct-engine.md +16 -0
  8. package/skills/assemble-scene/references/react.md +24 -0
  9. package/skills/assemble-scene/references/web-components.md +19 -0
  10. package/skills/build-app/SKILL.md +41 -0
  11. package/skills/build-app/references/direct-engine.md +12 -0
  12. package/skills/build-app/references/react.md +20 -0
  13. package/skills/build-app/references/web-components.md +55 -0
  14. package/skills/build-hud/SKILL.md +36 -0
  15. package/skills/calibrate-model/SKILL.md +65 -0
  16. package/skills/calibrate-model/references/direct-engine.md +22 -0
  17. package/skills/calibrate-model/references/react.md +27 -0
  18. package/skills/calibrate-model/references/web-components.md +19 -0
  19. package/skills/configure-animation/SKILL.md +50 -0
  20. package/skills/configure-animation/references/direct-engine.md +32 -0
  21. package/skills/configure-animation/references/react.md +32 -0
  22. package/skills/configure-animation/references/web-components.md +13 -0
  23. package/skills/find-examples/SKILL.md +79 -0
  24. package/skills/find-examples/references/direct-engine.md +15 -0
  25. package/skills/find-examples/references/react.md +17 -0
  26. package/skills/find-examples/references/web-components.md +17 -0
  27. package/skills/inspect-glb/SKILL.md +72 -0
  28. package/skills/inspect-glb/scripts/inspect.mjs +239 -0
  29. package/skills/light-scene/SKILL.md +84 -0
  30. package/skills/manage-game-state/SKILL.md +48 -0
  31. package/skills/reuse-scripts/SKILL.md +59 -0
  32. package/skills/reuse-scripts/references/direct-engine.md +41 -0
  33. package/skills/reuse-scripts/references/react.md +28 -0
  34. package/skills/reuse-scripts/references/web-components.md +27 -0
  35. package/templates/engine/base/.prettierignore +2 -0
  36. package/templates/engine/base/README.md +4 -0
  37. package/templates/engine/base/eslint.config.mjs +1 -1
  38. package/templates/engine/base/package.json +2 -2
  39. package/templates/react/base/.prettierignore +2 -0
  40. package/templates/react/base/README.md +4 -0
  41. package/templates/react/base/eslint.config.mjs +1 -0
  42. package/templates/react/base/package.json +2 -2
  43. package/templates/web-components/base/.prettierignore +2 -0
  44. package/templates/web-components/base/README.md +4 -0
  45. package/templates/web-components/base/eslint.config.mjs +1 -1
  46. package/templates/web-components/base/package.json +3 -3
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ // zero-dep glb metadata inspector. bounds come from decoded vertex positions, not accessor min/max,
3
+ // so a rotated node reports its true extent and models can be placed touching.
4
+ import fs from 'node:fs';
5
+
6
+ const GLB_MAGIC = 0x46546c67;
7
+ const JSON_CHUNK = 0x4e4f534a;
8
+ const BIN_CHUNK = 0x004e4942;
9
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
10
+
11
+ // componentType -> byte size, signedness and normalized divisor (glTF 2.0 table 3.1 / 5.1)
12
+ const COMPONENTS = {
13
+ 5120: { size: 1, signed: true, norm: 127, read: (b, o) => b.readInt8(o) },
14
+ 5121: { size: 1, signed: false, norm: 255, read: (b, o) => b.readUInt8(o) },
15
+ 5122: { size: 2, signed: true, norm: 32767, read: (b, o) => b.readInt16LE(o) },
16
+ 5123: { size: 2, signed: false, norm: 65535, read: (b, o) => b.readUInt16LE(o) },
17
+ 5125: { size: 4, signed: false, norm: 4294967295, read: (b, o) => b.readUInt32LE(o) },
18
+ 5126: { size: 4, signed: true, norm: 1, read: (b, o) => b.readFloatLE(o) }
19
+ };
20
+
21
+ const mul = (a, b) => {
22
+ const o = new Array(16);
23
+ for (let c = 0; c < 4; c++) {
24
+ for (let r = 0; r < 4; r++) {
25
+ o[c * 4 + r] =
26
+ a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
27
+ }
28
+ }
29
+ return o;
30
+ };
31
+
32
+ const compose = ([tx, ty, tz], [x, y, z, w], [sx, sy, sz]) => {
33
+ const x2 = x + x,
34
+ y2 = y + y,
35
+ z2 = z + z;
36
+ const xx = x * x2,
37
+ xy = x * y2,
38
+ xz = x * z2;
39
+ const yy = y * y2,
40
+ yz = y * z2,
41
+ zz = z * z2;
42
+ const wx = w * x2,
43
+ wy = w * y2,
44
+ wz = w * z2;
45
+ return [
46
+ (1 - (yy + zz)) * sx,
47
+ (xy + wz) * sx,
48
+ (xz - wy) * sx,
49
+ 0,
50
+ (xy - wz) * sy,
51
+ (1 - (xx + zz)) * sy,
52
+ (yz + wx) * sy,
53
+ 0,
54
+ (xz + wy) * sz,
55
+ (yz - wx) * sz,
56
+ (1 - (xx + yy)) * sz,
57
+ 0,
58
+ tx,
59
+ ty,
60
+ tz,
61
+ 1
62
+ ];
63
+ };
64
+
65
+ const xform = (m, [x, y, z]) => [
66
+ m[0] * x + m[4] * y + m[8] * z + m[12],
67
+ m[1] * x + m[5] * y + m[9] * z + m[13],
68
+ m[2] * x + m[6] * y + m[10] * z + m[14]
69
+ ];
70
+
71
+ // + 0 normalizes -0 so a floored model reports 0, not -0
72
+ const round = (v) => Math.round(v * 1e4) / 1e4 + 0;
73
+
74
+ // buffer 0 of a GLB is the BIN chunk; other buffers are external files or data URIs
75
+ const bufferBytes = (g, bin, index) => {
76
+ const uri = g.buffers?.[index]?.uri;
77
+ if (uri === undefined) return bin;
78
+ const base64 = /^data:[^,]*;base64,(.*)$/s.exec(uri);
79
+ return base64 ? Buffer.from(base64[1], 'base64') : null;
80
+ };
81
+
82
+ // widen min/max with every decoded POSITION transformed by matrix. returns null on success,
83
+ // otherwise a reason the vertices could not be read so the caller can fall back and say so.
84
+ const accumulate = (g, bin, accessor, matrix, min, max) => {
85
+ const acc = g.accessors?.[accessor];
86
+ if (!acc) return 'no-position-accessor';
87
+ if (acc.sparse) return 'sparse-accessor';
88
+ if (acc.bufferView === undefined) return 'no-vertex-data';
89
+ const comp = COMPONENTS[acc.componentType];
90
+ if (!comp || acc.type !== 'VEC3') return 'unsupported-accessor';
91
+ const view = g.bufferViews?.[acc.bufferView];
92
+ if (!view) return 'unsupported-accessor';
93
+ if (view.extensions?.EXT_meshopt_compression) return 'meshopt-compressed';
94
+ const bytes = bufferBytes(g, bin, view.buffer ?? 0);
95
+ if (!bytes) return 'external-buffer';
96
+ const stride = view.byteStride ?? comp.size * 3;
97
+ const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
98
+ if (!acc.count || base + (acc.count - 1) * stride + comp.size * 3 > bytes.length) return 'truncated-buffer';
99
+ const scale = acc.normalized ? comp.norm : 1;
100
+ for (let v = 0; v < acc.count; v++) {
101
+ const o = base + v * stride;
102
+ let x = comp.read(bytes, o) / scale;
103
+ let y = comp.read(bytes, o + comp.size) / scale;
104
+ let z = comp.read(bytes, o + comp.size * 2) / scale;
105
+ if (acc.normalized && comp.signed) {
106
+ if (x < -1) x = -1;
107
+ if (y < -1) y = -1;
108
+ if (z < -1) z = -1;
109
+ }
110
+ // inlined to avoid allocating a vector per vertex on million-vertex meshes
111
+ const px = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
112
+ const py = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
113
+ const pz = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
114
+ if (px < min[0]) min[0] = px;
115
+ if (py < min[1]) min[1] = py;
116
+ if (pz < min[2]) min[2] = pz;
117
+ if (px > max[0]) max[0] = px;
118
+ if (py > max[1]) max[1] = py;
119
+ if (pz > max[2]) max[2] = pz;
120
+ }
121
+ return null;
122
+ };
123
+
124
+ // loose fallback: bound the 8 corners of the accessor's local box. over-estimates a mesh that does
125
+ // not fill its box when the node rotation is not a multiple of 90 degrees.
126
+ const accumulateCorners = (acc, matrix, min, max) => {
127
+ if (!acc?.min || !acc?.max) return;
128
+ for (let c = 0; c < 8; c++) {
129
+ const point = xform(matrix, [
130
+ c & 1 ? acc.max[0] : acc.min[0],
131
+ c & 2 ? acc.max[1] : acc.min[1],
132
+ c & 4 ? acc.max[2] : acc.min[2]
133
+ ]);
134
+ for (let k = 0; k < 3; k++) {
135
+ if (point[k] < min[k]) min[k] = point[k];
136
+ if (point[k] > max[k]) max[k] = point[k];
137
+ }
138
+ }
139
+ };
140
+
141
+ export const inspectGlb = (buf) => {
142
+ if (buf.length < 20 || buf.readUInt32LE(0) !== GLB_MAGIC || buf.readUInt32LE(16) !== JSON_CHUNK) {
143
+ throw new Error('not a GLB (or JSON chunk missing)');
144
+ }
145
+ const jsonLength = buf.readUInt32LE(12);
146
+ const g = JSON.parse(buf.toString('utf8', 20, 20 + jsonLength));
147
+ const binAt = 20 + jsonLength;
148
+ const bin =
149
+ binAt + 8 <= buf.length && buf.readUInt32LE(binAt + 4) === BIN_CHUNK
150
+ ? buf.subarray(binAt + 8, binAt + 8 + buf.readUInt32LE(binAt))
151
+ : null;
152
+ const nodes = g.nodes ?? [];
153
+ const nodeName = (i) => nodes[i]?.name ?? `node_${i}`;
154
+ const parents = new Array(nodes.length).fill(-1);
155
+ for (let i = 0; i < nodes.length; i++) {
156
+ for (const child of nodes[i].children ?? []) parents[child] = i;
157
+ }
158
+ const nodePath = (i) => {
159
+ const names = [];
160
+ for (; i >= 0; i = parents[i]) names.unshift(nodeName(i));
161
+ return names.join('/');
162
+ };
163
+ const children = new Set(nodes.flatMap((n) => n.children ?? []));
164
+ const roots = g.scenes?.[g.scene ?? 0]?.nodes ?? nodes.map((_, i) => i).filter((i) => !children.has(i));
165
+
166
+ const world = new Array(nodes.length);
167
+ const walk = (i, parent) => {
168
+ const n = nodes[i];
169
+ const local = n.matrix ?? compose(n.translation ?? [0, 0, 0], n.rotation ?? [0, 0, 0, 1], n.scale ?? [1, 1, 1]);
170
+ world[i] = mul(parent, local);
171
+ for (const child of n.children ?? []) walk(child, world[i]);
172
+ };
173
+ for (const root of roots) walk(root, IDENTITY);
174
+
175
+ const min = [Infinity, Infinity, Infinity];
176
+ const max = [-Infinity, -Infinity, -Infinity];
177
+ const notes = new Set();
178
+ let meshNodes = 0;
179
+ for (let i = 0; i < nodes.length; i++) {
180
+ if (nodes[i].mesh === undefined || !world[i]) continue;
181
+ meshNodes++;
182
+ // glTF 2.0: "the transform of the skinned mesh node MUST be ignored" - joints drive the pose
183
+ const matrix = nodes[i].skin === undefined ? world[i] : IDENTITY;
184
+ for (const prim of g.meshes?.[nodes[i].mesh]?.primitives ?? []) {
185
+ const note = prim.extensions?.KHR_draco_mesh_compression
186
+ ? 'draco-compressed'
187
+ : accumulate(g, bin, prim.attributes?.POSITION, matrix, min, max);
188
+ if (!note) continue;
189
+ notes.add(note);
190
+ accumulateCorners(g.accessors?.[prim.attributes?.POSITION], matrix, min, max);
191
+ }
192
+ }
193
+
194
+ const ok = min[0] !== Infinity;
195
+ return {
196
+ aabb: ok ? { min: min.map(round), max: max.map(round) } : null,
197
+ dims: ok ? max.map((v, i) => round(v - min[i])) : null,
198
+ center: ok ? max.map((v, i) => round((v + min[i]) / 2)) : null,
199
+ groundOffset: ok ? round(-min[1]) : null,
200
+ boundsSource: ok ? (notes.size ? 'accessor-minmax' : 'vertices') : null,
201
+ ...(notes.size ? { boundsNotes: [...notes].sort() } : {}),
202
+ nodes: nodes.length,
203
+ nodePaths: nodes.map((_, i) => nodePath(i)).sort(),
204
+ meshNodes,
205
+ materials: (g.materials ?? []).map((m, i) => m.name ?? `material_${i}`),
206
+ clips: (g.animations ?? []).map((a, i) => ({
207
+ name: a.name ?? `clip_${i}`,
208
+ duration: round(Math.max(0, ...a.samplers.map((s) => g.accessors?.[s.input]?.max?.[0] ?? 0)))
209
+ })),
210
+ joints: [...new Set((g.skins ?? []).flatMap((s) => s.joints ?? []).map(nodeName))].sort(),
211
+ animationTargets: [
212
+ ...new Set(
213
+ (g.animations ?? [])
214
+ .flatMap((a) => a.channels ?? [])
215
+ .flatMap((c) =>
216
+ c.target?.node === undefined ? [] : [`${nodePath(c.target.node)}.${c.target.path}`]
217
+ )
218
+ )
219
+ ].sort(),
220
+ skinned: !!g.skins?.length
221
+ };
222
+ };
223
+
224
+ if (import.meta.main ?? import.meta.filename === process.argv[1]) {
225
+ const files = process.argv.slice(2);
226
+ if (!files.length) {
227
+ console.error('usage: node inspect.mjs <file.glb> [more.glb ...]');
228
+ process.exit(1);
229
+ }
230
+ // report per-file failures so one unreadable file cannot discard a whole glob
231
+ const out = files.map((file) => {
232
+ try {
233
+ return { file, ...inspectGlb(fs.readFileSync(file)) };
234
+ } catch (e) {
235
+ return { file, error: e.message };
236
+ }
237
+ });
238
+ console.log(JSON.stringify(out.length === 1 ? out[0] : out, null, 2));
239
+ }
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: light-scene
3
+ description: Use when a PlayCanvas scene must read as a deliberate cinematic image rather than a flat asset preview, to set key and ambient lighting, sky and image-based lighting, shadows, tone mapping and exposure, and a restrained post-process grade of bloom, fog, and colour.
4
+ ---
5
+
6
+ # Cinematic rendering
7
+
8
+ A polished frame is lit and graded, not just populated. Treat lighting, sky, shadows, tone mapping,
9
+ and post as one pass over the assembled scene, and tune every value against a real screenshot rather
10
+ than from memory.
11
+
12
+ Prefer shipped building blocks over hand-written shaders and passes. Discover them with the
13
+ `reuse-scripts` skill and adapt the closest official example with `find-examples`:
14
+
15
+ - the core `CameraFrame` helper for tone mapping, ambient occlusion, bloom, vignette, and grade;
16
+ - a procedural sky and image-based light for ambient colour and reflections;
17
+ - a shadow catcher for grounding subjects that have no lit floor.
18
+
19
+ For an outdoor scene with water, stop before authoring the scene and read both `reuse-scripts` and
20
+ `find-examples`. Use `graphics/water.example.mjs` as the initial composition recipe: it already
21
+ joins the production `Water` script, sky, sun, `CameraFrame`, water layer, scene depth, and
22
+ reflection pipeline. Get that complete frame rendering before replacing its assets or tuning its
23
+ look. A custom normal-mapped material, tessellated plane, or reflection-coloured mesh is not the
24
+ baseline and does not substitute for `Water` when the brief asks for reflective water.
25
+
26
+ Keep the two named production integrations visible in the implementation:
27
+
28
+ - import and create `Water` from `playcanvas/scripts/esm/water.mjs` on the water entity, preserving
29
+ the example's required render component, camera reference, water layer, depth map, and textures;
30
+ - construct `CameraFrame` from `playcanvas` for the gameplay camera, set a deliberate tone map and
31
+ restrained grade, call `update()`, and destroy it with the application.
32
+
33
+ Do not continue to cosmetic tuning while either integration is missing or diagnostics report a
34
+ shader, texture, or framebuffer error.
35
+
36
+ ## Light and expose
37
+
38
+ - Establish one dominant key light with a clear direction and warm or cool intent, then add soft
39
+ fill or ambient so shadows are not crushed to black. A single flat ambient with no key reads as an
40
+ asset gallery.
41
+ - Choose a physically based tone mapping and a deliberate exposure, then balance light intensities to
42
+ that exposure, not the reverse.
43
+ - Drive ambient and reflections from the sky or an environment map, not a constant colour, so
44
+ materials pick up their surroundings.
45
+ - Tune the HDR stack in isolation before styling it. Begin with neutral tone mapping, scene exposure
46
+ `1`, procedural-sky luminance near its official-example value, bloom off, grading neutral, fog off,
47
+ and reflection/specular strengths at their defaults. Capture a screenshot, then change one class of
48
+ value at a time. Do not simultaneously raise key intensity, sky luminance, exposure, reflection,
49
+ saturation, fog brightness, and bloom.
50
+ - Treat linked procedural skies as the owner of the sun light's direction, colour, and intensity.
51
+ Set the light's base intensity before assigning it to the sky and tune the sky elevation/azimuth;
52
+ do not also hand-author the light transform or colour as though they remained independent.
53
+ - Preserve the source assets' albedo colours. If diffuse texture colours collapse toward white or a
54
+ single warm tint, reduce the HDR stack before compensating with darker material colours.
55
+
56
+ ## Shadow and ground
57
+
58
+ - Enable shadows on the key light and size the shadow area and resolution to the framed subject, not
59
+ the whole world. Soften the penumbra rather than only raising resolution.
60
+ - Ground subjects with a contact or catcher shadow when the surface beneath them is not itself lit.
61
+
62
+ ## Grade with restraint
63
+
64
+ - Bloom, fog, vignette, and colour grade unify a frame; overdone bloom and heavy fog destroy
65
+ silhouette and depth. Start subtle and increase only against the screenshot. `CameraFrame` bloom
66
+ intensity is documented for the `0` to `0.1` range; stay inside it.
67
+ - Match fog colour and density to the sky so the horizon stays coherent.
68
+ - Preserve surface detail through the brightest reflection path. If glare clips a large part of the
69
+ frame to white or a reflection becomes an opaque black blob, rebalance exposure, light intensity,
70
+ reflection strength, and shadow bias before adding more post-processing.
71
+ - Compare both the brightest surface and the shadow-facing side of the hero subject in the same
72
+ screenshot. Reject the grade if either broad highlight clipping or crushed-black silhouette hides
73
+ texture detail. A mechanically valid frame is not visually complete while either failure remains.
74
+
75
+ ## Prove the look
76
+
77
+ Tune against pixels, not numbers. Capture the intended framing with an image-returning browser
78
+ screenshot tool and inspect the returned image, as `apply-conventions` and the local agent guide
79
+ require; a saved filepath is not evidence. Confirm the subject is exposed and readable, shadows are
80
+ present but not crushed, and the grade is restrained, from the real gameplay camera at the target
81
+ resolution. Confirm the post-process camera keeps rendering as it orbits and pitches.
82
+
83
+ Choose the authoring surface with the `build-app` skill and set lights, camera, and scene settings
84
+ through its own primitives before reaching into the Engine.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: manage-game-state
3
+ description: Use when structuring a PlayCanvas game's control flow — a small state machine such as ready, playing, paused, and over, pointer-lock capture, pausing on focus or pointer-lock loss, a full reset, and a stable clock and timestep — so the loop stays deterministic and recoverable.
4
+ ---
5
+
6
+ # Game state and loop
7
+
8
+ Model the game as a small explicit state machine with one owner of the clock. Every transition names
9
+ its states; behaviour, input, and the interface read the current state rather than a scatter of
10
+ booleans.
11
+
12
+ ## States and transitions
13
+
14
+ - Enumerate the states — for example ready, playing, paused, over — and make transitions explicit
15
+ and total. Do not infer state from side effects.
16
+ - Gate the simulation on the active state: advance gameplay, timers, and physics only while playing.
17
+
18
+ ## Capture input and pause
19
+
20
+ - Request pointer lock from a user gesture such as a click, never on load. Treat loss of pointer lock
21
+ and tab blur as a pause: freeze the simulation and the clock, and resume on the next gesture.
22
+ - Make `pointerlockchange` idempotent: when `document.pointerLockElement` is the canvas, transition
23
+ ready or paused to playing and leave playing unchanged; only transition playing to paused when the
24
+ element is no longer the canvas. Never implement it as a toggle. Browsers and automation can both
25
+ report an already-acquired lock, and a duplicate enter event must not pause the game.
26
+ - Clamp the per-frame delta before integrating anything, as `build-app` requires, so a backgrounded
27
+ tab or a slow frame cannot inject one large step into movement, cooldowns, or the clock.
28
+
29
+ ## Reset
30
+
31
+ - A reset restores every owned system to its ready values — entities, camera, clock, timers,
32
+ cooldowns, effects, and overlays — not only the player. Route reset through the same setup the
33
+ initial state uses, so ready and reset cannot drift apart.
34
+ - Transition to `ready` explicitly before releasing pointer lock. Never derive the reset state from
35
+ `document.pointerLockElement`; pointer lock is an input side effect, not the state machine.
36
+ - Define timer semantics once and keep them literal in state and snapshots. A field named `reload`
37
+ is remaining cooldown time: `0` means ready, firing assigns a positive duration, and the active
38
+ loop counts it down to exactly `0`. Do not expose a normalized readiness value under that name.
39
+
40
+ ## Prove the loop
41
+
42
+ Drive the full cycle with real input: enter play, lose pointer lock and confirm the clock and
43
+ simulation freeze, resume, reset and confirm every field returns to its ready value, then reach the
44
+ end state and restart. Read these from the app's own state snapshot, never from console mutation —
45
+ manufactured state is not evidence.
46
+
47
+ Choose the authoring surface with the `build-app` skill and own the loop and lifecycle in the surface
48
+ that creates the application.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: reuse-scripts
3
+ description: Use before implementing non-core PlayCanvas behavior to discover and reuse production scripts shipped with the installed Engine, reproduce their official example integration, and verify the result against the installed version.
4
+ ---
5
+
6
+ # Engine scripts
7
+
8
+ The installed `playcanvas` package ships production scripts under `scripts/esm/**`. Discover the
9
+ current set rather than maintaining a stale list:
10
+
11
+ ```sh
12
+ rg 'static scriptName =' node_modules/playcanvas/scripts/esm \
13
+ | sed "s|.*/scripts/esm/||; s|:.*static scriptName = ['\"]| -> |; s|['\"].*||" \
14
+ | sort
15
+ ```
16
+
17
+ Read the selected file for its named export, `@attribute` properties, and defaults. Not every module
18
+ is a `Script`; the parsers below `scripts/esm/parsers` are plain classes registered with a resource
19
+ handler instead.
20
+
21
+ Only import from `scripts/esm/**`. Legacy sibling directories depend on the global Engine namespace.
22
+ After selecting a script, use `find-examples` to locate its matching versioned Engine example.
23
+
24
+ Use a relevant shipped script as the default implementation. If it cannot own the whole feature,
25
+ preserve its input signs, angular damping, bounds, and lifecycle invariants in the custom portion
26
+ instead of replacing them from memory.
27
+
28
+ ## Reproduce the reference integration
29
+
30
+ Treat the selected script source and its closest official example as complementary references:
31
+
32
+ 1. Read the source for exports, properties, defaults, fallbacks, required components, and lifecycle.
33
+ 2. Read the example for assets, entity references, mesh requirements, layer ordering, scene settings,
34
+ and render-pipeline setup.
35
+ 3. Reproduce the smallest working baseline before customizing it. Preserve defaults that are not
36
+ deliberately changed, and tune one property group at a time.
37
+ 4. After a rendered frame, fail on console, shader, or missing-asset diagnostics. Exercise the
38
+ behavior with real input where applicable and inspect returned screenshots from representative
39
+ views at the final backbuffer density.
40
+ 5. Compare the result with the official example when visual quality matters. Keep iterating or
41
+ report the gap; a running fallback is not proof of correct integration.
42
+
43
+ If no matching example exists, state that and derive the integration from installed source instead
44
+ of inventing it from memory.
45
+
46
+ For an outdoor water scene, start from `graphics/water.example.mjs` as one integration recipe. It
47
+ combines `Water`, `ProceduralSky`, `CameraControls`, `CameraFrame`, a dedicated water layer, scene
48
+ depth, normal and caustics textures, and conservative rendering defaults. Establish that complete
49
+ baseline before adapting its camera, assets, time of day, or water tuning.
50
+
51
+ ## Preserve grouped defaults
52
+
53
+ Grouped property updates differ by authoring surface. Read the selected reference and preserve
54
+ defaults that are not being changed.
55
+
56
+ Read exactly one reference matching the code being edited:
57
+ [direct Engine](references/direct-engine.md), [React](references/react.md), or
58
+ [Web Components](references/web-components.md). Choose from imports and markup, not installed
59
+ dependencies alone.
@@ -0,0 +1,41 @@
1
+ # Direct Engine projects
2
+
3
+ Import the named script class and attach it to the owning entity:
4
+
5
+ ```ts
6
+ import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
7
+
8
+ camera.addComponent('script');
9
+ const controls = camera.script?.create(CameraControls);
10
+ ```
11
+
12
+ Some production helpers are core Engine exports rather than scripts. Follow the matching example's
13
+ construction style instead of forcing them through `script.create`:
14
+
15
+ ```ts
16
+ import { CameraFrame } from 'playcanvas';
17
+
18
+ const frame = new CameraFrame(app, camera.camera!);
19
+ frame.rendering.sceneDepthMap = true;
20
+ frame.update();
21
+ ```
22
+
23
+ Keep script constructors at module scope. `create` takes two distinct option keys that are not
24
+ interchangeable: `properties` is assigned straight onto the instance, while `attributes` supplies
25
+ declared `@attribute` fields. Passing the wrong one silently does nothing.
26
+
27
+ Prefer mutating an existing grouped property when only a few fields change:
28
+
29
+ ```ts
30
+ import { ProceduralSky } from 'playcanvas/scripts/esm/sky/procedural-sky.mjs';
31
+
32
+ const sky = skyEntity.script?.create(ProceduralSky);
33
+ if (sky) sky.sunLight = sun;
34
+ ```
35
+
36
+ If the installed script subpaths have no declarations, declare only the patterns the project uses:
37
+
38
+ ```ts
39
+ declare module 'playcanvas/scripts/esm/*.mjs';
40
+ declare module 'playcanvas/scripts/esm/*/*.mjs';
41
+ ```
@@ -0,0 +1,28 @@
1
+ # React projects
2
+
3
+ Import the named script class and attach it through React:
4
+
5
+ ```tsx
6
+ import { Entity } from '@playcanvas/react';
7
+ import { Camera, Script } from '@playcanvas/react/components';
8
+ import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs';
9
+
10
+ <Entity name="Camera" position={[4, 1, 4]}>
11
+ <Camera />
12
+ <Script script={CameraControls} />
13
+ </Entity>;
14
+ ```
15
+
16
+ Keep imported or custom script constructors at module scope. Declaring a class inside a component
17
+ creates a new identity on render and can recreate the script.
18
+
19
+ Props on `<Script>` become script properties. Pass complete grouped properties or use a ref to
20
+ mutate the existing group after mount. Verify the current component props in the installed
21
+ `@playcanvas/react` declarations.
22
+
23
+ If the installed Engine script subpaths have no declarations, add:
24
+
25
+ ```ts
26
+ declare module 'playcanvas/scripts/esm/*.mjs';
27
+ declare module 'playcanvas/scripts/esm/*/*.mjs';
28
+ ```
@@ -0,0 +1,27 @@
1
+ # Web Components projects
2
+
3
+ Expose an Engine script as an ES module and register it as an asset:
4
+
5
+ ```html
6
+ <pc-app>
7
+ <pc-asset src="/scripts/camera-controls.mjs"></pc-asset>
8
+ <pc-scene>
9
+ <pc-entity name="Camera">
10
+ <pc-camera></pc-camera>
11
+ <pc-scripts>
12
+ <pc-script name="cameraControls" move-speed="4"></pc-script>
13
+ </pc-scripts>
14
+ </pc-entity>
15
+ </pc-scene>
16
+ </pc-app>
17
+ ```
18
+
19
+ The `name` must equal the class's static `scriptName`. Extra kebab-case attributes map to camelCase
20
+ script properties. Use the `attributes` JSON attribute for nested or reserved properties; it
21
+ recursively merges partial grouped objects into the script's defaults. Arrays are not merged element
22
+ by element, so an array in the JSON replaces the script's default array whole, and a kebab-case
23
+ attribute always wins over the same key inside the JSON.
24
+
25
+ Use `asset:`, `entity:`, `vec2:`, `vec3:`, `vec4:`, or `color:` prefixes where a property needs a
26
+ typed reference. Verify the selected script's export, property names, and defaults against the
27
+ installed Engine file.
@@ -0,0 +1,2 @@
1
+ .claude
2
+ .agents
@@ -30,6 +30,10 @@ Open <http://localhost:5173>. Edit the files under `src/` and save to see the sc
30
30
 
31
31
  Run `npm run build` to generate a deployable static site in `dist/`.
32
32
 
33
+ ## Agent skills
34
+
35
+ This project includes [`@playcanvas/skills`](https://github.com/playcanvas/skills) under `.claude/skills/` and `.agents/skills/`, so Claude Code, Codex and Cursor pick up PlayCanvas-specific workflows automatically.
36
+
33
37
  ## Further reading
34
38
 
35
39
  - [PlayCanvas Engine manual](https://developer.playcanvas.com/user-manual/engine/)
@@ -1,3 +1,3 @@
1
1
  import typescriptConfig from '@playcanvas/eslint-config/typescript';
2
2
 
3
- export default typescriptConfig;
3
+ export default [{ ignores: ['.claude', '.agents'] }, ...typescriptConfig];
@@ -15,14 +15,14 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "playcanvas": "^2.21.3"
18
+ "playcanvas": "^2.21.4"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@playcanvas/eslint-config": "3.0.0-beta.8",
22
22
  "eslint": "^9.39.5",
23
23
  "prettier": "^3.9.6",
24
24
  "typescript": "~6.0.3",
25
- "vite": "^8.2.0"
25
+ "vite": "^8.2.1"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=22.23.2"
@@ -0,0 +1,2 @@
1
+ .claude
2
+ .agents
@@ -30,6 +30,10 @@ Open <http://localhost:5173>. Edit the files under `src/` and save to see the sc
30
30
 
31
31
  Run `npm run build` to generate a deployable static site in `dist/`.
32
32
 
33
+ ## Agent skills
34
+
35
+ This project includes [`@playcanvas/skills`](https://github.com/playcanvas/skills) under `.claude/skills/` and `.agents/skills/`, so Claude Code, Codex and Cursor pick up PlayCanvas-specific workflows automatically.
36
+
33
37
  ## Further reading
34
38
 
35
39
  - [PlayCanvas React manual](https://developer.playcanvas.com/user-manual/react/)
@@ -2,6 +2,7 @@ import reactConfig from '@playcanvas/eslint-config/react';
2
2
  import typescriptConfig from '@playcanvas/eslint-config/typescript';
3
3
 
4
4
  export default [
5
+ { ignores: ['.claude', '.agents'] },
5
6
  ...typescriptConfig,
6
7
  ...reactConfig,
7
8
  {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@playcanvas/react": "^0.11.5",
19
- "playcanvas": "^2.21.3",
19
+ "playcanvas": "^2.21.4",
20
20
  "react": "^19.2.8",
21
21
  "react-dom": "^19.2.8"
22
22
  },
@@ -28,7 +28,7 @@
28
28
  "eslint": "^9.39.5",
29
29
  "prettier": "^3.9.6",
30
30
  "typescript": "~6.0.3",
31
- "vite": "^8.2.0"
31
+ "vite": "^8.2.1"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=22.23.2"
@@ -0,0 +1,2 @@
1
+ .claude
2
+ .agents
@@ -58,6 +58,10 @@ JetBrains IDEs pick it up automatically. For VS Code, add to `.vscode/settings.j
58
58
  }
59
59
  ```
60
60
 
61
+ ## Agent skills
62
+
63
+ This project includes [`@playcanvas/skills`](https://github.com/playcanvas/skills) under `.claude/skills/` and `.agents/skills/`, so Claude Code, Codex and Cursor pick up PlayCanvas-specific workflows automatically.
64
+
61
65
  ## Further reading
62
66
 
63
67
  - [PlayCanvas Web Components manual](https://developer.playcanvas.com/user-manual/web-components/)
@@ -1,3 +1,3 @@
1
1
  import typescriptConfig from '@playcanvas/eslint-config/typescript';
2
2
 
3
- export default typescriptConfig;
3
+ export default [{ ignores: ['.claude', '.agents'] }, ...typescriptConfig];
@@ -15,15 +15,15 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@playcanvas/web-components": "^0.10.1",
19
- "playcanvas": "^2.21.3"
18
+ "@playcanvas/web-components": "^0.15.0",
19
+ "playcanvas": "^2.21.4"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@playcanvas/eslint-config": "3.0.0-beta.8",
23
23
  "eslint": "^9.39.5",
24
24
  "prettier": "^3.9.6",
25
25
  "typescript": "~6.0.3",
26
- "vite": "^8.2.0"
26
+ "vite": "^8.2.1"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=22.23.2"