Accumulated best practices, lessons learned, and coding standards for the 3D platformer project. Updated by the learning agent after each feature cycle.
Accumulated knowledge from building Super Mario 3D Web Edition. This file is continuously updated by the learning agent.
src/game/objects/constructor(engine, config) → super(engine) → this.config = config → this.create()update() with if (!this.isActive) return;enum for state machines (not string literals)GameEnginescene, physicsWorld, or renderer directlythis.engine.addToScene() / this.engine.addPhysicsBody() for registration| Use Case | Material | Key Properties |
|---|---|---|
| Solid objects | MeshStandardMaterial | color, roughness: 0.8, metalness: 0.1 |
| Metallic/shiny | MeshStandardMaterial | metalness: 0.8, roughness: 0.2 |
| Glowing | MeshStandardMaterial | + emissive, emissiveIntensity |
| Transparent overlay | MeshBasicMaterial | transparent: true, opacity: 0.15 |
| Shadow decal | MeshBasicMaterial | color: 0x000000, transparent, depthWrite: false |
0xFF0000 (Mario, hat)0x0000CC (overalls)0x4CAF50 (grass), 0x388E3C (pipes), 0x2E7D32 (foliage)0x8B4513 (enemies), 0x5D4037 (wood), 0x795548 (stone steps)0xFFD700 (coins)0x9E9E9E / 0xBDBDBD (castle, stone)0x87CEEB| Entity Type | Mass | fixedRotation | Notes |
|---|---|---|---|
| Player | 1 | true | linearDamping: 0.1 |
| Static platform | 0 | N/A | Default static body |
| Collectible | 0 | N/A | isTrigger: true |
| Enemy (patrol) | 0 | N/A | Move via position update |
| Projectile | 0.1-0.5 | false | Apply velocity/force |
| Moving platform | 0 | N/A | Kinematic — update position |
CANNON.Sphere for round objects, CANNON.Box for blocky onesCANNON.Cylinder for pipes and columnsArray.from({ length: N }, (_, i) => ...) for patternscastShadow — Objects look flat without shadow castingCANNON.Box takes half-extents, not full sizebody.position.y < N to detect ground; it breaks on elevated platforms. Use collision normals insteadcontact.ni direction depends on body order (contact.bi vs contact.bj); always check contact.bi === this.body before reading the normalbody.velocity.x = speed), always zero velocity in the "no input" branch. An early return without zeroing leaves the body sliding forever (especially with low friction/damping)body.addEventListener('collide')) for physical interactions: ground detection, wall sliding, platform riding. These need contact normals.| Interaction | Radius | Notes |
|---|---|---|
| Coin collection | 1.2 | Generous — feels better to collect easily |
| Enemy contact (damage) | 1.0 | Tighter — unfair hits feel bad |
| Stomp detection | Check dy > 0.5 | Player must be above enemy |
mario.die() — Sets isDead=true, disables collisionResponse, starts pop-up animationhandleDeathComplete() — Decrements lives; if lives <= 0, sets isGameOver=true; otherwise calls respawn()main.ts game loop detects isGameOver flag and shows overlaymario.resetGame() which resets all state and respawnsKeep separate typed arrays (coins: Coin[], goombas: Goomba[]) alongside the generic entities: GameObject[]. This enables efficient, type-safe collision checking without casting:
addEntity(entity: GameObject): void {
this.entities.push(entity);
if (entity instanceof Mario) this.mario = entity;
}
index.html with display: none default.visible (display: flex)main.ts game loop, not from game objectsdocument.exitPointerLock() when showing overlays.dae, .fbx, .glb) for complex characters like Mario — they look much better than hand-built primitive shapesCANNON.Box for physics — the physics shape doesn't need to match the visual exactlycreate() — the object is functional with physics before the model arrivesmodelLoaded boolean flag, initially falsemodelLoaded = true inside the loader callbackif (!this.modelLoaded) returnFor loaded models that need animation, use a 3-level hierarchy:
marioGroup (THREE.Group) ← this.mesh, rotated to face direction
├── shadow (PlaneGeometry) ← shadow decal, always flat on ground
└── container (THREE.Group) ← isolates loader's Z_UP rotation
└── model (loaded scene) ← the actual 3D model
This prevents animation code on marioGroup from conflicting with coordinate system corrections applied by the loader.
public/assets/<object-name>/<object>_<part>.png (e.g., mario_eyes_center.png)<object>_<part>_edit.png suffix<part>_unused.png suffix (kept for future use)When building large multi-part objects like castles or buildings:
THREE.Group, position the group oncecreate() and reuse across all meshesdestroy()) when >1 body is neededFor objects with many meshes, define all materials once and reuse:
// 10 materials × ~49 meshes = significant savings
const stoneMat = new THREE.MeshStandardMaterial({ color: 0xD3CFC7, roughness: 0.9 });
const roofMat = new THREE.MeshStandardMaterial({ color: 0xC85A34, roughness: 0.7 });
// Reuse references — never create duplicate materials
Rule of thumb: If two meshes have the same color and material properties, they MUST share the same material instance. Creating new MeshStandardMaterial({ color: 0xFF0000 }) twice wastes GPU resources.
When building real-world structures, establish a scale factor from one known measurement:
3 / 4 = 0.75 game units per meterPeachCastle reference: 0.75 gu/m — entrance arch 3gu (4m), main body 11gu tall (14.7m), central tower 15gu (20m)
Extending the project's color palette for stone/castle structures:
0xD3CFC7 (main walls)0xC85A340x8B6914 (bridge, doors)0x2196F3 (transparent, opacity 0.6)0xDAA520 (metalness 0.7)0xFFB6C1 with emissive 0xFF69B4 (stained glass)0x1A1A1A (window interiors, doorways)0x6B4226 (mound, foundation)When adding a large structure that replaces multiple smaller objects:
addEntity() call is sufficient — the object manages its own sub-bodiesWhen placing a structure (e.g., castle) relative to sculpted terrain:
position.y to the mound top height so the structure sits on the moundconfig.position offsets, they automatically move with the new positionPlace a prominent concentric-cylinder hill between the player and a distant structure to:
Layout reference: Mario spawns at z=0, foreground hill at z=-12, castle at z=-50. The hill is ~8m tall (10 layers), partially hiding the castle behind it.
Use multiple overlapping ground platforms at different Y levels instead of a single flat plane:
| Layer | Size | Y Position | Purpose |
|---|---|---|---|
| Dark earth base | 300×300 | -2.0 | Catch-all, prevents void gaps |
| Grass field | 200×200 | -0.25 | Main playable area |
| Sandy plaza | 15×20 | -0.1 | Localized area near spawn |
Key benefit: The large base layer at y=-2 prevents the player from falling into the void if they walk off the main grass area.
Not every visual terrain layer needs physics. For hills the player won't climb:
When placing trees on terrain hills, pass baseY to set vertical position:
this.createTree(x, z, baseY); // baseY matches the hill layer height at that position
Estimate baseY from the hill layer radii — a tree at distance D from center sits at the Y of the outermost layer whose radius ≥ D.
When adding or restructuring terrain features, all game objects in the affected Z-range must be checked and relocated:
Example: When adding a foreground hill at z=-12 with radius 20, the floating platform at z=-12 and coins near z=-12 were moved to z=12 (positive Z, away from the hill).
Whenever you excavate terrain (moats, pits, gaps), always add a thin physics body at the bottom:
When surrounding terrain features change size, connectors (bridges, ramps, walkways) must be resized to match:
Example: When the moat was widened from ~5 to ~13 units, the bridge was extended from 5→13 game units, railing posts increased from 5→13 per side.
CylinderGeometry(r, r, h, segments, 1, true) — the true parameter removes top/bottom caps, creating tube walls that can be seen through from inside. Useful for:
Keep terrain construction in a dedicated buildTerrain() method called from buildLevel(). This separates terrain from gameplay objects (platforms, coins, enemies) and keeps buildLevel() readable.
setFromEuler vs setFromAxisAnglecanon-es provides two different methods for setting body rotation:
body.quaternion.setFromEuler(x, y, z) — Sets rotation from Euler angles (all three axes at once). Best for matching mesh.rotation.set(x, y, z) or when you have data-driven rotY values.body.quaternion.setFromAxisAngle(axis, angle) — Sets rotation around a single axis. Best for single-axis rotations like ramps.WARNING: There is NO setFromEulerAngles method in cannon-es. If you see this in documentation or AI-generated code, it's wrong. The correct method is setFromEuler(x, y, z).
// For Y-axis rotation (paths, roads):
body.quaternion.setFromEuler(0, rotY, 0);
// For single-axis rotation (ramps):
body.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -0.12);
Terrain built via buildTerrain() uses inline construction (direct mesh + body creation) rather than creating GameObject subclasses. This is the correct choice when:
update() needed)Use class-based GameObject when:
Exception: The Stone Platform (Zone F) uses Platform class because it's a distinct gameplay surface that benefits from entity tracking. This is a good example of mixing both patterns in the same level.
When using mesh.scale.x/mesh.scale.z on CylinderGeometry to create elongated shapes, always use CANNON.Box (not CANNON.Cylinder) for the physics body:
CANNON.Cylinder doesn't support non-uniform scalingCANNON.Box with scaled half-extents provides more predictable collision behavior for large terrain featuresOrganize complex terrain into named zones (A, B, C...) with comments separating each zone in the code. This makes the terrain map navigable and debuggable:
// === Sandy Path (Zone A) — L-shaped curve from SW to SE ===
// ... zone A code ...
// === Main Hill (Zone B) — Elongated, center-left ===
// ... zone B code ...
Maintain a companion architecture document (castle-grounds-terrain-map.md) that maps zones to game coordinates.
When restructuring terrain, update the player spawn/respawn position to match the new layout:
Last updated: 2026-03-02 Updated by: learning agent — Castle Grounds terrain map: setFromEuler API, inline terrain pattern, CANNON.Box for scaled cylinders, zone organization, spawn alignment