logo

Babylon.js Market

By Lawrence

7 minutes

Data-Driven Game Worlds

In this series, we're diving into building a game framework using a simple yet performant, Entity Component System architecture. This way of setting up your Babylon.js game is optional, and there are many ways to build a game. One of my core goals is to possibly run many games off the same code, using different data as inputs. Data which will define everything from the locations of downloaded assets (Like Meshes, Textures and Sounds) to defining options for how the controller maps to player movement.

Many traditional game engines tightly couple game logic and data directly within the code. While this can work for smaller projects, separating your game's data from its code offers significant advantages as your game grows in complexity.

Project Setup

Add Our Directory Structure

Open your terminal in your project's root directory and run these commands to create some folders and files we'll use.:

shell
mkdir -p data/GameData/Shared
mkdir -p data/GameData/FirstGame/scenes
mkdir -p data/GameData/FirstGame/screens
mkdir -p public/GameData
mkdir -p scripts
touch scripts/make-game-data.ts
touch data/GameData/FirstGame/game.ts
touch data/GameData/FirstGame/scenes/0.ts
touch data/GameData/FirstGame/scenes/Start.ts

This creates the following directory structure:

stdout
project-root/
├── data/
│   └── GameData/
│       ├── FirstGame/
│       │   └── scenes/
│       │       └── 0.ts
│       └── Shared/
├── public/
│   └── GameData/
└── scripts/
    └── make-game-data.ts

We'll be using these files and folders throughout the course, but quickly, in your project root directory, you should have a public folder, where the compliles game data will live in GameData.

The data itself is typescript files, because as we'll see it makes building games more flexible than using plain JSON. The scripts folder will hold scripts we run in the terminal, in this case make-game-data.ts will hold the code that turns typescript to a JSON file for every game scene.

The Minimum Data

In 0.ts let's create a simple object with an entities object that holds the entities for the game. Let's write something with the right format, to turn into json for our first scene.

data/FirstGame/0.ts
export default {
  entities: {
    Camera: {
      components: {
        Camera: {},
      },
    },
  },
};

From Code to Data

Now, let's write the make-game-data.ts script. This script will automate the process of converting our TypeScript data files into JSON files that our game engine can easily load over a network.

We'll use Node.js's built-in path and fs modules for file system operations and path manipulation.

Open scripts/make-game-data.ts and paste or type the following code:

scripts/make-game-data.ts
import path from "path";
import fs from "fs";

const DATA_DIR = path.resolve(process.cwd(), "data");
const GAMES_DIR = path.resolve(DATA_DIR, "GameData");
const SHARED_DIR = path.resolve(GAMES_DIR, "Shared");
const OUTPUT_DIR = path.resolve(process.cwd(), "public/GameData");
const LEVEL_DIR = "scenes";

const MakeGameData = async (GAME_DIR: string) => {
  const GAME = path.resolve(GAMES_DIR, GAME_DIR);
  if (GAME == SHARED_DIR) return; // 2.
  console.log("Making: ", GAME_DIR);
  const scenes = fs.readdirSync(path.resolve(GAME, LEVEL_DIR));
  for (const scene of scenes) {
    // 3.
    const sceneFile = path.resolve(GAME, LEVEL_DIR, scene);
    console.log("Scene: ", scene, sceneFile);
    const mod = await import(sceneFile);
    const sceneData = mod.default; // 4.
    const dirPath = path.resolve(OUTPUT_DIR, GAME_DIR, LEVEL_DIR);
    const outputFileName = path.resolve(
      dirPath,
      `${scene.replace(".ts", ".json")}`, // 5.
    );
    console.log("Writing: ", outputFileName);
    if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
    const data = JSON.stringify(sceneData, null, 2); // 6.
    fs.writeFileSync(outputFileName, data); // 7.
  }
};

const AllGameData = async () => {
  const games = fs.readdirSync(GAMES_DIR);
  for (const game of games) await MakeGameData(game);
};

await AllGameData(); // 1.
process.exit(0);

To understand this script, we start at the bottom.

  1. AllGameData() runs and reads the folder we've declared in GAMES_DIR.

  2. We skip the Shared Directory, as that's not a game itself.

  3. Then we iterate through the scenes folder in each game to complete the scenes one by one.

  4. await import() does the work of loading each .ts file and

  5. Changing it's name to end with .json.

  6. JSON.stringify should be familiar to JS developers. We want it indented to make it human readable.

  7. And we write the file to the OUTPUT_DIR defined at the top of the file.

If you duplicate the FirstGame folder and name it SecondGame. You've got another complete seperate game now. One could be a 3d platformer named PlatformerLike the other a 2D board game or anything else you can think of. Name them however you'd like.

stdout
project-root/
├── data/
│   └── GameData/
│       ├── PlatformerLike/
│       │   └── game.ts
│       │   └── scenes/
│       │   │   └── 0.ts
│       │   │   └── 1.ts
│       │   └── screens/
│       │        └── Start.ts
│       ├── BoardGameLike/
│       │   └── game.ts
│       │   └── scenes/
│       │   │   └── 0.ts
│       │   │   └── 1.ts
│       │   └── screens/
│       │        └── Start.ts
│       └── Shared/
├── public/
│   └── GameData/
└── scripts/
    └── make-game-data.ts

Shared is a folder that will hold data used by all games, so as not to repeat ones self.

Okay, so let's see what happens when we run the script/make-game-data.ts.

shell
npm run games

Did you get an error like this?

stderr
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for ...
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:219:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:245:36)
    at defaultLoad (node:internal/modules/esm/load:120:22)
    at async ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:580:32)
    at async ModuleJob._link (node:internal/modules/esm/module_job:154:19) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

Sadly there's one more hoop to jump through to run typescript files from the terminal.

Running typescript files in 2025

The easiest way is to install tsx globally.

shell
npm i tsx -g

I've tried the npm modules vite-node & ts-node and determined tsx is the best in class way to run typescript from the terminal in 2025.

package.json
// Add to the scripts object.
scripts: {
  "dev": "vite",
  "games": "tsx scripts/make-game-data.ts",
}

Then we can always run typescript from the terminal in 2025.

shell
npm run games
stdout
Making:  FirstGame
Scene:  0.ts ../my-arcade/data/GameData/FirstGame/scenes/0.ts
Writing: ../my-arcade/public/GameData/FirstGame/scenes/0.json

We can see there are new .json files in the public/GameData folder. This public folder is where we'll be downloading static files to setup a game scene.

stdout
├── public/
│   └── GameData/
│       ├── PlatformerLike/
│       │   └── scenes/
│       │   │   └── 0.json

Every scene is the same in that it's a complete refresh of the browser. So scenes can represent anything from a new game world to a mini-game with completely different code and asset needs.

Watch the Data Folder with Vite

So now that we have the static .json files building from the command line. We want the data folder to automatically rebuild on changes. For that we can use a simple custom vite-plugin defined in vite.config.js:

Let's install a new package in the terminal.

shell
npm i chokidar

We'll use a library to do better cross platform watching. In vite, plugins can be defined as a object with a name and a configureServer property which is a function that has the devServer as its first argument.

Custom Reloading Vite Plugin

shell
mkdir -p src/vite-plugins
touch src/vite-plugins/build-data.ts
vite.config.js
import { defineConfig } from "vite";
import path from "path";
import buildDataPlugin from "./src/vite-plugins/build-data";

const __dirname = process.cwd();

export default defineConfig(({ mode }) => {
  // Common configuration for all modes
  let config = {
    build: {
      outDir: "dist", // Default output directory
      sourcemap: mode === "development", // Generate sourcemap only in development
    },
    server: {
      host: true,
      strictPort: true,
      port: 3002,
    },
    plugins: [buildDataPlugin],
    resolve: {
      alias: [
        {
          find: "~",
          replacement: path.resolve(__dirname, "./src"),
        },
      ],
    },
  };
  return config;
});

The vite plugin is basically an object with a name and a configureServer function. server is an argument and can be configured, in this case to reload the browser after running our npm run games command.

src/vite-plugins/build-data.ts
export default {
  name: "custom-watch-plugin",
  configureServer(server) {
    server.allowedHosts = [
      "localhost",
    ];

    const watcher = chokidar.watch("./data"); // 1.

    watcher.on("change", (path) => {
      console.log(`File ${path} has been changed`);
      exec("npm run games", (err, stdout) => {   // 2.
        if (err) {
          console.error(`exec error: ${err}`);
          return;
        }
        console.log(`stdout: ${stdout}`);
        // 3. Send reload command to the client
        server.ws.send({
          type: "full-reload",
        });
      });
    });
  },
},

Now, in the defineConfig itself,

  1. We set the watcher to watch the data folder. It watches all files in their recursivey.

  2. Next we define the exec library to spawn a process to run the games script just like it was the command line.

  3. If there's no error we send a websocket command to the browser telling it we need a full reload.

In Sum

In this article we creating a data-driven approach to our games. In the next one we'll get started implementing the Game code logic and logic, while keeping the data seperate in the .json files.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search