Poster artwork by my good friend (and former flatmate) Liam Doyle
More Than Meets The Eye (MTMTE) is a game designed to make you feel like a Transformer.
It's a third-person platformer prototype, featuring both a robot and car movement system, refined polish effects and environmental interaction. You can transform seamlessly at any time.
It was developed for my final university project, investigating how game feel design and embodiment in the player character are linked and how to design for embodiment in practice.
I received an A+ grade and was shortlisted for the GameMaker Award for Game and System Design for MTMTE.
My main design goal for this project was to create a game where the player felt embodied as a transformer. My dissertation showed three main principles to design for to create a sense of embodiment:
Agency: The player must feel they have a high degree of control over the avatar (player character). There should be a continuous sense of control from the player's input and avatar response.
Body Ownership: The player’s expectations of the avatar must align with how it looks and feels. The player should perceive a vivid identity for the avatar.
Self-Location: The avatar must exist in a consistent environment. Each interaction between the avatar and the game world should adhere to some internal logic.
I choose Transformers as the basis of my game as I believed it would offer a fun technical and design challenge to put together. Also, transformers are awesome.
My first idea for the controller was this diagram. A top level controller which uses a state machine to change between modes, and each mode would have a mover class and its own state machine to handle mode specific movement.
This would be modular, each mode would handle movement, animation etc. separately allowing for mode specific implementations of each.
It would also be scalable, more modes, such as an airplane mode, could be added without interfering with existing mode functionality.
The BaseMode class would have some abstract methods such as GetVelocity() and GetPosition(), which the controller could use during transformation to maintain position and velocity throughout the switch.
When the player presses transform while in robot mode, the player controller's state machine exits RobotState and enters TransformingState for 0.75 seconds. On exiting RobotState, the controller calls the TransformFrom() function of the robot mode, and the TransformTo() function of the car mode.
These methods allow each mode to do a number of things, like play the transforming animation, or reorient rotation.
Once transforming is complete, the controllers calls ExitMode() and EnterMode() on the RobotMode and CarMode respectively. These functions are when control is actually changed from mode to mode, when you begin to drive instead of walk for example.
This CarMode function is called from TransformTo(), and reorients the (invisible) car object to follow the robot mode during its transformation animation, to create a seamless transition.
You can see this in-action in this GIF, where the car is the yellow wireframe rectangle in debug mode.
The PlayerController and the Modes implement a StateController interface. Each StateController creates a state machine, with its own states, transitions and in-state logic.
Because both modes would implement different movement logic, I made the state machine and the states abstract. This way, both modes could have their own definitions of "grounded" and their own logic to change from "grounded" to "falling", for example.
Below are snippets of the "brains" of each modes state machine. Each At() method determines a state transition. For example:
At(grounded, rising, new FuncPredicate(() => !IsGrounded() && IsRising()));
Means "At the grounded state, move to the rising state, when we are NOT grounded AND we are rising".
Notice how each mode has a different definition for this transition, as CarMode and RobotMode use different methods to determine whether they are grounded or not.
The CarMode State Machine
The RobotMode State Machine
My design goal was to make the robot movement feel weighty, but powerful and responsive; a huge metal mass, with enough power to move itself around easily. A careful balance between maintaining player agency while also selling the illusion of a hefty avatar.
For this, I eventually realised that a force-based, physics approach would allow me to have elements like acceleration, momentum and even physics collisions, which would all contribute to providing the desired effect of heft and weight.
Additionally, I also added the ability to climb up the various buildings in the level. This fleshed out environmental interaction and allowed more freedom of movement for the player, allowing them to move vertically up the level and jump off tall buildings.
Sticking by the physics would work for most of the horizontal movement. However, for jumping movement, I would break the simulation to create a more deterministic jump, as well as utilizing techniques like coyote time and jump buffering.
Every frame, the RobotMode calculates a horizontal and vertical force throughout the frame, depending on input, current move state etc.
The forces are split so that each can be calculated and adjusted independently.
At the end of the frame, these forces are combined and applied to the RobotMode's RigidBody physics component.
Method for calculating horizontal movement (running). With the direction depending on input, a force is calculated, scaled to remove the vertical component, and then added to the horizontal force in this frame.
Method for calculating wall / climbing movement. Similar to horizontal movement, it gets the direction based on input (remapped to the plane of the wall) and creates a force. The component of the force vector facing the wall is removed (which stops the player getting smooshed into the wall).
This method rotates the player in the direction of velocity, or toward the wall if climbing.
This method does some last minute checks before adding the forces together and applying them to the RigidBody.
Exposed variables for adjusting the robot mode movement.
To prevent the jumps feeling floaty, I used a more deterministic approach, inspired by the excellent talk “Math for Game Programmers: Building a Better Jump” (Pittman, 2016).
By setting a max jump time and height, both the avatar's gravity and jump speed are adjusted to create a jumping arc. This also allows fine design control over how high and how long the avatar can jump for.
The jump is further tweaked by increasing gravity after the apex of the arc, which allows the avatar to land quicker and gives the player a higher sense of control.
Additionally, if the player stops holding jump while midair, the avatar will fall much quicker. This means tapping the jump button allows for shorter, more precise jumps. This further improves the sense of control and gives more options to the player.
Coyote time and jump buffering are also utilized to reduce frustration and better translate player intent.
This methods adjust gravity and jumpSpeed based on the jump height and time specified in the inspector.
This method adds a multiplier to the avatar's gravity when at the apex of a jump, or when the player stops holding the jump button.
When the player begins falling, the short duration jumpCoyoteTimer is started. While this timer is still running, the player can still make a jump input, even if they aren’t technically on the ground.
When the player inputs jump while falling, the short duration jumpBufferTimer is started. If the avatar detects ground while the timer is running, it will jump and reset the timer.
I had two methods in mind when I approached creating the car mode of the player.
Base the physics on a sphere collider which "rolls" around, creating a simple arcade style of vehicle.
Simulate forces such as acceleration and suspension and apply them at the positions of the wheels on a box collider, for a more complex and realistic simulation.
I chose Option 2. This method would be harder than Option 1, but more in line with the design goals. It would allow for more convincing physics interactions such as suspension bounce and vehicular collisions, as it models the actual physics of cars much closer.
Transforming from robot to car is a major point of focus for creating embodiment in the character, so I wanted to ensure that this transition has satisfying and expected interactions no matter if the player transformed midair, mid-jump, or at an awkward angle.
My implementation was heavily influenced by the excellent video “Making Custom Car Physics in Unity (for Very Very Valet)” (Toyful Games,2022).
Option 1: The rolling ball approach. While easier to implement movement, collisions and suspension would be more difficult to model.
Option 2: The simulated force approach. Each set of three lines represents a "wheel", with three different directional forces applied at the positions on the box collider.
The car is made of two axles, which each contain a left and right wheel.
Each of these wheels applies a force on the main car rididbody. The wheel force consists of three components:
Suspension: Up/Down
Slide/Steer: Left/Right
Acceleration: Forward/Backwards
The car steers by rotating the steer wheel transforms, changing the direction of their forces relative to the car.
The exposed variables for the car axles. The system can allow for any drive layout and any number of axles. In this example, the car is front-wheel steering and rear-wheel drive.
The suspension force. This force uses spring logic to simulate a car's suspension. This creates a satisfying bounce when the car lands on the ground.
These are the exposed variables for adjusting the car.
The slide/steer force. This force is calculated by first finding the ratio of each wheel's velocity in the left/right direction.
Then it uses a lookup curve (below) to determine how much grip the wheel should have. This is because as tires continue to slide, they lose more traction and so slide even more.
This grip force acts as a friction against the slide force, allowing the car to be controlled and not slide everywhere.
The acceleration force. It uses another lookup curve to simulate engine torque. If the player holds accelerate, they drive forward. If they hold reverse, they will brake until they are stationary, and then begin to reverse.
Polish effects are the primary way to engineer game feel, and thus also vital to creating an avatar which can be embodied by the player. I would use many, many different types of polish effects, as layering effects different types of effect amplifies the impact. I would also have to ensure there was variety in the effects, else they become stale or annoying.
To organise these effects and the logic used to activate them, I created various effect player scripts. These effect players each subscribe to events which are fired from the mode classes.
Keeping each type of effect player in its own class kept the logic self-contained, which made debugging much simpler.
To actually store the effect data, I created "sheets", scriptable objects which contained references to each effect, grouped together by mode and effect type (Car Haptic Sheet, Robot Audio Sheet etc.) This meant the effect players did not have to have a half dozen individual references to effects, and instead reference one object.
Visual effects included particle effects such as a dust cloud and decals like a ground crack. Both of these effects would be activated when the avatar landed at high speed.
Each visual effect was stored as a prefab that was instantiated by the visual effect player when needed.
The ground crack decal. Each time it is instantiated, one of three sprites are chosen at random to introduce some variety.
The dust cloud particle effect.
For audio effects, I made use of Fmod, an external software that allows you to play audio events instead of audio clips. This means that each audio effect, such as a jump effect, can have pitch and position randomness built in, providing novelty and variety.
Additionally, these events can be influenced during play, allowing the audio to be altered through game logic.
The Fmod event for the car engine effect. As the acceleration variable increases, the event blends between a low rpm and high rpm engine sound sample, as well as constantly increasing the pitch.
The car audio player. Here, the acceleration variable in the Fmod event is set, depending on the ratio of current speed to the maximum speed.
To fully flesh out my polish effects, I wanted to include haptic effects, or controller rumble.
I needed a system capable of:
Playing multiple haptic effects simultaneously
Having effects loop and be adjusting during runtime
Playing complex effects with variable durations and intensities.
I noticed that by making something similar to Fmod implementation inside Unity, I could achieve all of these design goals.
Ultimately, the haptic system is made up of three classes:
HapticEffect: The actual data of the effect.
HapticInstance: A particular instance of a HapticEffect created by HapticManager
HapticManager: A global manager which creates and handles all current haptic instances.
This is HapticEffect. The actual data of the effect. Both high and low speed motor intensities can be adjusted over time using the animation curve to create more varied effects. The effect can also be made to loop or be a one shot.
This is HapticInstance. This class has an internal timer, set to the duration of the effect. When this duration is reached, the instance will either loop and start again, or stop and flag itself for destruction by HapticManager.
This is HapticManager. It is a persistent singleton, meaning it ensures that only one instance of itself exists throughout the game, even in different scenes.
It stores a list of all active HapticInstances, and every frame sums up the haptic intensities of each effect, before setting the motor speeds inside the gamepad controller. This allows for multiple effects to be played simultaneously instead of overriding each other.
Any class can play a haptic effect if they have a reference to the effect, such as the CarHapticPlayer class. Either the class can use PlayOneShot() and let the manager handle it, or use Play() to handle the instance themselves.
Play() allows for looping effects, like the car's engine rumble, to be adjusted and stopped inside the CarHapticPlayer during runtime, while also allowing for more "set and forget" functionality for basic effects like a footstep.
An example of how to play haptic effects. This is the CarHapticPlayer.
When the car mode is entered, the player creates and stores a reference to the Engine effect instance using HapticManager.
When the car transforms back into a robot, a Oneshot effect is played for transforming, and the Engine instance is stopped and released from memory.
Every frame in Update(), the engine effect intensity is set to the speed of the car, so there is a higher rumble when the car is moving faster.
For camera polish effects, I added a screenshake (for collisions and landings) and a sliding FOV. This would help create a sense of weight and speed respectively.
The screenshake implementation was inspired by the GDC talk “Math for Game Programmers: Juicing Your Cameras With Math” (Eiserloh, 2017).
Each screenshake event adds to a trauma value between 0 and 1.This trauma ticks down every frame. Depending on this trauma, the camera rotation is randomly set based on perlin noise.
The camera lerps between a minimum and maximum field of view, depending on the normalized speed of the car. This enhances the sensation of speed.
I do not like the unity animator. It is prone to error, and having to replicate existing logic in the animator state graph is tedious. Additionally, it relies on the use of hard coded string references, which are prone to human error and mis-input.
Because my own state machine handles state and transition logic already, I could bypass the animator system by subscribing to events triggered by state machine transitions and playing animation clips programmatically.
If the player is transforming while an transition event is triggered, the animation is cached to play after transformation has completed.
There was one huge hurdle for animations in my project. I used two sources for my animations. Mixamo for all movement animations like jumping, and the Transformer Model I used which included transformation animations in it.
Both animation sources changed different bones, and so when the model was changed by a transformation animation, it would break the skeleton permanently.
To fix this, I devised a system where each bones transform would be saved in a dictionary on game start. Then, after transforming, these transforms would be loaded back to their original state. This fixed the problem and allowed me to use both types of animation.