The Last Pin

You are the last remaining pin! Tilt your head to dodge oncoming bowling balls, your goal is to survive for as long as you can!

thelastpin

Make it your own with the sample project

globalscorecontroller

Studio: GLOBAL SCORE CONTROLLER checkmark bullet

A sample project designed to help you understand global events and specifically how they can be used to update a score.

View sample project

Behind the Build: The Last Pin

Written by Sam Guilmard

May 5, 2025


Introduction

This project was created to help gain further understanding when making a game inside of Niantic Studio that requires the user's physical inputs to control the core game mechanic. In this example the user’s head rotation is used to control the main player. It also uses the built in animation component to control many elements in the game such as the movement of the enemies. 


The game is called “THE LAST PIN”, where you play as a bowling pin that is trying to dodge oncoming bowling balls. The longer you survive, the more points you get. Once the player is struck by a bowling ball, the game is over. Throughout the game the rate in which bowling balls are spawned increases and so does the speed of the balls.

Project Structure

3D Scene
  • The overall scene is fairly simple and uses a mix of 3D models and primitive shapes to create the environment. It also utilises 3D UI elements so that their position can be manipulated in 3D space.
Assets
  • There are 2 3D assets in this project. The gutter is placed in the scene either side of the bowling lane and the pin is what the user controls with their head rotation. You can think of the pin and the main player. 
  • There is 1 audio file which is played when the pin is hit by the ball. 
  • In the texture folder is a combination of model textures and UI elements.
  • There are also a total of 10 different ball textures. These are randomly applied to each ball as it spawns.
Scripts
  • There is only 1 script in this project “GameController” which handles all the logic required for the game. As this is a fairly simple game, only 1 script is needed and it should make it easier to follow how all the functions, components and values work and interact with each other. 

Implementation

This section of the documentation will go over some of the core mechanics within the game.

- Player Movement 
Inside the schema there is reference made to the “Pin” object, this allows you to modify components of the object such as the position, rotation, scale, material etc. 

         
schema: { 
// Add data that can be configured on the component. 
pin: ecs.eid, 
}, 

      

Now inside the scene hierarchy, we select the Game Controller component (where the “GameController” script is attached) and navigate towards the script in the inspector panel. There is an option for Pin so make sure you select the correct object using the drop down panel.

Now in the add function of the “GameController” script we can create a reference to this object so that we can easily refer to it in other functions. 

         
const { 
pin, 
} = component.s

      

Also in the add function we create 2 event listeners. One is listening for when the face is shown “facecontroller.faceupdated” and the other is listening for when the face is hidden “facecontroller.facelost”. At the end of the event listener we define the name of the function we want to be called when this event happens - when the face is updated, call the “show” function and when the face is lost, call the “lost” function. 

         
// listener for head rotation and pin movement 
world.events.addListener(world.events.globalId, 'facecontroller.faceupdated', show) 
// listener for losing head rotation 
world.events.addListener(world.events.globalId, 'facecontroller.facelost', lost) 

      

The “show” function passes in the values of the user’s head position and rotation. A variable is created within this function “xRot” which is assigned the value of the user’s head x rotation. Inside of an IF statement, which is checking if the game is currently playing, the xRot value is constantly applied to the pins X position within the scene. This value is multiplied by another variable “pinSpeed” which can easily be edited in the GameController scene object as this is another schema variable. 

         
// controls the movement of the pin 
const show = ({data}) => { 
const xRot = data.transform.rotation.x 
// console.log(xRot) 
if (playing == true) { 
ecs.Position.set(world, pin, {x: Math.min(Math.max(xRot * -pinSpeed, -1.5), 1.5), y: 0, z: 8}) 
} 
} 

      

Pin Speed

The higher this number, the ‘faster’ the pin will move. The reason this value is negated is so that it moves in the correct direction in regards to the scene and the user’s position. You may also see that when this value is applied to the pin object, it is also nested within a Math.min and Math.max function. This is to limit the pins position on the screen so that they have to stay within the bounds of the game and cannot cheat. 
In the “lost” function, the pin's position is reset to the original start position, this is so that the player cannot cheat by hiding their face.


- Ball Spawning 
When the game starts (the user taps the screen) the balls start to spawn - each ball has a random X start position and a random X end position, this is to make the balls move diagonally thus making the game harder. There is an increasing yet random speed applied to each ball, some rotation applied depending on the distance between the X start and end position and also a random texture (from 10 options) applied to each ball. Most of these functions are handled by implementing the built in animation controller within Niantic Studio. 
Similar to how we made an object reference to the Pin we again have to make 10 different object references inside the schema for all 10 of the bowling balls. 

         
schema: { 
// Add data that can be configured on the component. 
ball1: ecs.eid, 
ball2: ecs.eid, 
ball3: ecs.eid, 
ball4: ecs.eid, 
ball5: ecs.eid, 
ball6: ecs.eid, 
ball7: ecs.eid, 
ball8: ecs.eid, 
ball9: ecs.eid, 
ball10: ecs.eid, 
pin: ecs.eid, 
}, 

      

Again we can now use the drop down selection bar on the GameController script attached to the GameController object in the scene to apply each ball.

In the Add function we can create a reference to each ball so that it is easily accessible throughout the script. 

         
add: (world, component) => { 
// Runs when the component is added to the world. 
const { 
ball1, 
ball2, 
ball3, 
ball4, 
ball5, 
ball6, 
ball7, 
ball8, 
ball9, 
ball10, 
pin, 
} = component.schema 

      

When the game starts, either at the beginning of the experience or when it is restarted, the “spawnBall” function is called. 

 

         
function spawnBall() { 
// checks if the game is currently playing 
if (playing == true) { 
// decrease the spawn time every time a ball is spawned 
if (spawnTime > spawnFrequencyMinimum) { 
spawnTime -= spawnFrequencyMultiplier 
} 
const randomxStart = getRandomStartX() 
const randomxEnd = getRandomEndX() 
// this calculates the difference between the x start position and x end position - the higher the difference the more rotation that is added const differenceInDistance = Math.abs(randomxStart - randomxEnd) 
// if the x start position is bigger than the end then the ball is moving left 
if (randomxStart >= randomxEnd) { 
curveLeft = true 
} else { 
curveLeft = false 
}
updateMaterial() 
applyScaleAnimation() 
world.setPosition(balls[currentBall], randomxStart, 0.375, -0.5) applyPositionAnimation(randomxStart, randomxEnd) 
applyRotationAnimation(differenceInDistance) 
// Increase current ball or reset 
if (currentBall == 9) { 
currentBall = 0 
} else { 
currentBall++ 
} 
// time out function to delay the next spawning of a ball 
world.time.setTimeout(() => { 
if (playing == true) { 
spawnBall() 
} 
}, spawnTime) 
} 
} 

      

Everything inside the “spawnBall” function is nested within an if statement that is checking if the game is currently being played. This is so that if the game is over then no further balls are spawned. 
At the end of the function you can see that a “setTimeout” function is used to re-call the “spawnBall” function. The value “spawnTime” is the amount of time needed to pass before the function is called. This value is modified at the start of the function every time it is called. 

         
if (spawnTime > spawnFrequencyMinimum) { 
spawnTime -= spawnFrequencyMultiplier 
} 

      

It works by first checking if the current spawnTime is greater than the spawnFrequencyMinimum. If it is then subtract the spawnFrequencyMultiplier from the spawnTime, if it isn’t then do nothing. We limit this value so that there aren't too many balls on the screen at once which would make the game impossible and could drastically affect the frame rate.

Next, we assign the X start position and X end position by calling these 2 functions. 

         
const randomxStart = getRandomStartX() 
const randomxEnd = getRandomEndX() 

      

Both of these functions work in the same way and it starts by defining the value “negativeStart” with a random value of either 0 or 1. This is used to determine if the X position is either negative or positive. Then the “xValue” is assigned a random value between 0 and 2. This value is then returned but if “negativeStart” is equal to 0 then the xValue is negated. 

         
// Get a random x value for ball start position 
function getRandomStartX() { 
const negativeStart = Math.floor(Math.random() * (2)) 
const xValue = Math.random() * (2) 
if (negativeStart == 0) { 
return xValue * -1 
} else { 
return xValue 
} 
} 

      

Next, a value is assigned to “differenceInDistance” using the 2 new x positions to calculate how far away they are in the X axis. This is used when applying the spin rotation later on, the further the distance the quicker the ball should rotate. 

         
const differenceInDistance = Math.abs(randomxStart - randomxEnd) 

      

We then calculate which direction the ball is moving in, is the ball moving diagonally left or right? We use this again for applying the rotation animation, if the ball is moving left the ball should also be rotating left. The “differenceInDistance” value and “curveLeft” value are used later when calling the “applyRotationAnimation” function. 

         
// if the x start position is bigger than the end then the ball is moving left 
if (randomxStart >= randomxEnd) { 
curveLeft = true 
} else { 
curveLeft = false 
}

      

The next function called is the “applyMaterial” function. At the start of the add function an array for texture names was created. Another array is defined that contains all the balls, this is so we can keep track of which ball is being spawned and apply things like the position animation and new material to the correct ball. 

         
const textures = ['ballTexture1', 'ballTexture2', 'ballTexture3', 'ballTexture4', 'ballTexture5', 'ballTexture6', 'ballTexture7', 'ballTexture8', 'ballTexture9', 'ballTexture10'] 
const balls = [ball1, ball2, ball3, ball4, ball5, ball6, ball7, ball8, ball9, ball10] 

      

It is important that these names match the textures within your asset folder but do not include the extension e.g. png. 
At the start of the “applyMaterial” function a random number between 0 and 9 is generated. Depending on that number a specific texture is then applied to the ball. The ecs.Material.set function is applied to the current ball in the array and sets a new texture from the texture array list of names, depending on the randomly generated option. 

         
currentTexture = Math.floor(Math.random() * 10) 
// Apply the texture to the object 
ecs.Material.set(world, balls[currentBall], { 
textureSrc: `${require(`./assets/${textures[currentTexture]}.png`)}`, roughness: 0.5, 
metalness: 0.5, 
}) 

      

It is important that these names match the textures within your asset folder but do not include the extension e.g. png. 
At the start of the “applyMaterial” function a random number between 0 and 9 is generated. Depending on that number a specific texture is then applied to the ball. The ecs.Material.set function is applied to the current ball in the array and sets a new texture from the texture array list of names, depending on the randomly generated option. 

         
currentTexture = Math.floor(Math.random() * 10) 
// Apply the texture to the object 
ecs.Material.set(world, balls[currentBall], { 
textureSrc: `${require(`./assets/${textures[currentTexture]}.png`)}`, roughness: 0.5, 
metalness: 0.5, 
}) 

      

Next the “applyScaleAnimation” is called - this is a simple function that is used to scale the balls up as they appear on the screen so that they don't simply “pop” into existence. 

         
function applyScaleAnimation() { 
ecs.ScaleAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: 0, 
fromY: 0, 
fromZ: 0, 
toX: 1, 
toY: 1, 
toZ: 1, 
duration: 500, 
loop: false, 
easeIn: false, 
}) 
}

      

Next the “applyScaleAnimation” is called - this is a simple function that is used to scale the balls up as they appear on the screen so that they don't simply “pop” into existence. 

         
world.setPosition(balls[currentBall], randomxStart, 0.375, -0.5) 

      

The “applyPositionAnimation” function is then called to add the movement of the bowling ball. The “randomxStart” and “randomxEnd” values are passed through into this function. This is a prime example of how the inbuilt animation system in Niantic studio works. The advantage of using the animation system and not a physics system that applies force is that you can have more control over the speed and it won’t differ depending on the quality of the device for example. 

         
applyPositionAnimation(randomxStart, randomxEnd) 
// Apply the position animation to the new ball 
function applyPositionAnimation(xStart, xEnd) { 
ecs.PositionAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: xStart, 
fromY: 0.375, 
fromZ: -0.5, 
toX: xEnd, 
toY: 0.375, 
toZ: 10, 
duration: getRandomSpeed(), 
loop: false, 
easeIn: true, 
}) 
} 

      

As you can see the “duration” of this animation is defined by calling the “getRandomSpeed” function. The duration is in milliseconds and the lower this value is - the faster the animation will be. 


The “getRandomSpeed” function works by generating a random number between 0 and the “ballSpeedVariation” which is set by default to 200. This value is then added to the “initialSpeed” variable which is set to 3000. This means that the duration of the animation can be between 3000 and 3200 milliseconds (adding some slight variation). Every time this function is called, it checks to see if the “initialSpeed” is greater than the “maximumBallSpeed” if it is then the value of “ballSpeedIncreaseValue” is subtracted which makes the position animation quicker over time (making the game harder the longer you last). If the value is not bigger then it is not altered, this is to limit the speed of the balls because if the animation speed is constantly reduced then eventually you could get animations that are so fast they are impossible to dodge.

         
function getRandomSpeed() { 
newSpeed = (Math.random() * ballSpeedVariation) + initialSpeed 
if (initialSpeed > maximumBallSpeed) { 
initialSpeed -= ballSpeedIncreaseValue 
} 
return newSpeed 
} 

      

Then the “applyRotationAnimation” function is called and the “differenceInDistance” value is passed into this function. This is also where the “curveLeft” variable is used. The rotational animation that is applied to the ball is only on the Z axis, this is to mimic the way in which the bowling balls rotate in real life. At the start of the function it checks if “curveLeft” is true - if it is then set the “endRotation” value to 720 (curve left) and if not then set it to -720 (curve right). 

         
applyRotationAnimation(differenceInDistance) 
function applyRotationAnimation(differenceInDistanceValue) { 
const rotationValue = 4000 - (Math.floor(differenceInDistanceValue * 500)) 
// this changes the end rotation based on if the ball is moving left or right let endRotation 
if (curveLeft == true) { 
endRotation = 720 
} else { 
endRotation = -720 
} 
ecs.RotateAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: 0, 
fromY: 0, 
fromZ: 0, 
toX: 0, 
toY: 0, 
toZ: endRotation, 
shortestPath: false, 
duration: rotationValue, 
loop: true, 
}) 
} 

      

The speed of the rotation is altered by affecting the “duration” value of the animation. At the start of the function a “rotationValue” variable is defined by taking 4000 and subtracting the “differenceInDistance” value multiplied by 50. This means the higher the difference in x start and x end position, the lower the rotation value which is applied to the duration of the animation (it completes the animation in a shorter amount of time). This animation is looping so that the balls constantly spin and it’s also important that you set “shortestPath” to false, otherwise it will look like nothing is happening. 


Finally, at the end of the “spawnBall” function, the “currentBall” value is altered. If the current ball is 9 then set the current ball to 0, if not then increase the “currentBall” value by 1. 

         
// Increase current ball or reset 
if (currentBall == 9) { 
currentBall = 0 
} else { 
currentBall++ 
} 

      

 

Collision Detection 

Throughout the game there are 2 instances of when a collision is detected. One when a ball collides with the “CollisionBox” which is located behind the pin. When this collision is detected it means that the player has successfully dodged a bowling ball and a point should be scored. The other instance is when a ball hits the pin, this causes the game to end. 
Like most things in this project, it starts by creating a reference to the object in the schema. The name of the object that we want to check has collided with anything is “collision”. We then apply this object in the scene and create a reference at the start of the add function. 

         
schema: { 
collision: ecs.eid, 
}, 
add: (world, component) => { 
// Runs when the component is added to the world. 
const { 
collision, 
} = component.s

      

All the balls also have a Physics Collider attached to them but the “Rigidbody” here is set to “Dynamic” as the balls are moving. The “Event Only” check box is also checked here, this is because we want a collision to be detected but we don't want the balls to actually interact with the box and bounce off.

         
world.events.addListener(collision, ecs.physics.COLLISION_START_EVENT, handleCollision) 

      

Inside of the “add” function an event listener is created - “ecs.physics.COLLISION_START_EVENT”. This component is added to the “collision” object and when a collision is detected it calls the “handleCollision” function. We do not need to keep track of what object is colliding because the balls are the only thing that can collide in this project. 


The collision between the ball and the pin is handled in the exact same way but instead of creating an event listener for the “collision” object, we check for the “pin” object which we already have a reference to as we are controlling its position with the user's head rotation. It calls the “hitPin” function which is where all the logic is contained for the end game scenario. 

         
world.events.addListener(pin, ecs.physics.COLLISION_START_EVENT, hitPin) 

      

 

Customise  

All these values can be altered by selecting the “GameController” in the scene hierarchy and then navigating over to the inspection panel and under the “GameController” script you will find them. 
Pin Speed - This is the speed in which the pin (the main player) moves. The higher this value is the faster it moves. Don’t worry about this causing the pin to go off the screen as we are clamping its position. 
Initial Spawn Frequency - This is the amount of time (in milliseconds) it takes for a new ball to be spawned after the previous. Remember this value is reduced every time a ball is spawned, so it’s a good idea to keep this fairly high for the start of the game so that it is not too difficult too soon.

Spawn Frequency Multiplier - This is the value that is subtracted from the Initial Spawn Frequency. You can think of this value as the rate in which the game gets harder. With its default value currently at 5, it means that every time a ball spawns the time in which the next ball is spawned is reduced by 5 milliseconds. The higher this number, the quicker the spawn time will be reduced and maximum ball spawn frequency will be reached. Again, it is a good idea to keep this number fairly low, you don’t want the game to get too hard too quickly. 


Spawn Frequency Minimum - This is the minimum value that you wish the spawn frequency to be. By default it is set to 500 milliseconds which means the spawn delay between each ball will never be less than half of a second. You can think of this value as the maximum difficulty, if you want to really test the player you could make this value lower so that balls spawn at a quicker rate. 


Initial Ball Speed - This is the initial time it takes for the ball to travel towards the player. Remember that this value is reduced throughout the game to make the ball's speed increase. The default value means that at the start of the experience the ball will take approximately 3 seconds to reach the player (depending on the variation that is added). 


Ball Speed Variation - This is the random value that is applied to the speed of the ball so that all the balls on the screen aren’t moving at the same speed. With the default value at 200 this means that the speed of the ball can be anything from 0 to 200 milliseconds faster. The higher this value, the more variation in ball speeds, the lower the number the more consistent the ball speed is. 


Ball Speed Increase Value - This number is subtracted from the ball speed every time a ball is spawned. This means that every time a ball is spawned the speed increases by 10 milliseconds. The higher this number, the quicker the balls will reach maximum speed. 


Maximum Ball Speed - This is the maximum speed in which the balls can move. At the moment this is set to 2000 milliseconds which means, with the addition of a potential 200 millisecond variation, the shortest amount of time a ball could take to reach the player is 1800 milliseconds. If you reduce this number it means that the maximum speed will be faster. 


Moving Forward 
The idea behind this template was to provide the basic core mechanics for a game. The best thing about making games is that you can tell any story you like! Instead of simply adjusting the values of this game, try changing the story by swapping out the assets. At the moment you are a bowling pin trying to dodge bowling balls on a bowling lane, but you could be a skier dodging huge snowballs while going down a mountain, or a spaceship that has to avoid asteroids. Maybe if you change to snowballs you could alter the rotation to spin forward and not sideways, or if you like the spaceships and asteroids idea, you could randomise the size of the asteroids when they are spawned so they are not all the same size. There are many ways in which you can alter this game to suit your narrative that isn’t just from changing the overall look. When you really think about it, the possibilities are endless…

 

Your cool escaped html goes here.