Rolling ball - Gameplay programming
Last updated
Last updated
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.
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.
To create our initial scene, we need 3 things.
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),
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.
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.
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.
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
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.
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
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.
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.
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.
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.
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
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.
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.
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.
This is a very basic game but it contains the basic building blocks that you can use to build much more.
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.
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.
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,
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
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.
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.