Implementation
Key Implementation Features of each Level
Click on each section below to expand and read more about its implementation details.
Overview
To enable motion-based gameplay, we integrated MotionInput into our system. Team 6 helped us by creating a prefab, which we added to each of our game scenes. We attached a corresponding MotionInput configuration file (in JSON format) for each game to the prefab. This configuration loads automatically when the game scene starts.
Json Configuration
Each JSON file defines input areas for different body parts and links them to specific keyboard actions. These areas are detected live on the MotionInput window.
Below is an example configuration used to detect when a player turns left by moving their nose into a defined area:
{
"action": {
"args": ["left"],
"class": "keyboard",
"method": "hold"
},
"color": [255, 200, 200],
"file": "hit_trigger.py",
"skin": "common/turn_left.png",
"landmark": "nose",
"pos": [140, 200],
"radius": 160,
"text": "Turn Left"
}
The landmark
refers to the tracked body part (in this case, the nose), while pos
and radius
define the detection area. When the user moves the selected body part into the zone, the corresponding action (e.g., pressing the "left" arrow key) is triggered. This enables gesture-based control across all of our mini-games.
Movement & Animation
SkateMoveAnimator.cs
handles character movement and animation. The method HandelMovement()
fetches user input to move the character and determines whether the character is walking or standing, updating the animation state accordingly.
Jump & Spin
The method HandelJump()
listens for the jump input (space key). When triggered, it applies an upward force and calls StartSpin()
, storing the initial rotation and detaching the camera to avoid spinning. PerformJumpSpin()
then rotates the character mid-air by 360°, before reattaching the camera.
void StartSpin() {
isSpinning = true;
spinProgress = 0f;
if (cinemachineCam != null)
followOffset = cinemachineCam.transform.position - transform.position;
cinemachineCam.Follow = null;
}
void PerformJumpSpin() {
float spinAmount = jumpSpinSpeed * Time.deltaTime;
spinProgress += spinAmount;
transform.rotation = startRotation * Quaternion.Euler(0f, spinProgress, 0f);
if (jumpDirection != Vector3.zero)
rb.MovePosition(rb.position + jumpDirection * Time.deltaTime);
if (spinProgress >= 360f) {
isSpinning = false;
transform.rotation = startRotation;
cinemachineCam.Follow = transform;
}
}
Boundary Setting
Movement is validated using CanMoveToPosition()
which raycasts the next position. If not walkable, ApplyBounceBack()
pushes the character away. This prevents players from entering restricted zones.
if (CanMoveToPosition(nextPosition))
{
rb.MovePosition(nextPosition);
animator.SetBool("Run", true);
animator.SetBool("Stand", false);
}
else
{
ApplyBounceBack(-transform.forward);
currentSpeed = 0f;
}
Collectable Object Behaviour
CoinsOnLand.cs
animates coins by floating them up and down while rotating on the Y-axis to make them visually engaging.
transform.position = new Vector3(
transform.position.x,
initialY + floatingOffset,
transform.position.z
);
transform.Rotate(0, rotationSpeed * Time.deltaTime, 0, Space.World);
Coin Spawning & Lifetime
RandomCoinSpawner.cs
spawns coins on walkable surfaces using raycasts. The DestroyAfterTime()
coroutine removes coins after a delay and triggers ReplaceDestroyedObject()
to regenerate a new one elsewhere.
if (spawnedObjects.Count < maxObjects)
{
Transform land = landPositions[Random.Range(0, landPositions.Count)];
Vector3 spawnPosition = GetRandomPositionOnLand(land);
GameObject newObject = Instantiate(objectPrefab, spawnPosition, Quaternion.identity);
newObject.transform.Rotate(0, 0, 90);
newObject.AddComponent();
newObject.tag = "Coin";
newObject.transform.localScale = new Vector3(2f, 0.5f, 2f);
SphereCollider sphereCollider = newObject.GetComponent();
if (sphereCollider == null)
{
sphereCollider = newObject.AddComponent();
}
sphereCollider.isTrigger = true;
spawnedObjects.Add(newObject);
StartCoroutine(DestroyAfterTime(newObject, objectLifetime));
}
IEnumerator DestroyAfterTime(GameObject obj, float delay)
{
yield return new WaitForSeconds(delay);
if (obj != null)
{
Destroy(obj);
spawnedObjects.Remove(obj);
ReplaceDestroyedObject();
}
}
Ring Behaviour
RingBehaviour.cs
mimics the coin’s behaviour with continuous floating and rotation to enhance visibility in the scene.
Point System
PtSysSkating.cs
tracks coin and ring collection. Coins increase counters and spawn rings when thresholds are met. Collecting a set number of rings ends the game and displays a win panel.
private void OnTriggerEnter(Collider other) {
if (other.gameObject.CompareTag("Coin")) {
collectedCoins++;
Destroy(other.gameObject);
if (collectedCoins >= coinsPerRing) {
collectedCoins = 0;
SpawnRing();
}
UpdateRingUI();
}
if (other.gameObject.CompareTag("Ring")) {
collectedRings++;
Destroy(other.gameObject);
UpdateRingUI();
if (collectedRings >= maxRings) {
EndGame();
}
}
}
Ring Generation
SpawnRing()
uses GetValidRingSpawnPosition()
to find valid, walkable nearby surfaces via raycast before spawning.
Vector3 GetValidRingSpawnPosition()
{
Debug.Log("Finding Valid Position...");
float minDistance = 10f;
float maxDistance = 30f;
for (int i = 0; i < 10; i++)
{
Vector3 randomDirection = Random.insideUnitSphere;
randomDirection.y = 0;
Vector3 spawnPosition = transform.position + randomDirection.normalized * Random.Range(minDistance, maxDistance);
spawnPosition.y += 10f;
RaycastHit hit;
if (Physics.Raycast(spawnPosition, Vector3.down, out hit, 20f))
{
if (hit.collider.CompareTag("Land"))
{
Debug.Log("Found valid position: " + hit.point);
return hit.point + Vector3.up * 0.5f;
}
}
}
Debug.Log("No valid position found");
return Vector3.zero;
}
Animal Behaviour
AnimalWander.cs
gives water animals wandering behaviour by selecting new destinations periodically using PickNewTarget()
.
while (true)
{
PickNewTarget();
yield return new WaitForSeconds(wanderInterval);
}
Animal Generation
AnimalSpawner.cs
creates animals within a defined area using GetValidWaterPosition()
. Spawned animals are assigned wander behaviour to populate the environment.
while (spawnedCount < numberOfAnimals && maxAttempts > 0)
{
Vector3 spawnPoint = GetValidWaterPosition();
if (spawnPoint != Vector3.zero)
{
GameObject animalPrefab = animalPrefabs[Random.Range(0, animalPrefabs.Count)];
GameObject spawnedAnimal = Instantiate(animalPrefab, spawnPoint, Quaternion.identity);
spawnedAnimal.AddComponent();
spawnedAnimals.Add(spawnedAnimal);
spawnedCount++;
}
maxAttempts--;
}
Overhead Map Logic
SkatingMapFollow.cs
keeps a top-down map camera synchronized with the character’s position and orientation to support player navigation.
Adjustable Parameters
SkatingController.cs
is responsible to fetch the game parameter set by the user from the homepage, then use this to configure the skating game.
private void OnTriggerEnter(Collider other) {
foreach (var param in data.adjustableParameters)
{
if (param.paramName == "Show Timer:")
{
if (!param.boolValue){
timerText.gameObject.SetActive(false);
}
}
}}
Movement Handler
BoatMovement.cs
is responsible for controlling the movement of the kayak. The method HandleAlternatingMovement()
ensures that the kayak only moves forward when the W and S keys are pressed alternately. This makes it ideal for motion input mapping, enabling users to mimic realistic paddling gestures.
void HandleAlternatingMovement() {
if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow)) {
if (!lastKeyWasUp) {
lastKeyWasUp = true;
canMove = true;
} else {
canMove = false;
}
}
if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) {
if (lastKeyWasUp) {
lastKeyWasUp = false;
canMove = true;
} else {
canMove = false;
}
}
}
Since movement only occurs when alternating keys are pressed, the boat doesn’t move continuously when a key is held. To smooth the experience, the method GlideAfterMove()
is used to apply a forward transition after each movement burst.
IEnumerator GlideAfterMove() {
isGliding = true;
float glideSpeed = currentSpeed;
while (glideSpeed > 0) {
glideSpeed -= moveSpeed * (Time.deltaTime / glideDuration);
Vector3 nextPos = transform.position + lastMoveDirection * glideSpeed * Time.deltaTime;
if (NoObstacle(nextPos)) {
transform.position += lastMoveDirection * glideSpeed * Time.deltaTime;
}
yield return null;
}
isGliding = false;
}
Obstacle Avoidance
During the glide, NoObstacle()
is used to raycast in three directions (front-left, front, front-right) to detect land obstacles. If any are detected, the glide is cancelled to prevent movement into unwalkable terrain.
private bool NoObstacle(Vector3 targetPosition) {
RaycastHit hit;
// Front-left
if (Physics.Raycast(transform.position, (-transform.right - transform.forward) * obstacleAvoidanceRangeSide, out hit, 5f)) {
if (hit.collider != null && hit.collider.CompareTag("Land")) {
Debug.Log("Blocked by Land! Movement prevented.");
return false;
}
}
// Front
if (Physics.Raycast(transform.position + transform.right * backOffset + transform.up * upOffset, -transform.right * obstacleAvoidanceRange, out hit, 5f)) {
if (hit.collider != null && hit.collider.CompareTag("Land")) {
Debug.Log("Blocked by Land! Movement prevented.");
return false;
}
}
// Front-right
if (Physics.Raycast(transform.position, (-transform.right + transform.forward) * obstacleAvoidanceRangeSide, out hit, 5f)) {
if (hit.collider != null && hit.collider.CompareTag("Land")) {
Debug.Log("Blocked by Land! Movement prevented.");
return false;
}
}
return true;
}
Speed Boost
DetectAndCollectRings()
is called during Update()
to check if the kayak collides with a speed ring. If so, ActivateSpeedBoost()
sets the boosted state to true, and speed is adjusted accordingly using:
currentSpeed = isBoosted ? moveSpeed * boostMultiplier : moveSpeed;
The ring is destroyed once collected.
Collectable Objects Behaviour
CoinFloating.cs
controls the movement of coins. On every frame update, coins float up and down while rotating horizontally for visual feedback.
coin.position = new Vector3(
coin.position.x,
waterLevel + heightOffset + floatingOffset,
coin.position.z
);
coin.Rotate(0, rotationSpeed * Time.deltaTime, 0, Space.World);
RingFloating.cs
applies the same floating and rotating animation logic to rings.
Point System
PointSyste.cs
handles collision detection and score updates. The method DetectAndCollectCoins()
uses a spherical collider to detect nearby coins. If detected, the coin is destroyed and the score is increased.
void DetectAndCollectCoins() {
Collider[] hitColliders = Physics.OverlapSphere(transform.position, coinDetectionRadius);
foreach (Collider col in hitColliders) {
if (col.gameObject.tag == "Coin") {
score += 1;
UpdateScoreText();
Destroy(col.gameObject);
}
}
}
A countdown timer tracks remaining time. When it reaches 0, EndGame()
is called to show the end screen.
Overhead Map Logic
MapFollow.cs
keeps the overhead map camera in sync with the kayak’s position and rotation.
Adjustable Parameters
KayakController.cs
fetches parameters from the homepage UI and uses them to configure the gameplay experience (e.g., enabling or disabling UI elements).
Core Script: ObjectController.cs
ObjectController.cs
serves as the main script that handles physics, visual updates, jump, spin, direction changes, and more. It acts as the skeleton for the entire sledding level.
Frame-Based Updates
The Update()
method performs frequent checks, such as whether the sled should emit snow particles, be jumping, stick to terrain, or initiate direction changes. It ensures that all visual and physics-related boolean flags are immediately applied when changed.
Physics-Based Updates
FixedUpdate()
is responsible for all physics-based behavior including directional movement, lateral override, terrain sticking, and consistent forward speed. It uses vector math to determine sled movement:
Vector3 forwardDirection = new Vector3(
Mathf.Cos(Mathf.Deg2Rad * -targetMovementAngle),
0,
Mathf.Sin(Mathf.Deg2Rad * targetMovementAngle)
);
Collision Handling
OnCollisionEnter()
and OnCollisionExit()
detect ramps and terrain. They toggle booleans like isTouchingRamp
and disableSticking
that are referenced by other systems.
if (collision.gameObject.CompareTag("Ramp")) {
isTouchingRamp = true;
disableSticking = true;
snowParticle = false;
Invoke(nameof(EnableSpin), 0.3f);
}
Trigger Handling
OnTriggerEnter()
handles three key trigger types:
- "DirectionTrigger" – changes sled direction.
- "GiftBox" – increments score via
ScoreManager
. - "LeftNet"/"RightNet" – activates override controls to avoid trees.
Spinning & Jumping
The sled automatically performs a 360-degree spin when jumping off a ramp. This is handled by EnableSpin()
, DelayedSpin()
, and Perform360SpinJump()
.
while (totalRotation < 360f) {
float deltaRotation = angularVelocity * Time.deltaTime;
totalRotation += deltaRotation;
transform.Rotate(Vector3.right, deltaRotation, Space.Self);
yield return null;
}
transform.Rotate(Vector3.right, 360f - totalRotation, Space.Self);
Originally, the game included a timing-based jump input, but it was removed for accessibility reasons after testing with NAS therapists.
Control Overrides
The method OverrideControls()
disables player input if they get too close to trees, auto-steering them back to safety.
Terrain Adjustment
IsTouchingGround()
and AdjustToTerrain()
use raycasts to align the sled's rotation smoothly with the terrain slope.
Direction & Rotation
EndTransitionAfterDelay()
handles sled rotation during direction changes:
yield return new WaitForSeconds(0.5f);
isTransitioning = false;
storedYRotation = targetRotation.eulerAngles.y;
AdjustToTerrainSlope()
then aligns the sled with the terrain surface over 2 seconds. GetTerrainNormal()
determines slope angle using raycasting.
Camera System: CameraFollow.cs
Because the sledding terrain is large (5000x5000) and steep, CameraFollow.cs
dynamically adjusts the angle and distance of the camera relative to the player using slope data. It uses Vector3.SmoothDamp
and angle interpolation:
sledSlopeDegrees = Mathf.Clamp(sledSlopeDegrees, -maxSlopeAngle, maxSlopeAngle);
float normalizedSlope = Mathf.InverseLerp(maxSlopeAngle, -maxSlopeAngle, sledSlopeDegrees);
float targetTiltAngle = Mathf.Lerp(minTiltAngle, maxTiltAngle, normalizedSlope);
currentTiltAngle = Mathf.Lerp(currentTiltAngle, targetTiltAngle, smoothTiltSpeed * Time.fixedDeltaTime);
The result is a smooth and responsive camera that maintains a consistent view without cutting through terrain or losing the player.
Supporting Scripts
FinishLine.cs
handles end-game triggers, while ScoreManager.cs
updates the player score when GiftBox
objects are collected.
Core Script: ObjectController.cs
ObjectController.cs
manages physics, player control, animation states, and transitions. It handles movement, slowdown, terrain alignment, and integrates inputs into gameplay.
Frame-Based Updates
Update()
checks for ground contact, user input to slow down, and activates animations through SantaAnimation
. It ensures a smooth transition post-turn and updates velocity in real time:
bool touchingGround = IsTouchingGround();
bool isSlowingDown = Input.GetKey(KeyCode.Q) && Input.GetKey(KeyCode.W);
if (santaAnimation != null)
{
santaAnimation.AdjustForearmRotation(isSlowingDown);
}
Physics-Based Updates
FixedUpdate()
handles forward and lateral physics-based movement, and smooths the height based on terrain normals:
if (Input.GetKey(KeyCode.LeftArrow))
{
sideVelocity = -transform.right * zMoveSpeed;
}
else if (Input.GetKey(KeyCode.RightArrow))
{
sideVelocity = transform.right * zMoveSpeed;
}
Collision Handling
OnCollisionEnter()
detects terrain and resets airborne flags and particle emission:
if (collision.gameObject.CompareTag("Terrain"))
{
disableSticking = false;
isAirborne = false;
snowParticle = true;
}
Trigger Handling
OnTriggerEnter()
detects:
- “DirectionTrigger” to change direction
- “Flag” to increase score
- “LeftNet” / “RightNet” to enable control override
Control Overrides
OverrideControls()
disables player input and overrides direction to prevent collisions with nets.
Terrain Alignment
IsTouchingGround()
and AdjustToTerrain()
use raycasts to align player rotation with the slope.
Direction & Rotation Handling
Smooth direction transitions are handled by SmoothDirectionTransition()
, blending velocity changes over time, and AdjustToTerrainSlope()
realigns to terrain:
while (elapsedTime < transitionTime)
{
elapsedTime += Time.deltaTime;
rb.linearVelocity = Vector3.Lerp(
initialVelocity,
new Vector3(newDirection.x * moveSpeed, preservedYVelocity, newDirection.z * moveSpeed),
elapsedTime / transitionTime
);
}
Camera System: CameraFollow.cs
CameraFollow.cs
is responsible for dynamic camera tracking over steep 5000x5000 terrain. It adjusts camera tilt, distance, and direction:
if (sledSlopeDegrees > 180f) {
sledSlopeDegrees -= 360f;
}
sledSlopeDegrees = Mathf.Clamp(sledSlopeDegrees, -maxSlopeAngle, maxSlopeAngle);
float normalizedSlope = Mathf.InverseLerp(maxSlopeAngle, -maxSlopeAngle, sledSlopeDegrees);
float targetTiltAngle = Mathf.Lerp(minTiltAngle, maxTiltAngle, normalizedSlope);
currentTiltAngle = Mathf.Lerp(currentTiltAngle, targetTiltAngle, smoothTiltSpeed * Time.fixedDeltaTime);
Quaternion lookRotation = Quaternion.LookRotation(target.position - transform.position);
Quaternion dynamicTilt = Quaternion.Euler(currentTiltAngle, lookRotation.eulerAngles.y, 0);
transform.rotation = dynamicTilt;
Dynamic Camera Adjustments
RotateCameraWithSled()
rotates the camera using newYRotation - 90
and aligns behind player:
Quaternion rotationOffset = Quaternion.Euler(0, newYRotation - 90, 0);
Vector3 targetOffset = rotationOffset * baseOffset;
targetRotation = Quaternion.LookRotation(target.position - (target.position + targetOffset));
AdjustCameraOffset()
adjusts z
offset during slowdown to prevent camera from passing the player:
float targetZ = isSlowingDown ? 1f : 5f;
baseOffset.z = Mathf.Lerp(baseOffset.z, targetZ, 3f * Time.deltaTime);
Custom Animation: SantaAnimation.cs
SantaAnimation.cs
rotates the forearms when both slowdown keys are pressed:
while (Quaternion.Angle(leftForeArm.localRotation, leftTarget) > 1f ||
Quaternion.Angle(rightForeArm.localRotation, rightTarget) > 1f)
{
leftForeArm.localRotation = Quaternion.Lerp(leftForeArm.localRotation, leftTarget, rotationSpeed * Time.deltaTime);
rightForeArm.localRotation = Quaternion.Lerp(rightForeArm.localRotation, rightTarget, rotationSpeed * Time.deltaTime);
yield return null;
}
When activated, snow particles emit from the poles for added realism:
if (isSlowingDown)
{
if (leftPoleTrail != null && !leftPoleTrail.isPlaying) leftPoleTrail.Play();
if (rightPoleTrail != null && !rightPoleTrail.isPlaying) rightPoleTrail.Play();
}
Supporting Scripts
FinishLine.cs
handles end-of-level logic and displays the score, while ScoreManager.cs
tracks "Flag"
triggers to update points.
Snowball
For the snowball fight, the key scripts are divided into three sections: player, snowball, and presents.
PlayerCam.cs is responsible for determining where the player is looking and tracking hand (mouse) movement to predict where the snowball will be thrown. It also includes sensitivity settings to control how much the camera responds to movement. Additionally, it sets vertical and horizontal limits to prevent the camera from rotating too far in any direction.
private void MyInput()
{
mouseX = Input.GetAxisRaw("Mouse X");
mouseY = Input.GetAxisRaw("Mouse Y");
yRotation += mouseX * sensX * multiplier;
xRotation -= mouseY * sensY * multiplier;
xRotation = Mathf.Clamp(xRotation, -45f, 45f);
yRotation = Mathf.Clamp(yRotation, -45f, 45f);
}
private void ApplySensitivitySetting()
{
GameLevelData data = GameDataBridge.currentLevelData;
if (data == null)
{
Debug.LogWarning("No GameLevelData found in GameDataBridge!");
return;
}
foreach (var param in data.adjustableParameters)
{
if (param.paramName == "CameraSensitivity:")
{
switch (param.intValue)
{
case 0:
sensX = 40f;
sensY = 40f;
break;
case 1:
sensX = 100f;
sensY = 100f;
break;
case 2:
sensX = 150f;
sensY = 150f;
break;
}
Debug.Log($"Camera sensitivity set to: sensX = {sensX}, sensY = {sensY}");
}
}
}
HandController.cs handles the throwing motion of the player’s hand, making it move back and forth when the mouse is clicked (same as making a fist). It also checks whether the hand animation is currently playing or has finished.
IEnumerator AttackCoroutine()
{
isAttack = true;
currentHand.anim.SetTrigger("Attack");
yield return new WaitForSeconds(currentHand.attackDelayA);
isSwing = true;
StartCoroutine(HitCoroutine());
yield return new WaitForSeconds(currentHand.attackDelayB);
isSwing = false;
yield return new WaitForSeconds(currentHand.attackDelay - currentHand.attackDelayA - currentHand.attackDelayB);
isAttack = false;
}
Throwing.cs is responsible for launching the snowball. It uses raycasting to ensure the snowball is thrown in a straight line and to detect if it hits any presents. The script also adds a delay between throws to prevent multiple snowballs from being thrown with a single hand motion.
private void Throw()
{
readyToThrow = false;
GameObject projectile = Instantiate(objectToThrow, attackPoint.position + Vector3.up * 0.5f, cam.rotation);
Destroy(projectile, 1.5f);
Rigidbody projectileRb = projectile.GetComponent();
Vector3 forceDirection = cam.transform.forward;
RaycastHit hit;
if(Physics.Raycast(cam.position, cam.forward, out hit, 500f))
{
forceDirection = (hit.point - attackPoint.position).normalized;
}
Vector3 forceToAdd = forceDirection * throwForce + transform.up * throwUpwardForce;
projectileRb.AddForce(forceToAdd, ForceMode.Impulse);
totalThrows--;
Invoke(nameof(ResetThrow), throwCooldown);
}
TargetObject.cs detects whether a snowball hits a present. When a hit is detected, the present briefly shakes and quickly scales up and then back down to emphasize the impact.
private void OnCollisionEnter(Collision collision)
{
Debug.Log($"[TargetObject] Collision detected with: {collision.gameObject.name}");
GameManager.Instance.AddScore(10);
StartCoroutine(HitEffect());
}
private IEnumerator HitEffect()
{
float duration = 0.1f;
float magnitude = 0.1f;
float elapsed = 0f;
while (elapsed < duration)
{
Vector3 randomOffset = new Vector3(
Random.Range(-magnitude, magnitude),
Random.Range(-magnitude, magnitude),
Random.Range(-magnitude, magnitude)
);
transform.position = originalPosition + randomOffset;
elapsed += Time.deltaTime;
yield return null;
}
transform.position = originalPosition;
transform.localScale = originalScale * 1.18f;
yield return new WaitForSeconds(0.08f);
transform.localScale = originalScale;
}
TargetPoint.cs indicates where the snowball will be thrown by displaying a white crosshair at the centre of the screen.
void CreateCrosshair()
{
float yOffset = 5f;
GameObject verticalLine = new GameObject("VerticalLine");
verticalLine.transform.SetParent(crosshairPanel.transform, false);
Image verticalImage = verticalLine.AddComponent();
verticalImage.color = Color.white;
RectTransform vRect = verticalLine.GetComponent();
vRect.sizeDelta = new Vector2(2, 20);
vRect.anchoredPosition = new Vector2(0, yOffset);
GameObject horizontalLine = new GameObject("HorizontalLine");
horizontalLine.transform.SetParent(crosshairPanel.transform, false);
Image horizontalImage = horizontalLine.AddComponent();
horizontalImage.color = Color.white;
RectTransform hRect = horizontalLine.GetComponent();
hRect.sizeDelta = new Vector2(20, 2);
hRect.anchoredPosition = new Vector2(0, yOffset);
}
Snowangel
For Snowangel, our main focus was on player movement and the animation for tilting.
RealPlayerMove.cs is responsible for handling movement in all directions (left, right, forward, and backward) and ensuring the player stays grounded properly.
void Update()
{
dir.x = Input.GetAxis("Horizontal");
dir.z = Input.GetAxis("Vertical");
dir = dir.normalized;
CheckGround();
if (Input.GetButtonDown("Jump") && ground)
{
rb.AddForce(Vector3.up * jumpHeight, ForceMode.Impulse);
}
}
void CheckGround()
{
RaycastHit hit;
if (Physics.Raycast(transform.position + Vector3.up * 0.5f, Vector3.down, out hit, 1.0f, layer))
{
ground = true;
}
else
{
ground = false;
}
}
Presents Delivery
The key features of the Presents Delivery level include sleigh movement (player movement), vertical height zone transitions, a colour-based present delivery system, scoring and endgame UI and visual effects using particle systems. Unity’s built-in Input System package was used to handle keyboard inputs. Each feature was modularised into different scripts to improve maintainability.
Frameworks and Tools Used
- Unity Input System: for managing keyboard input
- TextMeshPro: for rendering UI elements with advanced text styling capabilities
- URP Shader Graph: used for aurora lights environment effects
- Unity’s Physics System: For trigger detection and Rigidbody-based movement
- Tags System: Used for identifying objects in trigger and collision events
Player Movement
The sleigh is controlled using a custom script, PresentsPlayerController.cs
, which reads input from the Unity Input System. The player moves forward automatically unless the player holds the “S” key, which is mapped to the stopButton
input. Left and right steering is done with the A and D keys, while the sleigh’s vertical height is influenced by “heightZones” in the environment.
bool moveForward = !stopButton.IsPressed();
float scaledForwardSpeed = moveForward ? controlForwardSpeed : 0f;
Vector3 forwardMovement = transform.right * scaledForwardSpeed * Time.deltaTime;
This approach automates forward movement, allowing young players to focus only on steering left and right. This approach reduces complexity, ensuring accessibility.
Height Zone Transitions
We implemented the HeightZone.cs
script with Unity’s trigger system to automatically transition the sleigh’s vertical height based on invisible zones in the environment:
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("HeightZone"))
{
HeightZone heightZone = other.GetComponent<HeightZone>();
targetHeight = heightZone.GetTargetHeight();
}
}
The target height is interpolated using Mathf.SmoothDamp
to ensure smooth gliding.
Present Delivery Logic
The main mechanic of this level is to deliver colour-coded presents to matching houses based on their chimney colour. Each House game object has a required present colour, either “yellow” or “blue”, stored as a string in its House.cs script.
When the player is within the trigger zone of a house and presses the delivery key, Y for yellow or B for blue, the system evaluates if the present colour matches the house’s requirement.
Implemented in DeliverySystem.cs, the DeliverPresent(string presentColour) method handles correctness checks, scoring, visual feedback and delivery status tracking.
private void DeliverPresent(string presentColour)
{
if (currentHouse.presentColour == presentColour)
{
deliveredPresents++;
totalScore += 10;
}
else
{
incorrectPresents++;
totalScore = Mathf.Max(0, totalScore - 10);
}
}
This method evaluates the delivery by checking whether the passed-in presentColour matches the currentHouse.presentColour. If correct, the system increments the deliveredPresents counter and adds 10 points to the total score. If incorrect, it increments the incorrectPresents counter and deducts 10 points, clamping the score at a minimum of 0 using Mathf.Max(0, totalScore - 10).
The current target house is tracked using the OnTriggerEnter(Collider other) method in DeliverySystem.cs, which uses Unity’s tag system and trigger collider mechanism to detect nearby houses that require a present delivery. Each house that require a delivery is tagged as “House”, enabling the following logic implemented in DeliverySystem.cs to register it:
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("House"))
{
currentHouse = other.GetComponent<House>();
}
}
This ensures that only GameObjects tagged “House” are processed, and their corresponding House component is assigned to currentHouse.
House.cs
handles delivery feedback and state updates. When a delivery is made, the MarkAsDelivered() method is called to visually mark the house as completed and prevent duplicate deliveries:
public void MarkAsDelivered()
{
if (!isDelivered)
{
isDelivered = true;
DisableOutline(); // Visually removes outline
StopChimneySmoke(); // Stops chimney particle system
}
}
To summarise the present delivery logic, each house self-manages its delivery requirements through House.cs, while DeliverySystem.cs handles player input, delivery logic, scoring and tracking which house is currently targeted. This modular separation ensures clarity and maintainability.
Visual Feedback
To help players clearly identify when they are near a house that is able to receive a delivery, we implemented visual and text-based feedback cues using Unity’s ParticleSystem and TextMeshProUGUI components. This is especially important for accessibility.
When the sleigh enters the trigger collider of a house that has not received a delivery, a delivery prompt is shown on screen via a UI TextMeshProUGUI element called deliveryPromptText. This prompt dynamically changes colour based on the required present:
if (house.presentColour == "yellow")
{
deliveryPromptText.text = "Deliver <b><color=yellow>Yellow</color></b> Present";
}
else if (house.presentColour == "blue")
{
deliveryPromptText.text = "Deliver <b><color=blue>Blue</color></b> Present";
}
This logic is located in the OnTriggerEnter method of the DeliverySystem.cs script. It ensures the prompt appears only when the player enters the trigger zone of a valid, undelivered house. The prompt is hidden again when the player leaves the zone or makes a delivery:
if (deliveryPromptText != null)
{
deliveryPromptText.text = "";
}
This reset occurs in both OnTriggerExit and at the end of the DeliverPresent() method.
To reinforce successful deliveries, we added colour-specific visual effects using Unity’s ParticleSystem. When a player delivers a present, the corresponding particle system is triggered based on the present colour:
if (presentColour == "yellow" && presentEffectYellow != null)
{
presentEffectYellow.Stop();
presentEffectYellow.Play();
}
This particle effect not only acts as visual stimulation but also provides immediate confirmation that a delivery has been made.
We used Unity’s tag and trigger system to detect the sleigh’s proximity to houses, and the TextMeshProUGUI component provided stylised, colour-coded prompts with bold tags and embedded colour formatting (e.g.
Together, the combination of UI prompts and particle effects significantly enhances clarity and feedback responsiveness, helping ensure the mechanic is easier to understand.
Score Tracking
Scoring and game completion logic are managed in DeliverySystem.cs. The score is displayed throughout gameplay and updated every time the player delivers a present, whether it is correct or incorrect.
The method, in DeliverySystem.cs, responsible for score update is:
private void UpdateScoreUI()
{
if (scoreText != null)
{
int totalPresentsDelivered = deliveredPresents + incorrectPresents;
scoreText.text = $"Presents: {totalPresentsDelivered} / {totalHouses}\nScore: {totalScore}";
if (totalPresentsDelivered >= totalHouses)
{
ShowEndScreen();
}
}
}
The above method calculates the total number of presents delivered through deliveredPresents + incorrectPresents and updates the on-screen text using a TextMeshProGUI component assigned to scoreText. Additionally UpdateScoreUI() method checks whether the player has delivered presents to all houses in the scene through totalPresentsDelivered >= totalHouses. If so, it calls the ShowEndScreen() method, which activates the end screen panel, displays the final score breakdown, and disables further player input to end the level.
Speed Skating
The key features of the Speed Skating level include alternating key-based player movement, turning mechanics, booster and obstacle interactions, lap and checkpoint tracking, timer and best lap display and endgame UI handling. Each feature was implemented using a modular approach, with individual scripts managing specific responsibilities for improved maintainability.
Frameworks and Tools Used
- Unity’s Input System: for managing keyboard input
- TextMeshPro: for rendering UI elements with advanced text styling capabilities
- Unity’s Physics System: for trigger detection and Rigidbody-based movement
- Coroutines: used to manage time-based effects such as speed boosts
- Tags System: Used for identifying objects in trigger and collision events
Player Movement
The player skater is controlled using a custom script, SpeedSkatingPlayerController.cs
, which handles movement using Unity’s built-in Input system and physics components. The player gains speed by alternating presses of the Up and Down arrow keys, simulating a skating movement’s rhythm. Steering is handled via A and D keys, which rotates the player object left and right using Rigidbody physics.
To implement forward acceleration, the HandleInput() method checks whether the player alternates between the Up and Down arrows. If the same key is pressed consecutively, speed does not increase. This logic is implemented as follows:
if ((Input.GetKeyDown(KeyCode.UpArrow) && lastKeyPressed != KeyCode.UpArrow) ||
(Input.GetKeyDown(KeyCode.DownArrow) && lastKeyPressed != KeyCode.DownArrow))
{
forwardSpeed += mashSpeedIncrement;
forwardSpeed = Mathf.Clamp(forwardSpeed, 0f, maxForwardSpeed);
lastKeyPressed = Input.GetKeyDown(KeyCode.UpArrow) ? KeyCode.UpArrow : KeyCode.DownArrow;
}
The code first verifies that the key press is alternating, then increments forwardSpeed by a configurable amount, through mashSpeedIncrement, and clamps it so it does not exceed maxForwardSpeed. This mechanic was designed to promote coordination and immersion by mimicking actual skating movement.
To move the player forward, the ProcessTranslation() method calculates a velocity vector based on current speed. If the player is currently boosted, a higher boostSpeed is used, otherwise, it uses the accumulated forwardSpeed. This value is applied as the player’s Rigidbody.linearVelocity, allowing smooth, physics-based movement:
float currentSpeed = isBoosted ? boostSpeed : forwardSpeed;
rb.linearVelocity = transform.forward * currentSpeed;
Turning is handled in the ProcessRotation() method. This method allows the player to steer left or right based on inputs while maintaining smooth physical movement using Unity’s Rigidbody system. The player turns by rotating the Rigidbody using rb.MoveRotation, which safely rotates the object while still respecting physics interactions like collisions. This is better than directly setting transform.rotation, because transform changes are immediate and bypass Unity’s physics engine, which can cause ignored collisions.
To detect left and right input, horizontal axis values are first checked in the HandleInput() method:
float hInput = Input.GetAxis("Horizontal");
aPressed = hInput < 0;
dPressed = hInput > 0;
This sets flags depending on whether the A or D keys, the rotating keys, are being held down. The ProcessRotation() method than evaluates those flags:
float turnDirection = 0f;
if (aPressed && !dPressed) { turnDirection = -1f; }
else if (dPressed && !aPressed) { turnDirection = 1f; }
if (turnDirection != 0f) {
float turnAmount = turnDirection * turnSpeed * Time.deltaTime;
Quaternion turnRotation = Quaternion.Euler(0f, turnAmount, 0f);
rb.MoveRotation(rb.rotation * turnRotation);
}
This ensures that only one direction is processed at a time, this means that holding both rotation inputs cancels out each other. This prevents jerky or unintended behaviour. The final turn is applied using a Quaternion to ensure smooth rotation over time.
To summarise the player movement system, The main focus is on alternating forward inputs to gain momentum and using directional inputs to steer. This mechanic simplifies the gameplay by removing complex controls and encourages coordination.
Speed Modifiers: Boosters and Obstacles
To create dynamic gameplay, two key game mechanics were implemented: Boosters and Obstacles. Boosters, represented by gingerbread man, increase the player’s speed temporarily, while obstacles, represented by a pile of snow, slows the player down. These features are both implemented using Unity’s built-in Physics System through trigger colliders and are associated with a custom script, Booster.cs and Obstacle.cs.
Boosters work by calling the ActivateBoost() method in the SpeedSkatingPlayerController.cs script. When triggered, this method sets a boolean flag called isBoosted to true and starts a coroutine that lasts for a set number of seconds (boostDuration).
While this flag is true, the player’s movement speed is set to a faster value (boostSpeed) for a fixed duration (boostDuration). During this time, the player’s forwardSpeed is overridden in the ProcessTranslation() method:
float currentSpeed = isBoosted ? boostSpeed : forwardSpeed;
rb.linearVelocity = transform.forward * currentSpeed;
This logic ensures that while isBoosted is true, the player moves at a consistent boost speed regardless of mash input. After the duration ends, the coroutine resets isBoosted to false, restoring normal movement.
When a player enters a booster’s trigger zone, the OnTriggerEnter() method in Booster.cs checks if the object has the “Player” tag. If so, it calls the player’s ActiveBoost() method and disables the booster so it can’t be used again :
if (other.CompareTag("Player"))
{
player.ActivateBoost();
gameObject.SetActive(false);
}
Obstacles, on the other hand, reduce the player’s current speed. They work similarly to boosters in structure but have the opposite effect. When the player enters an obstacle’s trigger, the Obstacle.cs script calls the player’s ApplySlowdown() method:
player.ApplySlowdown(slowdownAmount, cancelBoost);
This method reduces the forwardSpeed and optionally cancels the active boost:
forwardSpeed -= amount;
if (cancelBoost && isBoosted) isBoosted = false;
By separating logic into Booster.cs and Obstacle.cs, both mechanics are easy to manage, and new behaviours could be added with minimal changes. Together, these features make gameplay more dynamic and exciting and rewards players for strategic movement.
Lap Tracking and Checkpoints
Lap progression and checkpoint validation are essential mechanics in the Speed Skating level, handled by the LapManager.cs script. This system ensures players follow the correct track path before completing each lap.
The lap system is triggered when the player enters the finish line zone. This is done using a BoxCollider on the finish line object and Unity’s OnTriggerEnter() method:
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
if (!raceStarted) StartRace();
else if (checkpointsPassed >= totalCheckpoints) LapCompleted();
}
}
The first time the player crosses the finish line, the race starts. After that, each time the player crosses the finish line, the script checks whether they have passed all required checkpoints using the checkpointsPassed counter. Only then can a lap be marked complete.
Checkpoints are separate GameObjects tagged “Checkpoint” that trigger LapManager.CheckpointPassed() when entered:
public void CheckpointPassed()
{
checkpointsPassed++;
Debug.Log("Checkpoint passed! Total: " + checkpointsPassed);
}
Once a lap is completed, the current lap number is incremented, and booster, obstacle and checkpoint objects are reset for the next lap:
currentLap++;
UpdateLapUI();
RespawnBoosters();
RespawnObstacles();
RespawnCheckpoints();
Each checkpoint is reset using ResetCheckpoint() method in Checkpoint.cs:
public void ResetCheckpoint()
{
gameObject.SetActive(true);
}
To prevent players from skipping the track, checkpoints must be passed in every lap. The lap text UI is updated using TextMeshPro to reflect progress:
lapText.text = "Lap: " + currentLap + "/" + totalLaps;
This system helps ensure fair gameplay and encourages full track completion, while still allowing flexibility to scale up lap count or add more checkpoints.
Timer and Best Lap Display
To provide players with feedback on their performance, the game features a race timer and best lap display. These elements are managed within the LapManager.cs script and updated in real time using Unity’s Update() method.
When the race starts, a timer begins counting up using Time.deltaTime, which tracks how many seconds have passed since the last frame:
private void Update()
{
if (raceStarted)
{
raceTimer += Time.deltaTime;
UpdateTimerUI();
}
}
The UpdateTimerUI() method updates the timerText UI element, a TextMeshProUGUI component, every frame:
timerText.text = "Time: " + raceTimer.ToString("F2") + "s";
The lap manager also tracks the duration of each lap by calculating the difference between the current time and time of the last lap:
float currentLapTime = raceTimer - lastLapTime;
lastLapTime = raceTimer;
If the current lap time is faster than the previously recorded best lap time, it is saved and shown on screen using UpdateBestLapUI():
if (currentLapTime < bestLapTime)
{
bestLapTime = currentLapTime;
UpdateBestLapUI();
}
The best lap is shown during the race and also on the end screen when the player finishes all laps. If no valid best lap was recorded, the display shows “N/A”:
bestLapEndText.text = "Best Lap: " +
(bestLapTime == Mathf.Infinity ? "N/A" : bestLapTime.ToString("F2") + "s");
Using this timer system, players can see their progress and try to beat their own records. Displaying this information clearly encourages replayability and adds a competitive edge to the experience.
End Game Logic and UI
The end of the race is handled by the EndRace() method in the LapManager.cs script. This method is triggered when the final lap is completed. To freeze gameplay, it disables the player’s movement and sets Time.timeScale = 0f, effectively pausing the game. It also disables physics movement by making the player’s Rigidbody kinematic:
playerRb.linearVelocity = Vector3.zero;
playerRb.angularVelocity = Vector3.zero;
playerRb.isKinematic = true;
Once the player's movement is stopped, the method activates a UI panel (endGamePanel) that displays the final race time and the best lap. These values are updated using TextMeshPro UI elements:
finalTimeText.text = "Final Time: " + raceTimer.ToString("F2") + "s";
bestLapEndText.text = "Best Lap: " +
(bestLapTime == Mathf.Infinity ? "N/A" : bestLapTime.ToString("F2") + "s");
This provides the player with clear feedback.