CTX Game Engine header

CTX Game Engine

A Unity-like game engine for the browser with a native Canvas2D rendering API

Motivation

JavaScript is the ultimate prototyping language. It’s forgiving, malleable, and has good developer ergonomics. In contrast, Unity is bloated and slow to use in comparison, yet has far superior tooling for making more complex graphical games.

I wanted to merge the best of both and bring the editor experience of Unity to the web, while still maintaining a simple web-first approach to rendering using the native Canvas2D API.

Clone to own

Rather than distributing the engine as a standalone executable, CTX was designed to be cloned and customized for each project—giving you direct access to engine internals and a smooth developer experience through Vite.

This also provides the benefit that the engine becomes a bespoke tool suite for players to easily create mods and new content for your game.

Architecture

The project is scaffolded with Vite and is cleanly separated into four major parts:

Editor

Scenes are composable with a simple drag and drop interface. The editor is written in React and uses React-Dockable for dockable tabbed window management which allows for user-customizable workspaces.

Currently there are four main views: Scene Hierarchy, Object Inspector, Game Assets, and the Scene View. Objects can be created, renamed, and customized through the inspector. Gizmos also allow you to manipulate object transforms directly.

Behavior System

GameObjects are nodes with a collection of attached Behavior components. Each Behavior is responsible for its own update and draw logic. Every GameObject is required to have a Transform behavior as the engine relies on this to render the scene.

Key Methods:

Inspectors

Since JavaScript doesn’t have true reflection, decorators are used to tag a property as serializable which will be saved in the scene file and become automatically editable in the inspector. The @inspect decorator also takes an object which contains metadata such as the type, min or max ranges, etc.

Below is an example behavior which draws a rectangle to the screen with a given position, size, and color inspectable fields:

class Rectangle extends Behavior {
  transform: Transform | undefined;

  @inspect()
  position: Vector2 = new Vector2(0, 0);

  @inspect()
  size: Vector2 = new Vector2(1, 1);

  @inspect({ type: "color" })
  color: string = "red";

  draw(ctx: CanvasRenderingContext2D, renderPass?: string) {
    if (renderPass == "editor") return;
    ctx.fillStyle = this.color;
    ctx.fillRect(this.position.x, this.position.y, this.size.x, this.size.y);
  }
}

Serializable field metadata is then stored into a global map where each constructor key contains a map of property/value pairs. If the type was not specified in the metadata, the reflect-metadata polyfill is used to infer it.

We then export a getSerializableFields function which allows for easy lookup of serializable fields for a given behavior.

const fieldMetadata = new Map<Function, Map<string, FieldMeta>>();

export function inspect(meta: FieldMeta = {}): PropertyDecorator {
  return (target, propertyKey) => {
    const ctor = target.constructor;

    // add the field if it didn't already exist
    if (!fieldMetadata.has(ctor)) {
      fieldMetadata.set(ctor, new Map());
    }

    const fields = fieldMetadata.get(ctor)!;

    // Try to infer type if none provided
    if (!meta.type) {
      const reflected = Reflect.getMetadata("design:type", target, propertyKey);
      if (reflected) {
        meta.type = reflected.name.toLowerCase(); // e.g. "number", "string"
      }
    }

    fields.set(propertyKey as string, meta);
  };
}

export function getSerializableFields<T>(instance: T): [keyof T, FieldMeta][] {
  const ctor = (instance as Behavior).constructor;
  return [...(fieldMetadata.get(ctor)?.entries() ?? [])] as [
    keyof T,
    FieldMeta
  ][];
}

The Behavior base class implements an inspector method which returns a React component and uses getSerializableFields to automatically render any inspectable field for any behavior subclass.

// behavior.tsx
inspector = ({ refresh }: { refresh: () => void }) => {
  const fields = getSerializableFields(this);

  const renderFieldType = (key: string, meta: FieldMeta) => {
    switch (meta.type) {
      case "number":
        return <NumberInput ... />;
      case "vector2":
        return <Vector2Input ... />;
      case "color":
        return <ColorInput ... />;
      default:
        return null;
    }
  };

  return (
    <div>
      {fields.map(([key, meta]) => {
        const keyString = String(key);
        return (
          <Fragment key={keyString}>
            {renderFieldType(keyString, meta)}
          </Fragment>
        );
      })}
    </div>
  );
};

This method can also be overridden for custom inspectors as well for more complex UI needs.

Rendering

CTX implements immediate-mode rendering which, as the name suggests, leverages the HTML5 Canvas 2D API. No need for meshes, shaders, texture maps, or other complicated 3D rendering concepts, just draw what you want.

Some key rendering features:

Observer Pattern Reactivity

Usually reactivity in a React application is achieved through immutable state which is passed down through the top of the component tree. While this approach could technically work, it’s not well-suited to game engine design patterns. Game objects are inherently “live” objects with custom base classes.

One could, in theory, manually serialize and deserialize the scene on every change, but that’d be clunky and non-performant - especially if you want to inspect state while in play mode. Instead I use an Observer pattern to listen for external state changes to trigger a re-render.

reactivity diagram

The GameObject base class implements subscribe and updateSubscribers. Any time updateSubscribers is called, any subscriber’s callback will be triggered. For the default inspector, this is done automatically for you. If you implement a custom inspector, you must manually call it.

// GameObject
subscribe = (callback: () => void): (() => void) => {
  this.subscribers.add(callback);
  return () => {
    this.subscribers.delete(callback);
  };
};

updateSubscribers() {
  this.subscribers.forEach((callback) => callback());
}

To subscribe to updates, a React component only needs to use the useGameObject hook which will automatically trigger a re-render.

function useGameObject(gameObject: GameObject) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    if (!gameObject) return;
    const unsubscribe = gameObject.subscribe(() => {
      setCount((count) => count + 1);
    });
    return () => unsubscribe();
  }, [gameObject]);

  return count;
}

For more granular updates I also provide a useGameObjectSelector which allows you to re-render only for specific slices of state you’re interested in.

Scene Serialization

Since we’re using non-primitive objects, we need to manually serialize and deserialize the scene for saving, loading, and undo. A scene is simply a gameObject, so we recursively walk the tree started from the root object calling serialize on its respective behaviors and children.

// GameObject.ts
serialize(): SerializedGameObject {
  return {
    name: this.name,
    id: this.id,
    behaviors: Object.values(this.behaviors).map((behavior) =>
      behavior.serialize()
    ),
    children: this.children.map((child) => child.serialize()),
  };
}

static deserialize(data: SerializedGameObject): GameObject {
  const gameObject = new GameObject({ name: data.name });
  gameObject.id = data.id;
  gameObject.behaviors = data.behaviors.reduce((acc, behavior) => {
    acc[behavior.name] = deserializeBehavior(behavior);
    return acc;
  }, {} as Record<string, Behavior>);

  data.children.forEach((child) => {
    gameObject.addChild(GameObject.deserialize(child));
  });

  return gameObject;

  function deserializeBehavior(data: SerializedBehavior) {
    const behaviorClass = Game.instance?.behaviors[data.name];
    if (!behaviorClass) {
      throw new Error(`Behavior ${data.name} not found`);
    }
    const behavior = new behaviorClass();
    behavior.gameObject = gameObject;
    behavior.init(data.properties);
    return behavior;
  }
}

Behaviors leverage the getSerializableFields method described earlier to determine which properties of the behavior needs to be saved

// Behavior.ts
serialize() {
  return {
    name: this.constructor.name,
    id: nanoid(),
    properties: getSerializableFields(this).reduce((acc, [key]) => {
      acc[key as string] = this[key as keyof this];
      return acc;
    }, {} as Record<string, unknown>),
  };
}

Transparent Native RPC Bridge

For saving and loading the scene a server is needed to expose native functionality like file I/O to the browswer. Native functions are lightly-wrapped in a lookup table, only changing signatures where necessary for serialization across the network. The type of this object is then exported so the client can consume it and maintain type-safety.

const api = {
  saveFile: async (path: string, data: string) => fs.writeFileSync(path, data),
  readFile: async (path: string) => fs.readFileSync(path, "utf-8"),
  ...
};

export type NativeApi = typeof api;

The server then exposes an HTTP RPC-style endpoint to call these functions.

app.post("/call", async (req, res) => {
  const { method, args }: { method: keyof NativeApi; args: unknown[] } =
    req.body;
  try {
    const result = await (api[method] as (...args: unknown[]) => unknown)(
      ...args
    );
    res.json({ result });
  } catch (err) {
    res.status(500).json({ error: (err as Error).message });
  }
});

app.listen(3001, () =>
  console.log("Native API server running on http://localhost:3001")
);

On the client, an abstraction layer is leveraged to make interfacing with the RPC endpoint type-safe and “transparent.” This uses Proxies to allow for a native-feeling API with direct function calling in the browser:

import type { NativeApi } from "../../server/server.ts";

const native = new Proxy({} as NativeApi, {
  get:
    (_, method: string) =>
    (...args: unknown[]) =>
      fetch("http://localhost:3001/call", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ method, args }),
      })
        .then((res) => {
          return res.json();
        })
        .then((res) => {
          if (res.error) throw new Error(res.error);
          return res.result;
        }),
});

export default native;
native.saveFile(
  "src/game/scenes/default.json",
  JSON.stringify(serialized, null, 2)
);