Rolling ball - Gameplay programming

Level: Beginner

In this tutorial, we will create a small game as a way to learn the basics of Hology Engine. The game we will create is about moving a ball from a spawn point to a target. The player will be able to apply forces to the ball to make it move.

You will learn the basics of implementing game play mechanisms in Holog such as how to connect player input to an object controlled by the player, how to apply physical forces, using spawn points and handling events with trigger volumes.

Prerequisites

Before continuing, you need to create a new project and familiarise yourself with the basics of the editor. Go through the article The first steps to learn how to set up a project. Then learn how to navigate in the editor using the articles in Editor basics.

While some coding skills are useful, all the code needed will be provided in this tutorial.

Concepts are explained in the tutorials and just briefly explained. Other articles will go into depth to explain these concepts.

Setup a scene

To create our initial scene, we need 3 things.

  1. Create a box that will act as a platform for our ball to roll on. To add a box, select shapes in the asset browser, click and drag the box into the scene. Use the scale tool by selecting the box and clicking R on your keyboard or use the toolbar on the left. Make the size of the box about 2 meters wide (X), 1 meter thick (Y), 5 meters long (Z),

  2. Add a spawn point on one end of the box. Spawn points are actors which can be found in the asset browser. Click and drag it into the scene.

  3. Add a trigger volume and place it on the opposite side of the box.

This is all we need to start developing the game. We can come back later to create a more interesting and challenging game level.

Coding the game

Now that we have a basic scene setup, we can get into the code. In summary, we will need to code the following:

  • Ball actor

    • The visual and physical representation of the ball

    • Spawning the ball actor

    • Adding forces to the ball using player input

  • Player camera

    • Make the camera follow the ball to create a third person view.

  • Player controller

    • Setup player input handling so the ball actor can apply forces based on what keys the player is pressing.

  • Respawning

    • When the ball reaches the goal or falls of the map, it should reset its position to the spawn point.

Create the ball actor

Let’s start by creating the most important character in our game, the ball. Create a new file in your code editor with the path src/actors/ball-actor.ts in the project. Add the following code to the file to start and save the file. We will go over this in detail to understand what goes on.

import { PhysicalShapeMesh, SphereCollisionShape } from "@hology/core";
import { Actor, BaseActor, PhysicsBodyType, attach } from "@hology/core/gameplay";
import { MeshComponent } from "@hology/core/gameplay/actors";
import { NodeShaderMaterial, rgba, select, varyingAttributes } from "@hology/core/shader-nodes";
import { SphereGeometry } from "three";

const ballMaterial = new NodeShaderMaterial({
  color: select(varyingAttributes.position.y().lt(0), rgba(0xffff00, 1), rgba(0x00ffff, 1))
})

@Actor()
class BallActor extends BaseActor {
  private mesh = attach(MeshComponent<PhysicalShapeMesh>, {
    object: new PhysicalShapeMesh(
      new SphereGeometry(.2, 50, 50), 
      ballMaterial, 
      new SphereCollisionShape(0.2)),
    bodyType: PhysicsBodyType.dynamic,
    mass: 2,
    friction: 1
  })
}

export default BallActor
Code explanation

At first, we create a material to use for the ball. If we were to simply use a single color, it wouldn’t be possible to see that the ball is in fact rolling. While we could use a texture for this, another easier approach can be to use a custom shader as we do here. To assign a color to each half of the sphere, we can use the varying position attribute. If the position’s y value is less than 0, we can use one color and otherwise another.

const ballMaterial = new NodeShaderMaterial({
  color: select(varyingAttributes.position.y().lt(0), rgba(0xffff00, 1), rgba(0x00ffff, 1))
})

Next we define the actor class and export it so that we can use it elsewhere in our code.

@Actor()
class BallActor extends BaseActor {
}

export default BallActor

Inside the actor we define a property as we attach the MeshComponent. This component provides both the visual representation and how physics should work. We use a SphereCollisionShape with the same radius as the SphereGeometry to make the ball behave as expected. The body type is set to dynamic so that we can use forces to affect the ball’s movement. We also set the mass and friction to some values to start with. Later on, when you have the game is running, feel free to change these to see how it affects the gameplay.

  private mesh = attach(MeshComponent<PhysicalShapeMesh>, {
    object: new PhysicalShapeMesh(
      new SphereGeometry(.2, 50, 50), 
      ballMaterial, 
      new SphereCollisionShape(0.2)),
    bodyType: PhysicsBodyType.dynamic,
    mass: 2,
    friction: 1
  })

Adding the ball to the world

Instead of adding the ball to the scene in the editor, we will use the spawn point we created earlier. In the file src/services/game.ts, replace its content with the following code.

import { GameInstance, Service, World, inject } from '@hology/core/gameplay';
import { SpawnPoint } from '@hology/core/gameplay/actors';
import BallActor from '../actors/ball-actor';

@Service()
class Game extends GameInstance {
  private world = inject(World)

  async onStart() {
    const spawnPoint = this.world.findActorByType(SpawnPoint)
    const ball = await spawnPoint.spawnActor(BallActor)
  }
}

export default Game

This is the main class from which we setup the game. We will add more to this class later on in the tutorial.

Code explanation

To find something in the world and to add something to it, we need to inject the World class where we want to use it. We create a new property on the Game class and use the inject function with the World class.

private world = inject(World)

Inside the onStart method, which will be called automatically by the engine as the game starts, we can put our code to setup the gameplay. The first line here is used to find the spawn point that we placed in the scene. We can find an actor by its class. The function findActorByType will return the first actor it finds with the actor class we pass in. As we only have one spawn point in the scene, this works for us.

const spawnPoint = this.world.findActorByType(SpawnPoint)

On the second line we use use the spawn point to spawn the actor.

const ball = await spawnPoint.spawnActor(BallActor)

It could be good to know that you don’t necessarily need spawn points to spawn actors. This is just a shorthand for using the world.spawnActor function to which you can pass in any position and rotation. You can achieve the same thing with the following code.

const ball = await this.world.spawnActor(BallActor, spawnPoint.position, spawnPoint.rotation)

Applying forces

Next up, we will add the code for controlling the ball with physics. While the ball is controlled by the physics simulation, we want to be able to apply a force forward or backward in the direction the player is rotated towards.

Replace the code in the ball-actor.ts file created earlier with the following code.

import { PhysicalShapeMesh, SphereCollisionShape } from "@hology/core";
import { Actor, BaseActor, PhysicsBodyType, PhysicsSystem, attach, inject } from "@hology/core/gameplay";
import { MeshComponent } from "@hology/core/gameplay/actors";
import { AxisInput } from "@hology/core/gameplay/input";
import { NodeShaderMaterial, rgba, select, varyingAttributes } from "@hology/core/shader-nodes";
import { Euler, SphereGeometry, Vector3 } from "three";

const ballMaterial = new NodeShaderMaterial({
  color: select(varyingAttributes.position.y().lt(0), rgba(0xffff00, 1), rgba(0x00ffff, 1))
})

@Actor()
class BallActor extends BaseActor {  
  private mesh = attach(MeshComponent<PhysicalShapeMesh>, {
    object: new PhysicalShapeMesh(
      new SphereGeometry(.2, 50, 50), 
      ballMaterial, 
      new SphereCollisionShape(0.2)),
    bodyType: PhysicsBodyType.dynamic,
    mass: 2,
    friction: 1
  })
  
  private physicsSystem = inject(PhysicsSystem)
  public readonly axisInput = new AxisInput()
  public readonly direction = new Vector3() 

  onInit() {
    this.physicsSystem.setLinearDamping(this, .2)
    this.physicsSystem.setAngularDamping(this, 5)

    const rotation = new Euler().set(0, this.rotation.y, 0)
    const impulse = new Vector3()

    const inputForce = 100
    const sensitivity = 1.8
    
    this.physicsSystem.beforeStep.subscribe(deltaTime => {
      rotation.y += deltaTime * -this.axisInput.horizontal * sensitivity
  
      this.direction
        .set(0,0,1)
        .applyEuler(rotation)

      impulse
        .copy(this.direction)
        .multiplyScalar(inputForce * deltaTime * this.axisInput.vertical)

      this.physicsSystem.applyImpulse(this, impulse)
    })
  }
}

export default BallActor
Code explanation

First, we added some new properties to the class.

The physics system is injected to be available to our actor. This service is used later to hook into the physics processing and apply forces.

private physicsSystem = inject(PhysicsSystem)

We create an axisInput property which we later will use to connect the player’s input. We can use the state of the axis input to know how the player wants to move without having to worry about key presses or key binds in this context.

public readonly axisInput = new AxisInput()

A direction vector is set up and made public. This will be used later to position the camera. Note that this is the direction that the player rotated to, not the direction vector of the ball as that will be spinning as the ball is rolling around.

public readonly direction = new Vector3() 

Inside the onInit method, the first thing we do is to apply damping. Although not always necessary, damping helps simulate air resistance. This will slow down its movement and rotation to prevent it from spinning out of control or going hypersonic. If these values are too high however, it can make it feel sluggish.

this.physicsSystem.setLinearDamping(this, .2)
this.physicsSystem.setAngularDamping(this, 5)

A rotation variable is defined which should represent the rotation of the direction the player want to face. We use the initial rotation around the y axis of the actor here so that we can face towards the direction of the spawn point.

const rotation = new Euler().set(0, this.rotation.y, 0)

When we later apply forces as an impulse we need to do some vector math. Creating new vectors every physics step is something we want to avoid as it creates more work for the garbage collector. A common pattern is therefore to create the vectors you need upfront and then perform operations that change the vector.

const impulse = new Vector3()

To execute before each physics step, the physics system exposes a beforeStep observable to which we can subscribe and get access to the time since the last update, which is referred to as deltaTime. In the subscription callback, we will place the code that should update before the physics step which will be described below.

this.physicsSystem.beforeStep.subscribe(deltaTime => {
})

At first, modify the rotation around the y axis using the axis input’s horizontal value. Because this is called on each physics step, we want to use the deltaTime to make sure that updates are consistent even if updates were to happen more or less frequently. Also, a sensitivity constant is applied to be able to control how much the rotation is changed.

rotation.y += deltaTime * -this.axisInput.horizontal * sensitivity

Then, the rotation is applied to the vector. Note how we first initialize the vector to point forward down the Z axis and then apply the rotation.

this.direction
    .set(0,0,1)
    .applyEuler(rotation)

The impulse is a vector for how much force to apply at this step. We calculate the impulse by multiplying the direction vector which represents how much force to apply. The axis input’s vertical property will be either 1 or -1 depending on if the player presses the key to go forward or backward. Notice again how we use the deltaTime value to ensure the force is applied consistently and not dependent on how frequent the physics step is.

impulse
    .copy(this.direction)
    .multiplyScalar(inputForce * deltaTime * this.axisInput.vertical)

this.physicsSystem.applyImpulse(this, impulse)

If you were to open the game in your browser now, you would likely not see much of the scene you created or the spawned ball because the camera is positioned at the world’s origin point 0,0,0. In the next section we will program the player’s camera to follow the ball.

Using physics for controlling a player character

In many games that has a character, you would likely not use a dynamic physics body as we do here as it would make it hard to control. However, in this game, the idea is that being hard to control is the main challenge and is something that the player will learn to master the more they play. If you are creating a game with a character, look into using the built in character controller which is using a kinematic physics body or create your own with a similar approach.

Player camera

Start by creating a file at src/services/player-camera.ts and add the following code.

import { Service, ViewController, inject } from "@hology/core/gameplay";
import { Vector3 } from "three";
import BallActor from "../actors/ball-actor";

@Service()
class PlayerCamera {
  private view = inject(ViewController)
  private cameraOffset = new Vector3(0, .5, 0)

  public setup(target: BallActor) {
    const camera = this.view.getCamera()

    this.view.onLateUpdate(target).subscribe(() => {
      camera.position
        .copy(target.position)
        .addScaledVector(target.direction, -2)
        .add(this.cameraOffset)
      camera.lookAt(target.position)
    })
  }
}

export default PlayerCamera

Before going into details of what happens in this code, we can start using it from the Game class. In the file src/services/game.ts, replace the code with the following. Note that we only added a couple of lines. We added the import of the PlayerCamera class, injected the PlayerCamera, and in the onStart method, called the setup method.

import { GameInstance, Service, World, inject } from '@hology/core/gameplay';
import { SpawnPoint } from '@hology/core/gameplay/actors';
import BallActor from '../actors/ball-actor';
import PlayerCamera from './player-camera';

@Service()
class Game extends GameInstance {
  private world = inject(World)
  private playerCamera = inject(PlayerCamera)

  async onStart() {
    const spawnPoint = this.world.findActorByType(SpawnPoint)
    const ball = await spawnPoint.spawnActor(BallActor)
    this.playerCamera.setup(ball)
  }
}

export default Game

If you now open the game in your browser by going to http://localhost:5173, you should be able to see the ball. Make sure you also run the game in development mode by running npm run dev in the terminal in the game project’s directory.

Code explanation

Let’s go over the PlayerCamera. First, we define a new service. There will only be one instance of a service in a game. As we only will have one camera for the player, we can use a service to contain this code. We use the @Service() decorator on top of the class to denote that this is a service which we need for dependency injection. Other than that, it is just a regular TypeScript class.

@Service()
class PlayerCamera {

}

export default PlayerCamera

To access the default camera and to execute code every frame, we can use the ViewController service. This is injected like any other service as a property in our PlayerCamera class.

 private view = inject(ViewController)

An offset property for the camera is created. We will use this later to slightly offset the camera’s position upwards in the Y axis so we more easily can see what is in front of the ball.

private cameraOffset = new Vector3(0, .5, 0)

We define a method called setup which takes a BallActor. The point of this method is to setup the view controller to follow a specific BallActor instance.

public setup(target: BallActor) {
}

We access the currently active camera of the view. By default, there is always one camera available. Note that it is possible to have multiple cameras in your game and you can switch between them by setting the active camera in the ViewContoller.

const camera = this.view.getCamera()

Then, we want to execute some code every frame to update the position of the camera. This can be done using the onLateUpdate method which returns an observable. This method can also optionally take an actor instance as an argument. If this actor is removed from the world and disposed, the observable will stop generating events every frame. We are using a late update here which is the event that gets triggered after all other per frame updates and the physics step. This enables us to update the position of the camera after the physics system has updated the position of the ball.

this.view.onLateUpdate(target).subscribe(() => {
})

To position the camera, we first copy the position of the ball so the camera is at the center of the ball. Then we adjust the position in the negative direction of the player’s direction. The value 2 here means that we position the camera 2 meters behind the center of the ball. To also move the camera slightly above the ball, we add the camera offset vector we defined before.

Finally, we call the lookAt method on the camera to rotate towards the center of the ball.

camera.position
    .copy(target.position)
    .addScaledVector(target.direction, -2)
    .add(this.cameraOffset)
camera.lookAt(target.position)

While we now can see the ball in the game, the only force applied to the ball is gravity. In the next section we will set up player input to change that.

Player controller

Create a file at src/services/player-controller.ts and add the following code.

import { Service, inject } from "@hology/core/gameplay";
import { InputService, Keybind } from "@hology/core/gameplay/input";
import BallActor from "../actors/ball-actor";

enum InputAction {
  moveForward,
  moveBackward,
  rotateLeft,
  rotateRight,
}

@Service()
class PlayerController {
  private inputService = inject(InputService)

  public setup(ballActor: BallActor) {
    this.inputService.bindToggle(InputAction.moveForward, ballActor.axisInput.togglePositiveY)
    this.inputService.bindToggle(InputAction.moveBackward, ballActor.axisInput.toggleNegativeY)
    this.inputService.bindToggle(InputAction.rotateLeft, ballActor.axisInput.toggleNegativeX)
    this.inputService.bindToggle(InputAction.rotateRight, ballActor.axisInput.togglePositiveX)

    this.inputService.setKeybind(InputAction.moveForward, new Keybind('w'))
    this.inputService.setKeybind(InputAction.moveBackward, new Keybind('s'))
    this.inputService.setKeybind(InputAction.rotateLeft, new Keybind('a'))
    this.inputService.setKeybind(InputAction.rotateRight, new Keybind('d'))

    this.inputService.start()
  }
}

export default PlayerController
Code explanation

Again, we use a service to separate out the code for setting up player input.

At first we define the actions a player can do as an enum.

enum InputAction {
  moveForward,
  moveBackward,
  rotateLeft,
  rotateRight,
}

In the setup method of the PlayerController, we bind functions to call with either true or false when an action is used.

this.inputService.bindToggle(InputAction.moveForward, ballActor.axisInput.togglePositiveY)
this.inputService.bindToggle(InputAction.moveBackward, ballActor.axisInput.toggleNegativeY)
this.inputService.bindToggle(InputAction.rotateLeft, ballActor.axisInput.toggleNegativeX)
this.inputService.bindToggle(InputAction.rotateRight, ballActor.axisInput.togglePositiveX)

Then, we add keybinds for these actions. By separating the keybinds from the rest of our code like this, we can easily reconfigure keybinds even while the game is running. This could enable the player to change keybind settings which could be stored on the device and loaded to replace the defaults.

this.inputService.setKeybind(InputAction.moveForward, new Keybind('w'))
this.inputService.setKeybind(InputAction.moveBackward, new Keybind('s'))
this.inputService.setKeybind(InputAction.rotateLeft, new Keybind('a'))
this.inputService.setKeybind(InputAction.rotateRight, new Keybind('d'))

Finally, we must call start on the input service so that the game engine will start monitoring key presses.

this.inputService.start()

To setup the player controller, we need to take the same steps we did with the player camera.

Replace the game.ts file with the following code. We import the PlayerController class, inject it, and call the setup method.

import { GameInstance, Service, World, inject } from '@hology/core/gameplay';
import { SpawnPoint } from '@hology/core/gameplay/actors';
import BallActor from '../actors/ball-actor';
import PlayerCamera from './player-camera';
import PlayerController from './player-controller';

@Service()
class Game extends GameInstance {
  private world = inject(World)
  private playerCamera = inject(PlayerCamera)
  private playerController = inject(PlayerController)

  async onStart() {
    const spawnPoint = this.world.findActorByType(SpawnPoint)
    const ball = await spawnPoint.spawnActor(BallActor)
    this.playerCamera.setup(ball)
    this.playerController.setup(ball)
  }
}

export default Game

Now when you try the game in your browser at http://localhost:5173/, you should be able to move the ball by pressing W,A,S,D on your keyboard. When you fall off the platform, reload the browser tab to respawn. In the next section, we will go into how to automatically respawn.

Respawning

When the ball rolls of the platform it will just continue to fall forever, which is not very fun. Instead, we can reset the ball’s position. One way to do this is to check every frame if the ball’s position in the y axis is below a certain value.

Replace the contents of the file src/services/game.ts with this the following code.

import { GameInstance, Service, ViewController, World, inject } from '@hology/core/gameplay';
import { SpawnPoint } from '@hology/core/gameplay/actors';
import BallActor from '../actors/ball-actor';
import PlayerCamera from './player-camera';
import PlayerController from './player-controller';

@Service()
class Game extends GameInstance {
  private world = inject(World)
  private playerCamera = inject(PlayerCamera)
  private playerController = inject(PlayerController)
  private view = inject(ViewController)

  async onStart() {
    const spawnPoint = this.world.findActorByType(SpawnPoint)
    const ball = await spawnPoint.spawnActor(BallActor)
    this.playerCamera.setup(ball)
    this.playerController.setup(ball)

    this.view.onLateUpdate().subscribe(() => {
      if (ball.position.y < -10) {
        ball.moveTo(spawnPoint.position)
      }
    })
  }
}

export default Game

We injected the view controller and use it to subscribe to the onLateUpdate event. In this, we check if the ball’s position in the y axis is less than the hard coded value -10. If that is the case, we call a new method on the ball called moveTo which we will define up next.

In the ball actor’s class (ball-actor.ts), add the following method within the class definition (before the last "}").

public moveTo(position: Vector3) {
  this.position.copy(position)
  this.physicsSystem.updateActorTransform(this)
  this.physicsSystem.setLinearVelocity(this, new Vector3())
}

This code will first set the actor’s position using the given position vector. The physics system is normally in control of the ball’s position so we have to tell it to use the actor’s new transform which contains the position. If we only change the position of the ball, the ball will still continue moving at the same velocity it did whenever it was reset. Instead, we want to cancel any existing movement which we can do by setting the linear velocity to 0 in all axis which can be done with a new vector as it defaults to all zeroes.

Now when you play the game and the ball falls of, it should reset to the spawn point. Depending on how your scene is designed, you may need to increase or decrease the value we set to -10 here.

Reaching the goal

The challenge of the game is not to just roll around aimlessly but to reach a goal. To understand that the ball has reached the goal, we can use the trigger volume that we created in the scene initially.

Add the following code to the Game class at the end of the onStart method in the file src/services/game.ts.

const triggerVolume = this.world.findActorByType(TriggerVolume)
triggerVolume.trigger.onBeginOverlapWithActor(ball).subscribe(() => {
  ball.moveTo(spawnPoint.position)
})

Here we subscribe to the begin overlap with actor event which is triggered as soon as the ball intersects with the trigger volume. At this point, we could do many things like informing the player that it has won the game, moving to a new level, registering the total time it took to complete, etc. However, that is out of scope of this tutorial. For now we can just reset the ball’s position to the spawn point for the purpose of this exercise.

You’ve made it!

This is a very basic game but it contains the basic building blocks that you can use to build much more.

Next steps

Give us your feedback

Did you get stuck somewhere, was something difficult to understand, or is there something else you would see explained? Then we would love to hear about it so we can improve.

Creating a more interesting map

Go back to the editor and change your scene to make the game more fun and challenging. You can add obstacles such as ramps or gaps between sections to jump over.

Add more features

You could make the game more interesting by adding new game play mechanisms. For example moving platforms or puzzles where the ball has to collide with certain objects,

Deploy your game

While the game may not be enough just yet to hit the front page of the App Store, you can launch it is a website and share it. See our documentation of distributing your game. Distribution

Continue learning

We only touched on the most important concepts in this tutorial. You may want to dive in deeper to learn all about actors and other game player features.

If you are new to game math like vectors and physics, you may want to read up on these.

Get started on your own game

This was just an introduction to game development with Hology Engine and you may feel unsure how to continue from here to build the game you want to build. Please feel free to contact us and tell us about what you want to create and we can help guide you with how to approach it.

Last updated