4+ Years Experience | Unity & C# | VR & Multiplayer
Download CVI'm Victor Rosas, a Gameplay Programmer with 4+ years of professional experience specializing in Unity and C#. My focus is multiplayer systems, gameplay mechanics, and complex interactive simulations — from award-winning VR industrial applications to co-op action games.
I have a proven track record of shipping award-winning titles, developing 300+ production scripts across large-scale codebases, and architecting real-time networked systems using client-server architecture. I'm passionate about clean, performant code and continuously push the limits of what gameplay can feel like.
When I'm not coding, I explore new game mechanics, stay sharp on industry trends, and find inspiration in the worlds around me.
Gained hands-on experience across individual and team game projects using state-of-the-art equipment and industry-experienced faculty. Built a strong portfolio spanning game programming, level design, and systems architecture. Developed a deep foundation in game development pipelines and collaborative production workflows.
A 3rd-person beat-em-up for 2 players set in a gang-warfare revenge story. Rise through the ranks, battle enemy gangs across their territories, and uncover who murdered your older brother. Weapons, items, and teamwork are key.
View Project
Navigate the magician's absurd and escalating challenge across increasingly difficult levels. A quirky world full of comedic elements wrapped around a clever puzzle-platformer experience.
View Project
A cooperative puzzle game for two players who must work together to solve oxygen-recovery challenges. Communication and coordination are everything.
View Project
A time-traveling child must collect ancient stones to return to the present. Help the dinosaurs and solve puzzles using the ancient statue to find your way home.
View ProjectThings I actually built — and why they ended up the way they did
Hit reg was broken early on — players swore they were on target but the server kept saying miss. Root cause: the server runs the raycast 80–200ms after the client fires, by which point everyone's already moved. This records hitbox transforms server-side at 20Hz in a linked list per player, then rewinds each one back to the client's fire timestamp before casting. Capped at 500ms of lookback — any longer and it starts to feel unfair going the other way.
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public class LagCompensatedShooter : NetworkBehaviour
{
[SerializeField] private Transform muzzle;
[SerializeField] private float range = 25f;
[SerializeField] private LayerMask hitMask;
[SerializeField] private int historyCapacity = 32;
[SerializeField] private float snapshotRate = 20f;
private struct HitboxFrame
{
public Vector3 Position;
public Quaternion Rotation;
public float Timestamp;
}
private readonly Dictionary<ulong, LinkedList<HitboxFrame>> _history = new();
private float _nextSnapshot;
private void Update()
{
if (IsServer && Time.time >= _nextSnapshot)
{
_nextSnapshot = Time.time + 1f / snapshotRate;
RecordAllHitboxes();
}
if (IsOwner && Input.GetButtonDown("Fire1"))
FireServerRpc(muzzle.position, muzzle.forward, Time.time);
}
private void RecordAllHitboxes()
{
foreach (var client in NetworkManager.Singleton.ConnectedClientsList)
{
ulong id = client.ClientId;
if (!_history.ContainsKey(id))
_history[id] = new LinkedList<HitboxFrame>();
Transform t = client.PlayerObject?
.GetComponentInChildren<Collider>()?.transform;
if (t == null) continue;
_history[id].AddFirst(new HitboxFrame
{
Position = t.position,
Rotation = t.rotation,
Timestamp = Time.time
});
while (_history[id].Count > historyCapacity)
_history[id].RemoveLast();
}
}
[ServerRpc]
private void FireServerRpc(Vector3 origin, Vector3 dir, float clientTs)
{
// rewind every hitbox to where it was when the client pulled the trigger
var saved = new Dictionary<ulong, (Vector3 pos, Quaternion rot, Transform t)>();
foreach (var pair in _history)
{
var frame = ClosestFrame(pair.Value, clientTs);
if (frame == null) continue;
var t = NetworkManager.Singleton.ConnectedClients[pair.Key]
.PlayerObject?.GetComponentInChildren<Collider>()?.transform;
if (t == null) continue;
saved[pair.Key] = (t.position, t.rotation, t);
t.SetPositionAndRotation(frame.Value.Position, frame.Value.Rotation);
}
bool hit = Physics.Raycast(origin, dir, out RaycastHit info, range, hitMask);
if (hit && info.collider.TryGetComponent(out IDamageable dmg))
dmg.TakeDamage(25f, OwnerClientId);
// restore everyone to the present before next frame
foreach (var s in saved.Values)
s.t.SetPositionAndRotation(s.pos, s.rot);
}
private static HitboxFrame? ClosestFrame(LinkedList<HitboxFrame> frames, float ts)
{
HitboxFrame? best = null;
float bestDiff = float.MaxValue;
foreach (var f in frames)
{
float diff = Mathf.Abs(f.Timestamp - ts);
if (diff < bestDiff) { best = f; bestDiff = diff; }
if (f.Timestamp < ts - 0.5f) break; // don't look back more than 500ms
}
return best;
}
}
Had a crowd shooter level with 200+ simultaneous projectiles and was burning 2ms per frame just on movement updates — that's most of a 16ms budget gone right there. Moved all position/velocity/lifetime math into a Burst-compiled IJobParallelFor so it runs in parallel on worker threads. Physics.CheckSphere stays on the main thread after Complete() since Unity's physics API isn't thread-safe. Dead projectiles get swap-removed to keep the array packed. Went from ~2.4ms down to ~0.18ms for 512 active projectiles.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
public class ProjectileSimulator : MonoBehaviour
{
[SerializeField] private float gravity = -18f;
[SerializeField] private float drag = 0.015f;
[SerializeField] private int maxProjectiles = 512;
private NativeArray<float3> _positions;
private NativeArray<float3> _velocities;
private NativeArray<float> _lifetimes;
private NativeArray<bool> _alive;
private int _count;
private JobHandle _handle;
[BurstCompile]
struct SimulateJob : IJobParallelFor
{
public float DeltaTime;
public float Gravity;
public float Drag;
public NativeArray<float3> Positions;
public NativeArray<float3> Velocities;
public NativeArray<float> Lifetimes;
public NativeArray<bool> Alive;
public void Execute(int i)
{
if (!Alive[i]) return;
Lifetimes[i] -= DeltaTime;
if (Lifetimes[i] <= 0f) { Alive[i] = false; return; }
float3 vel = Velocities[i];
vel.y += Gravity * DeltaTime;
vel = vel * (1f - Drag * DeltaTime);
Positions[i] += vel * DeltaTime;
Velocities[i] = vel;
}
}
private void Awake()
{
_positions = new NativeArray<float3>(maxProjectiles, Allocator.Persistent);
_velocities = new NativeArray<float3>(maxProjectiles, Allocator.Persistent);
_lifetimes = new NativeArray<float>(maxProjectiles, Allocator.Persistent);
_alive = new NativeArray<bool>(maxProjectiles, Allocator.Persistent);
}
private void Update()
{
_handle.Complete();
// collision + cleanup on main thread (can't do Physics calls inside jobs)
for (int i = 0; i < _count; )
{
if (!_alive[i] || Physics.CheckSphere(
(Vector3)_positions[i], 0.12f,
LayerMask.GetMask("Enemy", "World")))
{
OnDied((Vector3)_positions[i]);
// swap-remove: pull last element into this slot
_count--;
_positions[i] = _positions[_count];
_velocities[i] = _velocities[_count];
_lifetimes[i] = _lifetimes[_count];
_alive[i] = _alive[_count];
}
else i++;
}
_handle = new SimulateJob
{
DeltaTime = Time.deltaTime,
Gravity = gravity,
Drag = drag,
Positions = _positions,
Velocities = _velocities,
Lifetimes = _lifetimes,
Alive = _alive,
}.Schedule(_count, 64);
}
public void Spawn(Vector3 pos, Vector3 vel, float life)
{
if (_count >= maxProjectiles) return;
_handle.Complete(); // can't write to NativeArrays while job is running
_positions[_count] = pos;
_velocities[_count] = vel;
_lifetimes[_count] = life;
_alive[_count] = true;
_count++;
}
private void OnDied(Vector3 pos) { /* trigger pooled VFX here */ }
private void OnDestroy()
{
_handle.Complete();
_positions.Dispose();
_velocities.Dispose();
_lifetimes.Dispose();
_alive.Dispose();
}
}
Kinematic hands clip through geometry. AddForce-based hands feel floaty and lag a frame behind. This drives the Rigidbody by setting velocity directly toward a target transform each FixedUpdate — tight tracking with real physics collisions. The velocity ring buffer exists because grabbing a single-frame velocity at release gives garbage throw results if the player lets go at the top of their swing (that frame is nearly zero). Averaging 8 frames fixed throw detection immediately.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
[RequireComponent(typeof(Rigidbody))]
public class VRPhysicsHand : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private float posStrength = 30f;
[SerializeField] private float rotStrength = 20f;
[SerializeField] private float hapticThreshold = 2.5f;
[SerializeField] private XRNode hand = XRNode.RightHand;
private Rigidbody _rb;
private InputDevice _device;
private readonly Queue<Vector3> _linHistory = new();
private readonly Queue<Vector3> _angHistory = new();
private const int HistorySize = 8;
private void Awake() => _rb = GetComponent<Rigidbody>();
private void OnEnable()
{
InputDevices.deviceConnected += OnConnected;
InputDevices.deviceDisconnected += OnDisconnected;
TryAcquireDevice();
}
private void OnDisable()
{
InputDevices.deviceConnected -= OnConnected;
InputDevices.deviceDisconnected -= OnDisconnected;
}
private void FixedUpdate()
{
// position: drive velocity toward target each physics step
Vector3 delta = target.position - transform.position;
_rb.velocity = delta * posStrength;
// rotation: convert delta quaternion to angular velocity
Quaternion dRot = target.rotation * Quaternion.Inverse(transform.rotation);
dRot.ToAngleAxis(out float angle, out Vector3 axis);
if (!float.IsInfinity(axis.x))
_rb.angularVelocity = axis * (angle * Mathf.Deg2Rad * rotStrength);
Enqueue(_linHistory, _rb.velocity);
Enqueue(_angHistory, _rb.angularVelocity);
}
private static void Enqueue(Queue<Vector3> q, Vector3 v)
{
q.Enqueue(v);
if (q.Count > HistorySize) q.Dequeue();
}
// call these from the grab/release handler instead of reading rb.velocity directly
public Vector3 GetThrowVelocity() => Average(_linHistory);
public Vector3 GetThrowAngular() => Average(_angHistory);
private static Vector3 Average(Queue<Vector3> q)
{
if (q.Count == 0) return Vector3.zero;
Vector3 sum = Vector3.zero;
foreach (var v in q) sum += v;
return sum / q.Count;
}
private void OnCollisionEnter(Collision col)
{
float impulse = col.impulse.magnitude;
if (impulse < hapticThreshold || !_device.isValid) return;
float strength = Mathf.Clamp01(impulse / 12f);
float duration = Mathf.Lerp(0.05f, 0.3f, strength);
_device.SendHapticImpulse(0, strength, duration);
}
private void TryAcquireDevice()
{
var devices = new List<InputDevice>();
var flags = InputDeviceCharacteristics.Controller |
(hand == XRNode.RightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
InputDevices.GetDevicesWithCharacteristics(flags, devices);
if (devices.Count > 0) _device = devices[0];
}
private void OnConnected(InputDevice d)
{
if (d.characteristics.HasFlag(InputDeviceCharacteristics.Controller))
TryAcquireDevice();
}
private void OnDisconnected(InputDevice d)
{
if (_device == d) _device = default;
}
}
File.WriteAllText on the main thread was causing 40–80ms hitches when saves got large. Moved writes to a background thread via Task.Run, wrapped in a SemaphoreSlim so two saves can't race each other. CancellationTokenSource means if the player saves twice fast, the first write gets cancelled instead of both landing out of order. Atomic write via a .tmp rename prevents a corrupted save if the game crashes mid-write — the rename only happens after the full write completes.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public sealed class SaveSystem
{
private static readonly Lazy<SaveSystem> _lazy =
new Lazy<SaveSystem>(() => new SaveSystem());
public static SaveSystem Instance => _lazy.Value;
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private CancellationTokenSource _cts;
private SaveSystem() { }
public async Task SaveAsync<T>(string slot, T data) where T : class
{
// cancel any in-flight save for this slot — last write wins
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
await _lock.WaitAsync(token);
try
{
token.ThrowIfCancellationRequested();
string json = JsonUtility.ToJson(data, prettyPrint: false);
await WriteAtomicAsync(SlotPath(slot), json, token);
}
catch (OperationCanceledException)
{
Debug.Log($"[SaveSystem] '{slot}' write superseded by a newer save.");
}
finally
{
_lock.Release();
}
}
public async Task<T> LoadAsync<T>(string slot) where T : class, new()
{
string path = SlotPath(slot);
if (!File.Exists(path)) return new T();
await _lock.WaitAsync();
try
{
string raw = await Task.Run(() => File.ReadAllText(path));
return JsonUtility.FromJson<T>(raw) ?? new T();
}
finally
{
_lock.Release();
}
}
private static async Task WriteAtomicAsync(
string path, string json, CancellationToken ct)
{
string tmp = path + ".tmp";
await Task.Run(() => File.WriteAllText(tmp, json), ct);
ct.ThrowIfCancellationRequested();
File.Move(tmp, path, overwrite: true); // atomic on same filesystem
}
private static string SlotPath(string slot) =>
Path.Combine(Application.persistentDataPath, slot + ".json");
}