Skip to content

Pick the right door ​

Goal ​

Place a kit door with keys, switches or level travel, without writing a door system. Start with place actors from a level file; keep its floor, player and movement code.

Files you will edit ​

  • src/level.json
  • src/game.ts

Steps ​

  1. Choose a door. Dimensions are metres; positions are the closed panel's centre (except TrapDoor, placed at floor height). Shared defaults: startOpen: false, lockedBy: '', consumeKey: false, stayOpen: false, autoCloseSeconds: 0 (disables the timed close). asset overrides the built-in look; assets are manifest ids. The default interaction is E, with range: 2.

    DoorWhat it isHow it opensDefaults worth knowing
    HingedDoorOne wood leafInteraction; swings away from the playerhinge: 'left', swingDegrees: 95, swingAway: true; width 1, height 2.1, thickness 0.1; seconds: 0.6
    SlidingDoorMetal sliding panelsInteraction; slides in local spacepanels: 1, slide: 'right', travel: 0 uses panel width; dimensions and seconds as HingedDoor
    DoubleDoorTwo wood leavesInteraction; outer hinges swing away togetherwidth: 2, swingDegrees: 90, swingAway: true; height 2.1, thickness 0.1, seconds: 0.6
    AutomaticDoorTwo metal sliding panelstoggledBy: 'proximity'; closes after players leavepanels: 2, range: 2.5, seconds: 0.4, holdSeconds: 0.5; no E prompt
    GateBarred metal portcullistoggledBy: 'signal'; rises by height minus lipwidth: 3, height: 3, seconds: 2, lip: 0.1
    TrapDoorWood floor hatchtoggledBy: 'signal'; swings down about local X, hinged at local -Zwidth: 1.5, height: 1.5 is depth along Z, thickness: 0.1, swingDegrees: 90; no onPassed
    LevelDoorA swinging or sliding travel doorInteraction, then crossingHingedDoor defaults; opens: 'swing', toLevel: '', toPlayerStart: '', travel: 'party', requireOpen: true, travelPrompt: 'Walk through to travel'
  2. Append one of these sets of entries to the level's actors array.

    Locked wooden door and a pickup giving the inventory item key:

    json
    [
      {
        "class": "Pickup",
        "position": [2, 0.9, -2],
        "props": { "asset": "sphere-mint", "give": { "item": "key", "count": 1 } }
      },
      {
        "class": "HingedDoor",
        "name": "woodDoor",
        "position": [0, 1.05, -4],
        "props": { "look": "wood", "lockedBy": "key" }
      }
    ]

    Add this helper in src/game.ts and call configureDoorInventory(ctx) from the game's existing init(ctx). Inventory owners are player seats; this is solo. Add sphere-mint to the game's assets list. The key is retained by default; set consumeKey: true to spend it.

    ts
    import { configureGameplayActors, createInventory, type GameContext } from 'gameable';
    
    const bag = createInventory({ owners: 1, slots: 4, items: [{ id: 'key' }] });
    function configureDoorInventory(ctx: GameContext): void {
      configureGameplayActors(ctx, { inventory: bag });
    }

    Gate opened by a lever AND a pressure plate:

    json
    [
      { "class": "Lever", "name": "lever", "position": [-2, 1, -3] },
      { "class": "PressurePlate", "name": "plate", "position": [0, 0, -3] },
      {
        "class": "Gate",
        "position": [0, 1.5, -5],
        "props": { "toggledBy": "signal", "opensWhen": ["lever", "plate"] }
      }
    ]

    Every named signal must be on. Lever starts off and stays in its chosen state (startOn: false, resetSeconds: 0); E flips it. PressurePlate requires one occupant (weight: 1), releases immediately (holdSeconds: 0), and does not latch (once: false). Players count automatically; pressedBy adds actor instance or class names. Pull the lever, then stand on the plate.

    Automatic sci-fi door, using the built-in metal look:

    json
    [
      {
        "class": "AutomaticDoor",
        "position": [0, 1.05, -4],
        "props": { "look": "metal" }
      }
    ]

    LevelDoor to floor2, arriving at the marker named start:

    json
    [
      {
        "class": "LevelDoor",
        "name": "stairs",
        "position": [0, 1.05, -4],
        "props": { "toLevel": "floor2", "toPlayerStart": "start" }
      }
    ]

    Use this entry in a game that already declares floor2 in levels, with a PlayerStart or TargetPoint named start in that destination. See switch levels at runtime for that setup. An empty toPlayerStart selects the first PlayerStart. travel: 'player' selects only the crossing player; the default moves the party. requireOpen: false accepts any unlocked door phase. opens: 'slide' changes the leaf movement.

  3. Import and retain the classes used by your level in src/game.ts, before validateLevel(data), so their registrations survive bundling. Keep the existing PlayerStart and MeshActor registrations.

    ts
    import {
      HingedDoor, SlidingDoor, DoubleDoor, AutomaticDoor, Gate, TrapDoor,
      LevelDoor, Lever, PressurePlate, PickupActor,
    } from 'gameable';
    
    void HingedDoor;
    void SlidingDoor;
    void DoubleDoor;
    void AutomaticDoor;
    void Gate;
    void TrapDoor;
    void LevelDoor;
    void Lever;
    void PressurePlate;
    void PickupActor;
  4. React in code only when the game needs an extra rule. Derive a class and use its name in the level entry (PassageDoor or TravelDoor here). onOpened fires at fully open; onPassed(self, who, ctx) fires on a crossing of the closed doorway plane while not closed. who is a player entity id. Preserve LevelDoor's built-in onPassed, which queues travel.

    ts
    import { actors, defineActor, HingedDoor, LevelDoor, isDoor, isDoorOpen } from 'gameable';
    
    const PassageDoor = defineActor('PassageDoor', {
      ...HingedDoor.spec,
      onPassed(self, who) {
        self.state.lastPlayerThrough = who;
      },
    });
    const TravelDoor = defineActor('TravelDoor', {
      ...LevelDoor.spec,
      onTravel(self, who) {
        self.state.lastTraveller = who;
        // Clear self.props.toLevel to cancel, or set another declared level.
      },
    });
    void PassageDoor;
    void TravelDoor;
    
    export function passageIsOpen(): boolean {
      const door = actors.named('woodDoor');
      return !!door && isDoor(door) && isDoorOpen(door);
    }

    onTravel runs once per accepted crossing, before loading. An empty toLevel fires only the event. isDoor() identifies kit doors, derived specs and legacy Door by their marker. Use openDoor/closeDoor for motion, lockDoor/unlockDoor for locks, and doorState for a read-only live view of phase, fraction and locked status; reacquire that view after restore.

Verify ​

Run npx tsc --noEmit -p tsconfig.json, then npm run build in the game root. Open the bundle in the common Gameable player (blank has no dev-server script). The wooden door refuses E before the pickup and swings after collecting it; the gate opens only with both signals on; the automatic panels split within 2.5 m and close after leaving; walking through the open stairs loads floor2 at start. With the derived classes, inspect lastPlayerThrough on crossing and observe onTravel before the old level unloads.

See also ​