Skip to content

Style Guide

Josh Field edited this page Jan 9, 2024 · 24 revisions

Refactoring for Efficiency and Elegance

Redundant getters

Most functions that deal with components are retrieving access to them via getComponent in the beginning of the function definition:

const halfMyComponentValue = (entity: Entity) => {
  const myComponent = getComponent(entity, MyComponent)
  myComponent.value /= 2
}

const quarterMyComponentValue = (entity: Entity) => {
  const myComponent = getComponent(entity, MyComponent)
  myComponent.value /= 4
}

However, most of these functions are being used in a context where the components have already been retrieved, or will need retrieved again in a following function, thus creating a lot of redundant getComponent calls:

const MySystem = (world) => {
  const entities = query(world)
  for (const entity of entities) {
    const myComponent = getComponent(entity, MyComponent)

    myComponent.value += 100
    
    halfMyComponentValue(entity)
    
    quarterMyComponentValue(entity)
  }
}

This potentially may have a significant impact on performance by inducing more lookups than are necessary. Instead, getComponent should be called once per entity iteration in a system, the reference of which can then be passed into respective function calls.

const halfMyComponentValue = (myComponent) => {
  myComponent.value /= 2
}

const quarterMyComponentValue = (myComponent) => {
  myComponent.value /= 4
}

const MySystem = (world) => {
  const entities = query(world)
  for (const entity of entities) {
    const myComponent = getComponent(entity, MyComponent)

    myComponent.value += 100
    
    halfMyComponentValue(myComponent)
    
    quarterMyComponentValue(myComponent)
  }
}

In this example, the number of lookups is reduced 3 * entities.length times. This also makes the functions more referentially transparent.

Referential Transparency

Most functions in the codebase do not exhibit referential transparency. This can considerably enhance the readability of code, making it easier to work with, especially those who are new to the codebase.

Writing Composable, Extensible and Testable Functions

In all cases. it is best to write lots of small functions, rather than large functions or classes. This allows us to easily create test cases for each function. These functions can then be overridden and extended by projects to enable easy modifications to the base functionality. The functional paradigm also enables a data-oriented approach.

Functions should not rely on external state where possible, such as mutating variables outside of the function's scope (see Pure vs Impure Functions). The exception is when a function's purpose is to mutate state, such as a FLUX pattern receiver, a database mutation, or ECS interaction.

Functions should have clear and readable names, to aid ease of understanding. It is perfectly fine for these to be verbose, the module export can have shortened names if they are unreasonably long.

Provide function-specific extensibility by introducing module exporters

// CustomMathUtil.ts
// modules files must be named identitcally to their module, while lone function files must be named identically to the function they export

const addOne = (i: number) => {
  return i++
}

// functions must be camelCase
const subtractOne = (i: number) => {
  return i--
}

// modules must be CapitalCase
export const CustomMathUtil = {
  addOne,
  subOne
}

Importing

When importing, it is important not to deconstruct these modules outside of the scope they are being used in, as the references will be kept, and if they have since been overridden, there will be a mismatch in functionality.

Bad

import { CustomMathUtil } from './CustomMathUtil'

const { addOne } = CustomMathUtil

function addTwo (number) {
  return addOne(addOne(number))
}

Good

import { CustomMathUtil } from './CustomMathUtil'

function addTwo (number) {
  const { addOne } = CustomMathUtil
  return addOne(addOne(number))
}

Running Tests

To run all the unit & integration tests across the entire monorepo, use npm run test in the root folder. To run

Individual tests can be run by navigating to the package the specific test is running in (such as cd packages/engine) and running npm run test -- --grep describeHookLabel where 'describeHookLabel' is the label of a describe hook in a specific test file.

Writing tests

The goal with testing to is to provide a variety of input states to a function, and test that a variety of output states are as expected. We define 3 types of tests.

Unit tests

These test individual pieces of functionality, such as isolated functions. Examples of some well-written unit tests can be found here and here

All global state (such as Engine.userId) must be reset before and after as to not affect the tests. This will soon be refactored to not be an issue.

An example of the styling for writing tests can be found below. It is important to respect the gap lines as to ensure readable code.

import assert from 'assert'
import { CustomMathUtil } from './CustomMathUtil'

describe('CustomMathUtil', () => {

  describe('addOne', () => {
    
    // first test case
    it('should add one', () => {

      const result = CustomMathUtil.addOne(1)
      assert.equal(result, 2)

    })

    // second test case
    it('should return NaN to non-number', () => {

      const result = CustomMathUtil.addOne(undefined!)
      assert.equal(result, NaN)

    })

  })

})

Integration tests

End to End tests