Unity Developer

Games are built in the details most players never see.

I'm David — I build the systems, shaders, and edge-case fixes underneath three in-progress games. This page walks through how each one actually got made.

David
Photo add profile.jpg here

Selected work

Four games, four different builds

Each card opens into the actual process — the systems built, bugs chased, and decisions made along the way.

Phantom Index screenshot

Phantom Index

Published

A puzzle-mystery told through folders, files, and a keypad that doesn't forgive typos.

Unity C# DOTween Odin Inspector WebGL

Phantom Index frames its puzzles as an interface rather than a traditional game board — folders, files, and a locked keypad standing in for a mystery you have to dig through by hand. The build was as much about making a UI feel unfriendly and glitchy on purpose as it was about the puzzle logic underneath.

Concept

The whole experience was designed to feel like using someone else's abandoned computer — a little unfriendly, a little glitchy, and rewarding to players who pay attention to detail rather than brute-force every screen.

Core Architecture

The project is built around a GlobalContext singleton that handles registration across systems, with Odin Inspector driving most of the ScriptableObjects in the project. Level data, node data, and story content all live as designer-editable assets instead of hardcoded values.

The Folder & Node System

The main puzzle structure supports up to five folders and 30 nodes per level. This went through a full refactor pass to fix load ordering, recursive chain solving so a solved node can cascade through dependent nodes, and scroll snapping normalized to a clean 0–1 range so snap behavior stayed consistent regardless of content length.

Files & Stories Screen

FileController drives the Files/Stories screen: horizontal scroll views with snap scrolling, dot indicators for position, tap-to-back guards so an accidental tap doesn't kick the player out mid-read, and persisted scroll position so returning to a file resumes where they left off.

The Keypad

The retro keypad is one of the most detail-heavy pieces of UI in the game — DOTween-driven glitch effects, procedural audio feedback, chromatic aberration, scanline overlays, and context-sensitive emoji/color states that react to correct versus incorrect entries. StoryController ties directly into it through a lock/unlock system, so story pages only open once the right code has been entered.

ColdCases Panel

A horizontal, snap-scrolling card carousel — also DOTween-driven — for browsing case files. Getting it to feel right took a few passes on drag forwarding, layout timing, and snap position normalization so cards settled cleanly instead of overshooting.

NodeView: The Pill UI

One of the trickier UI problems was the word-pair "pill" labels used throughout NodeView. Pills use 9-slice sprites and needed to resize based on actual rendered text width — solved with world-corner-based width measurement, a _maxPillWidth hard cap with a TextMeshPro auto-sizing fallback for anything that would overflow, and a one-frame deferred refit coroutine so labels never visibly popped after a text change.

Bug: Stale Text Animations

EasyTextEffect had a bug where stale character counts left text animations incomplete on level completion — the effect thought it had already finished animating characters that hadn't actually been revealed yet. Fixed by resetting the counter alongside the animation state instead of assuming the two would always stay in sync.

Shipping to WebGL

Getting Phantom Index onto itch.io surfaced a run of WebGL-specific problems:

  • Save data vanishing between sessions — traced to Application.persistentDataPath writing into MEMFS, which never synced back to IndexedDB. A jslib + FS.syncfs() approach was tried first; the simpler, more reliable fix was switching to PlayerPrefs, which Unity guarantees is mounted before Start() runs.
  • WASM out-of-memory crashes and URP shader stripping — required trimming what shipped in the build and being more deliberate about which shader variants URP was allowed to strip.
  • Large file download failures on itch.io's CDN — some build files were too large for reliable delivery through itch.io's own hosting.
  • Unity 6 macro naming changes and a canvas search-ordering bug in KeyboardClickEffect.cs that only showed up once deployed to WebGL, not in editor testing.
  • Cross-deploy save loss — eventually traced to itch.io's hashed CDN paths changing on every new deploy, orphaning browser storage tied to the old path. The fix was moving the live build to a stable hosting URL, with itch.io kept as a redirect rather than the primary host.

Reflections

Most of Phantom Index's difficulty wasn't the puzzle logic — it was making browser-based persistence and WebGL's stricter runtime behave as predictably as a native build. The lesson that stuck: never trust editor testing alone once WebGL-specific storage and memory behavior is involved.

Tappy Greens screenshot

Tappy Greens

Releases Early October

A mobile mini golf game built around feel — wind, weight, and the wobble of a well-timed hit.

Unity URP Shader Graph VFX Unity Gaming Services Cloud Code

A mobile mini golf game built around feel — quick, tappable sessions, a playful cartoon aesthetic, and a fair amount of engineering hiding underneath the swing.

Concept

Tappy Greens started as a mobile mini golf game — quick, tappable sessions with a cartoon aesthetic rather than a realistic golf sim. The goal was to keep the visual language playful and readable at a glance: vibrant courses, snappy feedback, and mechanics that feel good on a phone screen with one thumb.

Visual Feel: VFX and Environment

A big part of establishing the game's identity came from environmental effects rather than the core swing mechanic itself:

  • Lightning trail VFX — integrated using Vefects to give certain shots or moments a bit of visual flair and energy.
  • Wind-reactive grass — built with the URP Dynamic Grass shader, so the environment feels alive even when the ball isn't moving. Small touches like grass swaying in the wind go a long way toward making static courses feel dynamic.
  • Shattering box obstacles — destructible elements on courses that reward experimentation and give hazards a satisfying payoff when hit.

Camera and Feel

Camera work went through iteration to get the right sense of impact. Camera shake was implemented via coroutine-driven positional offsets, applied after the LateUpdate follow logic — this ordering mattered to avoid the shake fighting with or being overwritten by the camera's normal tracking behavior.

Cross-Platform Development Snags

Building for mobile while iterating in the Unity Editor surfaced a few practical development problems:

  • Editor input handling — needed alternative mouse input paths for testing in-editor, handled with preprocessor directives to separate editor-only code from the mobile build. This mattered more than usual working off a trackpad rather than a mouse.
  • Render pipeline mismatch — diagnosed a bug where visuals broke because parts of the project were mixing Built-in Render Pipeline and URP assumptions. Isolating and fixing this was necessary before VFX and shaders would behave consistently across builds.

Character Selection: Design Iteration

The character selection screen went through a deliberate redesign process:

  • Landed on vector/cartoon-style avatars to match the game's overall tone, rather than more realistic character art.
  • Adopted a vertical scroll layout for browsing characters, which fit mobile screens better than a horizontal carousel.
  • Backed the system with a CharacterData ScriptableObject, letting each character's data — art, stats, unlock state — live as a reusable, designer-friendly asset rather than being hardcoded.

Related Course Work (36-Scene Build)

Alongside Tappy Greens' core systems, work on a related mini golf build with 36 scenes involved cleaning up several gameplay bugs and systems:

  • Fixed animation conflicts between ButtonAnimator and ButtonPopIn fighting over the same UI elements.
  • Solved car spawner randomization so obstacles/props didn't spawn in repetitive or predictable patterns.
  • Refactored star tracking from an ad-hoc setup into a clean per-level indexed array, making it far easier to track and persist star ratings across many levels.

Backend: Unity Gaming Services & Daily Rewards

Tappy Greens runs a server-authoritative daily reward system built on Unity Gaming Services Cloud Code — DailyScheduler.js, GetDailyFreeWorld.js, and ClaimDailyReward.js — so reward state lives on the server rather than something a player could fake by changing their device clock. A UGSInitializer with two-tier readiness (IsAuthReady / IsFullyReady) and DontDestroyOnLoad singletons for the core managers made sure no scene tried to touch Cloud Save or Leaderboards before auth had actually finished. A reset lock window around UTC midnight closed off the edge case where a claim request landing right at rollover could double-claim.

Getting UGS integration solid took a run of genuinely subtle bug fixes:

  • A Cloud Save SDK key-filtering bug that silently wrapped objects instead of returning them directly.
  • A misidentified LeaderboardVersion field that was being read as something it wasn't.
  • 0-indexed rank handling that had been assumed 1-indexed in a couple of places.
  • Task.WhenAll quietly dropping successful results when one task in the batch failed.
  • A PlayerPrefs.DeleteAll() call — meant to reset local test data — that was also wiping the cached Auth session token, forcing an unintended re-authentication.

Monetization: Ads

Ad monetization started on AdMob but hit Android cold-start race conditions that were hard to reproduce reliably. The fix was switching to Unity LevelPlay, with a drop-in AdsManager.cs written to preserve the existing public API so nothing calling into ads elsewhere in the codebase needed to change. That migration still needed its own debugging pass — a LevelPlayBannerAd constructor error, 509 no-fill errors while platform approval was pending, and a rewarded-ad load race condition fixed with an isRewardedLoading guard.

Camera Systems: Deeper Debugging

Beyond the initial camera shake work, CameraController and WorldCameraController went through several more debugging passes: ballClicked never clearing after a weak putt, a Slerp rotation running outside its intended input gate, and double-taps on the Shoot button accidentally triggering SwitchCamera(). Switching camera views also revealed a follow-drift bug, fixed by saving only height and rotation angles (savedMainCamHeight, savedMainCamHorizontalRotation, savedMainCamVerticalRotation) and recomputing position relative to the ball each time, instead of caching a raw position that went stale.

Performance on Budget Android Hardware

Profiling on a budget Android device with a Mali GPU surfaced a clear bottleneck list, worked down one item at a time: per-frame FindFirstObjectByType calls in HeartUI removed, stacked TMP outline objects consolidated, grass instance counts thinned (the single biggest GPU cost), the correct Mobile_RPAsset swapped in, cars pooled with a Dictionary<Queue> pattern instead of instantiate/destroy, and textures moved to ASTC compression.

Audio: The SoundManager

The SoundManager shuffles tracks with gaps rather than looping predictably, and includes a radio "tune-in" effect — a low-pass filter sweep combined with a volume fade — for a bit of texture between tracks. Volume is backed by PlayerPrefs with a polling coroutine, and lastPlayed initialization was randomized to kill a bias where the same track always played first on a fresh launch.

UI Assets: Sprite Slicing

UI sprite sheets — coins, gems, buttons, banners — were sliced in Unity's Sprite Editor using Automatic slicing, which handled the irregularly sized elements better than a fixed grid would have.

Reflections

Most of the creative work here wasn't in a single big feature — it was in the accumulation of small, tactile details (grass motion, shatter effects, camera punch) combined with unglamorous but essential engineering work (render pipeline consistency, data-driven character systems, per-level state tracking, and a server-authoritative backend) that let the game scale to dozens of levels without falling apart.

BoardBlast icon

BoardBlast

Early build

A real-time 1v1 multiplayer court game built on SpacetimeDB — a database that doubles as the game server.

Unity SpacetimeDB

This project started with a simple question: what would it take to build a real Unity multiplayer game using SpacetimeDB, a database that doubles as your game server? The plan was to research the tool, work through the official Unity tutorial (Blackholio, an agar.io-style clone), and then use everything learned there to build something original from scratch.

The Starting Point

The appeal of SpacetimeDB was architectural: no separate game server, no separate database, no manual networking layer. Tables are your persisted world state. Reducers — transactional, identity-authenticated functions — are the only doors into changing that state, whether triggered by a player action or by the clock itself via scheduled tables. Once that mental model clicked from the Blackholio tutorial, the natural next step was to design a game around it from the ground up.

Concept: BoardBlast

The idea: a 1v1 real-time court game. Two players face off with paddles at opposite ends of a rectangular court. A ball starts in the center and moves toward one end or the other. Each player slides their board horizontally to keep the ball from passing their own end. Let it through, and the opponent scores. First to 3 points wins the match.

The game went through a few rounds of rule clarification before landing on its final shape:

  • Early version — a fixed 3-round structure with round-based speed scaling.
  • Revised — rounds became rallies: winning a rally scores a point, first to 3 points wins the match, no fixed round count.
  • Final ball-speed rule — every rally starts at the same base speed, then accelerates continuously the longer the rally goes on, creating a natural tension curve within each point rather than an artificial round-to-round difficulty ramp.

The game was originally sketched in 2D, then converted to a full 3D environment partway through — which meant redesigning the coordinate system (X/Z court plane instead of X/Y), replacing the tutorial's DbVector2 with a custom DbVector3 type (SpacetimeDB has no built-in vector types — both are just structs you define yourself with [SpacetimeDB.Type]), and rethinking camera framing for a 3D arena instead of a flat top-down view.

Core Architecture

The server module ended up organized around a few key table groups:

  • Identity & progressionPlayer, DivisionRuleRow, PaddleType, OwnedPaddle, EquippedPaddle
  • MatchmakingMatchmakingQueue
  • Live match stateCourtConfig, Match, Board, Ball
  • TimingGameTickTimer, RoundCountdown, CountdownTickTimer, PreMatchTimer
  • "Flash" eventsPaddleHitEvent, RoundWinEvent, single-row-per-match tables overwritten every time something visually notable happens, purely so the client has something to react to

That last category turned out to be one of the more elegant patterns in the whole build. Rather than inventing a separate networking channel for "hey, play a particle effect here," a paddle hit or a round-winning shot simply overwrites a tiny table row with a position. The client subscribes, reacts to the insert, and picks a random particle prefab to play at that exact server-authoritative position — no extra protocol needed, just the same subscribe/react model used for everything else.

The Match Lifecycle

A match moves through distinct phases, each with its own scheduled reducer driving it forward:

  • MatchmakingJoinQueue scans for another queued player in the same division (divisions are strictly separated — no matching outside your tier). Instant matching, no polling delay, since the check happens the moment someone joins.
  • PreMatch — once paired, a 4-second window shows both players' profiles side by side (avatar, username, rank points) before anything moves. This phase was added specifically to give the "VS screen" moment its own beat, and required extending MatchState with a distinct PreMatch value.
  • Round countdown — a 3-second on-screen countdown plays before the ball spawns, both at match start and after every point scored. A particle effect plays before this countdown too, but only when a round was actually won, distinguished client-side by checking whether the match's score is still 0-0.
  • GameTick — the actual physics loop, running at 20 ticks/second via a scheduled reducer: ball movement, wall bounces, paddle collision with angle-based deflection, and boundary crossing detection for scoring.
  • Match end — win, loss, ranking point adjustments, coin rewards, promotion/demotion checks, and full cleanup of every timer and transient row tied to that match.

Ranking: The Five-Division System

Progression was designed as five divisions, each with its own win/loss point ranges and its own promotion threshold — explicitly modeled as data (DivisionRuleRow) rather than hardcoded branching logic, so balance changes are a SQL update, not a redeploy:

DivisionPromotes atWin pointsLoss points
1130030–3510–15
2150030–3510–15
317002010–15
420001510–15
5— (top tier)1010–15

Demotion was added symmetrically later — falling back below the exact score that promoted you into your current division drops you back one tier, with division 1 acting as a floor.

A disconnect mid-match doesn't let you dodge the consequence either: the server detects an active match tied to the disconnecting player's identity and awards the win to whoever remained, running through the exact same ranking/coin logic as a natural match conclusion.

The Economy: Coins and Paddles

Winning matches pays out coins, spendable in a paddle shop offering boards of increasing length — a small progression loop layered on top of the ranking system. Paddle length isn't just cosmetic: it's read server-side at match creation and directly affects the actual collision boundary in GameTick, so a purchased upgrade has real gameplay weight, not just a visual difference.

Identity: Avatars and the VS Screen

Every new player is randomly assigned one of ten avatars at registration. That single avatar_id integer flows everywhere identity needs to show up — the in-match scoreboard, the pre-match VS screen, the player's own profile panel — all resolved client-side through one shared lookup utility mapping the number to the correct image asset.

Making It Feel Good: Particles, Shake, and Motion

Once the core loop worked, the focus shifted to feedback and feel:

  • Random hit particles — a pool of different "impact" text effects, one chosen at random and spawned at the exact server-reported collision point every time the ball connects with a paddle.
  • Random win particles — a separate pool of fire effects, triggered at the losing end of the court, positioned using the ball's exact crossing point at the moment of scoring.
  • Camera shake — a lightweight, ease-out positional shake layered onto both of the events above, subtle on a paddle hit, stronger on a round win.
  • UI motion — the countdown panel and match-over banner both animate in and out with DOTween scale tweens (OutBack/InBack easing) rather than snapping instantly, giving key moments a bit of weight.

Input: Rethinking Control for Mobile

Keyboard input (Input.GetAxis("Horizontal")) worked fine for early testing but didn't fit the eventual target of a touch-first game. It was replaced with a spring-back UI Slider — drag to move, release and it snaps back to center, functioning like a one-axis joystick. PaddleController reads the slider's current value on the same throttled send-tick it always used, so the underlying networking pattern didn't need to change at all — only where the input number came from.

The Debugging Trail

A project like this accumulates a specific kind of debugging history, and a few of these were genuinely instructive:

  • Publishing from the wrong folder — an early and very common mistake: running spacetime publish from inside the Unity project itself picked up Unity's own generated .csproj files instead of the actual server module, causing a cryptic CLI panic. The fix was structural: the server module needs to live in a completely separate folder from the Unity project, never nested inside Assets/.
  • Stale client bindings — several rounds of "reducer doesn't exist" errors traced back to editing the server module without re-running spacetime generate afterward. Publishing and generating are two separate steps, and skipping the second one silently leaves Unity working against an outdated schema.
  • The player_id vs identity update bugPlayer.player_id is [Unique], not [PrimaryKey]; only the primary key column supports .Update(). A few reducers called .player_id.Update(...) out of habit, which doesn't compile — a good reminder that Unique and PrimaryKey aren't interchangeable even though both support lookups.
  • The blank-username collision — the single most subtle bug in the whole project. Player.username was originally marked [Unique], and every newly connected, not-yet-registered player got inserted with username = "". Since a unique constraint applies globally, only the first ever unregistered connection could hold that value — every subsequent fresh identity got flatly rejected at the connection layer with "value already exists," even though nothing was actually wrong with their registration. The fix was removing the database-level constraint entirely and relying on the manual uniqueness check already present inside the RegisterPlayer reducer, which only needs to apply to genuinely chosen usernames, not the shared placeholder default.
  • Silent reducer failures — a recurring lesson: calling a reducer from the C# client SDK is fire-and-forget. A try/catch around the call site catches connection-level failures, but never a server-side rejection (insufficient coins, duplicate username, and so on). The correct pattern is subscribing to that reducer's specific event callback and checking ctx.Event.Status for Committed versus Failed. Several UI scripts were quietly assuming success and updating regardless of what the server actually did, until this was corrected.
  • Court scale mismatches — the gameplay bounds (CourtConfig.court_width/court_length) and the actual visual arena asset drifted out of sync more than once: walls repositioning to the wrong spot, paddles ending up off the real playing surface, a custom floor mesh getting incorrectly rescaled based on a "default 10-unit plane" assumption that didn't apply to a bespoke asset. The fix each time came back to the same principle — treat the server's court dimensions as the single source of truth for both rendering and physics, and measure the actual asset's real-world bounds rather than guessing.

Reflections

  • Data-driven rules beat hardcoded branches. The division system, court dimensions, and paddle catalog all live in tables specifically so they can be tuned with a SQL statement instead of a redeploy.
  • Every visual moment worth reacting to can be a table row. Particles and camera shake didn't need a custom event system — they needed one small, frequently-overwritten table each.
  • Fire-and-forget reducer calls demand explicit result handling. Assuming success is the single easiest mistake to make when moving from a traditional request/response API mindset into SpacetimeDB's model.
  • Match the server's gameplay math to the client's actual geometry, always. Any hardcoded assumption about scale, on either side, is a bug waiting to surface the moment the art doesn't match the placeholder.

BoardBlast is still growing — the paddle shop, division ladder, and presentation layer are all in place, with room ahead for deeper progression systems and a real cross-device login bridge once the core game loop is fully proven out.

CatDash screenshot

CatDash

Published

My first game — a 2D endless runner where a cat dashes and leaps over obstacles to beat its own high score.

Unity C# 2D Endless Runner

Step into the thrilling world of CatDash — an action-packed endless runner where a cat leaps over obstacles, collects coins, and grabs power-ups on the way to a new high score. This was the very first game built from scratch, before any of the systems or workflows behind the other projects existed.

Concept

A 2D endless runner in the vein of Chrome's dino-dash game, but starring a cat. The core loop is simple on purpose: run, time a jump, dodge an obstacle, collect coins and power-ups along the way, and try to beat the previous run's high score.

A First Game, Lightly Documented

CatDash predates the habit of keeping a running devlog, so there isn't a detailed build-by-build history the way the other projects have. What's here is closer to a summary than a log — but it's the project everything else was learned from.

Reflections

Every pattern that shows up more deliberately in the later projects — object pooling, tuning game feel, shipping something a player can actually pick up and beat a score in — started here first, just without the vocabulary yet to name what was being learned.

Experience

Where I've worked

Internships that took me outside my own projects and into teams shipping games with other people.

Game Development Intern

Leti Arts

Nov 2025 – Jan 2026

Worked alongside a team of designers, artists, and audio artists on African-themed games, contributing across gameplay systems and features as part of the wider team rather than working solo.

Unity Developer Intern

Mikael Studios

May – Jul 2026

Built out Phantom Index end to end — core puzzle systems, UI, and the WebGL release — as part of my time with the studio.

Toolbox

Tools & systems

Unity / C# DOTween URP & Shader Graph Odin Inspector ScriptableObjects WebGL / itch.io SpacetimeDB Node.js / Express Web3 integration Unity Gaming Services

Let's talk

Grab the résumé for the full history, or send an email directly.