top of page
Puzzle Paradox Banner

Engine | Custom C++ Engine

Play Time | 15-20 Minutes

Download | 

Team Size | Solo

Platform | PC

Puzzle Paradox is a first-person puzzle platformer game. Plunge into an eerie ancient lava tomb as Solver the Skeleton. To escape, you'll gather magical artifacts, dodge relentless lava rocks, and solve puzzles in an attempt to unlock each tomb’s massive escape door. I took on various roles in this solo project, including but not limited to Systems, Gameplay, and Graphics Programmer. This was also my final capstone project at Full Sail University.

Roles | Systems/Gameplay/Graphics Programmer

Duration | 08/2023 - 11/2023 (4 Months)

Responsibilities

Core/sub-systems and the interaction/communication across systems:

  • Designed and implemented lower-level systems (2D and 3D renderer, audio, input, event dispatcher, game and render states, cleanup)

  • Built higher-level gameplay systems using a Fast Lightweight Entity Component System (FLECS)

  • Utilization of several third-party open-source APIs. Heavy documentation research and application

Custom asset pipeline:

  • Exporting custom level data (models, textures, blender custom properties) from Blender with Python scripts to the custom engine

  • File input/output and string/char manipulation for exporting and importing data in an organized data-driven manner, eliminating hard-coded values

Gameplay systems:

  • Implemented ECS and built all game logic systems utilizing entities and systems that run across the entities

  • Custom pipeline phases/order of execution and the coordination/timing of the various gameplay systems

  • Implementation of third-party 2D and 3D positional/rotational audio systems with audio sources and audio listeners

  • Custom physics and collision detection/resolution systems with great use of mass, gravity, velocity, acceleration, etc.

  • Matrix transformations including quaternion rotations and transforming vectors back and fourth through various spaces

  • Custom view-to-world space raycast interaction system, similar to screen-to-world point in Unity

DirectX 11 renderer:

  • Initialization of vertex, index, instance buffers, and many other interfaces - abstracted across several render state wrapper classes for modularity and ease of access with single responsibilities

  • Handling of render state transitions and clean up without memory leaks

  • Custom lighting and material interactions following standard lighting models (Diffuse, Ambient, Specular) and practices/procedures (Lambert's Cosine Law/Schlick Fresnel, Blinn Phong). 

  • Implementation of directional, point, and spotlights with custom attenuation and range settings assigned and exported from Blender

Player Interaction System: Object Picking

The Problem

In each level, two brick pressure plates require bricks to be placed on top. Once this has been completed, a third collectible, a sphere, will be spawned into the level – at which point the player will need to bring this to the third pressure plate called the altar. Doing so will open the level’s escape door, allowing the player to move on to the next level. The core problem to solve here is to get the player interacting with objects, which can trigger specific events dependent on the current state of the player’s inventory and the current state of the level’s puzzle requirements.

Solving this problem required multiple systems to interact with each other, including but not limited to the physics/collisions, input, and user interface systems. Some of the overall tasks to complete this larger problem included:

  • The identification/marking of interactable objects as such

  • Picking – detect what object the player is looking at

  • Verification of ability to currently interact with an object, and perform the appropriate action

 

The Solution

We do not want the player to interact with every type of object – such as the static world. Certain objects need to be identified as interactable in general, as well as further identified as to what type of object they are – a collectible or a destination to deliver collectibles to. I took advantage of the beauty of the entity component system and the engine’s data-driven asset pipeline to mainstream and automate this identification process the best I could. Using Blender as the level editor, I was able to give specifics to certain objects using Blender’s custom properties, enabling me to export some extra meta-data along with each object incredibly easily and efficiently. Then when importing/loading the level data and generating entities inside the game world, appropriate identification components are added to each object if necessary. Without this data-driven approach, there would have been a lot of hard-coding things (there still is a small amount of this but in a relatively safe way), which certainly isn’t as scalable – and to be frank, ugly.

File I/O

Brief overview of the assignment of identification tags to entities at runtime, starting from Blender custom properties, through the file I/O stage, and into the creation of entities when creating the game world

Now, to the meat and potatoes – player interaction. I could have gone the easy route and just used the existing collision system to detect if the player itself collided with these interactable objects, but that’s no fun. I want to be able to detect what the player is looking at with their crosshair for interaction. To do this, in short – I decided to “ray cast” from the camera’s origin in the camera’s forward direction – testing for any intersections with object bounding boxes – and I don’t want to test for just any collider intersection – just the colliders whose entity has an interactable component! With the existing collision test cache system that collects all entities with a collider and places them in a cache which is later used to resolve any collisions – I decided to separate the interactable colliders into a separate test cache to be more efficient. If we are only looking for an interactable collider, I do not need to check all of the non-interactable colliders in the scene for nothing as well. This was the easier part.

 

The main task was interacting effectively. This involved creating a line segment using two vector positions and feeding the line into Gateware’s IntersectLineToOBBF function. These calculations only occur if no interaction with an object is ongoing. When the player looks at an object, they get a LookingAt component with a reference to the collider. This collider includes data linking it to its entity, allowing for versatile connections. When the player stops looking, the component is removed, restarting the interaction system. For debugging, the line segment can be sent to the renderer system if the visual collider debugger is active, instructing it to draw the segment (the blue line in the images below).

No intersection
Intersection

No intersection (notice the very small gap)

Accurate intersection

On top of that, a few more things were necessary to get the player to collect the object:

  • When the LookingAt component is added to or removed from the player, we communicate that to the input system, telling it to start or stop listening for the player interaction input to be pressed.

  • We also tell the renderer to switch out the crosshair sprite for an Interact Key sprite, giving feedback and instruction to the player.

Last but not least, I had to incorporate some level logic to wrap up the collection and drop-off of the objects. In short, I created a level manager of sorts to hold the current state of the level, which dictates what should happen when the player drops off these objects to their destination, as well as determining what the player is allowed to do at the current moment. For easier demonstration of this level logic, here is a concise list of what is being taken care of:

  • Disable the sphere entity on start. All brick drop-offs must be activated first to reveal the sphere

  • Collecting bricks/spheres – can only hold one at a time

  • Each drop-off destination can only be activated and interacted with once

  • Cannot interact with a drop-off if its required item (brick or sphere) is not currently being held

  • Finally, when the sphere object has been dropped off to the altar, trigger the giant escape door to open

Level logic

Brief overview of the level logic that handles performing the correct action when interacting with objects

Collision Detection + Resolution: Wall Detection and Is-Grounded Systems

By implementing visual collider and wireframe debug modes, it was much easier to solve some of the issues brought up with collision detection and resolution

The Problem

During this project, a significant challenge arose when I had to overhaul the physics, collision detection, and resolution systems. Initially, the player could only navigate flat surfaces and would bypass vertical walls (tunneling), courtesy of a collision system that was overly aggressive. It attempted to manage various object interactions, accommodating convex and concave shapes using the separating axis theorem. However, due to time constraints, a decision was made to revamp the collisions, leading to a shift in the game's scope to a simpler first-person platformer. Consequently, the focus narrowed down to performing collision detection and resolution exclusively for the player, aligning with the new game requirements.

With quite a bit of time lost on all this reworking, I decided to take a much simpler and straightforward approach. I opted to build two major systems: a wall detection system and an "is-grounded" detection system. Given my limited time and knowledge of the more advanced separating axis theorem, I didn't think it was the right moment to reinvent the wheel.

The Solution (1): Wall Detection System

Wall detection and resolution system with dynamically colored debug lines

For the wall detection system, I implemented four line segments originating from the player—one extending in each global direction on the XZ plane (forward, rear, left, and right). These lines utilized the IntersectLineToOBB function. You might wonder why I chose global direction lines instead of aligning them with the player's local axes transformed into global space. The decision was primarily based on the level design. Since there were no static scene objects (like wall colliders, pillars, floors, etc.) with rotations, the normal direction of any wall face would always align orthogonally with the global space axes. I chose not to rotate these bounding boxes to concentrate on other aspects of development.

Since the wall detection lines were set perpendicular to the global-space vectors, I could determine the direction of my minimal translation vector based on which line intersected. The only missing information I required was the "out interval," essentially the distance or magnitude of the translation vector needed to move the player and avoid overlapping with the wall, thus resolving the collision before frame rendering. Luckily, the out parameters of the IntersectLineToOBB function I had access to supplied me with this crucial out interval.

Wall detection system

A + B) Top-down view of the wall-detection intersection lines.

C) Dynamic debug line color: Red indicates intersection. Turns back to green after the collision is resolved.

Note: Images are from a much earlier version.

The Solution (2): Is-Grounded System

Is-grounded system for floor detection and resolution also with dynamically colored debug intersection lines

It's quite interesting how the is-grounded system works. The main collision cache is constantly updated and populated or emptied with the entity pair relationships that are currently causing a collision, calculated via OBB (object bounding box) to OBB intersection tests. In other words, when an object collides with another object and they become overlapped, a pair containing information about the two colliders is placed in a cache, indicating that this collision needs to be resolved.

Similar to the wall detection system, four other line segments that are perpendicular to the XZ plane (vertical lines) are mounted at the base of the player's feet.
When the player is not grounded (in mid-air from jumping or falling off an edge), gravity is applied to the player. When the player collider overlaps another collider of any static scene object (the world), the is-grounded system is engaged. The four intersection lines begin performing tests on the collider(s) that the player has made a collision with. 

Becoming grounded - all debug lines are red
All four lines must intersect simultaneously. The out-interval result of the deepest intersection calculation is used to resolve the collision, fixing the collider overlap and preventing sinking into the floor. The player's dynamic physics component then ignores gravity while they are grounded, essentially placing them on this floor.

Becoming un-grounded - all debug lines are green

To become ungrounded, causing gravity to affect the player's velocity again, the player must either jump or fall off of an edge.  Since the is-grounded system is currently engaged and performing calculations, we do the opposite logic: look for when neither of the four lines is intersecting!

Bonus: Is-grounded system driving the player model/pose
Depending on whether the player is currently grounded or not, the renderer is told which set of vertices and vertex indices to use to draw the player model.

After rigging and skinning the player model in Blender and creating two different poses (idle and in-air) I exported two different models. On the import side when generating the game world entities, the player is given a model override component, which simply holds the range of numbers pointing to which vertices and indices make up the triangles of the second model, thereby later on communicating to the renderer saying - "draw these triangles instead."

From Blender to Custom Engine: Randomized & Data-Driven Lava Rock Spawner System

The Problem

The hazardous lava rock spawner system was one of my favorite gameplay systems in the project. The challenge extended beyond merely generating lava rocks; it involved finding a reusable and efficient solution while introducing an element of randomness to enhance the overall gaming experience.

One major task focused on establishing the spawner objects themselves. It was crucial to create a system that could adapt seamlessly to various levels, allowing for manual configuration of specific spawner settings when needed. I aimed to avoid hardcoding these parameters, especially considering the significant quantity of spawners required per level (20+), which would have been a daunting task. To achieve this, I once again utilized Blender's custom properties, thereby elevating Blender's role to that of a glorified level editor.

Another major task was to get the lava rocks spawning at these spawner objects' locations. One requirement on top of this is that I need to spawn these lava rocks continuously every so often. A FLECS system that runs on an interval sounded like the way to go. Also, with already having laid the groundwork for instantiating instances of prefabs, this was the easier part of the solution.

In short:

  • Create spawner objects in the levels, and export custom meta-data along with them to aid in the random generation of the lava rock at each spawner

  • Instantiate instances of the lava rock prefab continuously at an interval with as much randomness as possible

The Solution

Setting up the spawner objects was incredibly enjoyable, and the end outcome proved highly satisfying. The primary goal was to export these spawner objects along with the level data for efficient spawning. Instead of creating cubes and hiding them in the game as spawn positions, which would lead to unnecessary processing, I opted for a different approach using Blenders curve objects.

Similarly to meshes, curves have a transform matrix, which is crucial for export. I chose circle curves for simplicity, duplicating them to create multiple spawners. Notably, the scale of each circle curve is embedded in its matrix, allowing me to denote the scale of the spawned lava balls based on the randomly chosen spawner's scale.

Level 1 top-down view
Level 2 top-down view
Level 3 top-down view

Top-down views of levels 1-3 in Blender. Note the varied scale of each of the spawners. This is one way that I took advantage of Blender to randomize each of the lava rocks.

Next, the goal is to introduce more variety to the spawned objects when they appear. To achieve this, custom properties needed to be exported with each spawner object. This approach eliminates the need for extensive hardcoding on the importer side and provides greater flexibility in configuring specific attributes for different objects on the Blender side, making tuning these properties incredibly easier.

A crucial custom property I devised for adding variety to each lava rock is the "spawn force," serving as an "upward force multiplier." This allows me to adjust the force with which certain spawners launch the lava ball, creating noticeable differences, particularly evident in level 3.

In this level, the lava rocks between major platforms are propelled with more force than the others scattered throughout the map for more decorative purposes.

Finally, the primary focus of the overall problem centered around the spawner system itself. An excellent feature I found in FLECS was its built-in Timer Plugin, which proved immensely useful. This plugin allows me to execute any system at specified intervals, easily adjusted in my Initialization code.

FLECS interval

A look at how the spawner system is calling the spawn function continuously over an interval of time

Following these intervals, I aim to spawn a random number of lava rocks, each originating from a unique, randomly selected spawner. Additionally, I want each lava rock to possess random attributes such as rotation, upward velocity, and mass. This approach introduces a considerable amount of variety to each lava rock, enhancing the overall appearance of randomness. Some of these pseudo-random numbers are pre-generated during level-initialization, while others are computed on the spot, such as a random integer for array indexing or determining whether to flip angular velocity across an axis to be either negative or positive. It's worth noting that a small random deviation is added to the mass of each lava rock as well, contributing further to the variety as gravity affects them.

Random lava rocks

The bulk of this code shows how the number of lava rocks to spawn in this wave is determined. By also using a std::set data structure, I prevent duplications of the same spawner index. This ensures that two lava rocks don't spawn out of the same spawner.

Instantiation of lava rock

This function is what sets all of the properties for a single lava rock being instantiated.

bottom of page