Dev Diary #4
This one might be a bit dry and boring to non tech people, but I have learned a lot this sprint and is pretty excited about it. Blog #3 ended with the combat loop working for the first time — the player attacks, the scout chases, the scout attacks back, the player reacts. That was the threshold I needed to hit before this blog could exist. This sprint the goal shifted: the loop runs, so now it needs to feel like something is actually happening.
That turned out to mostly be a question of signal. When a hit lands, the player needs to feel it. When loot drops, it needs to be readable from across the screen. When a piece of gear has three affixes, they should mean something when you read them. A lot of the work this sprint — the shaders, the weapon design, the item system — was answering some version of that same question.
Shaders turned out to be the right tool for more of it than I expected.
Shader
As I was working on one of affix's visual effect, which is a circular shockwave expanding outwards, I started with a simple idea: draw a ring with 1 pixel thickness, then manipulate the scale based on time. This, however, did not work as I wanted. For some reason I think the thickness will also increase, but what happens is the size of the ring increased, which made much more sense than what I was expecting. So I went to do some research on this type of effect, and came across shaders.
A shader is a small program that runs directly on the GPU, and its job is to determine the final color of every pixel on screen. I had heard of shaders before but never written one. It sounded intimidating, so I did what any engineer would do: I found a resource — the Book of Shaders — and started from chapter 1.
The key insight that unlocked everything for me is that shaders do not know about the world. They do not know where your character is, they do not know the screen resolution, and they do not know what frame you are on. All they know is what you explicitly pass in, in engineering terms, they are essentially stateless. These passed-in values are called uniforms, and once I understood that — that a uniform is just a CPU-written value that every GPU thread can read — a lot of things started to make sense.
For the shockwave ring, the math turned out to be simpler than I expected. Calculate the distance from the current pixel to the center of the quad, then check if that distance falls within a band defined by an inner and outer radius. Expand that band over time and you have an expanding ring. smoothstep() handles the soft edges so it doesn't look like a hard cutoff.
There were a few gotchas that cost me some time. The first was that everything in the shader has to be in the same coordinate space. Shaders operate in UV space — 0 to 1 across the quad — so if you pass in a radius of 120 pixels it means nothing to the shader. Everything has to be normalized first. The second was premultiplied alpha. MonoGame's default blend mode expects you to multiply your RGB by alpha before returning from the shader. Without it, pixels that should be transparent were instead adding their full color on top of the background, which is why the ring first appeared as a solid yellow square.
It took a few sessions to get to a working result, but seeing the ring expand and thin out in game for the first time made it worth it. And unlike the circle.png approach, the shader gives full control over the shape, color, and timing entirely in code — no art asset needed.
The gif should demonstrate the idea, but since it's 240p and low framerate, it would not look as good as the one in the video.
Drop Indicator Shader
After the shockwave I felt more comfortable with the idea of writing shaders, so when I needed a visual for item drops I decided to lean into it rather than reach for a static sprite.
The design goal was readable at a glance: when an enemy dies and drops loot, the player needs to immediately understand that something is on the ground and roughly how valuable it is. The rarity tint — white for common, blue for magic, yellow for rare — handles the "how valuable" part from the C# side. The shader's job is to make the drop look alive and holographic rather than just a flat square sitting on the floor.
I landed on three layered effects: chromatic aberration, scanlines, and flicker. Chromatic aberration has to go first because it is the sampling step, it is how you get the pixels off the texture in the first place. Scanlines go second because they only darken horizontal bands and do not need to be distorted by the aberration. Flicker goes last because it dims the entire output, so everything built in the previous steps gets affected uniformly.
Chromatic aberration in a shader is simpler than the name suggests. Instead of sampling the texture once and taking all four channels, you sample it three times — once per color channel — each at a slightly different UV offset. Red shifts right, blue shifts left, green stays anchored at the center. The direction is not arbitrary: red light has a longer wavelength than blue, so when light bends through a lens the two channels separate on opposite sides. I did not expect physics to be relevant when writing a GPU program, but here we are.
Scanlines use a function I now think of as the shader workhorse: frac(). If you multiply uv.y by some number N and take the fractional part, you get a value that resets to zero N times across the height of the quad. That gives you N repeating bands, and within each band the value runs from 0 to 1. From there it is just a matter of picking a threshold — I used 0.8 — and darkening the top 20% of each band. The step() function handles the threshold.
The trickiest part of the scanline logic was getting the darkening formula right. My first instinct was step(0.8, scanPos) * 0.5, which gives you 0.5 in the dark band and 0.0 everywhere else. Multiplying the pixel color by 0.0 makes everything black, which is backwards. The fix I landed on was (step(scanPos, 0.8) + 1) * 0.5 — flipping the argument order so step returns 1 for most pixels and 0 for the dark band, then using the add-one-and-halve trick to convert that binary output into a 1.0 / 0.5 range. I like this formula because once you see the trick, it is obvious.
There was also a compiler bug I introduced without realizing it. I named my local variable frac, which is also the name of the built-in HLSL function I was calling on the same line. The compiler cannot resolve the call because the variable shadows the built-in. Renaming it to fraction fixed it, and it is the kind of thing that would take a long time to debug if you did not already know to look for it (and it did take me a long time lol)
Flicker was the most design-driven of the three. I wanted mostly-on with brief drops, not a smooth pulse — a glitch, not breathing. sin(elapsed * 12) gives a cycle fast enough to feel erratic, and step(0.8, sin(...)) ensures the image only dips when the sine value pushes past the high threshold, which happens briefly each cycle. Same step-as-threshold pattern as the scanlines, just applied to time instead of UV.
The shader is wired to the item drop system now. Each drop tracks its own elapsed time and manages its own draw pass with the shader applied. When I have the art asset in place, the effect should be immediately visible in playtesting — which is exactly what I want from a loot indicator. If a playtester walks past a drop without noticing it, the shader has failed its job.
After getting the chromatic aberration, scanlines, and flicker working, I wanted to push the holographic feel further by adding a horizontal stretch — a band of UV distortion that travels down the sprite, like a VHS tracking error scrolling through a bad signal. The pixels inside the band shift left or right by a smooth amount, with the center of the band distorting the most and the edges tapering off cleanly.
The math for this turned out to be the most involved of the three effects, and also the most satisfying to reason through. The distortion amount per pixel follows a bell curve: if you plot horizontal displacement on the Y axis against row position within the band on the X axis, you want a smooth peak in the middle and zero at both edges. The function that gives you that shape from a 0-to-1 input is sin(t * π) — it starts at zero, peaks at one halfway through, and returns to zero at the other end. That is the entire bell curve, one line.
Getting t requires knowing where in the band the current pixel sits. I track the band's position using frac(elapsed / cycleDuration) which gives a 0-to-1 value that resets each cycle — that is the band's Y position traveling down the sprite. The pixel's normalized position within the band is then (uv.y - bandPos) / maxShiftBand, which is 0 at the band's top edge and 1 at the bottom. Pixels outside the band have t outside [0, 1], and a pair of step() calls multiplied together masks those to zero.
The final displacement is sin(t * π) * band * maxShift, added to uv.x before any sampling. Since it goes first in the pipeline, the chromatic aberration, scanlines, and flicker all apply on top of the already-distorted image — which is exactly what you want, because it means the aberration fringing follows the stretched pixels rather than fighting against them.
One thing worth noting: I work in microservices professionally and almost never touch trigonometry in my day job. Going from step() thresholds to bell curves built from sin() is a different mode of thinking entirely — you are not writing logic, you are describing shapes. The shockwave taught me to think in distances. This shader taught me to think in curves. I expect that shift in perspective will keep paying off.
After all of this was working, I realized the shader was doing something I had not originally planned for: it was communicating rarity through signal behavior, not just color. A rare drop uses the sin(t * π) bell curve for its horizontal stretch — the distortion is periodic, predictable, rhythmic. It glitches in an expected way. A magic drop uses a different function altogether: 2 * sin(10.5x) * cos(3.2x), the product of two frequencies that share no common period. The wave never quite repeats. The pixels shift erratically, settle briefly, then jitter off in a different direction.
I did not set out to encode rarity into the math. I picked the chaotic function because I wanted magic items to feel visually distinct from rare ones, and "more erratic" seemed right. But once I had both shaders running side by side, it was obvious they were saying something: a rare item has a stable signal. A magic item has a noisy one. The distortion behavior tells you something about quality before you have read the color tint or the tooltip — the same way a strong radio signal feels different from a weak one even when you cannot yet make out the words. That turned out to be exactly the kind of detail I want baked into the game's visual language.
Shotgun vs Pistol
One challenge for me as a new game dev is giving unique identity for weapons. In this scenario, I need to make sure the choice between each pistol type and shotgun type is meaningful. The difference between pistol and shotgun itself is easy, these are classic video game weapon types since the dawn of time and we don't need to debate further about their respective specialization in range and accuracy. The challenge here is if I have different pistol or shotgun base types, how do I design meaningful choices between them to adhere to different preferences?
The answer came from a constraint I had not thought much about: CircuitMind is direction-locked. The player faces left or right, and weapons fire in that direction. There is no vertical aim. This means accuracy alone cannot separate two pistols — both would fire along the same horizontal line, and the only difference would be a number. That felt like a non-choice.
So I started thinking about two separate questions. First, how reliably does a weapon fire along the ideal horizontal — the line directly in front of the player at weapon height? Second, how tightly clustered are the shots around that center? If you have a background in machine learning, you will recognize these as bias and variance. A high bias weapon consistently pulls toward the target line. A high precision weapon clusters shots tightly relative to each other. A shotgun is high bias, low precision: it always fires centered on the horizontal, but pellets fan out widely. A pistol is high precision, low bias: shots group tightly, but the center of that group has a small systematic drift from the horizontal.
What I like about this framing is that the range bands fall out naturally. The shotgun is reliable at close range because the wide spread covers anything in front of you regardless of exact positioning. The pistol rewards positioning — at medium range, the tight cluster hits consistently if you are at the right height relative to the enemy. At long range, there is an interesting interaction I did not expect when I started: because the pistol's drift is small and consistent rather than random, a jump attack repositions your weapon pivot and can realign the cluster with the enemy. Jumping becomes a combat skill for pistol users at distance, not just a traversal tool.
The projectile system is not built yet — these stats are defined in the data and flow through the pipeline, but the actual shooting logic comes when I build the ranged skill. Still, I have found it worth locking down the design before writing the code. When I know exactly why a weapon behaves a certain way, the implementation decisions are a lot less ambiguous. Here is my ugly attempt at drawing guns.
Affixes
This sprint I got 58 affixes wired into the game. You can now drop a piece of gear, read its affixes, and make a decision about whether it moves your build forward. That feedback loop — find item, evaluate, equip, feel the difference — is the core of the game, and it is finally running end to end.
The affix system in CircuitMind is built around a provider and processor model. Chips are providers: they supply flat values and tags, things like raw thermal damage or the Dash tag. Chassis pieces are processors: they carry affixes that amplify chips by tag. An Incendiary affix on a chest piece gives between 10% and 20% bonus effectiveness to every Thermal chip you have slotted. It does not care which specific chips they are — as long as the tag matches, the bonus applies. This keeps individual affixes simple to read while letting combinations grow in complexity naturally.
Some affixes trigger on combat events rather than sitting passively. Blood-Fuel Converter is a chip that heals 3% of your maximum HP on every kill. It does not sound like much in isolation, but in a room of fodder enemies it turns aggression into sustain. You are rewarded for staying in the fight rather than backing off. That felt right for the tone of the game.
The weapon affix I have been testing the most is Terminating, which executes enemies below 5% HP. Paired with Berserker's — which adds 1% bonus damage for every 1% of HP you are missing — the two affixes start pulling in the same direction in a way I did not explicitly plan. Berserker's rewards you for absorbing damage. Terminating rewards you for finishing fights before you absorb more. There is a tension between them that only resolves if you play aggressively enough to keep both active at once.
The interaction I find most interesting right now involves System Overrides, which are a separate category of affix that sit on chassis pieces and carry a permanent cost. Volatile Core reserves 20% of your maximum HP in exchange for a significant amplification to Potency-family affixes. You are running at 80% health for the entire run. That is the price.
What I did not anticipate when I equipped Volatile Core alongside Berserker's is that the reservation feeds the damage bonus. Volatile Core takes 20% of your HP away before the first fight, which means Berserker's is already active from the moment you spawn. You never have to get hit to prime it. The System Override is doing two things at once: paying for the Potency amplification, and silently activating a damage bonus as a side effect. That is exactly the kind of interaction the system is supposed to produce, and seeing it emerge from two components that were designed independently makes me feel like the underlying architecture is pointed in the right direction.
There are still around 130 affixes that exist in data but are not yet wired to game logic. Most of them depend on systems that are not built yet — projectile geometry, minion AI, cooldown tracking. They will get there. For now, 58 affixes that actually do something in combat is enough to start making real build decisions, and that was the threshold I needed to cross before moving on.
Again there is putting everything together in a video
Shout-out to the extremely talented and skilled artist Olha Holonko. She is a professional 2D Game Artist & UI/UX Designer with almost a decade of experience, and I think she did a great job on the inventory UI design.
Thanks again for following along on this journey!