Chris Sprance

Character Technical Director

SVGame - SVG D3 Game Engine

I recently start a new project I'm calling SVGame. It is a simple easy to learn game engine that uses d3.js to render SVG. I'm trying to follow a lot of the patterns I see in Godot, since I've had a lot of fun with that engine.

Let's break each part of it down and look at the source code. Eventually this will be on GitHub

GameEngine Class

This class is the meat and potatoes of the whole thing. It controls and stores all of the things you'd want to do with your toy game engine here.

Game.ts
import { consola } from "consola";
import * as d3 from "d3";
import { Vec2 } from "gl-matrix/dist/esm";
import MainLoop from "mainloop.js";
import { EventBus } from "~/helpers/svgame/EventBus";
import { useGameStore } from "~/stores/game";

import { Entity } from "./Entity";
import { Input } from "./Input";

export type SVGD3Selection = d3.Selection<
  SVGSVGElement,
  unknown,
  HTMLElement,
  any
>;

/**
 * GameEngine is the core class responsible for managing the game's main loop, rendering,
 * and entity management. It integrates with various libraries and frameworks to facilitate
 * game development.
 */
export class GameEngine {
  /**
   * 3 info, 4 debug, 5 trace
   */
  logger = consola.create({ level: 4 }).withTag("GameEngine");
  /**
   * Is the engine in debug or not
   */
  debug = true;
  /**
   * The Game Viewport Resolution
   */
  resolution = new Vec2(1180, 600);
  /**
   * Indicates whether the game loop is currently paused.
   */
  paused = true;
  /**
   * The main game loop manager provided by the mainloop.js library.
   */
  loop: MainLoop;
  /**
   * A reference to the SVG element used for rendering, managed by D3.
   */
  svg: SVGD3Selection;
  /**
   * Input manager for handling player inputs.
   */
  input: Input<string> = new Input();
  /**
   * A list of entities currently present in the game.
   */
  entities: Entity[] = [];
  /**
   * An event bus for handling global game events.
   */
  eventBus = EventBus;
  // Elapsed Game Time
  time = 0;

  store = useGameStore();

  /**
   * Constructs the game engine.
   * @param svg - Reference to the SVG element used for rendering.
   * @param resolution - A Vec2 of the size of the game area
   */
  constructor({
                svg,
                resolution = new Vec2(1180, 600),
              }: {
    svg: SVGD3Selection;
    resolution?: Vec2;
  }) {
    this.svg = svg;
    this.resolution = resolution;
    this.svg.attr("width", this.resolution.x);
    this.svg.attr("height", this.resolution.y);
    this.loop = MainLoop.setBegin(this.begin)
      .setUpdate(this.update)
      .setDraw(this.draw)
      .setEnd(this.end);
    this.logger.trace("Setup Complete");
    this.store.setGame(this);
  }

  /**
   * Processes input and performs frame-start operations.
   * @param timestamp - Current frame timestamp in milliseconds.
   * @param delta - Elapsed time not yet simulated, in milliseconds.
   */
  private begin = (timestamp: number, delta: number) => {
    this.store.setGame({ ...this });
    this.time += delta;
    for (const entity of this.entities) {
      entity.begin(timestamp, delta);
    }
  };

  /**
   * Updates game state, e.g., AI and physics.
   * @param delta - Time in milliseconds to simulate.
   */
  private update = async (delta: number) => {
    if (this.paused) return;
    for (const entity of this.entities) {
      entity.update(delta);
    }
  };

  /**
   * Renders the current game state to the screen.
   * @param interpolationPercentage - Fraction of time to the next update.
   */
  private draw = async (interpolationPercentage: number) => {
    // Clear the screen before each draw
    this.svg.select("svg > *").remove();
    for (const entity of this.entities) {
      entity.draw(interpolationPercentage);
    }
  };

  /**
   * Cleans up after each frame and performs frame-end operations.
   * @param fps - Current frames per second.
   * @param panic - Indicates if simulation is too far behind real time.
   */
  private end = (fps: number, panic: boolean) => {
    for (const entity of this.entities) {
      entity.end(fps, panic);
    }
  };

  /**
   * Adds entities to the game, initializing each one.
   * @param entities - An array of entities to be added.
   */
  addEntities = (entities: Entity[]) => {
    for (const entity of entities) {
      entity.setup();
      this.entities.push(entity);
    }
  };

  /**
   * Retrieves an entity by name.
   * @param name - The name of the entity to retrieve.
   * @returns The entity with the specified name, or undefined if not found.
   */
  getEntity = <T>(name: string): T | undefined => {
    for (const entity of this.entities) {
      if (entity.name === name) {
        return entity as T;
      }
    }
    return undefined;
  };

  /**
   * Removes an entity from the game.
   * @param entity - The entity to be removed.
   */
  removeEntity = (entity: Entity) => {
    this.entities = this.entities.filter((e) => e !== entity);
    this.logger.trace("Removed Entity: ", entity.name);
  };

  /**
   * Starts the game loop.
   */
  start = () => {
    this.input.setupInput();
    this.paused = false;
    this.loop.start();
    this.logger.info("Started GameEngine loop");
  };
}

Entity Class

This class is what you will be extending to create all the building blocks of your game. It has a couple of lifecycle hooks that correspond to the main game loop plus a few others

Entity.ts
// Entity.ts
import { GameEngine } from "./Game";

/**
 * Represents a basic entity in the game world.
 * An entity has a lifecycle managed by the game engine,
 * and it can be extended with custom behavior for game-specific logic.
 */
export class Entity {
  name: string;
  game: GameEngine;

  /**
   * Constructs a new Entity.
   * @param game - Reference to the game engine managing this entity.
   * @param name - Unique name for the entity, defaults to a timestamp.
   */
  constructor(game: GameEngine, name: string = String(Date.now())) {
    this.name = name;
    this.game = game;
    this.game.logger.trace("Created Entity: ", this.name);
  }

  /**
   * Lifecycle method for initialization logic.
   * Called once when the entity is created.
   */
  setup() {
    // Override me
  }

  /**
   * Processes input and performs frame-start operations.
   * @param timestamp - Current frame timestamp in milliseconds.
   * @param delta - Elapsed time not yet simulated, in milliseconds.
   */
  begin(timestamp: number, delta: number) {
    // Override me
  }

  /**
   * Updates entity state, e.g., AI and physics.
   * @param delta - Time in milliseconds to simulate.
   */
  update(delta: number) {
    // Override me
  }

  /**
   * Renders the current entity state to the screen.
   * @param interpolationPercentage - Fraction of time to the next update.
   */
  draw(interpolationPercentage: number) {
    // Override me
  }

  /**
   * Cleans up after each frame and performs frame-end operations.
   * @param fps - Current frames per second.
   * @param panic - Indicates if simulation is too far behind real time.
   */
  end(fps: number, panic: boolean) {
    // Override me
  }

  /**
   * Method for handling the deletion of the entity.
   * Ensures proper removal from the game engine and cleanup.
   */
  delete() {
    // I can be overriden if you want but make sure to call the below
    this.game.removeEntity(this);
  }
}

Input

The input class manages all the inputs you can do and make it an easy to use system to bind inputs to an action. Then call that action later in the game without having to worry about keys pressed. I designed it after how I remembered Godots worked.

Input.ts
export class Input<GameActions> {
  private actionKeyMap: Map<GameActions, string[]> = new Map();
  private actionState: Map<GameActions, boolean> = new Map();
  private actionJustPressedState: Map<GameActions, boolean> = new Map();

  /**
   * Sets up the input listeners for the game.
   */
  setupInput = () => {
    document.addEventListener("keydown", (event) => {
      return this.handleKeyDown(event);
    });
    document.addEventListener("keyup", (event) => {
      return this.handleKeyUp(event);
    });
  };

  // Register a key to an action
  registerAction = (action: GameActions, ...keys: string[]) => {
    this.actionKeyMap.set(action, keys);
  };

  // Check if an action is currently active
  isActionPressed = (action: GameActions): boolean => {
    return this.actionState.get(action) || false;
  };

  // Check if an action was just pressed
  isActionJustPressed = (action: GameActions): boolean => {
    if (this.actionJustPressedState.get(action)) {
      // Reset the just pressed state after acknowledging it
      this.actionJustPressedState.set(action, false);
      return true;
    }
    return false;
  };

  // Update state when a key is pressed
  private handleKeyDown = (event: KeyboardEvent) => {
    this.actionKeyMap.forEach((keys, action) => {
      if (keys.includes(event.key)) {
        event.preventDefault();
        if (!this.actionState.get(action)) {
          // If action was not already pressed, mark it as just pressed
          this.actionJustPressedState.set(action, true);
        }
        this.actionState.set(action, true);
      }
    });
  };

  // Update state when a key is released
  private handleKeyUp = (event: KeyboardEvent) => {
    this.actionKeyMap.forEach((keys, action) => {
      if (keys.includes(event.key)) {
        event.preventDefault();
        this.actionState.set(action, false);
        this.actionJustPressedState.set(action, false);
      }
    });
  };
}

Event Bus

Beep beep! This is the event bus and it just uses the event emitter pattern to be sort of like signals in godot. Create an event listener and then somewhere else emit that event and you can hook everything together.

EventBus.ts
import EventEmitter from "eventemitter3";

/**
 * A global event bus using EventEmitter to facilitate communication between different parts of the game.
 * Useful for decoupling components and implementing an observer pattern.
 */
export const EventBus = new EventEmitter();