Balloon Search
Lost in the Woods: Balloon Search is an engaging 3D puzzle game that invites players to navigate a whimsical forest alongside the curious Purple Guy. With intuitive controls, dynamic elements, and charming visuals, the game is a perfect example of how Niantic Studio empowers creators to build unique and engaging 3D web games. We had the opportunity to learn about their creative journey and how they brought Lost in the Woods to life.
Make it your own with the sample project

Player Movement with Music
A sample project that features character's movement, animations, and sounds, with a camera that follows the character smoothly.
View sample projectBehind the Build: Balloon Search

Written by eenieweenie interactive
December 26, 2024
Introduction
This experience is a 3D browser game created using Niantic Studio (Beta) in October 2024.
The main character, Purple Guy, begins at the entrance of the woods and must navigate through the grounds to collect balloons and reach the finale.
Along the way:
- There are 8 balloons scattered across the map, tracked by a ticker in the top-right corner of the screen.
- Avoid bodies of water; stepping into water resets the game and you lose all collected balloons.
- Use the arrow keys to move up, down, left, and right, and press the spacebar to toggle the welcome screen and instructions.
Project Structure
3D Scene
- Base Entities: Includes the orthographic camera and ambient/directional lights for the game environment.
- UI Entities: Contains all user interface elements, including a tutorial at the start to explain the game's main goal.
Assets
- Audio: Background music, sound effects for balloon collection, game-over, and finale.
- Models: Balloons, Purple Guy, and environmental objects.
Scripts
/colliders/
:finale-collider.js
: Detects collisions with the finale area.game-over-collider.js
: Detects collisions leading to a game-over state.score-collider.js
: Detects collisions with balloons to update the score.
balloon.js
: Registers the Balloon component as a reusable entity
throughout the game.character-controller.js
: Manages player movement, animations, and
sound effects based on input and game state.camera-follower.js
: Keeps the camera positioned relative to the character.game-controller.js
: Serves as the central hub for managing game states, transitions, and UI updates.
Implementation
The game’s core logic is managed by a series of interconnected components. Key functionality is demonstrated with code snippets below.
Game Manager
The game-controller.js
acts as the game manager, connecting different events and managing state transitions.
Example: State Management
// Define other states
ecs.defineState('welcome')
.initial()
.onEvent('ShowInstructionsScreen', 'instructions')
.onEnter(() => {
console.log('Entering welcome state.')
dataAttribute.set(eid, {currentScreen: 'welcome', score: 0}) // Reset score
resetUI(world, schema, 'welcomeContainer') // Show welcome screen
})
ecs.defineState('instructions')
.onEvent('ShowMovementScreen', 'movement')
.onEnter(() => {
console.log('Entering instructions state.')
dataAttribute.set(eid, {currentScreen: 'instructions'})
resetUI(world, schema, 'instructionsContainer') // Show instructions screen
})
ecs.defineState('movement')
.onEvent('StartGame', 'inGame')
.onEnter(() => {
console.log('Entering movement state.')
dataAttribute.set(eid, {currentScreen: 'movement'})
resetUI(world, schema, 'moveInstructionsContainer') // Show movement screen
})
ecs.defineState('inGame')
.onEvent('gameOver', 'fall')
.onEvent('finale', 'final')
.onEnter(() => {
console.log('Entering inGame state.')
dataAttribute.set(eid, {currentScreen: 'inGame'})
resetUI(world, schema, null) // Show score UI
if (schema.character) {
world.events.dispatch(schema.character, 'start_moving') // Dispatch start moving event
}
world.events.addListener(world.events.globalId, 'balloonCollected', balloonCollect)
world.events.addListener(world.events.globalId, 'FinaleCollision', handleFinaleCollision)
})
.onExit(() => {
world.events.dispatch(eid, 'exitPoints') // Hide points UI
world.events.removeListener(world.events.globalId, 'balloonCollected', balloonCollect)
world.events.removeListener(world.events.globalId, 'FinaleCollision', handleFinaleCollision)
})
Key Features
- Transitions: Changes between game states like
welcome
,instructions
,inGame
, andfall
. - Event Handling: Manages events like
balloonCollected
andFinaleCollision
to update scores or trigger game-over logic.
Example: Event Handling
// Balloon collection handler
const balloonCollect = () => {
const data = dataAttribute.acquire(eid)
data.score += schema.pointsPerBalloon
if (schema.pointValue) {
ecs.Ui.set(world, schema.pointValue, {
text: data.score.toString(),
})
}
ecs.Audio.set(world, eid, {
url: balloonhit,
loop: false,
})
console.log(`Balloon collected! Score: ${data.score}`)
dataAttribute.commit(eid)
}
Dynamic Game Elements
- Finale Collider: Detects when the player reaches the finale area and transitions to the final score screen.
- Score Collider: Tracks balloon collection and updates the score.
- Follow Camera: Dynamically updates the camera’s position to follow the player.
Example: Collision Handling
const handleCollision = (event) => {
if (schemaAttribute.get(eid).character === event.data.other) {
console.log('Finale collision detected!')
world.events.dispatch(schemaAttribute.get(eid).gameController, 'FinaleCollision')
}
}
ecs.defineState('default')
.initial()
.onEnter(() => {
world.events.addListener(eid, ecs.physics.COLLISION_START_EVENT, handleCollision)
})
.onExit(() => {
world.events.removeListener(eid, ecs.physics.COLLISION_START_EVENT, handleCollision)
})
Example: Score Update
const handleCollision = (event) => {
if (schemaAttribute.get(eid).character === event.data.other) {
console.log(`Collision detected with character entity: ${event.data.other}`)
// Notify the GameController
world.events.dispatch(schemaAttribute.get(eid).gameController, 'balloonCollected')
console.log('balloonCollected event dispatched to GameController')
}
}
Example: Camera Tracking
const {Position} = ecs
const {vec3} = ecs.math
const offset = vec3.zero()
const playerPosition = vec3.zero()
const cameraFollower = ecs.registerComponent({
name: 'cameraFollower',
schema: {
player: ecs.eid, // Reference to the player entity
camera: ecs.eid, // Reference to the camera entity
offsetX: ecs.f32, // X offset for the camera position
offsetY: ecs.f32, // Y offset for the camera position
offsetZ: ecs.f32, // Z offset for the camera position
},
schemaDefaults: {
offsetX: 0, // Default X offset
offsetY: 5, // Default Y offset
offsetZ: 8, // Default Z offset
},
tick: (world, component) => {
const {player, camera, offsetX, offsetY, offsetZ} = component.schema
offset.setXyz(offsetX, offsetY, offsetZ)
// Set the camera position to the current player position plus an offset.
Position.set(world, camera, playerPosition.setFrom(Position.get(world, player)).setPlus(offset))
},
})
UI
Dynamic Screens
Each UI screen is dynamically shown or hidden based on the game state.
- Welcome Screen (
welcomeContainer
):- Purpose: Displays the "Start" button and resets the score.
- Instructions Screen (
instructionsContainer
):- Purpose: Provides gameplay instructions.
- Movement Instructions Screen (
moveInstructionsContainer
):- Purpose: Teaches movement controls.
- Game Over Screen (
gameOverContainer
):- Purpose: Displays the game-over message.
- Final and Perfect Score Screens (
finalScoreContainer
,perfectScoreContainer
):- Purpose: Highlights the player’s final score.
Interactive Elements
- Point Title and Value:
- Updates dynamically during gameplay to show the player’s score.
Example: Reset UI
const resetUI = (world, schema, activeContainer = null) => {
[
'welcomeContainer',
'instructionsContainer',
'moveInstructionsContainer',
'gameOverContainer',
'finalScoreContainer',
'perfectScoreContainer',
...Array.from({length: 9}, (_, i) => `scoreContainer${i}`),
].forEach((container) => {
if (schema[container]) {
ecs.Ui.set(world, schema[container], {
opacity: container === activeContainer ? 1 : 0, // Show active container, hide others
})
console.log(
container === activeContainer
? `Showing ${container}`
: `Hiding ${container}`
)
} else {
console.warn(`Container ${container} is not defined in the schema.`)
}
})
Example: Dynamic Score Update
ecs.Ui.set(world, schema.pointValue, { text: data.score.toString() })
Example: Highlight Score Container
ecs.Ui.set(world, schema.pointValue, { text: data.score.toString() })