Best Practices
Accumulated knowledge from building Super Mario 3D Web Edition. This file is continuously updated by the learning agent.
Code Architecture
Game Object Design
- •One class per file in
src/game/objects/ - •Config interface in the same file, exported alongside the class
- •Constructor pattern:
constructor(engine, config) → super(engine) → this.config = config → this.create() - •Guard clause: Always start
update()withif (!this.isActive) return;
State Management
- •Keep game state on the owning object (Mario owns coins/stars/lives)
- •Use TypeScript
enumfor state machines (not string literals) - •HUD reads state — never writes it
Engine Boundaries
- •Game objects should only interact with the engine through the
GameEngineAPI - •Don't access
scene,physicsWorld, orrendererdirectly - •Use
this.engine.addToScene()/this.engine.addPhysicsBody()for registration
Visual Design
Material Selection
| 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 |
Geometry Segment Counts
- •Character parts: 8-12 segments (good enough for small features)
- •Large objects: 16 segments (pipes, cylinders)
- •Tiny details (eyes, pupils): 6-8 segments
- •Ground/platforms: no segments needed (BoxGeometry)
Color Palette (Mario style)
- •Red:
0xFF0000(Mario, hat) - •Blue:
0x0000CC(overalls) - •Green:
0x4CAF50(grass),0x388E3C(pipes),0x2E7D32(foliage) - •Brown:
0x8B4513(enemies),0x5D4037(wood),0x795548(stone steps) - •Gold:
0xFFD700(coins) - •Gray:
0x9E9E9E/0xBDBDBD(castle, stone) - •Sky:
0x87CEEB
Physics Design
Body Mass Guide
| 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 |
Collision Shape Sizing
- •Physics shapes should approximately match visuals — not exact
- •Slightly smaller collision shapes feel better for platforming (player fits through gaps)
- •Use
CANNON.Spherefor round objects,CANNON.Boxfor blocky ones - •
CANNON.Cylinderfor pipes and columns
World Building
Object Placement
- •Use array-driven placement for repeating objects (coins, enemies)
- •Generate positions with
Array.from({ length: N }, (_, i) => ...)for patterns - •Use trigonometric functions for circular arrangements
- •Keep objects inside the ground platform bounds (40×40 default)
Level Design Principles
- •Ground level at y=0 (ground platform top surface)
- •Floating platforms at y=3, 5, 7, 9+ (progressively higher)
- •Coins at ~2 units above the surface they sit on
- •Enemies at y=1 (slightly above ground for visual clarity)
- •Trees and decorations near edges to frame the play area
Common Mistakes to Avoid
- •Forgetting
castShadow— Objects look flat without shadow casting - •Wrong physics shape size — Remember
CANNON.Boxtakes half-extents, not full size - •Not capping deltaTime — Tab-switch causes huge delta spikes; engine caps at 0.05
- •Forgetting isActive guard — Destroyed objects still get update() calls
- •Direct scene access — Always use engine API methods
- •Material per mesh — Reuse materials when colors are the same
- •Position-based ground checks — Never use
body.position.y < Nto detect ground; it breaks on elevated platforms. Use collision normals instead - •Wrong collision normal sign —
contact.nidirection depends on body order (contact.bivscontact.bj); always checkcontact.bi === this.bodybefore reading the normal - •Missing isDead/isGameOver guards — Always skip collision checks and input handling when Mario is dead or game is over
- •Missing velocity reset on no input — When using direct velocity control (
body.velocity.x = speed), always zero velocity in the "no input" branch. An earlyreturnwithout zeroing leaves the body sliding forever (especially with low friction/damping)
Collision & Interaction Design (Added 2026-02-11)
Distance-Based vs Physics-Event Collisions
- •Use distance-based (World-level loop) for game-logic interactions: coin collection, enemy contact, item pickups. Simpler to implement, easier to debug, reliable.
- •Use physics events (
body.addEventListener('collide')) for physical interactions: ground detection, wall sliding, platform riding. These need contact normals. - •Don't mix them for the same purpose — pick one approach per interaction type.
Collision Radius Guide
| 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 |
Death & Game-Over Flow
- •
mario.die()— SetsisDead=true, disables collisionResponse, starts pop-up animation - •Death animation runs for ~2 seconds (timer-based)
- •
handleDeathComplete()— Decrements lives; if lives <= 0, setsisGameOver=true; otherwise callsrespawn() - •
main.tsgame loop detectsisGameOverflag and shows overlay - •Restart button calls
mario.resetGame()which resets all state and respawns
Typed Object Arrays in World.ts
Keep separate typed arrays (coins: Coin[], goombas: Goomba[]) alongside the generic entities: GameObject[]. This enables efficient, type-safe collision checking without casting:
typescript
addEntity(entity: GameObject): void {
this.entities.push(entity);
if (entity instanceof Mario) this.mario = entity;
}
UI Overlay Pattern
- •Define overlay HTML in
index.htmlwithdisplay: nonedefault - •Toggle with CSS class
.visible(display: flex) - •Control from
main.tsgame loop, not from game objects - •Use
document.exitPointerLock()when showing overlays - •Re-lock pointer on restart
3D Model Loading (Added 2026-02-11)
Using External 3D Models vs Primitives
- •Prefer external models (
.dae,.fbx,.glb) for complex characters like Mario — they look much better than hand-built primitive shapes - •Keep primitives for simple geometric objects (platforms, coins, basic enemies) where the box/sphere/cylinder aesthetic fits the style
- •Hybrid approach works: Load a 3D model for the character mesh but use a simple
CANNON.Boxfor physics — the physics shape doesn't need to match the visual exactly
Async Model Loading Checklist
- •Start loading in
create()— the object is functional with physics before the model arrives - •Add a
modelLoadedboolean flag, initiallyfalse - •Set
modelLoaded = trueinside the loader callback - •Guard animation/visual code with
if (!this.modelLoaded) return - •The object still participates in physics while the model loads — the player can move immediately
Model Container Hierarchy
For loaded models that need animation, use a 3-level hierarchy:
code
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.
Asset File Conventions
- •Store assets in
public/assets/<object-name>/ - •Primary model file + all referenced textures in the same folder
- •Texture naming:
<object>_<part>.png(e.g.,mario_eyes_center.png) - •Editor variants:
<object>_<part>_edit.pngsuffix - •Unused variants:
<part>_unused.pngsuffix (kept for future use)
Last updated: 2026-02-11 Updated by: learning agent — Mario 3D model loading & asset patterns