agentsclimarketplace

Spaitial playcanvas world

Skill spaitial-dev/spaitial-playcanvas-world/skills/spaitial-playcanvas-world

Generate Spaitial worlds with the Spaitial Developer API and scaffold a runnable PlayCanvas + Vite + Rapier app that loads a PlayCanvas-compatible splat, reconstructed mesh collision, sane default controls, mesh/splat debug toggles, spawn reset, and playable physics. Use when creating a Spaitial world, exporting mesh, loading a generated world in PlayCanvas, or building a game/prototype from a Spaitial scene.From its SKILL.md

Install
npx -y skills add spaitial-dev/spaitial-playcanvas-world --skill spaitial-playcanvas-world

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its file declares

Copied from the file, not written here

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

11.2 KB, ~3.0k tokens by cl100k_base, as published. Nobody here has run it

Spaitial PlayCanvas World

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.

Non-Negotiables

  • Treat every level as having both a visual splat and a collision mesh.
  • Use the collision mesh as source of truth for collision, debug mesh view, spawn, and bounds. Manifests are metadata only.
  • Spaitial API splats are .spz; PlayCanvas gsplat does not load .spz directly. Convert .spz to a PlayCanvas-compatible binary little-endian Gaussian splat .ply, then load that .ply.
  • Pin Vite to the Rollup line. Do not use vite@latest; Vite 8/Rolldown can break PlayCanvas gsplat workers with __publicField... is not defined.
  • Disable PlayCanvas unified gsplat mode: entity.addComponent("gsplat", { asset, unified: false }). Unified sort worker has crashed in this setup.
  • Use explicit paths: npm --prefix "<project>" ... and absolute paths in conversion/download scripts. Cursor shell cwd can drift into a monorepo.
  • Add controls and debug toggles by default. A generated app is not done until it is playable.

Prompt Construction

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."

API Flow

Use SPAITIAL_API_KEY from env when available. If the user explicitly provides a key, use it but do not print it in summaries.

  1. POST /v1/worlds with input.type: "text", validation.skip: true, private visibility, and an idempotency key.
  2. Poll /v1/worlds/requests/:id/status until COMPLETED.
  3. Download visual splat:
curl -sSL -H "Authorization: Bearer $SPAITIAL_API_KEY" \
  "https://api.spaitial.ai/v1/worlds/requests/$REQ_ID/splat" \
  -o "$LEVEL_DIR/world.spz"
  1. Start and poll simplified mesh export:
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"
  1. When export is READY, download download_url to mesh_simplified.ply.

Project Defaults

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 dev

Asset Layout

Use 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 mesh

Manifest 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.

SPZ To PlayCanvas PLY

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" numpy

If pip/git fails in a sandbox with hook permission errors, rerun outside the sandbox.

Create scripts/convert-spz-to-playcanvas-ply.py that:

  • loads spz.load_spz(input)
  • reads positions, colors, alphas, scales, rotations
  • writes binary little-endian PLY with vertex properties:
    • x y z
    • f_dc_0 f_dc_1 f_dc_2
    • opacity
    • scale_0 scale_1 scale_2
    • rot_0 rot_1 rot_2 rot_3
  • maps SPZ quaternion x,y,z,w to PlayCanvas rot_0=w, rot_1=x, rot_2=y, rot_3=z
  • supports --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 0

Collision PLY Loader

Implement a runtime PLY parser for the simplified mesh:

  • Support binary little-endian and ASCII fallback.
  • Read vertex x, y, z regardless of property order/type.
  • Read face list indices and triangulate polygon faces by fan.
  • Apply Spaitial/SuperSplat PlayCanvas transform to collision: (x, y, z) -> (-x, -y, z).
  • Flip triangle winding after mirroring: append triangles as [face[0], face[i + 1], face[i]].
  • Compute post-transform AABB.
  • Create RAPIER.ColliderDesc.trimesh(vertices, indices) on a fixed rigid body.

Use the same transformed vertices for a debug mesh overlay.

PlayCanvas Splat Loading

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.

Spawn And Camera Defaults

Resolve spawn from the mesh, but tolerate imperfect generated collision:

  • Try center X/Z first, then rings around center.
  • Raycast downward against only the level collider.
  • Require upward-ish normal (normal.y >= 0.35).
  • Validate capsule/sphere clearance against only the level collider.
  • If no clean spot exists, fall back to 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.

Playable Ball Defaults

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.
  • Arrow keys mirror WASD.
  • 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.
  • Mouse drag: orbit camera.
  • Wheel: zoom.
  • 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.

Default Side Control Panel

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:

  • Header with level name.
  • Movement group:
    • Reset button (R)
    • Camera collision toggle (C)
    • Short helper text for WASD/arrows, Q/E, Space, drag, wheel.
  • View group:
    • Splat + Actor (B)
    • Mesh Overlay (M)
    • Splat Only (P)
    • Mesh Only (O)
  • Colored Balls group:
    • Five color swatch buttons for red, blue, green, yellow, purple.
    • Buttons map to 1-5.
    • Each click spawns another ball, same as pressing the number key.

Panel behavior:

  • Buttons should have active state for current view mode and camera collision.
  • Ball buttons should look like spawn buttons, not toggles. Do not keep them active after click.
  • Use a clean glassy side panel over the canvas: rounded corners, translucent dark background, subtle border/shadow, readable labels, keyboard hints in <kbd>.
  • Keep the panel compact enough for laptop screens and add a mobile fallback that stretches across the top.

Debug Mesh Overlay

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);

Expected Output

When asked to create a PlayCanvas world/app, produce:

  • API submit/poll/download script or executed commands.
  • package.json, tsconfig.json, index.html, src/*, README.md.
  • public/levels/<id>/manifest.json.
  • world.spz, converted world.ply, and mesh_simplified.ply.
  • Runtime PLY collision parser, SPZ-to-PLY converter script, Rapier physics, spawn resolver, camera, side control panel, keyboard controls, repeated colored-ball spawning, mesh/splat toggles.
  • Successful npm --prefix "$PROJECT_DIR" run build.

If anything was downsampled, explicitly tell the user the source count and output count.

What ships with it: 4 files

986 B alongside SKILL.md

content/

resources/

templates/

Keep looking

Skills are one crate of 326,367. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.