2D arena shooter proof of concept demo inspired by Christopher Nolan's Tenet
1. Overview
Palindrome was a project I started immediately after watching Tenet. I spent the next 6 days making a proof of concept game demo that features similar time rewind combat mechanics from the movie. The game is a 2D local multiplayer arena shooter where two players can rewind time for their character at any time. Each player has their own timeline that is separate from the world timeline. This means a player in normal time can actively fight an opponent who is going backwards in time.
Players can shoot, throw grenades, jump, climb walls, crouch, and dive. All actions, audio, and visual effects will reverse during time rewind as expected. This includes blood particles, bullet impacts particles, bullet trails, and damage taken. Unique combat situations arise with time reversal combat, such as not needing to reload when the bullets reenter the player’s gun, damaging your opponent with bullets going backwards, and watching blood go back into your character after rewinding a bullet wound. Try the mechanics yourself with the playable demo!
Note: The proof of concept demo only supports one player on keyboard and one player on controller.
2. Highlights
- Unique multiplayer time reversal combat
- Inspired by the movie Tenet
- Reversible player actions, audio, and visual effects
- Management of 3 independent timelines
- Quick 6 day development for a playable proof of concept build
- Some shader effects are adapted from previous projects (mainly from Last Secutor)
- Plugin "Chronos" used to speed up development
- Full local multiplayer gameplay loop, with round logic and scoring
- Polished combat controls with keyboard and controller
- Customizable game mode through main menu UI
3. Game Features
3.1 Time Reverse Combat
Each player has two buttons to control time. Pressing Q
(LT
for controller) at any time will begin reversing the individual player’s timeline and pressing E
(RT
for controller) resumes normal forward time. Once a player begins reversing their timeline, all their previous actions will begin to playback until the beginning of the round or the last time the player resumed to normal time.
Players can’t input controls other than resuming to normal time while reversing. Time reversing players can still be hurt and all reversing bullets and explosions still do damage. The amount of time left for a player to reverse is represented by a draining bar in the UI when they are reversing time.
Since actions and positions are always being recorded, it is possible for the computer to run out of memory if a player never rewinds time (See 4.1 Time Reversal for more implementation details). Making time reversal automatically end when a player reaches the point where they last resumed to normal time alleviates this problem completely for most rounds (previous timeline cache is flushed). This problem can also be solved by making a maximum amount of time the player can reverse (not implemented in proof of concept demo).
3.2 Gameplay Loop
The proof of concept demo has all the features for two local players to play competitively. When a player dies, world time is slowed down for dramatic effect, then time for the dead player will be rewound for 5 seconds to revive them (the other player can’t move during this time for fairness). This is called a Timeline Inversion Revive, and each player gets one per game, indicated by a glowing red orb in the UI. Players have unlimited ammo, but limited grenades. Health, revive, and grenade pickups are spawned in random locations on the stage at random times during gameplay.
Score display appears when one player runs out of revives.
Once a player is dead with no revives, the opposing players get a point, and the next round starts when both players press ready. First to 3 points win the game. Player names (shown in score screen), number of points needed to win, and number of starting revives can all be tweaked in the main menu.
4. Implementation
Below are a few code examples of how the time reversal effects are implemented. Since this was a proof of concept, optimization and code readability was not a priority.
4.1 Time Reversal
To save time, I used Chronos (a deprecated framework) to keep track of time events in individual timelines. Although it works great for this proof of concept, the implementation is relatively crude, with little to no space optimization (a non-trivial problem to solve unless you are Jonathan Blow ). The plugin comes with decent position tracking out of the box. However, other time reversible effects such as explosion animations, health management, and particles required additional code.
Chronos allows for time events to be scheduled into timelines, a delegate is given for the forward passage of the event, and a different delegate for the backwards passage. For example, the following is the scheduling of events for a bullet impact on a wall using the Do(...)
function from Chronos, with time
as one of the player’s timeline:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
time.Do(
true,
delegate() {
// find rotation to opposite direction of bullet velocity and rotate impact to that direction
Quaternion rotation = Quaternion.Euler(new Vector3(0, 0, Mathf.Atan2(-time.rigidbody2D.velocity.y, -time.rigidbody2D.velocity.x) * Mathf.Rad2Deg));
GameObject bulletImpactInstance = Instantiate(GameManager.manager.bulletImpactPrefab, transform.position, rotation);
bulletImpactInstance.transform.SetParent(transform, false);
Timeline bulletImpactTimeline = bulletImpactInstance.GetComponent<Timeline>();
bulletImpactTimeline.globalClockKey = time.globalClockKey;
bulletImpactTimeline.particleSystem.Play();
AudioSource impactAudioSource = bulletImpactInstance.GetComponent<AudioSource>();
impactAudioSource.pitch = 1;
impactAudioSource.PlayOneShot(stageHit);
bulletImpactTimeline.Plan(
stageHit.length,
true,
delegate () { },
delegate () {
impactAudioSource.clip = stageHit;
impactAudioSource.pitch = -1;
impactAudioSource.time = stageHit.length - 0.01f;
impactAudioSource.Play();
}
);
return bulletImpactInstance;
},
delegate(GameObject bulletImpactInstance) {
Destroy(bulletImpactInstance);
}
);
Note: Do
function signature: Do<T>(bool repeatable, ForwardFunc<T> forward, BackwardFunc<T> backward)
An additional Plan(...)
function is nested in the forward
delegate in line 17
. Plan(...)
is the same as Do(...)
, except it has an additional delay parameter to schedule it forward in time. This is useful for managing rewindable audio clips, since we can line up the backwards audio clip in the future with the length of the audio clip (line 18
). Setting pitch to -1 in the Unity audio component plays audio backwards (line 23
) , so we don’t need to create new audio clips. All audio effects in the game are added to a timeline and reversed using this method.
4.2 Bullets
The Problem
Reversible bullets pose a unique problem since they have trails effects. Traditionally, trails are created by procedurally adding and removing vertices to generate a 2D mesh (usually as a billboard) behind a moving object; the length of the trail is determined by the speed of the projectile.
Since I opted for a trail with a tapered end (it just doesn’t look right without the tapering), simply reversing the direction of the shot projectile will not work. The procedural trail mesh will not be rendered when time reversing first starts because the initial velocity of the bullet is zero. The tapered tail will also be on the wrong side of the trail because it is dependent on the projectile’s direction of travel.
The Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
public IEnumerator LaunchProjectile(Vector3 bulletSpawnPosition, Vector3 direction) {
// Launch bullet (trail head)
bulletTimeLine.rigidbody2D.velocity = direction * weapon.launchForce;
// cache bullet velocity for when bullet is set to normal time after rewinding
bullet.GetComponent<BulletController>().cachedVelocity = bulletTimeLine.rigidbody2D.velocity;
bullet.GetComponent<BulletController>().bulletForce = weapon.hitForce;
// Trail delay
yield return bulletTimeLine.WaitForSeconds(trailDelay);
trail.GetComponent<TrailController>().followBullet = true;
}
To fix this problem, two invisible projectiles are launched when a player shoots. The second projectile is launched with a delay (line 11
) and a tapered line is rendered with the two projectiles as fixed end points. Mesh vertices are generated and removed procedurally as the distance between the two projectiles change (e.g. when first projectile hits wall and stops).
This method creates the desired reversible trails by recording and reversing the positions of the two projectiles and leaving the line renderer on. There is no need to keep track of anything with the line itself, and the taper will face the right way since it only depends on the initial bullet velocity rather than the real time velocity.
4.3 Grenades
Reversible grenades are implemented by combining methods shown above. Grenades shoots the same bullet projectiles in random directions (to simulate shrapnel) with a reversible audio effect and sprite animation.
To make grenades more threatening, players will take additional damage if they are within a certain radius during the explosion. If implemented naively, players can be hit through walls with this damage. To check for obstacle blocking, we first check if there are colliders within a radius using the Unity physics engine (Physics2D.OverlapCircleAll(...)
), then we do a raycast from the centre of the explosion to the centre of each object in the radius to see if there is any stage geometry in between. This single raycast check is crude, but it suffices for most situations in this demo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public void ExplosionRadiusCheck(bool isInversed) {
// Check for colliders in explosion radius and apply appropriate force if not blocked by stage geometry
Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, explosionRadius);
foreach (Collider2D hit in colliders) {
Rigidbody2D rb = hit.GetComponent<Rigidbody2D>();
if (rb != null) {
// ignore stage and this grenade in radius check
if (rb.CompareTag("Stage") || rb == GetComponent<Rigidbody2D>()) {
continue;
}
Vector2 direction = rb.transform.position - transform.position;
float distance = direction.magnitude;
RaycastHit2D[] rayHit = Physics2D.RaycastAll(transform.position, direction, distance);
// Check if there is stage geometry between explosion and victim
// Note: not a perfect check, just a single raycast
bool blocked = false;
for (int i = 0; i < rayHit.Length; i++) {
if (rayHit[i].collider.CompareTag("Stage")) {
blocked = true;
break;
}
}
// if not blocked by stage, apply force and damage if this is a character
if (!blocked) {
// if this is an inverse explosion, reverse force direction
if (isInversed) {
explosionForce *= -1;
}
rb.AddForce(direction * explosionForce * Mathf.Lerp(1, 0, distance / explosionRadius), ForceMode2D.Impulse);
if (rb.CompareTag("Character")) {
rb.GetComponent<PlayerController>().Bleed(direction, -2);
}
}
}
}
}
Note: the stage raycast check loop (line 15 — 25
) can be simplified to an if statement with a LayerMask
raycast
Finding every collider in the radius with Physics2D.OverlapCircleAll(...)
is relatively slow. It would be much faster to just raycast each player to check for distance and stage geometry at the same time. However, this was done so other physics enabled objects can also react to the explosion; such as pushing away other grenades, bullets and dropped weapons (when a player dies). Ultimately, this method performs with no issues in the limited scenarios created by two players.