VRAMPAGE (Multiplayer First-person shooter)

,

Hello,

This is my first announcement of my new project VRAMPAGE :crossed_swords:
It’s a multiplayer first-person shooter, with a goal to be cross-platform across mobile, desktop and VR.

It’s powered by Babylon.js and Colyseus, it is also my first Typescript project.

Backstory
I have used Unity for many years, but decided to abandon it when they introduced the runtime fee.
After that I started looking for an open-source engine. The other candidates were Godot, Aframe and Three.js. I saw the potential and feel in love with Babylon.js.

Now I have dedicated myself to this engine and plan on using it to build my own game engine (Reboot) on top, and try my luck as a independent game developer with one more startup, which this time will be called vrooster.com

Features
The game runs a classic FFA game mode, where each player have 100 HP and can be shot and killed. When killed the players will respawn.

NPCs spawns and will chase down the player and shoot at them.

The game works on Desktop and VR (Only tested Oculus 2)

Try it at vrampage.com :joystick:

Controls

  • Left Mouse - Fire
  • Q - Select/Unselect Entity
  • CTRL - Change Gizmo for selected entity
  • T - Toggle Menu
  • 0-9 - Change Weapon

Have fun :blush:
Please leave any type of feedback (even the bad). Thanks :heart:

11 Likes

Man! what a good way to start the week!!

Keep up the good work!

2 Likes

Finally I reached a state where I wanted to share some progress of my project.
The newest version is 0.0.112, and the game now is available at vrampage.com.

Main new features/changes:

  • The map (Stranded), filled with beautiful FREE assets from https://quaternius.com/
  • Spawn component - for having multiple player spawn locations
  • Behavior component - Used for running behavior trees (Currently hostile-npc and hostile-npc-spawner)
  • Prefabs support (WIP) - Support for defining prefabs for easier spawning in-game and reducing the size of the map json.
  • The game now runs properly on VR again (Only tested oculus)

Known issues:

  • Everything else than terrain is not used for navigation mesh
  • The physics aggregates (used for players, npcs, projectiles) will loose their correct position when colliding with other physics aggregates. This can be seen by toggling the debug mode on through the quick menu, which can be activated with 5.
  • Performance is not great on VR. Implementing object pools and caching has not been a priority.
  • Entities spawned as prefabs during map startup does not always gets loaded correctly (etc. missing meshes)

Prefab example
So far one of the primary goals with the engine is to have as much as possible defined in JSON. This is the JSON content for npc_robot:

{
  "id": "npc_robot",
  "components": [
    {
      "type": "mesh_rendering",
      "meshes": [
        {
          "type": "model",
          "modelPath": "player/robot/robot.glb",
          "scale": {
            "x": 4.25,
            "y": 4.25,
            "z": 4.25
          },
          "rotation": {
            "x": 0,
            "y": 0,
            "z": 0
          },
          "position": {
            "x": 0,
            "y": 0,
            "z": 0
          }
        }
      ]
    },
    {
      "type": "collider",
      "colliders": [
        {
          "type": "cylinder",
          "position": {
            "x": 0,
            "y": -4,
            "z": 0
          },
          "radius": 5,
          "height": 4
        }
      ]
    },
    {
      "type": "animation_controller",
      "animations": [
        {
          "name": "walk",
          "path": "avatar/rifle_walk.glb",
          "speed": 1,
          "loop": true
        }
      ]
    },
    {
      "type": "transform",
      "position": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "scale": {
        "x": 1,
        "y": 1,
        "z": 1
      },
      "rotation": {
        "x": 0,
        "y": 0,
        "z": 0
      }
    },
    {
      "type": "interactive"
    },
    {
      "type": "ui",
      "elements": [
        {
          "type": "text",
          "text": "NPC Robot",
          "fontSize": "128",
          "color": "red"
        }
      ]
    },
    {
      "type": "physics"
    },
    {
      "type": "behavior",
      "tree": "hostile-npc"
    },
    {
      "type": "agent"
    },
    {
      "type": "health",
      "health": 100
    },
    {
      "type": "owner",
      "id": ""
    }
  ]
}

That’s it for now :blush:

1 Like

Version 0.0.123


Been working on the UI/Menu, attempting to follow the Model–view–controller (MVC) pattern.
Dynamic text now gets updated from the View Controller, like this implementation in the heads-up-display-view:

this.controller.state
      .getProperty<string>("fps")
      .onValueChangedObservable.add((value) => {
        this.fpsText.text = value;
      });

The main view can be toggled with T

Reworked my projectile (Previously it was implemented using a velocity-component, consisting of a multiplier and a direction that updated the transform-component position).
Now it’s implemented as a behavior tree (like the hostile-npc-spawner and hostile-npc), which made it easy to create a new type of a projectile. This is the rocket behavior tree:

{
    "type": "sequence",
    "children": [
        {
            "type": "velocity",
            "multiplier": 70
        },
        {
            "type": "delay",
            "delay": 0.15,
            "blackboardKey": "delay"
          },
          {
            "type": "spawn-entity",
            "prefab": "smoke_trail_fx"
          }
    ]
  }

I made it possible to change between 2 weapons to try out the different projectiles. The weapon can be switched with 1 - 2

Players and NPCs now have different models.

The main focus has been PC. The game works on VR, but the features are limited compared to the PC version.

The issues mentioned in the previous post is still not fixed.

2 Likes

0.0.124

Added simple weapon bobbing to keep the scene feeling alive and engaging.
vrampage-weapon-bobbing

export class WeaponBobbing {
  game: GameController;
  playerTransform: TransformComponent;
  weaponTransform: TransformNode;

  private idleAmplitude: Vector3 = new Vector3(0.002, 0.002, 0); // Amplitude for idle bobbing
  private moveAmplitude: Vector3 = new Vector3(0.015, 0.015, 0);
  private idleFrequency: number = 2; // Frequency of the sine wave
  private moveFrequency: number = 5;

  private lastPlayerPosition: Vector3 = Vector3.Zero(); // Tracks player position for velocity calculation
  private elapsedTime: number = 0; // Elapsed time since the start of the game

  constructor(game: GameController) {
    this.game = game;
  }

  initialize(weaponTransform: TransformNode) {
    this.playerTransform =
      this.game.entityController.localPlayerEntity.getComponent<TransformComponent>(
        ComponentType.TRANSFORM,
      );
    this.weaponTransform = weaponTransform;
    this.lastPlayerPosition.copyFrom(this.playerTransform.position);
  }
  update(deltaTime: number) {
    if (!this.playerTransform || !this.weaponTransform) {
      return;
    }

    // Update elapsed time
    this.elapsedTime += deltaTime;

    // Calculate the player's velocity
    const currentPosition = this.playerTransform.position;
    const velocity = currentPosition.subtract(this.lastPlayerPosition);
    this.lastPlayerPosition.copyFrom(currentPosition);

    // Determine bobbing amplitude based on velocity
    const isMoving = velocity.length() > 0.001;
    const amplitude = isMoving ? this.moveAmplitude : this.idleAmplitude;
    const frequency = isMoving ? this.moveFrequency : this.idleFrequency;

    // Calculate bobbing offsets using sine and cosine for smooth oscillation
    const xOffset = amplitude.x * Math.sin(this.elapsedTime * frequency);
    const yOffset = amplitude.y * Math.cos(this.elapsedTime * frequency);

    // Apply the calculated offsets to the weapon's position
    this.weaponTransform.position.addInPlace(new Vector3(xOffset, yOffset, 0));

    // Optional: Dampen the weapon's position slightly to avoid excessive accumulation
    this.weaponTransform.position = Vector3.Lerp(
      this.weaponTransform.position,
      Vector3.Zero(),
      0.1,
    );
  }
}
3 Likes

Looking good!

Weapon bobbing will never not remind me of my beloved HEXEN :heart:

1 Like

0.0.126
Implemented animation controller with basic functionality for controlling animations.
Currently it supports multiple states with multiple transitions and multiple conditions.
For now it is only used for controlling whether the characters should play their “idle”, “walk” or “run” animation.
animation-controller
The JSON configuration for the Animation Controller Component looks like this:

{
      "type": "animation_controller",
      "initialState": "idle",
      "parameters": [
        {
          "name": "speed",
          "type": "number",
          "value": 0
        }
      ],
      "states": [
        {
          "name": "idle",
          "animationGroup": "avatar/rifle-idle.glb",
          "speed": 1,
          "loop": true,
          "transitions": [
            {
              "to": "walk",
              "conditions": [
                {
                  "parameter": "speed",
                  "type": "number-greater-than",
                  "value": 1
                }
              ]
            }
          ]
        },
        {
          "name": "walk",
          "animationGroup": "avatar/rifle-walk.glb",
          "speed": 1,
          "loop": true,
          "transitions": [
            {
              "to": "idle",
              "conditions": [
                {
                  "parameter": "speed",
                  "type": "number-less-than-or-equal",
                  "value": 0.5
                }
              ]
            },
            {
              "to": "run",
              "conditions": [
                {
                  "parameter": "speed",
                  "type": "number-greater-than",
                  "value": 3
                }
              ]
            }
          ]
        },
        {
          "name": "run",
          "animationGroup": "avatar/rifle-run.glb",
          "speed": 1,
          "loop": true,
          "transitions": [
            {
              "to": "idle",
              "conditions": [
                {
                  "parameter": "speed",
                  "type": "number-less-than-or-equal",
                  "value": 0.5
                }
              ]
            },
            {
              "to": "walk",
              "conditions": [
                {
                  "parameter": "speed",
                  "type": "number-less-than-or-equal",
                  "value": 2
                }
              ]
            }
          ]
        }
      ]
    },

Future development could introduce functionality such as:

  • layers for playing animations simultaneously (such as upper body waving while the lower body walks)
  • avatar mask For limiting the influence of animations to specific parts of a character’s skeleton.
3 Likes

0.0.130
Added scoreboard which also introduced ping functionality for determining the latency.

Added chat which can be toggled with Enter. Currently the input is cut off, and the cursor can’t be moved using arrow keys.

1 Like

really really good!

2 Likes

0.0.152

  • Real-time Shadows Incredible easy to implement using the ShadowGenerator :star_struck:

  • Out of bounds Out of bounds system ensures those users daring to jump of the edge of map gets killed and respawned

  • Hotbar and Inventory Items can now be accessed using the hotbar keys (0-9) or through the inventory menu (T)

  • Weapons There are now various weapons available (such as Pistol, Ak47, Sub Machine Gun, Revolver, Shotgun)
    weapons

  • Aerial AI Navigation Experimenting with flying enemies
    ufo

  • Portals can take you to other scenes.

  • River scene Friendly apes wandering around


    river

  • Mystic Meadows scene Dance party with music


    dancing

I still haven’t done a lot of performance optimization yet, I am really surprised how good it is running, Babylon.js is a joy to work with :heart_eyes:

Other than the listed changes above, I am also experimenting with:

  • Responsive UI That should work across mobile, pc and vr, and change layout depending on the platform
  • Production Asset Pipeline Converting images to webp and spritesheets, audio to ogg and json to msgpack

Oh ye, my primary focus has been desktop, so VR is lacking behind regarding controls and ui :sleeping_face:

5 Likes

Most impressive. I had a problem using the icons on my tablet, but still!
Some of the features you’ve added would be a great reference for other Babylon users. Any chance of this being open source? I’d really love to add this to awesome-babylonjs.

1 Like

Thanks a lot Symbitic :slight_smile:

I have looked through the awesome-babylonjs list before, the list is awesome, and so are you for making it!

You are right the icons might be tricky on a mobile device, as mentioned in the previous post, I am also working with a responsive (and platform-aware) UI, to ensure the UI is usable on various screens.

Making the project open-source is something I have been thinking about from the beginning.

My main concern making it open-source right now, will be the time it takes to maintain it, PRs, bugs, docs, etc.

Right now the project consists of 7 required repos, and a few optional ones. I am using Github actions to build and deploy the engine, and I have a few scripts that help me with the assets pipeline.

The plan with the project is to isolate all the game/experience logic from the engine, and have it loaded as a mod.

There are also references/partial implementations in the code that refers to other projects which I have not yet made public.

My time is limited, mostly just a few hours during the evening, so right now I am just going to keep enjoying working on the project, without worrying about making breaking changes, coordinating, or maintaining a public project.

Don’t get me wrong, I would love to make it open-source, and give back to the community.

Also to clarify it is the engine that I would like to make open-source, not the game itself. The game is a commercial product.

Anyone is more than happy to ask questions, and I will try to answer them as best as I can and share my knowledge and code snippets when I can.

Right now the engine consists of the following systems:

  • animation

  • asset

  • behavior

  • camera

  • chat

  • collision

  • control

  • crosshair

  • debug

  • developer

  • editor

  • entity

  • glow

  • hotbar

  • interactive

  • inventory

  • lifetime

  • loading

  • material

  • mesh

  • navigation

  • network

  • notification

  • out-of-bounds

  • particle

  • performance

  • physics

  • platform

  • ray

  • resolution

  • scene

  • settings

  • shadow

  • sound

  • trail

  • transform

  • trigger

  • ui

  • user

  • xr

Right now I am working on making my Entity system more event-driven, with events such as onEntityAcquire, onEntityRelease, onEntityLocalUpdate, onEntityComponentAcquire, onEntityComponentRelease, etc.

This will allow me to decouple the systems, for example the transform system, will be able to listen to the onEntityComponentAcquire event, and then acquire the transform component and further listen to the onEntityComponentRemoteUpdate event, and update the transform accordingly.

1 Like

Thanks @Basic. Glad you enjoyed the list.

As someone who has seen a lot of Babylon projects, some of the things I’m most curious about yours are:

  • How systems are organized and how they connect to each other.

  • How different GitHub repos use each other. This looks cool, and a real example of how a professional game developer team would work.

  • How does deployment work? How do you handle running locally vs deployed? One of the challenges I’m facing with my babylonjs-archive project is that a lot of multiplayer games have gone offline because Heroku stopped making SocketIO servers running 24/7 free.

  • How the game handles state/status. There’s no shortage of demos showing how to fire a bullet using top-level bulletPos and isBulletFired. Few games that store all gameplay state as global variables are good sources of inspiration to other developers.

  • How the JSON configuration is loaded. Is it imported and bundled? Loaded at runtime? The number one thing I’m begging for these days are examples of data-driven games. There’s a playground example of almost anything, but most have everything hard-coded, which makes it very difficult to build a full game of off.

  • Portals. Again, plenty of search results showing demos. Not nearly as many examples of it being one of many features in a larger work.

  • Model-view-controller. How did you add MVC to Babylon?

  • AI and NPCs. I cannot overstate how few references there are for a developer who wants to add playable opponents to their game.

Even if you don’t think part of your code is the best, it can still inspire other programmers to consider alternate approaches. One of my personal projects was PlaylistBrowserXR, which is nothing more than an example of how to play Spotify playlists inside a VR environment. Not exactly impressive by itself, but it showed how to separate auth, routing, Spotify and the GUI by using Babylon Observables to avoid any direct dependencies between components. That lesson helped when I was modernizing some abandoned projects for babylonjs-archive and ran into issues with big untyped Game objects being passed around to every class and creating circular dependencies that TypeScript and ESM hated.

If you want to openly brag and explain every detail of your architecture without providing any copy-able code, I’d have no complaints. (I’d just request that it be in a new thread so I can link to it in awesome-babylonjs).

No matter what, I think you made a great game!

I am very satisfied with my code, this is not the reason why I ain’t sharing my full project YET.
Also this is in no way a bragging oppertunity for me, the game and architecture is not an impressive feat, it is merely just a demonstration/demo of the engine I am building.
Since this project will be released on the web, and not through any stores, this is more seen for me as a marketing opportunity, spreading awareness of the project and generally just a dev log.

I will now do a series of posts to answer your questions as best as I can :slight_smile:

- How systems are organized and how they connect to each other.
Initially to get things up running fast, I have been sending the game-controller around in the project, and pretty much everything was accessible from anywhere.
The game-controller is almost completely empty now, and most logic is moved to systems.

This is system.ts:

export class System {
  protected systems: System[] = [];
  protected game: GameController;

  id: string;
  state: SystemState = SystemState.Initial;
  modState: SystemModState = SystemModState.Unloaded;
  sceneState: SystemSceneState = SystemSceneState.Unloaded;

  constructor(id: string, game: GameController) {
    this.id = id;
    this.game = game;
  }

  async init() {
    Logger.debug(this.id, "init");
    this.state = SystemState.Initializing;
    return Promise.all(
      this.systems.map((system) => {
        return system.init();
      }),
    ).then(() => {
      Logger.debug(this.id, "init done");
      this.state = SystemState.Initialized;
    });
  }

  onBeforeRender(deltaTime: number) {
    this.systems.forEach((system) => {
      system.onBeforeRender(deltaTime);
    });
  }

  protected registerSystem<T extends System>(system: T): T {
    this.systems.push(system);
    return system;
  }

  public getSystem(id: string): System | undefined {
    var result = this.systems.find((system) => system.id == id);

    if (!result) {
      this.systems.forEach((system) => {
        if (!result) {
          result = system.getSystem(id);
        }
      });
    }

    return result;
  }

  onAfterPhysicsUpdate(deltaTime: number) {
    this.systems.forEach((system) => {
      system.onAfterPhysicsUpdate(deltaTime);
    });
  }

  async onBeforeUnloadScene() {
    Logger.debug(this.id, "onBeforeUnloadScene");
    this.sceneState = SystemSceneState.Unloading;
    return Promise.all(
      this.systems.map((system) => {
        return system.onBeforeUnloadScene();
      }),
    ).then(() => {
      Logger.debug(this.id, "onBeforeUnloadScene done");
      this.sceneState = SystemSceneState.Unloaded;
    });
  }

  async onAfterUnloadScene() {
    Logger.debug(this.id, "onAfterUnloadScene");
    return Promise.all(
      this.systems.map((system) => {
        return system.onAfterUnloadScene();
      }),
    ).then(() => {
      Logger.debug(this.id, "onAfterUnloadScene done");
    });
  }

  async onBeforeLoadScene() {
    Logger.debug(this.id, "onBeforeLoadScene");
    this.sceneState = SystemSceneState.Loading;
    return Promise.all(
      this.systems.map((system) => {
        return system.onBeforeLoadScene();
      }),
    ).then(() => {
      Logger.debug(this.id, "onBeforeLoadScene done");
      this.sceneState = SystemSceneState.Loaded;
    });
  }

  async onAfterLoadScene() {
    Logger.debug(this.id, "onAfterLoadScene");
    return Promise.all(
      this.systems.map((system) => {
        return system.onAfterLoadScene();
      }),
    ).then(() => {
      Logger.debug(this.id, "onAfterLoadScene done");
    });
  }

  async onBeforeUnloadMod() {
    Logger.debug(this.id, "onBeforeUnloadMod");
    this.modState = SystemModState.Unloading;
    return Promise.all(
      this.systems.map((system) => {
        return system.onBeforeUnloadMod();
      }),
    ).then(() => {
      Logger.debug(this.id, "onBeforeUnloadMod done");
      this.modState = SystemModState.Unloaded;
    });
  }

  async onAfterUnloadMod() {
    Logger.debug(this.id, "onAfterUnloadMod");
    return Promise.all(
      this.systems.map((system) => {
        return system.onAfterUnloadMod();
      }),
    ).then(() => {
      Logger.debug(this.id, "onAfterUnloadMod done");
    });
  }

  async onBeforeLoadMod() {
    Logger.debug(this.id, "onBeforeLoadMod");
    this.modState = SystemModState.Loading;
    return Promise.all(
      this.systems.map((system) => {
        return system.onBeforeLoadMod();
      }),
    ).then(() => {
      Logger.debug(this.id, "onBeforeLoadMod done");
      this.modState = SystemModState.Loaded;
    });
  }

  async onAfterLoadMod() {
    Logger.debug(this.id, "onAfterLoadMod");
    return Promise.all(
      this.systems.map((system) => {
        return system.onAfterLoadMod();
      }),
    ).then(() => {
      Logger.debug(this.id, "onAfterLoadMod done");
    });
  }
}

All systems are registered in the core-system as this:

const controlSystem = new ControlSystem(
      this.game,
      platformSystem,
      xrSystem,
      sceneSystem,
    );

and the systems then get the required systems passed as arguments:

const inventorySystem = this.registerSystem(
      new InventorySystem(
        this.game,
        platformSystem,
        xrSystem,
        cameraSystem,
        entityComponentSystem,
        controlSystem,
        sceneSystem,
        transformSystem,
        entitySystem,
        networkSystem,
        debugSystem,
      ),
    );

The index.ts is responsible for calling the various functions defined in system.ts:

this.game.scene.onAfterPhysicsObservable.add(() => {
    const deltaTime = this.game.scene.getEngine().getDeltaTime() / 1000;

    this.game.core.onAfterPhysicsUpdate(deltaTime);
});

this.game.scene.onBeforeRenderObservable.add(() => {
    const deltaTime = this.game.scene.getEngine().getDeltaTime() / 1000;

    if (this.game.state === State.SPACE_READY) {
    this.game.core.onBeforeRender(deltaTime);
    }
});

- How different GitHub repos use each other. This looks cool, and a real example of how a professional game developer team would work.

So right now I have:

reboot-client (Self explanatory)

reboot-client-assets (Models, sounds, textures, etc.)

reboot-design (ux, map design, etc.)

reboot-docs (todo, backlogs, ideas, documentation, etc.)

reboot-raw (svg, etc.)

reboot-reverse-proxy (https://nginxproxymanager.com/)

reboot-scripts (converting images to webp, audio to ogg, images to spritesheets, json to msgpack, etc.)

reboot-server (Self explanatory)

reboot-server-schema (Contains all the schema models using @colyseus/schema)

reboot-shared (Contains all the DTOs, as well as DTO from and to JSON logic)

reboot-shared-assets (Scenes, Prefabs)

reboot-tools (item icon generator for iterating through models and generating icon for the inventory)

note reboot-client and reboot-server uses “reboot-server-schema” and “reboot-shared”

I plan to build a centralized backend server with various sub-systems, such as auth, analytics, payment etc.

I also plan to move all the assets from client-assets and shared-assets to a object storage and have them served through the server.

- How does deployment work? How do you handle running locally vs deployed? One of the challenges I’m facing with my babylonjs-archive project is that a lot of multiplayer games have gone offline because Heroku stopped making SocketIO servers running 24/7 free.

There is not much difference between running it locally vs deployed.

I use GitHub - EndBug/version-check at v2 to detect when the client/server should be deployed.

That means if my package.json contains version 1.2.3 and I include “1.2.3” in the commit message, github action will start deploying.

The server will:

  • Checkout the repo

  • Checkout the shared-assets repo and copy them

  • Build the project

  • Build and push the docker image

  • Connect to the server host, pull down the newest docker image, and run it.

The client is similar, but instead of building a docker image, it will connect to the web server and upload the file using GitHub - SamKirkland/FTP-Deploy-Action at v4.3.5

There is not much magic in detecting if I am running locally or prod other than window.location.hostname === “localhost”

- How the game handles state/status. There’s no shortage of demos showing how to fire a bullet using top-level bulletPos and isBulletFired. Few games that store all gameplay state as global variables are good sources of inspiration to other developers.

Weapons are managed by the inventory-equipment-weapon-ranged-system, and the weapons are defined as such:

static pistolItem(): ItemDTO {
    const properties: ItemWeaponRangedPropertiesDTO = {
      id: this.randomId(),
      name: "Pistol",
      type: ItemTypeDTO.Weapon,
      rarity: this.randomRarity(),
      isEquipped: false,
      damage: 1,
      attackSpeed: 1,
      reloadTime: 1,
      zoomLevel: 1,
      spread: 0,
      maxAmmoCapacity: 10,
      projectileBehavior: "projectile",
      projectilePrefab: "projectile",
      muzzlePosition: {
        x: -1.5,
        y: 0.5,
        z: 0,
      },
      muzzleRotation: {
        x: 0,
        y: -1.5,
        z: 0,
      },
      recoil: {
        horizontalRecoil: 0,
        verticalRecoil: 0,
        recoverySpeed: 0,
      },
      sound: {
        fireSound: "weapon/glock17/shoot-0.wav",
        reloadSound: "assets/sounds/weapons/pistol-reload.mp3",
        emptySound: "assets/sounds/weapons/pistol-empty.mp3",
      },
      visual: {
        muzzleFlash: "assets/sprites/effects/muzzle-flash.webp",
        trailEffect: "assets/sprites/effects/bullet-trail.webp",
        impactEffect: "assets/sprites/effects/bullet-impact",
      },
      weaponType: ItemWeaponTypeDTO.RANGED,
      isStackable: false,
      sprite: "assets/sprites/items/pistol-1.webp",
      firstPersonPrefab: "first-person/pistol",
      thirdPersonPrefab: "third-person/pistol",
    };

    return {
      properties: properties,
      stackQuantity: 1,
    };
  }

The projectiles are using the behavior-system to control it’s movement, the prefab for projectile looks like this:

{
  "id": "projectile",
  "components": [
    {
      "type": "mesh_rendering",
      "meshes": [
        {
          "type": "model",
          "modelPath": "weapon/projectile.glb",
          "scale": { "x": 1, "y": 1, "z": 1 },
          "rotation": { "x": 0, "y": 0, "z": 0 },
          "glow": true,
          "material": {
            "diffuseColor": {
              "r": 1,
              "g": 0,
              "b": 0
            },
            "specularColor": {
              "r": 1,
              "g": 0,
              "b": 0
            },
            "emissiveColor": {
              "r": 0.0,
              "g": 0.2,
              "b": 0.2
            },
            "specularPower": 64
          }
        }
      ]
    },
    {
      "type": "transform",
      "position": { "x": 0, "y": 0, "z": 0 },
      "scale": { "x": 1, "y": 1, "z": 1 },
      "rotation": { "x": 0, "y": 0, "z": 0 }
    },
    {
      "type": "collider",
      "colliders": [
        {
          "type": "sphere",
          "radius": 1.5,
          "position": { "x": 0, "y": 0, "z": 0 },
          "size": { "x": 1, "y": 1, "z": 1 }
        }
      ]
    },
    {
      "type": "damage",
      "damage": 10
    },
    {
      "type": "physics"
    },
    {
      "type": "lifetime",
      "current": 0,
      "max": 5
    },
    {
      "type": "owner",
      "id": ""
    },
    {
      "type": "behavior",
      "tree": "projectile"
    }
  ]
}

and the behavior tree for projectile looks like this:

{
    "type": "selector",
    "children": [
        {
            "type": "velocity",
            "multiplier": 100
        }
    ]
}

The velocity-node looks like this:

export class VelocityNode extends BehaviorNode {
  private multiplier: number;

  constructor(multiplier: number) {
    super();
    this.multiplier = multiplier;
  }

  update(
    deltaTime: number,
    entity: Entity,
    blackboard: Blackboard,
    systemProvider: SystemProvider,
  ): BehaviorStatus {
    const transformContext = (
      entity.gameController.getSystem("transform") as TransformSystem
    ).getContext(entity.id);

    if (transformContext) {
      const position = transformContext.position;
      var rotation = new Vector3(
        blackboard.get("x"),
        blackboard.get("y"),
        blackboard.get("z"),
      );

      const updatedPosition = position.add(
        rotation.scale(deltaTime * this.multiplier),
      );

      (systemProvider.getSystem("transform") as TransformSystem)
        .getContext(entity.id)
        .setPosition(updatedPosition.x, updatedPosition.y, updatedPosition.z);
    }

    return BehaviorStatus.Success;
  }
}

- How the JSON configuration is loaded. Is it imported and bundled? Loaded at runtime? The number one thing I’m begging for these days are examples of data-driven games. There’s a playground example of almost anything, but most have everything hard-coded, which makes it very difficult to build a full game of off.

So right now everything gets loaded during runtime, right now both the client and server loads the scene (and I am working on making it only the server loading the scene, and sending it to the client).
So client is JSON → DTO → iterating through the entities in the entity-system, loading the required assets with the asset-system, and then putting them into the game.
The server is JSON → DTO → iterating through the entities and creating the required schemas.

The scene format is straight forward:

{
    "metadata": {
        "version": "0.1",
        "name": "Mystic Meadows",
        "author": "Basic",
        "description": "A mystical place."
    },
    "environment": {
        "lightDirection": {
            "x": 0,
            "y": 1,
            "z": 0
        },
        "lightIntensity": 1.2,
        "skybox": "mp_drakeq",
        "heightmap": {
            "name": "test",
            "width": 256,
            "height": 256,
            "minHeight": 0,
            "maxHeight": 16
        }
    },
    "entities": [
        {
            "id": "spawn_0",
            "prefab": "spawn",
            "components": [
              {
                "type": "transform",
                "position": {
                  "x": -10,
                  "y": 4,
                  "z": 30
                }
              },
              {
                "type": "mesh_rendering",
                "meshes": [
                  {
                    "type": "model",
                    "position": {
                      "x": 0,
                      "y": -3,
                      "z": 0
                    },
                    "modelPath": "prop/base.glb"
                  }
                ]
              },
              {
                "type": "ui",
                "elements": [
                  {
                    "id": "name",
                    "type": "text",
                    "text": "SPAWN",
                    "fontSize": "256",
                    "color": "white"
                  }
                ]
              },
              {
                "type": "spawn_point"
              }
            ]
          },     
          {
            "id": "boombox",
            "components": [
              {
                "type": "transform",
                "position": {
                  "x": 6,
                  "y": 5,
                  "z": 62
                }
              },
              {
                "type": "mesh_rendering",
                "meshes": [
                  {
                    "type": "model",
                    "position": {
                      "x": 0,
                      "y": -3,
                      "z": 0
                    },
                    "scale": {
                      "x": 0.2,
                      "y": 0.2,
                      "z": 0.2
                    },
                    "modelPath": "equipment/pls_donate_big_boombox/scene.gltf"
                  }
                ]
              },
              {
                "type": "ui",
                "elements": [
                  {
                    "id": "name",
                    "type": "text",
                    "text": "PARTY",
                    "fontSize": "256",
                    "color": "green"
                  }
                ]
              },
              {
                "type": "sound",
                "sound": "music/party-time.ogg",
                "loop": true
              }
            ]
          },      
          {
            "id": "portal_river",
            "components": [
              {
                "type": "transform",
                "position": {
                  "x": -55,
                  "y": 3,
                  "z": 13
                },
                "scale": { "x": 1, "y": 1, "z": 1 },
                "rotation": { "x": 0, "y": 0, "z": 0 }
              },
              {
                "type": "collider",
                "colliders": [
                  {
                    "type": "box",
                    "position": {
                      "x": 0,
                      "y": 0,
                      "z": 0
                    },
                    "size": {
                      "x": 5,
                      "y": 5,
                      "z": 5
                    }
                  }
                ]
              },
              {
                "type": "trigger",
                "actions": [
                  {
                    "type": "teleport_space",
                    "id": "river"
                  }
                ]
              },
              {
                "type": "mesh_rendering",
                "meshes": [
                  {
                    "type": "primitive",
                    "primitiveType": "sphere",
                    "radius": 2.5,
                    "position": {
                      "x": 0,
                      "y": 0,
                      "z": 0
                    },
                    "material": {
                      "diffuseColor": {
                        "r": 0,
                        "g": 0,
                        "b": 0
                      },
                      "specularColor": {
                        "r": 0,
                        "g": 0,
                        "b": 0
                      },
                      "emissiveColor": {
                        "r": 0.2,
                        "g": 0.2,
                        "b": 0.2
                      },
                      "specularPower": 64,
                      "reflection": {
                        "texturePath": "skybox/miramar/miramar",
                        "level": 0.5
                      },
                      "fresnel": {
                        "bias": 0.4,
                        "power": 2,
                        "leftColor": {
                          "r": 0,
                          "g": 0,
                          "b": 0
                        },
                        "rightColor": {
                          "r": 1,
                          "g": 1,
                          "b": 1
                        }
                      }
                    },
                    "glow": true
                  },
                  {
                    "type": "model",
                    "modelPath": "prop/teleporter.glb",
                    "position": { "x": 0, "y": -3, "z": 0 }
                  }
                ]
              },
              {
                "type": "ui",
                "elements": [
                  {
                    "id": "name",
                    "type": "text",
                    "text": "RIVER",
                    "fontSize": "128",
                    "color": "lime"
                  }
                ]
              },
              {
                "type": "particle",
                "texture": {
                  "path": "vfx/dot.png",
                  "hasAlpha": true
                },
                "color1": {
                  "r": 0.2,
                  "g": 0.2,
                  "b": 0.5,
                  "a": 1
                },
                "color2": {
                  "r": 0.2,
                  "g": 0.8,
                  "b": 1.0,
                  "a": 0
                },
                "colorDead": {
                  "r": 0.2,
                  "g": 0,
                  "b": 0,
                  "a": 0
                },
                "minSize": 0.1,
                "maxSize": 0.5,
                "minLifeTime": 0.3,
                "maxLifeTime": 1.5,
                "emitRate": 100,
                "minEmitPower": 1,
                "maxEmitPower": 3,
                "updateSpeed": 0.01
              }
            ]
          },
        }
    }
}

- Portals. Again, plenty of search results showing demos. Not nearly as many examples of it being one of many features in a larger work.

Right now the client decides when to load a new scene, I want to change it so the client requests the change from the server, and then changes the scene.
But to make it work, the entity contains a trigger-component (also note it’s called space, and I am now moving over to the term scene):

{
      "type": "trigger",
      "actions": [
        {
          "type": "teleport_space",
          "id": "stranded"
        }
      ]
},

the trigger-system detects the collision with the trigger, and calls:
(this.core.getSystem("scene") as SceneSystem).changeSceneByName(sceneId);

The index.ts then checks for sceneSystem.shouldChangeScene(), which will now be true, and call in order:

this.game.core.onBeforeUnloadScene()
this.game.core.onAfterUnloadScene()
this.game.core.onBeforeLoadScene()
this.game.core.onAfterLoadScene()

- Model-view-controller. How did you add MVC to Babylon?
Right now the UI havn’t got much love, and it’s been put together quickly to get it up running, it is not optimized and I am probably gonna change a bunch of stuff.

But for dynamic changing the content, I use the ui-state, which contains properties that can be subscribed to, it looks like this:

export class UIState {
  private _state: UIProperty<any>[] = [];
  private _onStateChangedObservable: Observable<void> = new Observable<void>();

  public get onStateChangedObservable(): Observable<void> {
    return this._onStateChangedObservable;
  }

  public get state(): UIProperty<any>[] {
    return this._state;
  }

  public addProperty<T>(key: string, value: T): UIProperty<T> {
    const valueCopy =
      typeof value === "object" ? Object.assign([], value) : value;
    const property = new UIProperty<T>(key, valueCopy);
    this._state.push(property);
    property.onValueChangedObservable.add(() => {
      this._onStateChangedObservable.notifyObservers();
    });
    return property;
  }

  public getProperty<T>(key: string): UIProperty<T> {
    return this._state.find(
      (property) => property.key === key,
    ) as UIProperty<T>;
  }

  public set<T>(key: string, value: T): void {
    const property = this.getProperty<T>(key);
    if (property) {
      // Clone if object
      if (typeof value === "object") {
        property.set(Object.assign([], value));
      } else {
        property.set(value);
      }
    } else {
      Logger.error(tag, "Property " + key + " not found in state");
    }
  }
}

export class UIProperty<T> {
  private _key: string;
  private _value: T;
  private _onValueChangedObservable: Observable<T> = new Observable<T>();

  public get onValueChangedObservable(): Observable<T> {
    return this._onValueChangedObservable;
  }

  public get key(): string {
    return this._key;
  }

  constructor(key: string, value: T) {
    this._key = key;
    this._value = value;
  }

  public get(): T {
    return this._value;
  }

  public set(value: T): void {
    if (this._value === value) {
      return;
    }
    this._value = value;
    this._onValueChangedObservable.notifyObservers(value);
  }
}

This is the heads-up-display-view-controller:

export class HeadsUpDisplayViewController extends UIViewController<HeadsUpDisplayViewModel> {
  state: UIState = new UIState();

  // Average FPS
  private fps: number = 0;
  private fpsCount: number = 0;
  private fpsTotal: number = 0;

  constructor(game: GameController, model: HeadsUpDisplayViewModel) {
    super(game, model);

    this.state.addProperty(
      "platform",
      "Platform: " +
        (this.game.getSystem("platform") as PlatformSystem).platform,
    );
    this.state.addProperty("fps", "FPS: 0");
    this.state.addProperty("network", "Network: ?ms");
    this.state.addProperty("entities", "Entities: 0");
    this.state.addProperty("health", "0 HP");
    this.state.addProperty("position", "Position: (0, 0, 0)");
  }

  public update(deltaTime: number) {
    // Update platform text
    const platformSystem = this.game.getSystem("platform") as PlatformSystem;
    this.state.set("platform", "Platform: " + platformSystem.platform);

    // Update network text
    const networkSystem = this.game.getSystem("network") as NetworkSystem;

    const roomId = networkSystem.sceneRoom.roomId();
    const sessionId = networkSystem.sceneRoom.sessionId();
    const isOpen = networkSystem.sceneRoom.isConnected();
    this.state.set(
      "network",
      `Network: room=${roomId}, session=${sessionId}, connected=${isOpen}`,
    );

    // Update entities text
    this.state.set(
      "entities",
      "Entities: " +
        (this.game.getSystem("entity") as EntityComponentSystem).sharedEntities
          .length +
        " (local: " +
        (this.game.getSystem("entity") as EntityComponentSystem).localEntities
          .length +
        ")",
    );

    // Update health text
    const player = (
      this.game.getSystem("entity") as EntityComponentSystem
    ).getLocalPlayerEntity();
    if (player && player.hasComponent(ComponentTypeDTO.HEALTH)) {
      const health = player.getComponent<HealthComponent>(
        ComponentTypeDTO.HEALTH,
      );
      if (health) {
        this.state.set("health", `${health.health} HP`);
      }
    }

    // Update position text
    if (player && player.hasComponent(ComponentTypeDTO.TRANSFORM)) {
      const transformContext = (
        this.game.getSystem("transform") as TransformSystem
      ).getContext(player.id);

      if (transformContext) {
        this.state.set(
          "position",
          `Position: ${transformContext.position.x.toFixed(2)}, ${transformContext.position.y.toFixed(2)}, ${transformContext.position.z.toFixed(2)}`,
        );
      }
    }

    // Update FPS text
    this.state.set("fps", "FPS: " + this.fps.toFixed(2));

    // Update average FPS
    this.fpsCount++;
    this.fpsTotal += 1 / deltaTime;
    if (this.fpsCount >= 60) {
      this.fps = this.fpsTotal / this.fpsCount;
      this.fpsCount = 0;
      this.fpsTotal = 0;
    }
  }
}

the heads-up-display-view.ts can then subscribe to changes like this:

this.controller.state
      .getProperty<string>("platform")
      .onValueChangedObservable.add((value) => {
        this.platformText.text = value;
      });

    this.controller.state
      .getProperty<string>("fps")
      .onValueChangedObservable.add((value) => {
        this.fpsText.text = value;
      });

- AI and NPCs. I cannot overstate how few references there are for a developer who wants to add playable opponents to their game.

I am using behavior trees (see Behavior trees for AI: How they work)
The behavior-system is responsible for the logic of AI and NPCs, for example the npc spawner, npcs and projectiles.
The npc-spawner is simple, and is defined like this:

{
    "type": "sequence",
    "children": [
      {
        "type": "delay",
        "delay": 15,
        "blackboardKey": "delay"
      },
      {
        "type": "spawn-entity",
        "blackboardKey": "prefab"
      }
    ]
}

The hostile-npc is defined like this:

{
    "type": "selector",
    "children": [
      {
        "type": "sequence",
        "children": [
          {
            "type": "sync-agent-position"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "check-health",
            "threshold": 20
          },
          {
            "type": "clear-target",
            "blackboardKey": "target"
          },
          {
            "type": "flee"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "check-target-in-range",
            "range": 20,
            "blackboardKey": "target"
          },
          {
            "type": "clear-agent-target"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "has-blackboard-key",
            "blackboardKey": "target"
          },
          {
            "type": "check-target-in-range",
            "range": 50,
            "blackboardKey": "target"
          },
          {
            "type": "look-at-target",
            "blackboardKey": "target"
          },
          {
            "type": "attack-target",
            "blackboardKey": "target"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "inverter",
            "child": {
              "type": "has-blackboard-key",
              "blackboardKey": "target"
            }
          },
          {
            "type": "find-target",
            "searchRadius": 1500,
            "blackboardKey": "target"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "inverter",
            "child": {
              "type": "has-blackboard-key",
              "blackboardKey": "target"
            }
          },
          {
            "type": "clear-target"
          }
        ]
      },
      {
        "type": "sequence",
        "children": [
          {
            "type": "has-blackboard-key",
            "blackboardKey": "target"
          },
          {
            "type": "look-at-agent-target"
          },
          {
            "type": "chase-target",
            "blackboardKey": "target"
          }
        ]
      }
    ]
  }

I find that using behavior trees is a easy and managable way to control simple and complex ais. I plan to introduce a tree-node which can contain an entire tree, for easy seperating complex AI logic.

Since the server does not know anything about the navigation mesh, and also to move the computing power to the clients, when an entity is instantiated, the server will choose which client should be the controller of the behavior tree, and that client will be responsible of sending the updates for the entity to the server.
I plan to moving the behavior logic into a shared repo, so the server can decide whether it should run the logic or if the client should run the logic (this way critical logic, that needs to be cheat-proof can be run on the server)


I hope I answered your questions @Symbitic, feel free to ask for more code and details :slight_smile:

I love reading your devlog!

1 Like