Skip to content

Load a made world ​

Goal ​

A world package (a folder with world.json) replaces the template arena. The hero stands at the world's start; loose things can be pushed and make sounds; game code can find things by name.

Files you will edit ​

  • src/main.ts
  • src/systems/world.ts

Steps ​

  1. Put the package in public/worlds/forgotten-attic/ and name it in src/main.ts. The third-person template wires the world module to the game; add its address:

    ts
    const WORLD_URL = 'worlds/forgotten-attic/';

    Write the address without a leading slash; the engine also accepts /worlds/<name>/ and reads it from the game's folder. For custom URLs, use gameUrl() from gameable/assets.

    The template reads VITE_WORLD from .env.local, so the address can stay out of code. With it set, the host loads the world instead of the arena:

    ts
    import { createWorldBridge, world, type WorldBridge } from 'gameable/world';
    
    // modules: [physics(), input(), audio(), splat(), world(), ...]
    await engine.get('world').load(WORLD_URL);
    
    let worldBridge: WorldBridge | null = null;
    const adapter = createEngineAdapter(engine, {
      modules,
      characters,
      thing: (command) => worldBridge?.handle(command),
    });
    worldBridge = createWorldBridge({ world: engine.get('world'), events: adapter.events });

    In the visit template the same package is one input: ?world=worlds/forgotten-attic/world.json (or world in the lookup's JSON) in place of the place's own fields.

  2. Build your level from the world's things in src/systems/world.ts. The system there already stands the hero at the world's start when the loaded event arrives and takes the arena's own props out. Ask the things facade for the rest; it answers inside the guest, with no call to the host:

    ts
    import { things, type GameContext } from 'gameable';
    
    const heroAt = { x: 0, y: 0, z: 0 };
    
    export function nearTheTrunk(ctx: GameContext): boolean {
      const trunk = things.find('the trunk');
      if (trunk === null) return false;
      // Where to stand to use it, and what the hero is beside now.
      const spot = things.standFor(trunk);
      const beside = things.near(heroAt, 1.2)[0];
      return beside === trunk || Math.hypot(spot.x - heroAt.x, spot.z - heroAt.z) < 0.5;
    }

    Knocks and moves arrive as events, and a loose thing takes commands:

    ts
    export function hearTheWorld(ctx: GameContext): void {
      for (const event of ctx.events) {
        if (event.tag !== 'world-event' || event.val.kind !== 'hit') continue;
        const knocked = things.all[event.val.thing];
        if (knocked.kind === 'bottle' && event.val.strength > 0.6) things.reset(knocked);
      }
    }

Verify ​

sh
npm run dev

The made world is on screen with the hero at its start; walking into a loose thing pushes it and plays its sound; the walls stop you. Then assert it headlessly, as tests/world.test.ts does: hand the game a world-event of kind loaded and check where the hero went.

sh
npm test
ts
expect(move.val.position.x).toBeCloseTo(list.spawn.x);

To see every thing's name over it, open examples/world-walk on the same folder (GAMEABLE_WORLDS_DIR=<folder> npm run dev -w examples/world-walk) and switch Names on. A world whose loose things come as points too shows a second switch there, Things as points; in a game it is one call, engine.get('world').drawThingsAs('splat') (or 'mesh', or 'record': as the package says).

A package of one thing and no place (a thing by itself) loads by the same call, and things.world.kind is 'object'. Pass lite: true on a phone for the thing's lighter copy:

ts
const made = await engine.get('world').load('worlds/pink-menace/', { lite: onAPhone });
console.log(made.plain, made.copies); // true, 'full' on a desk or 'lite' on a phone

See also ​