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

balloon-cover

Player Movement with Music checkmark bullet

A sample project that features character's movement, animations, and sounds, with a camera that follows the character smoothly.

View sample project

Behind 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, and fall.
  • Event Handling: Manages events like balloonCollected and FinaleCollision 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() })

      
Your cool escaped html goes here.