Jas Singh

I'm a passionate Computer Scientist interested in the logical puzzles that come with Game Development.


Resume

GitHub

LinkedIn

Projects

This section showcases some of my recent projects.


[Row] A wacky, polished Global BC Game Jam entry. Play it on itch.io!

[Gouken] a fighting game whose mechanics are modelled after Street Fighter: Third Strike.

[ShadowDancer] A Thief: the Dark Project (1998) clone with a single level and functioning light and sound detection-based AI..

[Hand-written Multi-Level-Feedback-Queue Scheduler for PintOS] A Stanford Computer Science assignment where I replaced a toy OS' round robin scheduler in favour of a priority-based feedback one instead.

Row - Global BC Game Jam Entry

Play now on itch.io | Repository

A wacky physics-based game inspired by Bennett Foddy. Made during a weeklong Game Jam. Play it here!




Gouken - A Third Strike-Esque Fighting Game

Repository
Built for iOS on SceneKit - a Swift framework for Game Development.

Features

Input Buffers and Special Inputs Code


In fighting games, special moves like Hadoukens and Shoryukens require players to input specific sequences of motions (like quarter-circles) and button presses. To recognize these moves, the game needs to track the player's input history. However, naively storing the last few inputs isn't enough because these inputs are read frame-by-frame.
For example, let's expand upon the ramifications of a naive approach to perform the move sequence below:

  • Down
  • Forward
  • Punch
A player would need to press these inputs in a frame-perfect sequence, meaning each input must be within 16 milliseconds of the next. This level of precision is too difficult for most players.

To make special moves more achievable, Gouken stores a longer history of inputs and processes them with some leniency. We use a Ring Buffer Data Structure for this purpose. This structure efficiently keeps track of many past inputs, allowing the game to recognize valid sequences even if the inputs are not perfectly timed.




Gouken uses a ring buffer to store the player's last 60 inputs and checks them against the character's move list. The parsing algorithm in Gouken determines which moves have higher priority and how leniently to interpret the inputs. For instance, when parsing the last 18 frames of input, as long as the sequence includes Down, Forward, and Punch in that order, a Dragon Punch will be triggered, regardless of any other inputs in between. Below is a code snippet showing how special input parsing is implemented:

for (_, move) in seq {
  let moveSequence = move.sequence
  let frameLeniency = move.frameLeniency
  var currentFrame = 0
  var sequenceIdx = moveSequence.count - 1
  while (currentFrame < frameLeniency) {
    if move.priority < currentMovesPrio {
        break
    }
    let readIdx = (buffer.writeIdx - currentFrame - 1) < 0 ? bufferSize - currentFrame - 1 
      : (buffer.writeIdx - currentFrame - 1) % bufferSize
    
    if (moveSequence[sequenceIdx] == buffer.buffer[readIdx]) {
        sequenceIdx -= 1
    }
    if (sequenceIdx == -1) {
        currentMove = move
        currentMovesPrio = currentMove.priority
        break
    }
    currentFrame += 1
  }
}

Made during a three month long course, we sadly did not have the time to expand much past this. Given more time, we would opt to closely follow Street Fighter 6's input buffer rules.



Dynamic Hitboxes and Hurtboxes Code

Fighting games need a system to dynamically draw hitboxes and hurtboxes for each move. Each move has its own animations and may include specific invulnerable areas, such as Ryu's Shoryuken. To manage this, we've linked keyframes to each animation. This allows us to enable and disable hitboxes and hurtboxes as needed, matching the behavior shown in the clip below:




The below code-snippet defines the hitboxes for a light punch:


CharacterMove(sequence: [ButtonType.LP], stateChages: CharacterState.Attacking, priority: 1, frameLeniency: 2, attackKeyFrames: [
            AttackKeyFrame(keyTime: 0.1, name: "Hand_R", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Active),
            AttackKeyFrame(keyTime: 0.2, name: "", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Inactive, setAll: true),
            AttackKeyFrame(keyTime: 0.3, name: "Hand_R", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Active),
            AttackKeyFrame(keyTime: 0.4, name: "", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Inactive, setAll: true),
            AttackKeyFrame(keyTime: 0.5, name: "Hand_R", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Active),
            AttackKeyFrame(keyTime: 0.6, name: "", boxType: BoxType.Hitbox, boxModifier: BoxModifier.Inactive, setAll: true)

This system also enables us to balance moves based on their "start-up," "active," and "recovery" times. It allows us to fine-tune each move for better game balance, as shown below:




Collision detection is handled using bitmasks along with SceneKit's physics system. This setup identifies which hitboxes/hurtboxes have collided and determines whether an attack should be triggered. They are defined programmatically like so:


// Hitboxes and Hurtboxes bitmasks.
let p1HitBox = 1 << 0  // Binary: 00000001
let p2HitBox = 1 << 1  // Binary: 00000010
let p1HurtBox = 1 << 2 // Binary: 00000100
let p2HurtBox = 1 << 3 // Binary: 00001000

This allows us to implement hit detection with the following table:


P1 Hitbox P1 Hurtbox P2 Hitbox P2 Hurtbox
P1 Hitbox No No No Yes
P1 Hurtbox No No Yes No
P2 Hitbox No Yes No No
P2 Hurtbox Yes No No No

Where "Yes" means a collision has occurred and "No" otherwise. Successful collision events get queued and processed at the beginning of our game's next update tick.

Characters as State Machines + Combo Routes Code

In Gouken, we model each character as a Finite State Machine (FSM). This simplifies game logic by limiting the actions a character can take based on their current state, rather than using complex nested if-else checks to account for every possible scenario.

For example, when a character is jumping, we only need to check if the player gets hit or completes the jump, instead of considering every possible situation. Below is a State Transition Diagram and Table for Gouken's character FSM:


Current State Next State
Input = Nothing Input = Left Input = Down Input = Right Input = Up Input = Attack Input = Get Hit By Low Attack Input = Get Hit By Heavy Attack
Idle Idle Walking Backward Crouching Walking Forward Jumping Heavy Attack Staggered Staggered
Low Attack* Idle Walking Backward Crouching Walking Forward Jumping Low Attacking Staggered Staggered
Heavy Attack* Idle Walking Backward Crouching Walking Forward Jumping Heavy Attacking Staggered Staggered
Staggered* Idle Idle Idle Idle Idle Idle Staggered Staggered
Walking Forward Idle Walking Backward Crouching Walking Forward Jumping Heavy Attack Staggered Staggered
Walking Backward Idle Walking Backward Crouching Walking Forward Jumping Heavy Attack Staggered Standing Block
Crouching Idle Walking Backward Crouching Walking Forward Jumping Low Attack Crouch Block Staggered
Jumping Jumping Jumping Jumping Jumping Jumping Jumping Staggered Staggered
Crouch Block* Idle Walking Backward Crouching Walking Forward Jumping Low Attack Crouch Block Staggered
Standing Block* Idle Walking Backward Crouching Walking Forward Jumping Heavy Attack Staggered Standing Block

*States are directly linked to animations, those with asterisks don't take inputs until after so most inputs are processed only after the animations are complete. With the only exception being taking a hit, which cancels you into another state (blocking or staggering).



For the attacking, staggered, and blocking states, we can loop the animation of the resulting state-transition depending on the input. For example, a character walking forwards being hit by a low or a heavy will get staggered, however, a heavy will stagger them for longer as depicted below:




This is crucial for fighting games like Gouken because it enables the creation of a combo system by varying the stagger times that different moves apply. Moreover, we can tune how long it takes a blocked attack to fully recover vs a whiffed attack, which opens up design space for balancing purposes. Unfortunately, we ran out of time in the course to fully design functional combo routes. Developing these would require creating animations, fine-tuning them, and expanding the move list. With more time-to-develop, this would be a top priority.


ECS, Update Loop, and Frame-Rate Loop Code | ECS Code

Regarding flow-of-control, SceneKit provides a rendering/physics loop where you can hook your code changes in using callbacks. Below is a complete diagram of SceneKit's update loop:



In the above diagram, blocks prepended with "renderer:" are functions we can implement that will be called at that specific time in the game loop. This allows us to implement per-frame game logic to be executed after or before specific events. For example, one can implement renderer:didSimulatePhysicsAtTime: to handle collision events right after physics has finished being simulated.

In Gouken, we implement both the renderer:updateAtTime: and renderer:didSimulatePhysicsAtTime: functions. The former houses all of our core game logic, handling character state transitions, win cons, netcode, etc. whereas the latter appends collision events to a queue to be processed in future game loop ticks by the renderer:updateAtTime: function.

Moreover, we opted to implement an Entity Component System to better handle the addition of new code. Because of this, a programmer need only create an entity or new component, and it will be called in the rendering loop automatically. as can be seen by the following code-snippet:

entityManager.entities.forEach { entity in
  entity.update(deltaTime: deltaTime)
}


Peer-to-Peer NetCode
Code



Two conditions are necessary for a fighting game to have functioning multiplayer: as fast-as-possible latency between players and deterministic gameplay. In order to service the former, a peer-to-peer approach is required (as an additional hop between the clients and a server would be too slow) and a deterministic approach is required so that both players can send eachother merely inputs and handle state transitions accordingly on their own machines. We will expand on both aspects in the below sections.



Peer-to-Peer via iOS' Multi-Peer Framework

iOS's Multi-Peer Connectivity framework enables seamless communication between nearby devices without requiring an internet connection. Utilizing both Bluetooth and Wi-Fi, it supports peer-to-peer connectivity for real-time multiplayer experiences. Two devices, on the same wireless network or within Bluetooth range, can join eachother for a match of Gouken.




As of now, only two users on the same network/within bluetooth range can play together. This is due to the need of a session key to be exchanged between both clients, of which only one is hard-coded at the moment. Given more time, we would implement a server browser and lobby system to match-make users.



Deterministic Game State

Thanks to the "Characters as State Machines" and "Input Buffer" implementations, a Gouken match can be completely reconstructed with just it's input history provided. This means that Gouken's game state is deterministic - i.e. 100% reproducible. The below figure gives a visual example of what a non-deterministic fighting game would play like:



A deterministic fighting game makes netcode much easier to reason with! It allows us to send packets between clients containing only the inputs of each player, plus maybe some metadata to detect disconnections. Each player's machines then inserts the incoming inputs into their respective input buffers and updates the game state accordingly - advancing to the next frame.

Netcode Steps Going Forward

Sadly, this is as far as we got with Gouken's netcode during the course. We found that it worked well locally on physical iOS machines as well as simulated ones, however, if we weren't playing over LAN or Bluetooth, it's likely the latency would be too slow for the game to run smoothly. If we were to continue working on the title, Delay-Based Netcode would be the first step made towards creating a smoother user experience. However, Rollback Netcode (or GGPO) would be the ultimate standard to achieve.

ShadowDancer - a 3D, First Person Stealth Game

Repository
Built using the Unity Game Engine over the course of a 4 month semester.

Features

Light Level Detection


The illumination at a point is inversely proportional to the square of the distance of the point from the light source and directly proportional to the intensity of the light source. The unit for measuring illumination, candelas per square metre (cd/m2), is called luminance.

In other words, let L be the luminance emitted by a given light source, then the luminance of the player, P, is given by:

P = L 2 π r 2
Where r is the distance from the player to the light source. This is how ShadowDancer calculates the illumination of the player. Every light source has a collider which runs a script to update the luminance value of the player with the above formula. It is then clamped to a value between 0.0 and 1.0 and is factored into a scalar multiplication of all enemy FOVs. When light sources overlap, ShadowDancer sums the illumination from all sources before clamping. The below figure gives a more visual depiction of how illumination is calculated in ShadowDancer:



Moreover, the light calculation is dynamic - i.e. a light source can be destroyed and it will no longer factor into determining the player's luminance. See below:

Sound Level Detection


ShadowDancer detects how much noise the player is making and notifies nearby enemies to investigate. This is implemented by having the enemy AI's update loop check how much noise the player is making and whether or not it is in audible range.

if (IsPlayerInAudableRange() || IsThrownObjectInAudableRange()) {
  if ((soundIntensity >= mediumSoundThreshold) && (soundIntensity < loudSoundThreshold)) {
    LookAroundForPlayer(positionToChase);
  }
  else if (soundIntensity >= loudSoundThreshold) {
    ChaseThePlayer(positionToChase);
  }
}

Additionally, the player can also find throwables on the map and use them to create noise - distracting nearby enemies - as depicted below:



Things that determine how much noise the player makes is the surface they are walking on and the action taken, such as crouch-walking, walking, or sprinting. We encode these noise levels with values from 1 to 10, where between 1-4 cause enemies to act normally, 5-7 cause enemies to investigate around the sound-source, and 7-10 cause enemies to barrel towards the source ravenously. Below is a table of the sound values different floor-tiles cause the player to emit:

Flooring Sound Emitted (Crouching) Sound Emitted (Walking) Sound Emitted (Sprinting)
Grass 2 4 6
Road 4 6 8
Metal 6 10 10


Initial Hurdles

We had encountered performance hurdles with our first naive approach: ray-casting around the sound-source (player or throwable) and checking for enemies within a certain distance. While, in retrospect, obvious, we soon realized after implementation that this was too compute-heavy as our frame-rate tanked to < 20.

This was a good learning experience overall for our team as we learned that raycasting was not an inexpensive procedure.

Enemy A.I., Alert Levels, and Waypoint System Code



ShadowDancer has enemies that patrol an area, detect the player through visuals/noise, and chase and attack the player. To codify these three capabilities, ShadowDancer's A.I. has three "alertness" states that determine their behaviour:

  • Patrolling

  • Searching

  • Chasing

Below is an FSM showing the state transitions between each:



Below, we will expand on the different states, their behaviour, and implementations.

Patrolling



In Patrolling mode, the enemy walks around an area in random locations or follows a set, pre-determined patrol path. To create a patrol path, the level designer places waypoint prefabs on the map (depicted in the above video by Gizmo Spheres) and appends them to an Enemy under a serialized field. The enemy then cycles between the waypoints, waiting at each one for a specified time (determined by another serialized field) before moving onto the next one. Looping back to the beginning when done. If no waypoints are specified, the enemy will randomly pick points on the map to patrol. The below screenshot shows off all of the serialized fields of the EnemyAI component:



The walk speed determines the patrolling and searching speed of the enemy, the wait time determines how long the enemy will wait at a waypoint before moving onto the next, the run speed determines the chasing speed of the enemy, and the medium and high sound thresholds affect the state transitions from the above diagram, making it so that the zombie can be as perceptive as the designer wants.

Searching

In the Searching mode, the Enemy pathfinds to the last location it saw or heard the player and then meanders around it in a random radius until a specified search time is up. This time is linked to the wait time serialized field in the above image.

Chasing

In the Chasing mode, the Enemy runs to the player's location and enters search mode if the player breaks LoS for 5 seconds.

Hand-Rolled Multi-Level-Feedback-Queue Scheduler

Repository

Done as part of a class project where we were given a toy Operating System named PintOS (about 300,000 LoC) and were given features to implement, requiring us to read an expansive code-base, understand it, and implement important changes. Chiefly of which was the thread scheduling, which was a flat Round Robin scheduler with a time quantum of 4 ticks (PintOS by default operated at 100 ticks/second). Readied Threads were simply dequeued in the order they were enqueued. My job was to extend the functionality of this system to have a feedback scheduler: a dynamic priority-based scheduler where the more time a thread spends executing - the less priority it has.

In order to do so, the following had to be implemented/updated:

Fixed-Point Arithmetic Library Code
PintOS doesn't natively support floating point numbers. Most compact OS like it don't, The reason being that they would slow the kernel down too much. This means that real numbers have to be emulated using integers. We look to implementing a Fixed-Point Library to do this.



The fundamental idea is to interpret the rightmost bits of an integer as representing a fraction. For instance, we can designate the lowest 14 bits of a signed 32-bit integer as fractional bits. This means that an integer x represents the real number x 2 14 This method is known as 17.14 fixed-point number representation.

In this format, there are 17 bits before the decimal point, 14 bits after it, and one sign bit. A number in 17.14 format can represent a maximum value of: 2 31 1 2 14 131,071.999 . Below are the general formulas for working with fixed-point and our C implementation:

Convert n to fixed point: n * f
Convert x to integer (rounding toward zero): x / f
Convert x to integer (rounding to nearest): (x + f / 2) / f if x >= 0,
(x - f / 2) / f if x <= 0.
Add x and y: x + y
Subtract y from x: x - y
Add x and n: x + n * f
Subtract n from x: x - n * f
Multiply x by y: ((int64_t) x) * y / f
Multiply x by n: x * n
Divide x by y: ((int64_t) x) * f / y
Divide x by n: x / n

#ifndef __THREAD_FIXED_POINT_H
#define __THREAD_FIXED_POINT_H
                
typedef int fixed_point;
                
#define FRACTION 15
#define CONVERT_TO_FIXED_POINT(x) ((fixed_point)(x << FRACTION))
#define CONVERT_TO_INT_TOWARD_ZERO(x) (x >> FRACTION)
#define CONVERT_TO_INT_NEAREST(x) (x >= 0 ? ((x + (1 << (FRACTION - 1))) >> FRACTION) 
  : ((x - (1 << (FRACTION - 1))) >> FRACTION))
#define ADD_FIXED_POINT(x,y) (x + y)
#define ADD_INTEGER(x,n) (x + (n << FRACTION))
#define SUB_FIXED_POINT(x,y) (x - y)
#define SUB_INTEGER(x,n) (x - (n << FRACTION))
#define MULTIPLY_FIXED_POINT(x,y) (x * y)
#define MULTIPLY_INTEGER(x,n) ((fixed_point)(((int64_t) x) * n >> FRACTION))
#define DIVIDE_FIXED_POINT(x,y) (x / y)
#define DIVIDE_INTEGER(x,n) ((fixed_point)((((int64_t) x) << FRACTION) / n))

#endif /* thread/fixed_point.h */

Dynamic Priority Calculation
PintOS comes with a basic Round Robin scheduler out of the box. Firstly, this needed reworking to factor in priority. This meant that the thread struct needed to take in a priority field, like so:
struct list_elem sleep_element;  
struct list held_lock;             
struct lock *curr_lock;       
int our_priority;                   
int nice;     
int64_t remaining_time;                      
fixed_point recent_cpu;
We then modified the queue of ready threads to be a priority queue, i.e. the highest priority thread gets dequeued to be ran in Round Robin fashion.

At this point, about half of the work was done. We now had to implement dynamic thread priority using the below formulas.

MLFQS Formulas

Every thread has a nice value between -20 and 20 directly under its control. Each thread also has a priority, between 0 (PRI_MIN) through 63 (PRI_MAX), which is recalculated using the following formula every fourth tick:

priority = PRI _ MAX - recent_cpu 4 - 2 × nice
recent_cpu measures the amount of CPU time a thread has received "recently." On each timer tick, the running thread's recent_cpu is incremented by 1. Once per second, every thread's recent_cpu is updated this way:

recent_cpu = 2 × load_avg 2 × load_avg + 1 × recent_cpu + nice

load_avg estimates the average number of threads ready to run over the past minute. It is initialized to 0 at boot and recalculated once per second as follows:

load_avg = 59 60 × load_avg + 1 60 × ready_threads

where ready_threads is the number of threads that are either running or ready to run at the time of update (not including the idle thread).

Code Snippets for our C implementation of the MLFQS prioritization
void update_thread (struct thread *t)
{
  enum intr_level old_level = intr_disable ();
  int our_priority = t->our_priority;
  if (list_empty (&t->held_lock))
  {
    t->priority = our_priority;
  }
  else
  {
      int priority = list_entry (list_max (&t->held_lock, compare_locks, NULL), struct lock, elem)->max_p;
      t->priority = our_priority > priority ? our_priority : priority;
  } 
  intr_set_level (old_level);
}

void thread_update_recent_cpu(struct thread *t, void *aux UNUSED)
{ 
  t->recent_cpu = ADD_INTEGER (DIVIDE_INTEGER (MULTIPLY_INTEGER (MULTIPLY_FIXED_POINT 
            (load_avg, 2), t->recent_cpu),
            ADD_INTEGER (MULTIPLY_FIXED_POINT (load_avg, 2), 1)), t->nice);
  thread_update_priority_mlfqs (t);
}

void thread_update_priority_mlfqs(struct thread *t)
{
  int new_priority = (int) CONVERT_TO_INT_NEAREST 
      (SUB_FIXED_POINT 
      (CONVERT_TO_FIXED_POINT ((PRI_MAX - ((t->nice) * 2))), 
    DIVIDE_FIXED_POINT (t->recent_cpu, 4)));
  if (new_priority > PRI_MAX)
    new_priority = PRI_MAX;
  else if (new_priority < PRI_MIN)
    new_priority = PRI_MIN;
  t->priority = new_priority;
}


Synchronization Primitive Tuning Code

PintOS came with two major synchronization primitives - semaphores and locks, which did not account for priority (as the initial scheduler was priority-less). The modifications we made were minor but significant:

Sema V

We modified this operation to unblock the highest priority thread, improving the semaphore mechanism by using the thread priorities. This change is essential where multiple threads with different priorities are waiting on a semaphore.

Lock Acquire

Modified to include priority donation to address the issue of priority overturn. It fixes the problem when a lower-priority thread holds a lock needed by a higher-priority thread.

Lock Release

Modified to calibrate priorities and reverse the effects of priority donation upon releasing a lock to maintain the correct execution order.

Conditional Variable Wait

Modified to insert threads into the wait list by priority to ensure that the highest-priority thread is awakened first. This modification is crucial to maintain condition variable usage.