Splatchemy!
Combine everyday objects to create a cozy, plant-filled environment in this interactive WebXR game built with Niantic Studio.
Make it your own with these sample projects

Studio: Detect Object Type On Tap
This sample project showcases the ability to tag an object and access it through a click. This allows different objects to be grouped and labeled.
View sample project
Studio: Merge Objects
This project showcases the core merging functionality, allowing players to instantiate different objects on a collision.
View sample projectBehind the Build: Splatchemy!
.jpg?width=56&height=56&name=pfp%20-%20Jessica%20Sheng%20(1).jpg)
Written by Jessica Sheng
March 7, 2025
Introduction
This experience is a Web3D game created using Niantic Studio Beta,in November 2024.
Splatchemy! is a game featuring an indoor environment where players combine everyday objects to create new ones to transform a splat of a plain dorm room into a magical space. Splatchemy! allows players to experiment with combinations of different objects to grow plants, create fairy lights, and more. As players progress, they can unlock new objects to place in the environment and bring the room to life.
The game revolves around discovering creative combinations and applying them to the scene to bring the room to life. There are a total of 9 objects that can be applied to the room. Your goal is to find the combinations to create all 9 objects and fully transform the plain dorm room into a cozy, green space.
Use the buttons to spawn in the basic objects, and as they collide, they will merge into new ones.
Project Structure
3D Scene
- Base Entities: Includes the Perspective AR camera and Ambient/Directional Lights
- Interactable Entities: Objects that players can interact with or combine (e.g., water, cup, soil).
- Environment Entities: Objects that spawn into the environment as a result of merging (e.g., vines, potted plants, fairy lights).
- UI Entities: Comprises all user interface elements displayed on the screen, providing information and feedback to the player. An onboarding flow is shown at the start.
Assets
Includes all 3D models, audio files, and images used throughout the game.
Assets are split into two folders for organization.
- Environment: Contains all assets used to decorate the environment
- Interactable: Contains all assets that are spawned in through buttons or merging others.
Scripts
There are only a few scripts in this game, composed of only components.
- Components: Contains the main game logic. This includes the UI screens, player movement, coin spawning logic on the frozen floor, mushroom behaviors and follow logic, and the game manager, which handles events during the game session.
- MergeableComponent: Defines objects that can be combined with others.
- MergeHandler: Handles logic for merging two objects and spawning new ones.
- UIComponent: Manages UI interactions for spawning primitives and displaying messages.
- Click Detection: Detects when a player clicks or taps on an object in the scene. This component determines what to spawn in or destroy when the user interacts with an object.
Implementation
Interactable Objects
These are the primitive objects players can interact with or combine:
Water
Cup
Soil
Wire
Derived objects such as:
Watering Can
Flower Pot
Potted Plant
Basket Plants
Vines
Lamp
Hanging Lights
Brick
Firewood
Fairy Lights
Objects are defined using the MergeableComponent, which tracks their current state (level) for combination logic.
const MergeableComponent = ecs.registerComponent({
name: 'Mergeable',
schema: {
level: ecs.string,
// Add data that can be configured on the component.
},
Then, MergeHandler handles all the logic behind which levels can be merged together.
MergeHandler.js
if (levelA === mergeLevelA && levelB === mergeLevelB) {
console.log('Merging objects with levels:', levelA, 'and', levelB)
// Spawn a new object if levels match
if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
spawnObject(component.schema.entityToSpawn1, spawnX, spawnY1, spawnZ)
if (MergeHandler.has(world, component.schema.entityToSpawn1)) {
const properties = MergeHandler.get(world, component.schema.entityToSpawn1)
MergeHandler.set(world, component.newSpawnedEntity, {...properties})
console.log('set Merge handler')
}
}
world.deleteEntity(component.eid) // Removes the current entity
world.deleteEntity(entityB) // Removes the other collided entity
}
This portion handles collision and level-checking. If the level of the current object and the collided object match the specified levels, only then can a new object be added to the scene.
const spawnObject = (sourceEntityId, x, y, z) => {
if (!sourceEntityId) {
console.warn('No source entity ID provided for spawning')
return
}
const newEid = world.createEntity()
cloneComponents(sourceEntityId, newEid, world)
const {spawnedScale} = component.schema
const spawnPosition = vec3.xyz(x, y, z)
Position.set(world, newEid, spawnPosition)
Scale.set(world, newEid, vec3.xyz(0.1 * spawnedScale))
ScaleAnimation.set(world, newEid, {
fromX: 0,
fromY: 0,
fromZ: 0,
toX: spawnedScale,
toY: spawnedScale,
toZ: spawnedScale,
duration: 500,
loop: false,
easeIn: true,
easeOut: true,
})
console.log('Object spawned with new entity ID:', newEid)
component.newSpawnedEntity = newEid
}
This portion of the script handles the spawning of entities by cloning the data from an existing component that is hidden.
Both of these sections of the script are then run every time two objects with MergeableComponents collide with each other.
Environment Objects
These are spawned into the scene when valid combinations are made:
Vines
Potted Plants
Fairy Lights
Fireplace
Lamps
Hanging Lights
Hanging Basket Plants
Birdcage
Flowerbuds
Environment objects are handled using ClickDetection, which tracks which level object has been clicked, and scales the corresponding environment object if the correct level object was clicked.
name: 'ClickDetection',
schema: {
flowerBuds: ecs.eid,
pottedPlants: ecs.eid,
vines: ecs.eid,
hangingPlants: ecs.eid,
lamps: ecs.eid,
stringLights: ecs.eid,
hangingLights: ecs.eid,
fireplace: ecs.eid,
birdCage: ecs.eid,
},
stateMachine: ({world, eid, schemaAttribute}) => {
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
let lastInteractionTime = 0
// Add state tracking for first wire click
let hasClickedWireFirst = false
// Define animation configurations
const animationConfigs = {
can: {target: 'flowerBuds', duration: 500},
pottedPlant: {target: 'pottedPlants', duration: 500},
vine: {target: 'vines', duration: 1000},
pottedVine: {target: 'hangingPlants', duration: 500},
lamp: {target: 'lamps', duration: 500},
stringLights: {target: 'stringLights', duration: 1000},
lightBulb: {target: 'hangingLights', duration: 500},
firewood: {target: 'fireplace', duration: 500},
wire: {target: 'birdCage', duration: 500},
}
The schema of this component specifies which objects to scale into the environment. animationConfigs configures the scaling animation for the fields in the schema. For each level that is associated with an environment change, it maps to a schema field and an animation duration.
// Generic function to handle all animations const applyAnimation = (entityId, config) => { deleteAnimation(entityId) const targetGroup = schemaAttribute.get(eid)[config.target] // Check if this group has already been counted if (!scaledObjects.has(config.target)) { // Convert generator to array and check children scales const children = Array.from(world.getChildren(targetGroup)) const hasScaledChildren = children.some((childEid) => { const scale = Scale.get(world, childEid) return scale.x !== 0 || scale.y !== 0 || scale.z !== 0 }) // If no children are scaled yet, increment score if (!hasScaledChildren) { scaledObjects.add(config.target) score++ scoreDisplay.innerHTML = `
Found objects: ${score}/9` } } // Animate child entities for (const childEid of world.getChildren(targetGroup)) { const scale = config.target === 'flowerBuds' ? Math.random() : 1 ScaleAnimation.set(world, childEid, { fromX: 0, fromY: 0, fromZ: 0, toX: scale, toY: scale, toZ: scale, duration: config.duration, loop: false, easeIn: true, easeOut: true, }) } }
Applying the animation results in:
- Deleting the clicked object
- Targets the corresponding group specified in the schema
- Animates the scale of the group
- Adds to score
// Check for intersections
for (const entityId of mergeableEntities) {
const tapObject = world.three.entityToObject.get(entityId)
if (!tapObject) {
console.error('Tappable object not found for entity ID:', entityId)
}
const intersects = raycaster.intersectObject(tapObject, true)
if (intersects.length > 0) {
touchPoint.setFrom(intersects[0].point)
console.log('Tapped on object with MergeableComponent:', tapObject.eid)
const applyObj = MergeableComponent.get(world, entityId)
const config = animationConfigs[applyObj?.level]
if (config) {
// Check if this is the first wire click
if (applyObj?.level === 'wire' && !hasClickedWireFirst) {
hasClickedWireFirst = true
// Emit custom event for first wire click
const newEvent = new CustomEvent('firstWireApplied', {
detail: {
entityId,
target: config.target,
},
})
window.dispatchEvent(newEvent)
}
applyAnimation(entityId, config)
world.time.setTimeout(() => {
checkCompletion()
}, config.duration)
break
} else {
deleteAnimation(entityId)
showErrorText()
}
}
}
Clicks and taps send out a raycast, and they check for whether it intersects any object with a MergeableComponent. There is also logic to detect whether it is the first time the user has clicked on a wire object to tell the onboarding UI to continue. Other than that, the animations to delete and scale in the target objects are applied.
UI
Each UI element shown is controlled by the UIController script. Everything is HTML/CSS injected into the window through Javascript, and it uses event listeners to determine which part of the UI should be shown next.
const showErrorText = () => {
// Create error message element
const errorMessage = document.createElement('div')
errorMessage.textContent = 'Nothing happened...'
errorMessage.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: white;
font-size: 24px;
font-family: Arial, sans-serif;
opacity: 0;
transition: opacity 500ms ease;
pointer-events: none;
z-index: 1000;
`
document.body.appendChild(errorMessage)
// Fade in
requestAnimationFrame(() => {
errorMessage.style.opacity = '1'
})
// Fade out and remove after delay
setTimeout(() => {
errorMessage.style.opacity = '0'
setTimeout(() => {
errorMessage.remove()
}, 500)
}, 2000)
}
Creates an error message saying that nothing spawned with a simple fade animation when the user clicks an object with a level not mapped to any environment changes.
// Create score display
const createScoreDisplay = () => {
const scoreDiv = document.createElement('div')
scoreDiv.style.cssText = `
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
color: white;
font-size: 20px;
font-family: 'Helvetica Neue', Arial, sans-serif;
text-align: center;
pointer-events: none;
z-index: 1000;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
letter-spacing: 0.5px;
`
return scoreDiv
}
Creates score display that is then exported and used in ClickDetection.ts
// Add score tracking let score = 0 const scaledObjects = new Set() // Track objects that have been scaled const scoreDisplay = createScoreDisplay() scoreDisplay.innerHTML = `
Found objects: ${score}/9
` document.body.appendChild(scoreDisplay)
UI Controller implements the score display constructed by UIController
Tutorial messages are created in a similar way, with CSS injected into the window.
// Initial tutorial message
showMessage('Click on the wire button to spawn a wire object')
The first message that tells the user to click the wire object displays on enter.
button.addEventListener('click', () => {
onClick()
if (tutorialState === 'start' && label === 'Wire') {
tutorialState = 'spawned'
hasSpawnedObject = true
showMessage('Tap on the spawned object to apply it to the scene')
}
})
return button
Then, within the code that sets the buttons, an event listener is added to check if the user has clicked the wire button
// Listen for first wire application
window.addEventListener('firstWireApplied', () => {
if (tutorialState === 'spawned' && !hasAppliedObject) {
tutorialState = 'applied'
hasAppliedObject = true
showMessage('Try spawning in different objects to see if they will merge together and create new ones!', 5000)
}
})
FirstWireApplied is an event that is broadcasted from the ClickDetection script that checks if the user has applied the wire for the first time. Once the user has, it finally shows the last onboarding message.
Process
Roadblocks + Solutions
Lack of a Tagging System
Challenge:
Unlike Unity, Niantic Studio doesn’t have a built-in tagging system to categorize entities or find them by tag. This was a limitation since the game logic required identifying objects by their classifications to enable specific object combinations.
Solution:
A custom component, MergeableComponet, was created to classify objects by assigning them a level property (or similar attributes) to define their type or state.
To access these properties, a separate script was implemented to read the fields of this component. However, accessing custom components sometimes caused runtime errors like Cannot read properties of undefined (reading 'has').
To address this:
Added conditional checks (if (MergeableComponent.has(world, entity))) before accessing the component.
Used optional chaining (MergeableComponent?.get(world, entity)?.level) to safely access properties without causing runtime errors.
Instantiating an entity with proper components and properties.
Challenge:
Niantic Studio lacks a prefab system like Unity, meaning that every entity must be manually instantiated with its components and properties. This made it difficult to clone objects with custom components like MergeableComponet.
Solution:
While the Niantic Studio sample project, Studio: World Effects, provides a componentsForClone array for cloning components, it does not work for custom components like Mergeable. Attempting to include custom components in this array resulted in errors.
The workaround involved manually cloning the MergeableComponent in a separate block of code:
const cloneComponents = (sourceEid, targetEid, world) => {
componentsForClone.forEach((component) => {
if (component.has(world, sourceEid)) {
const properties = component.get(world, sourceEid)
component.set(world, targetEid, {...properties})
}
})
if (MergeableComponent.has(world, sourceEid)) {
const properties = MergeableComponent.get(world, sourceEid)
console.log('Cloning component: MergeableComponent', properties) // Debugging log
MergeableComponent.set(world, targetEid, {...properties})
}
}
This approach ensured that entities with the MergeableComponet were processed while avoiding runtime errors.
Detecting specific collisions based on component properties
Challenge:
The game logic required detecting collisions between two objects and determining if their level properties matched specific criteria (e.g., mergeLevelA and mergeLevelB).
However:
When both objects were set as dynamic rigidbodies, the physics simulation sometimes caused collisions to be detected inconsistently.
if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
handleCollision(entityB, thisLevel, mergeWith1)
}
const levelA = MergeableComponent.get(world, component.eid)?.level
const levelB = MergeableComponent.get(world, entityB)?.level
if (levelA === mergeLevelA && levelB === mergeLevelB) {
// Spawn a new object if levels match
if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
spawnObject(component.schema.entityToSpawn1, spawnX, spawnY1, spawnZ)
if (MergeHandler.has(world, component.schema.entityToSpawn1)) {
const properties = MergeHandler.get(world, component.schema.entityToSpawn1)
MergeHandler.set(world, component.newSpawnedEntity, {...properties})
}
}
world.events.addListener(component.eid, ecs.physics.COLLISION_START_EVENT, ({data}) => {
const {mergeWith1, mergeWith2, mergeWith3} = component.schema
const {other: entityB} = data