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.jsonsrc/game.ts
Steps
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).assetoverrides the built-inlook; assets are manifest ids. The default interaction is E, withrange: 2.Door What it is How it opens Defaults worth knowing HingedDoorOne wood leaf Interaction; swings away from the player hinge: 'left',swingDegrees: 95,swingAway: true; width 1, height 2.1, thickness 0.1;seconds: 0.6SlidingDoorMetal sliding panels Interaction; slides in local space panels: 1,slide: 'right',travel: 0uses panel width; dimensions and seconds as HingedDoorDoubleDoorTwo wood leaves Interaction; outer hinges swing away together width: 2,swingDegrees: 90,swingAway: true; height 2.1, thickness 0.1,seconds: 0.6AutomaticDoorTwo metal sliding panels toggledBy: 'proximity'; closes after players leavepanels: 2,range: 2.5,seconds: 0.4,holdSeconds: 0.5; no E promptGateBarred metal portcullis toggledBy: 'signal'; rises by height minus lipwidth: 3,height: 3,seconds: 2,lip: 0.1TrapDoorWood floor hatch toggledBy: 'signal'; swings down about local X, hinged at local -Zwidth: 1.5,height: 1.5is depth along Z,thickness: 0.1,swingDegrees: 90; noonPassedLevelDoorA swinging or sliding travel door Interaction, then crossing HingedDoor defaults; opens: 'swing',toLevel: '',toPlayerStart: '',travel: 'party',requireOpen: true,travelPrompt: 'Walk through to travel'Append one of these sets of entries to the level's
actorsarray.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.tsand callconfigureDoorInventory(ctx)from the game's existinginit(ctx). Inventory owners are player seats; this is solo. Addsphere-mintto the game'sassetslist. The key is retained by default; setconsumeKey: trueto spend it.tsimport { 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;pressedByadds 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 namedstart:json[ { "class": "LevelDoor", "name": "stairs", "position": [0, 1.05, -4], "props": { "toLevel": "floor2", "toPlayerStart": "start" } } ]Use this entry in a game that already declares
floor2inlevels, with a PlayerStart or TargetPoint namedstartin that destination. See switch levels at runtime for that setup. An emptytoPlayerStartselects the first PlayerStart.travel: 'player'selects only the crossing player; the default moves the party.requireOpen: falseaccepts any unlocked door phase.opens: 'slide'changes the leaf movement.Import and retain the classes used by your level in
src/game.ts, beforevalidateLevel(data), so their registrations survive bundling. Keep the existing PlayerStart and MeshActor registrations.tsimport { 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;React in code only when the game needs an extra rule. Derive a class and use its name in the level entry (
PassageDoororTravelDoorhere).onOpenedfires at fully open;onPassed(self, who, ctx)fires on a crossing of the closed doorway plane while not closed.whois a player entity id. Preserve LevelDoor's built-inonPassed, which queues travel.tsimport { 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); }onTravelruns once per accepted crossing, before loading. An emptytoLevelfires only the event.isDoor()identifies kit doors, derived specs and legacy Door by their marker. UseopenDoor/closeDoorfor motion,lockDoor/unlockDoorfor locks, anddoorStatefor 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.