Event System and User Input in Lumi2d

Juriën MeerloJuriën Meerlo
5 min read

This week, I added the events system and used it to get input from the keyboard, mouse, touch, and gamepad/joystick.

Event System

When I started building the system, I had the following criteria:

  • It should be a synchronous

  • It should be flexible

  • It should be type-safe

  • There should be a way to make custom events.

  • There should be global events and events per scene. The scenes can stack. Only the callbacks added on the active one should be called.

All events inherit from a base event. Events can be canceled inside the event callback when the handler allows it. Canceled events won't be sent to any other handlers. There can be multiple event types per event. Each type has a separate handler list in the events tables. Events store the event type used. This is how we get the correct handlers.

Handler storage

The Events class is a static class for easy access. There are two dictionaries with handlers, one for global listeners and one for the current scene. The scene handlers are updated every time the active scene changes. This makes sure only those event handlers are called.

Type-safe event handlers

The callback event parameter should be the same type as the event you listen for when adding event callbacks. I created a generic EventType class that you add the type to like this:

/**
 * The event type makes it possible to have type safe callbacks when adding handlers in the event system.
 */
export class EventType<T extends Event> {
  /**
   * The type of event that will be sent.
   */
  readonly type: new (...args: any[]) => T;

  /**
   * The internal name for this particular event. (key_pressed) for example.
   */
  readonly typeName: string;

  /**
   * Create a new event type.
   * @param type The type of event that will be sent.
   * @param typeName The internal name for this particular event.
   */
  constructor(type: new (...args: any[]) => T, typeName: string) {
    this.type = type;
    this.typeName = typeName;
  }
}

Add a new type variable for each different event you want to be able to listen to. These are for the keyboard event:

export class KeyboardEvent extends Event {  
  static readonly PRESSED = new EventType(KeyboardEvent, 'lumi_key_pressed');

  static readonly RELEASED = new EventType(KeyboardEvent, 'lumi_key_released');
}

The signature to add an event callback is something like this:

function on<T extends Event>(type: EventType<T>, callback: (event: T) => void,): void;

It takes an event type and a callback with the same event. It won't compile if those types are not the same. For example, you can only add a callback with a KeyboardEvent parameter if the event type also has a KeyboardEvent.

You use it like this:

function keyCallback(event: KeyboardEvent) {
  // Do something with the event.
}

function mouseCallback(event: MouseEvent) {
  // Do something with the event.
}

Events.on(KeyboarEvent.PRESSED, keyCallback);

// This won't compile, because mouseCallback has a MouseEvent
// paramenter and KeyboardEvent.PRESSED is a KeyboardEvent type.
Events.on(KeyboardEvent.PRESSED, mouseCallback);

Event pools

Each event should implement an object pool so we don't use a bunch of memory that has to be garbage-collected every time an event is sent. Object pools can be simple. Just a static array of event objects like: private static pool: KeyboardEvent[] = [];. We add a static get function to the event class that gets an event from the pool or creates a new event if it is empty. This is the keyboard event one:

static get<T extends KeyboardEvent>(
  type: EventType<T>,
  key: KeyConstant,
  scancode: Scancode,
  isRepeat?: boolean
): KeyboardEvent {
  let event: KeyboardEvent;
  if (KeyboardEvent.pool.length > 0) {
    event = KeyboardEvent.pool.pop()!;
  } else {
    event = new KeyboardEvent();
  }
  event.reset(type.typeName, key, scancode, isRepeat);

  return event;
}

There is a reset function to update the event values. The base event class has a put() function that gets called at the end of sending the event. This can be used when overridden to put the event back into the object pool.

Custom events

Creating custom events is pretty straightforward. It should inherit from the base Event class and have one or more types you can listen to with a callback.

class CustomEvent extends Event {

  static readonly TRIGGER = new EventType(CustomEvent, 'trigger_event');

  field1: number;

  field2: string;

  private static pool: CustomEvent[] = [];

  static get<T extends CustomEvent>(type: EventType<T>, field1: number, field2: string): CustomEvent {
    let event: CustomEvent;
    if (CustomEvent.pool.length > 0) {
      event = CustomEvent.pool.pop()!;
    } else {
      event = new CustomEvent();
    }
    event.reset(type.typeName, field1, field2);

    return event;
  }

  override put(): void {
    super.put();
    CustomEvent.pool.push(this);
  }

  private reset(typeName: string, field1: number, field2: string): void {
    this.typeName = typeName;
    this.field1 = field1;
    this.field2 = field2;
  }
}

// usage
function callback(event: CustomEvent): void {
  print(`field1: ${event.field1}, field2: ${event.field2}`);
}

Events.on(CustomEvent.TRIGGER, callback);

CustomEvent.get(CustomEvent.TRIGGER, 2, 'test').send();

The amount of code per event is not too bad. I might improve on this later if I find a better solution.

User Input

With the event system in place, we can use it to send input events for user input. The LÖVE framework has callbacks for user input you can add. This makes it easy to set up the events. This is the keyboard key pressed, for example:

love.keypressed = (key: KeyConstant, scancode: Scancode, isrepeat: boolean): void => {
  KeyboardEvent.get(KeyboardEvent.PRESSED, key, scancode, isrepeat).send();
};

All the input callbacks are at the bottom of the game.ts file in Lumi. Anywhere in the game, you can have an input callback using Events.on() .

Next Steps

I think next up is implementing entities and the draw layer system.

Source code

The source code at the commit for this post

Lastest source

0
Subscribe to my newsletter

Read articles from Juriën Meerlo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Juriën Meerlo
Juriën Meerlo

I'm a senior software engineer using TypeScript, C#, and Python in web and application development. As a hobby, I make games using custom game engines. You can find my latest game Gravity Golfing on iOS. I'm Working on and posting about my new engine called Lilo2d.