Generate SpAItial worlds and scaffold PlayCanvas scenes with splat loading, mesh collision, Rapier physics, camera origin, and spawn placement.
SaferSkills independently audited spaitial-playcanvas-world (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Use this skill to one-shot a playable PlayCanvas starter from a Spaitial world. The starter must work in-browser without manual debugging: visible splat, collision mesh, physics actor, sensible controls, debug view toggles, and a successful build.
.spz; PlayCanvas gsplat does not load .spz directly. Convert .spz to a PlayCanvas-compatible binary little-endian Gaussian splat .ply, then load that .ply.vite@latest; Vite 8/Rolldown can break PlayCanvas gsplat workers with __publicField... is not defined.entity.addComponent("gsplat", { asset, unified: false }). Unified sort worker has crashed in this setup.npm --prefix "<project>" ... and absolute paths in conversion/download scripts. Cursor shell cwd can drift into a monorepo.When generating from text, rewrite the user prompt into an empty environment. Always append:
No people, no humans, no characters, no crowds, no portraits, no mannequins, no animals, no visible body parts.Avoid franchise wording if the API rejects the prompt. Preserve the visual intent with original phrasing.
Example:
User: "generate a 70s classroom"
Prompt: "An empty 1970s classroom interior with rows of wooden desks, chalkboard, warm film-era lighting, educational posters, linoleum floor, windows, period-correct furniture. No people, no humans, no characters, no crowds, no portraits, no mannequins, no animals, no visible body parts."Use SPAITIAL_API_KEY from env when available. If the user explicitly provides a key, use it but do not print it in summaries.
POST /v1/worlds with input.type: "text", validation.skip: true, private visibility, and an idempotency key./v1/worlds/requests/:id/status until COMPLETED.curl -sSL -H "Authorization: Bearer $SPAITIAL_API_KEY" \
"https://api.spaitial.ai/v1/worlds/requests/$REQ_ID/splat" \
-o "$LEVEL_DIR/world.spz"curl -sS -X POST -H "Authorization: Bearer $SPAITIAL_API_KEY" \
"https://api.spaitial.ai/v1/worlds/requests/$REQ_ID/exports/mesh-simplified"
curl -sS -H "Authorization: Bearer $SPAITIAL_API_KEY" \
"https://api.spaitial.ai/v1/worlds/requests/$REQ_ID/exports/mesh-simplified"READY, download download_url to mesh_simplified.ply.Create a new Vite app folder, usually under ~/Documents/workspace/<level-id>-playcanvas.
Use these package constraints:
{
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@dimforge/rapier3d-compat": "latest",
"playcanvas": "latest"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.8.0",
"vite": "^6.4.2"
}
}Build and run with:
npm --prefix "$PROJECT_DIR" install
npm --prefix "$PROJECT_DIR" run build
npm --prefix "$PROJECT_DIR" run devUse public/levels/<level-id>/ for Vite:
public/levels/<level-id>/
manifest.json
world.spz # original API artifact
world.ply # PlayCanvas-compatible converted visual splat
mesh_simplified.ply # collision/debug meshManifest should point PlayCanvas at world.ply, not world.spz:
{
"version": "1.0",
"id": "level-id",
"name": "Readable Name",
"description": "Short description",
"transform": { "origin": [0, 0, 0], "scale": 1.0, "upAxis": "y" },
"bounds": { "min": [0, 0, 0], "max": [0, 0, 0] },
"splats": [{ "url": "/levels/level-id/world.ply", "format": "ply" }],
"collision": [{ "url": "/levels/level-id/mesh_simplified.ply", "format": "ply" }],
"spawns": [{ "id": "main", "name": "Main Spawn", "position": [0, 0, 0], "rotation": 0, "isDefault": true }],
"hotspots": [],
"spatial": { "cellSize": 5, "nearRadius": 15, "farRadius": 30 }
}After parsing mesh_simplified.ply, write real post-transform bounds into the manifest.
Install converter deps in a local venv:
python3 -m venv "$PROJECT_DIR/.venv-spz"
"$PROJECT_DIR/.venv-spz/bin/pip" install "spz @ git+https://github.com/nianticlabs/spz" numpyIf pip/git fails in a sandbox with hook permission errors, rerun outside the sandbox.
Create scripts/convert-spz-to-playcanvas-ply.py that:
spz.load_spz(input)positions, colors, alphas, scales, rotationsx y zf_dc_0 f_dc_1 f_dc_2opacityscale_0 scale_1 scale_2rot_0 rot_1 rot_2 rot_3x,y,z,w to PlayCanvas rot_0=w, rot_1=x, rot_2=y, rot_3=z--max-splats; default to full quality (0) unless perf is too slow. If downsampling, say so explicitly.Run with absolute paths:
"$PROJECT_DIR/.venv-spz/bin/python" "$PROJECT_DIR/scripts/convert-spz-to-playcanvas-ply.py" \
"$PROJECT_DIR/public/levels/$LEVEL_ID/world.spz" \
"$PROJECT_DIR/public/levels/$LEVEL_ID/world.ply" \
--max-splats 0Implement a runtime PLY parser for the simplified mesh:
x, y, z regardless of property order/type.(x, y, z) -> (-x, -y, z).[face[0], face[i + 1], face[i]].RAPIER.ColliderDesc.trimesh(vertices, indices) on a fixed rigid body.Use the same transformed vertices for a debug mesh overlay.
Load the converted world.ply:
const asset = new pc.Asset("world-splat", "gsplat" as never, { url });
app.assets.add(asset);
app.assets.load(asset);
const entity = new pc.Entity("world-splat");
(entity as any).addComponent("gsplat", { asset, unified: false });
entity.setLocalEulerAngles(0, 0, 180);
app.root.addChild(entity);Do not point this at world.spz; that produces Invalid ply header.
Resolve spawn from the mesh, but tolerate imperfect generated collision:
normal.y >= 0.35).bounds.minY + radius + 0.1 and warn.Default camera collision should be off because generated meshes often include roofs/exterior shells; hard clamping can make zoom-out go black. Add a C toggle to enable camera collision for debugging.
Use a dynamic ball unless the user explicitly asks for an avatar. Tuned constants:
const MOVE_FORCE = 0.38;
const VERTICAL_FORCE = 0.42;
const JUMP_IMPULSE = 1.85;
const MAX_SPEED = 3.8;
RAPIER.RigidBodyDesc.dynamic()
.setLinearDamping(2.4)
.setAngularDamping(1.9);Controls:
W / S: forward/back relative to camera. Forward is {-sin(yaw), -cos(yaw)}.A / D: strafe relative to camera.Q / E: down/up impulse.Space: jump. Ground ray must ignore the ball body and filter to the level collider by handle.R: reset actor to resolved spawn/origin and zero linear/angular velocity.C: toggle camera collision.M: toggle collision mesh overlay.P: splat only.O: mesh only.B: both splat + actor, mesh hidden.1-5: spawn another colored physics ball each press/click. Do not toggle/remove existing balls; repeated presses should keep adding balls, with slight spawn scatter so they do not overlap exactly.Every generated app should include a polished side control panel, not only keyboard shortcuts. Keep keyboard shortcuts working, but expose the main actions as clickable buttons.
Required panel groups:
R)C)B)M)P)O)1-5.Panel behavior:
<kbd>.Build a PlayCanvas pc.Mesh from transformed collision vertices and indices. Display the simplified mesh as explicit triangle-edge line geometry, not RENDERSTYLE_WIREFRAME, because explicit edges show many more lines and make the mesh easier to inspect.
const mesh = new pc.Mesh(app.graphicsDevice);
const linePositions = createTriangleEdgePositions(collisionMesh);
mesh.setPositions(linePositions);
mesh.update(pc.PRIMITIVE_LINES, true);
const material = new pc.StandardMaterial();
material.diffuse = new pc.Color(0.1, 0.85, 1.0);
material.emissive = new pc.Color(0.08, 0.65, 0.9);
material.opacity = 0.72;
material.blendType = pc.BLEND_NORMAL;
material.cull = pc.CULLFACE_NONE;
material.depthWrite = false;
material.update();
const meshInstance = new pc.MeshInstance(mesh, material);When asked to create a PlayCanvas world/app, produce:
package.json, tsconfig.json, index.html, src/*, README.md.public/levels/<id>/manifest.json.world.spz, converted world.ply, and mesh_simplified.ply.npm --prefix "$PROJECT_DIR" run build.If anything was downsampled, explicitly tell the user the source count and output count.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.