[{"content":"Have you ever come across a problem that asks you to count how many numbers in a range [L, R] satisfy a specific property, only to realize that R can be up to 10^15?\nYour first instinct might be to write a simple for loop and check each number one by one. But when R is that massive, an O(N) loop will result in a Time Limit Exceeded error.\nThis is exactly where Digit Dynamic Programming saves the day. Instead of iterating through numbers, we construct them digit by digit, caching the states as we go.\nWe are going to look at a classic Digit DP problem to understand how to design states, handle boundaries, and deal with the leading zeros edge case.\nThe Problem Statement You are given three integers l, r and k.\nA number is considered good if the absolute difference between every pair of adjacent digits is at most k. Return the number of good integers in the range [l, r].\nExample 1:\n1 2 3 4 5 6 7 8 9 Input: l = 10, r = 15, k = 1 Output: 3 Explanation: The good integers in the range are 10, 11, and 12. For 10, abs(1 - 0) = 1. For 11, abs(1 - 1) = 0. For 12, abs(1 - 2) = 1. All these differences are at most k = 1. Thus, the answer is 3. Example 2:\n1 2 3 4 5 6 7 8 Input: l = 201, r = 204, k = 2 Output: 2 Explanation: The good integers in the range are 201 and 202. For 201, abs(2 - 0) = 2 and abs(0 - 1) = 1. For 202, abs(2 - 0) = 2 and abs(0 - 2) = 2. Thus, the answer is 2. Constraints:\n10 \u0026lt;= l \u0026lt;= r \u0026lt;= 10^15 0 \u0026lt;= k \u0026lt;= 9 The Prefix Counting The standard trick for any range counting problem [L, R] is to realize that: Count in [L, R] = Count in [0, R] - Count in [0, L - 1]\nInstead of writing a complex function that stays strictly between L and R, we just write a function solve(num) that counts all valid numbers from 0 to num. Then, we simply calculate the difference.\nDesigning the State Digit DP is basically a decision tree where we pick a number from 0 to 9 at each step to build our integer. To memoize this, we need to know our exact context.\nWe need 4 parameters for our state:\ni (index): Which digit are we currently placing (from left to right)? prev (previous digit): What was the last digit we placed? We need this to check if abs(prev - current) \u0026lt;= k. tight (is bound restricted?): This is crucial. If we are finding valid numbers up to 345, and our first digit is 3, the next digit cannot go up to 9. It is restricted by the upper bound 4. If tight is true, our upper limit is num[i]. If false, we can safely loop up to 9. 4.leadZero (leading zeros): Numbers can be shorter than R. For instance, 0042 is just 42. When building 0042, the first non-zero digit is 4. We shouldn\u0026rsquo;t check the difference between 0 and 4 because those leading zeros don\u0026rsquo;t actually exist in the number. If leadZero is true, we ignore the k constraint. The C++ Implementation Let\u0026rsquo;s look at the complete code and break down the transitions.\n1 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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 class Solution { using ll = long long; using table = vector\u0026lt;vector\u0026lt;vector\u0026lt;vector\u0026lt;ll\u0026gt;\u0026gt;\u0026gt;\u0026gt;; public: ll solve(table \u0026amp;memo, string \u0026amp;num, int i, bool tight, bool leadZero, int prev, int k) { // We successfully formed a valid number if (i == num.size()) return 1; // Cached result if we have been in this state if (memo[i][tight][leadZero][prev] != -1) { return memo[i][tight][leadZero][prev]; } ll total = 0; int lb = 0; // Upper bound is restricted by the actual number if tight is true int ub = tight ? num[i] - \u0026#39;0\u0026#39; : 9; for (int j = lb; j \u0026lt;= ub; j++) { // If the number has started, enforce the k difference rule if (!leadZero \u0026amp;\u0026amp; abs(prev - j) \u0026gt; k) continue; /* The next state is tight only if the current state is tight and we picked the max possible digit at this level */ bool tightP = (tight \u0026amp;\u0026amp; (j == ub)); /* We are still placing leading zeros if we were previously placing them and the current digit is also zero */ bool leadZeroP = (leadZero \u0026amp;\u0026amp; (j == 0)); // Count valid numbers for the remaining positions total += solve(memo, num, i + 1, tightP, leadZeroP, j, k); } return memo[i][tight][leadZero][prev] = total; } ll goodIntegers(ll l, ll r, int k) { string num1 = to_string(l - 1); string num2 = to_string(r); int n = num2.size(); int m = num1.size(); // Memoization tables: [index][tight][leadZero][prev] table memo1(n, vector\u0026lt;vector\u0026lt;vector\u0026lt;ll\u0026gt;\u0026gt;\u0026gt;( 2, vector\u0026lt;vector\u0026lt;ll\u0026gt;\u0026gt;(2, vector\u0026lt;ll\u0026gt;(10, -1)) )); table memo2(m, vector\u0026lt;vector\u0026lt;vector\u0026lt;ll\u0026gt;\u0026gt;\u0026gt;( 2, vector\u0026lt;vector\u0026lt;ll\u0026gt;\u0026gt;(2, vector\u0026lt;ll\u0026gt;(10, -1)) )); // Count for [0, R] ll upper = solve(memo1, num2, 0, true, true, 0, k); // Count for [0, L - 1] ll lower = solve(memo2, num1, 0, true, true, 0, k); return upper - lower; } }; Understanding the Transitions The hardest part of Digit DP is getting the tight and leadZero transitions right inside the loop:\ntightP = (tight \u0026amp;\u0026amp; (j == ub)) Imagine our limit is 345. If we pick 2 as our first digit, the prefix is smaller than 3, meaning the rest of the digits can be anything from 00 to 99. The tight constraint turns off! But if we pick 3 (which equals ub), we are still walking on a tightrope, and the next digit is restricted to 4. leadZeroP = (leadZero \u0026amp;\u0026amp; (j == 0)) If we are building the number 007, the first two digits are leading zeros. As long as we keep placing 0s, leadZeroP stays true. The moment we place the 7, leadZeroP turns false forever for this recursive branch, and the abs(prev - j) \u0026lt;= k rule activates. Complexity Analysis To understand how fast Digit DP is, we just need to calculate two things: the number of unique states, and the work done per state.\n1. Number of Unique States: Our memo table has 4 dimensions:\ni (Index): The number of digits. Since R \u0026lt;= 10^15, the number can have at most 15 digits. tight (Boolean): 2 possible values (true or false). leadZero (Boolean): 2 possible values (true or false). prev (Previous digit): 10 possible values (0 through 9). Total unique states = 15 x 2 x 2 x 10 = 600 states.\n2. Work Done Per State: Inside our solve function, if the state isn\u0026rsquo;t already cached, we run a for loop that iterates through the possible next digits. This loop runs at most 10 times.\nTotal Time Complexity: O(States x Transitions) = O(600 x 10) = 6000 operations\nSo, instead of running a brute-force loop 10^15 times, Digit DP solution finds the exact answer in roughly 6,000 operations executing in a fraction of a millisecond!\nSpace Complexity: O(log₁₀(R) x 2 x 2 x 10). This is simply the size of our 4D memo array (600 integers), plus the maximum depth of the recursion call stack. It uses virtually zero memory.\nConclusion Digit DP looks intimidating at first, but once you map out the state parameters (i, tight, leadZero, and whatever specific condition the problem asks for like prev), it becomes a highly repeatable template.\nNext time you see a problem asking to count valid numbers up to astronomical bounds, you\u0026rsquo;ll know exactly which tool to pull out of your algorithm toolkit!\n","permalink":"https://vjukic.com/posts/digit-dp/","summary":"Learn how to solve range-based digit counting problems using Digit DP.","title":"Digit Dynamic Programming"},{"content":"Let\u0026rsquo;s be brutally honest. Dynamic Programming has a terrifying reputation. For many developers, the phrase \u0026ldquo;Dynamic Programming\u0026rdquo; triggers PTSD flashbacks of staring at a blank whiteboard during an interview while the interviewer aggressively clears their throat.\nMany people think DP involves staring at a 2D Excel grid until a mathematical equation magically reveals itself in a vision. Or worse, they try to straight-up memorize the formulas!\nSorry to be the bearer of bad news, but that is the worst way to learn!\nHow do I know? Because that is exactly how it was taught at my university, and I failed my algorithms exam so spectacularly. I spent weeks trying to memorize different state transition formulas, convinced my brain was just fundamentally incompatible with computer science.\nDynamic Programming is nothing more than smart recursion. It is literally just spicy recursion with a notebook. If you can write a brute-force recursive solution (which you absolutely can), you can write a DP solution. It is a mechanical, step-by-step translation, not a magic trick.\nIn this post, we are going to do LeetCode 188: Best Time to Buy and Sell Stock IV. We are not going to start by drawing a grid. We will start with a simple decision tree, make our code realize it has amnesia, and evolve our solution into a blazing-fast, space-optimized masterpiece :D!\nThe Problem Statement You are given an integer array prices where prices[i] is the price of a given stock on the i-th day, and an integer k.\nFind the maximum profit you can achieve. You may complete at most k transactions. Note: You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).\nExample:\n1 2 3 4 5 6 7 8 k = 2 prices = [3, 2, 6, 5, 0, 3] Output: 7 Explanation: Buy on day 2 (price = 2) and sell on day 3 (price = 6), profit = 6 - 2 = 4. Then buy on day 5 (price = 0) and sell on day 6 (price = 3), profit = 3 - 0 = 3. Total profit = 4 + 3 = 7. The Brute Force Forget about performance for a second. Imagine you are a time traveler standing on day 0. What choices do you actually have?\nLike Dr. Strange looking at 14 million possible futures, your choices depend entirely on your current state in the timeline. What defines your exact situation at any given moment?\nCurrent Day (day): Which day is it? Transactions Remaining (txLeft): How many more times can I buy? Holding Status (holding): Do I currently own the stock? (0 = No, 1 = Yes). Based on this state, decision tree is simple:\nIf we have no stock (Holding = 0): Skip: Do nothing. Move to the next day. Buy: Spend money (-prices[day]), move to the next day, mark that we are now holding stock. This burns 1 transaction token. If we hold stock (Holding = 1): Skip: Hold the stock and move to the next day. Sell: Gain money (+prices[day]), move to the next day, and mark that we are no longer holding. At every day, we make a binary choice. Notice how the state toggles between Holding and Not Holding.\nLet\u0026rsquo;s turn this logic into pure, recursive C++\n1 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 #include \u0026lt;vector\u0026gt; #include \u0026lt;algorithm\u0026gt; using namespace std; class Solution { public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { // Start at day 0, with k transactions left, holding 0 stocks return solve(0, k, 0, prices); } private: int solve(int day, int txLeft, int holding, vector\u0026lt;int\u0026gt;\u0026amp; prices) { // Base Case 1 - we ran out of time if (day == prices.size()) return 0; // Base Case 2 - we ran out of tokens, and we aren\u0026#39;t holding anything to sell if (txLeft == 0 \u0026amp;\u0026amp; holding == 0) return 0; // Choice 1 - Do nothing today. Just time-travel to tomorrow int skip = solve(day + 1, txLeft, holding, prices); // Choice 2 - Transact (Buy or Sell) int transact = 0; if (holding == 1) { // We have a stock! Let\u0026#39;s sell it to get rich (+prices[day]) transact = prices[day] + solve(day + 1, txLeft, 0, prices); } else { // We have no stock. Let\u0026#39;s buy one and lose money (-prices[day]) // Buying costs us 1 transaction token transact = -prices[day] + solve(day + 1, txLeft - 1, 1, prices); } // Return whatever timeline makes us the most money return max(skip, transact); } }; This solution is 100% logically correct. It is also 100% going to give you a Time Limit Exceeded error.\nIt has a time complexity of roughly O(2^n). For an array of 50 days, this tree will branch into quadrillions of recursive calls. The heat death of the universe will happen before code finishes running.\nBrute force recursion calculates every single possibility, resulting in a massive, exponentially growing call stack.\nMemoization (The Top-Down Approach) Why is the recursive solution so slow? Because your code has severe amnesia. It calculates the exact same futures over and over again.\nImagine reaching Day 5 with 1 transaction left and holding stock via two completely different timelines:\nBuy Day 1 -\u0026gt; Sell Day 2 -\u0026gt; Buy Day 5. Buy Day 3 -\u0026gt; Sell Day 4 -\u0026gt; Buy Day 5. The recursion doesn\u0026rsquo;t know it has been in this exact state before. It recalculates the entire future from day 5 down to the end of the array twice.\nDifferent histories can result in the exact same current state. We should not predict the future twice.\nTo fix this, we introduce the concept of a Cache (Memoization). When we calculate the answer for a specific state, we write it down in our notebook. The next time we arrive at that exact state, we just read the answer from the notebook instead of calculating it again.\nThe Golden Rule: Store the Future, Ignore the Past This is the part that trips up almost everyone (including university-level me). What exactly are we storing in this notebook?\nWe are storing the future, not the past.\nThink of it like being dropped into a Las Vegas casino. The function solve(day, txLeft, holding) calculates the maximum profit you can make from today until the market closes.\nThe stock market does not care how you got to day 5. It doesn\u0026rsquo;t care if you made a fortune on day 2, or if you lost your life savings on day 4. If you stand on day 5, with 1 transaction token, holding 0 stocks, your future profit potential is exactly the same regardless of your past.\nBecause the past doesn\u0026rsquo;t matter, we can safely cache the result. If a timeline brings us to (day 5, 1 token, 0 stock), we look in our notebook: \u0026ldquo;Ah, I calculated this earlier. From this exact situation, the most money I can make by the end of the game is $10.\u0026rdquo; Boom. Sub-tree pruned.\nWhy a 3D Cache? To make our notebook work, every unique state needs its own specific slot to store its answer.\nLook at our recursive function signature: solve(day, txLeft, holding). The array prices never changes, but the other three variables do. Therefore, to uniquely identify our exact situation, we need a 3-dimensional grid.\nThink of it like finding a specific seat in a movie theater:\nBlock (day): Which day is it? (Values from 0 to N) Row (txLeft): How many tokens do we have? (Values from 0 to K) Seat (holding): Are we holding a stock? (Values 0 or 1) Our memoization table is essentially a 3D grid where every block holds the maximum future profit for that specific state.\nHere is what the C++ code looks like when we give it a memory:\n1 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 class Solution { // Our notebook (3D cache) vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt; memo; public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); // Resize notebook: [days][transactions][holding]. Fill with -1 (uncalculated) memo.assign(n, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 1, vector\u0026lt;int\u0026gt;(2, -1))); return solve(0, k, 0, prices); } int solve(int day, int txLeft, int holding, vector\u0026lt;int\u0026gt;\u0026amp; prices) { if (day == prices.size() || (txLeft == 0 \u0026amp;\u0026amp; holding == 0)) return 0; // Hey, have we been here before? Check the notebook! if (memo[day][txLeft][holding] != -1) { // Return cached answer! return memo[day][txLeft][holding]; } int skip = solve(day + 1, txLeft, holding, prices); int transact = 0; if (holding == 1) { transact = prices[day] + solve(day + 1, txLeft, 0, prices); } else { transact = -prices[day] + solve(day + 1, txLeft - 1, 1, prices); } // Before returning, write the answer in the notebook for next time return memo[day][txLeft][holding] = max(skip, transact); } }; Complexity:\nTime: O(N * K). We only calculate each state exactly once! Space: O(N * K * 2) for the 3D cache, plus recursion stack memory. This will pass LeetCode, but we can do better.\nTabulation (The Bottom-Up Approach) Recursion is cool until your input is massive, call stack explodes and it takes app down.\nTabulation simply means filling that memo array using for loops instead of recursion.\nIn recursion, we started at day 0 and looked forward. In Tabulation, we time-travel to the end of the array and work our way backwards to day 0.\nWhy backwards? Because to know the best decision to make on day 10, we already need to know the optimal futures for day 11.\nWe iterate backwards from the last day down to day 0 to ensure future dependencies are already calculated.\nLet\u0026rsquo;s translate the recursive logic directly into a loop:\n1 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 class Solution { using table = vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt;; public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); if (n == 0) return 0; // Create the 3D DP table: dp[day][transactions][holding] // Initialize everything to 0 (handles base cases automatically) table dp(n + 1, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 1, vector\u0026lt;int\u0026gt;(2, 0))); // Loop backwards from the last day to day 0 for (int day = n - 1; day \u0026gt;= 0; day--) { for (int txLeft = 1; txLeft \u0026lt;= k; txLeft++) { for (int holding = 0; holding \u0026lt;= 1; holding++) { // Choice 1 - Skip (take the value from tomorrow, same state) int skip = dp[day + 1][txLeft][holding]; // Choice 2 - Transact int transact = 0; if (holding == 1) { // Sell: gain price, holding becomes 0, txLeft stays the same transact = prices[day] + dp[day + 1][txLeft][0]; } else { // Buy: lose price, holding becomes 1, consume 1 transaction transact = -prices[day] + dp[day + 1][txLeft - 1][1]; } // Store the best choice dp[day][txLeft][holding] = max(skip, transact); } } } // The answer is the state we started: day 0, k transactions, holding 0 return dp[0][k][0]; } }; Space Optimization Look closely at the loop in the Tabulation code above. Notice anything interesting? To calculate values for day, we only look at values from day + 1. We don\u0026rsquo;t care about day + 2, or day + 5, or day + 100.\nWhy are we keeping the entire 3D calendar in memory when we only ever care about today and tomorrow?\nWe can completely remove the Day dimension from our array! All we need is a 2D array representing nextDay, and a 2D array representing currDay. As we step backward through time, we calculate currDay based on nextDay, and then overwrite nextDay with currDay.\nBecause we only ever look one step ahead, we don\u0026rsquo;t need the entire 3D cube. We can collapse the time dimension into two 2D grids swapping back and forth.\n1 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 class Solution { public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); if (n == 0 || k == 0) return 0; // We only need two 2D arrays: [holding (0 or 1)][transactions (0 to K)] vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; nextDay(2, vector\u0026lt;int\u0026gt;(k + 1, 0)); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; currDay(2, vector\u0026lt;int\u0026gt;(k + 1, 0)); for (int day = n - 1; day \u0026gt;= 0; day--) { for (int holding = 0; holding \u0026lt;= 1; holding++) { for (int txLeft = 1; txLeft \u0026lt;= k; txLeft++) { int skip = nextDay[holding][txLeft]; int transact = 0; if (holding == 1) { // Sell! transact = prices[day] + nextDay[0][txLeft]; } else { // Buy! transact = -prices[day] + nextDay[1][txLeft - 1]; } currDay[holding][txLeft] = max(skip, transact); } } // Move backwards in time for the next loop iteration nextDay = currDay; } return currDay[0][k]; } }; The Takeaway See how we completely avoided staring at a blank whiteboard trying to pull math formulas out of thin air?\nWe started with basic human logic: What are my choices right now?\nWe wrote a slow recursive function, gave it a notebook to remember things (Memoization), converted it into a loop (Tabulation), and threw away the parts of the notebook we didn\u0026rsquo;t need anymore (Space Optimization).\nThat is how you master Dynamic Programming. No magic required.\n","permalink":"https://vjukic.com/posts/mastering-dp/","summary":"Most people struggle with DP because they try to memorize tables and magic math. Here we will solve \u0026lsquo;Best Time to Buy and Sell Stock IV\u0026rsquo; by evolving a brute force recursion into a space-optimized DP solution.","title":"Stop Memorizing DP Formulas: The Real Way to Learn Dynamic Programming"},{"content":"Lighting is the difference between a flat, boring 2D game and atmospheric experience. When I started building Merciless Warrior, I knew that I wanted the player to feel the darkness and the warmth of the world.\nHowever, implementing lighting in a framework like Java Swing (which is not designed for high-performance game graphics) was a massive challenge. I struggled with this for weeks. I would try an approach, watch the FPS drop, get frustrated, and delete the code. I’d go work on the inventory system or combat logic just to feel productive, but the lack of atmosphere always pulled me back.\nHere is the story of my failed attempts, and the specific optimization that finally fixed everything.\nThe Naive Approach - Cutting Holes My first idea was geometric. I thought that if I want darkness, I need to draw a black rectangle over the screen. If I want light, I\u0026rsquo;ll cut a circle out of that rectangle.\nI tried using Java\u0026rsquo;s Area class to perform constructive geometry (subtracting an ellipse from a rectangle every single frame).\n1 2 3 4 5 Area darkness = new Area(new Rectangle(0, 0, width, height)); Area lightShape = new Area(new Ellipse2D.Double(playerX, playerY, 100, 100)); // This is incredibly slow :/ darkness.subtract(lightShape); g2d.fill(darkness); The Result: The game went to 5 FPS. Calculating complex geometry intersections on the CPU ~200 times a second is simply too expensive, especially with multiple light sources.\nThe Solution - Alpha Compositing I realized I needed to stop thinking about geometry and start thinking about blending modes. Instead of cutting shapes, I needed to erase pixels from an image.\nI switched to using a BufferedImage as a lightmap.\nFill the image with a semi-transparent black color (Ambient Darkness). Set the Graphics Composite mode to DST_OUT. This mode dictates that whatever I draw next will remove the transparency from the destination image. Draw my lights onto this map. Here is the core logic from my LightManager:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Create an off-screen buffer BufferedImage lightmap = new BufferedImage(WID, HEI, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = lightmap.createGraphics(); // Fill with darkness g2d.setColor(new Color(0, 0, 0, ambientAlpha)); g2d.fillRect(0, 0, WID, HEI); // Switch to eraser mode g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.DST_OUT)); // Draw lights (erasing the darkness) for (LightSource light : lights) { g2d.drawImage(light.texture, x, y, null); } Better, but still laggy. Drawing RadialGradientPaint from scratch for every torch, every frame, was still chewing up the CPU. I gave up and went back to working on enemy AI.\nOptimization 1 - Texture Caching One day I got idea to use cache. Generating a smooth gradient for a torch or the player\u0026rsquo;s aura is expensive. If I have 20 torches on screen, I shouldn\u0026rsquo;t calculate 20 gradients every frame.\nI implemented a caching. I pre-render the gradients into BufferedImage objects once during startup.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private BufferedImage createLightTexture(int d) { BufferedImage texture = new BufferedImage(d, d, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = texture.createGraphics(); // Gradient from Center to Edge RadialGradientPaint p = new RadialGradientPaint( center, radius, new float[]{0f, 1f}, new Color[]{Color.WHITE, new Color(1f, 1f, 1f, 0f)} ); g2d.setPaint(p); g2d.fillRect(0, 0, d, d); return texture; } Now, the render loop just draws existing images onto the screen, which is what Java\u0026rsquo;s Graphics2D is best at.\nOptimization 2 - Downscaling Even with caching, main FPS problem was still there. Constant FPS drops. This was the final trick that solidified the FPS. Lighting in games is naturally fuzzy and soft. It doesn\u0026rsquo;t need to be pixel-perfect sharp.\nI introduced a LIGHTMAP_SCALE factor (set to 2).\nI create the lightmap at half the resolution of the game window. I do all the drawing and erasing on this smaller image (4x fewer pixels to process!). I stretch the image back up to full size when drawing it over the game world. 1 2 3 4 5 6 7 8 private static final int LIGHTMAP_SCALE = 2; // In render method g2d.setRenderingHint( RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR ); g2d.drawImage(lightmap, 0, 0, GAME_WIDTH, GAME_HEIGHT, null); Using bilinear interpolation smoothes out the pixelation from the upscaling, making the lights look even softer and more natural, while drastically reducing the CPU load.\nAdvanced Effects - Day/Night Cycle With the performance budget secured, I added a TimeCycleManager. Instead of a static darkness, the ambientAlpha value interpolates between colors based on the game time.\nNight: High alpha (dark), Dark Blue tint. Dawn/Dusk: Orange/Purple tint. Day: Low alpha (bright), Yellow tint. I even added a breathing effect to torches using a simple sine wave function to modulate the size of the light texture slightly every frame, making the fire feel alive.\nConclusion This feature was a test of perseverance. It would have been easy to stick with the geometric approach and accept a laggy game, or just remove lighting entirely. But by stepping away, working on other things, and coming back with a fresh perspective (and learning about downscaling), I turned the engine\u0026rsquo;s biggest bottleneck into its best visual feature.\n","permalink":"https://vjukic.com/posts/mw/2d-lighting-system/","summary":"How I went from 10 FPS to 144 FPS by abandoning geometric subtraction and adopting Alpha Compositing, Caching and Downscaling in Java Swing.","title":"Illuminating the Game: Building a High-Performance 2D Lighting Engine"},{"content":"The Health Inspection So, the restaurant is built. We have chefs delegating work (launch), a head chef managing the kitchen\u0026rsquo;s lifespan (CoroutineScope), and a buffet line that stays stocked (Flow).\nOn paper, it\u0026rsquo;s a masterpiece. But here’s the reality: Async code is a nightmare to prove.\nIn a real kitchen, if a recipe says \u0026ldquo;simmer for 4 hours\u0026rdquo;, the inspector isn\u0026rsquo;t going to sit there for 240 minutes with a stopwatch. They’d go crazy. In the dev world, we have the same problem. We can\u0026rsquo;t let our CI/CD pipeline sit idle for 5 seconds just because a delay(5000) is sitting in a repo. Even worse are flaky tests, those annoying ones that pass on your machine but fail randomly in the cloud because of a millisecond of network lag.\nTo pass the inspection without losing our minds, we need to warp time.\nThe Secret Ingredient: runTest Old school coroutine testing involved runBlocking and manual Thread.sleep(). Honestly? Don\u0026rsquo;t do that. It’s slow and unreliable. Instead, we use kotlinx-coroutines-test.\nThe MVP of this library is runTest. Think of it as a simulated kitchen where the clock only moves when you say so. If your code hits a delay(10_000), runTest doesn\u0026rsquo;t actually wait. It just teleports the virtual clock forward 10 seconds instantly.\n1. Faking the Simmer (suspend functions) Let’s say we’re fetching a user. It takes a second to simulate a network round-trip.\nThe Code:\n1 2 3 4 5 6 class UserRepository { suspend fun getUser(id: String): String { delay(1000) // This would normally kill your test speed return \u0026#34;User $id\u0026#34; } } The Test: We use runTest to skip the boring stuff.\n1 2 3 4 5 6 7 8 9 10 @Test fun `getUser should be instant`() = runTest { val repository = UserRepository() // We call the suspend function val user = repository.getUser(\u0026#34;123\u0026#34;) // The test finishes in about 20ms, skipping the 1s wait! assertEquals(\u0026#34;User 123\u0026#34;, user) } 2. Watching the Status Board (StateFlow) Testing a ViewModel is where most people get tripped up. You want to see the UI state go: Idle -\u0026gt; Loading -\u0026gt; Success.\nA quick opinionated tip: If you hardcode Dispatchers.IO inside your classes, you\u0026rsquo;re going to have a bad time. Always inject your dispatchers. It makes faking them in tests actually possible.\n1 2 3 4 5 6 7 8 9 10 11 12 13 class OrderViewModel(private val testDispatcher: CoroutineDispatcher) { private val _uiState = MutableStateFlow(\u0026#34;Idle\u0026#34;) val uiState = _uiState.asStateFlow() fun fetchOrder() { // Use the injected dispatcher! CoroutineScope(testDispatcher).launch { _uiState.value = \u0026#34;Loading\u0026#34; delay(500) _uiState.value = \u0026#34;Order #1 Ready\u0026#34; } } } In the test, we use advanceUntilIdle(). This is basically a fast-forward to the end button for all pending tasks.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Test fun `verify state transitions`() = runTest { val viewModel = OrderViewModel(StandardTestDispatcher(testScheduler)) viewModel.fetchOrder() // Execute everything until the first delay() advanceUntilIdle() assertEquals(\u0026#34;Loading\u0026#34;, viewModel.uiState.value) // Warp forward 500ms advanceTimeBy(500) assertEquals(\u0026#34;Order #1 Ready\u0026#34;, viewModel.uiState.value) } 3. Checking the Buffet Line (Flow) How do you test a stream? If our Flow emits three dishes, we need to be sure they arrive in order and don\u0026rsquo;t just vanish.\nThe simplest trick? Turn the Flow into a List. runTest is smart enough to wait for the stream to finish (virtually) before asserting.\n1 2 3 4 5 6 7 8 9 10 11 12 13 fun serveBuffet(): Flow\u0026lt;String\u0026gt; = flow { delay(1000); emit(\u0026#34;Pasta\u0026#34;) delay(1000); emit(\u0026#34;Salad\u0026#34;) delay(1000); emit(\u0026#34;Bread\u0026#34;) } @Test fun `check buffet order`() = runTest { // toList() collects every emission into one neat package val results = serveBuffet().toList() assertEquals(listOf(\u0026#34;Pasta\u0026#34;, \u0026#34;Salad\u0026#34;, \u0026#34;Bread\u0026#34;), results) } Series Finale: The Kitchen is Open Testing isn\u0026rsquo;t about bureaucracy. It’s about not getting a phone call at 3 AM because your app crashed. By using runTest, we move from \u0026ldquo;I think this works\u0026rdquo; to \u0026ldquo;I have proof this works.\u0026rdquo;\nThis series covered a lot of ground:\nThe Basics: Stop blocking threads. Management: Use Scopes to avoid memory leaks. Streams: Master Flow for data-heavy apps. Resilience: Handle the dinner rush with backpressure. Verification: Warp time to make sure your logic is solid. The kitchen is yours now. Go build something fast.\nHappy coding!\n","permalink":"https://vjukic.com/posts/coroutines/coroutines-deep-dive-part5/","summary":"Testing async code usually feels like trying to catch smoke. Let\u0026rsquo;s learn how to warp time and skip the waiting game.","title":"Coroutines Deep Dive - Part 5: Testing"},{"content":"The Dinner Rush: Building a Resilient Kitchen Our restaurant is now a model of modern efficiency. We can handle complex orders and manage live state (StateFlow) and events (SharedFlow). But let\u0026rsquo;s agree, we\u0026rsquo;ve been operating under ideal conditions.\nWhat happens when the Saturday night dinner rush hits?\nThe kitchen (producer) starts churning out dishes far faster than the waiters (consumers) can deliver them. A specialized task like butchering meat (IO-intensive) is done right next to the delicate plating station (UI-bound), causing chaos :(. One dish gets burnt, and the entire buffet line shuts down in response. To survive the rush, our kitchen needs to be more than just efficient. It needs to be resilient.\nBackpressure: When the Chef is Too Fast Backpressure is what happens when a producer emits items faster than a consumer can process them. By default, Flow is sequential. The chef waits for the waiter to deliver one dish before starting the next. This is safe but not always performant.\nLet\u0026rsquo;s see example for fast chef and a slow waiter:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 fun makeDishesQuickly(): Flow\u0026lt;Int\u0026gt; = flow { repeat(5) { dishNumber -\u0026gt; println(\u0026#34;Chef: Cooking dish $dishNumber\u0026#34;) // Chef is fast delay(100) emit(dishNumber) } } val startTime = System.currentTimeMillis() makeDishesQuickly().collect { dish -\u0026gt; println(\u0026#34;Waiter: Serving dish $dish...\u0026#34;) // Waiter is slow delay(500) } println(\u0026#34;Total time: ${System.currentTimeMillis() - startTime}ms\u0026#34;) Output:\n1 2 3 4 5 6 7 8 9 10 11 12 Chef: Cooking dish 0 Waiter: Serving dish 0... Chef: Cooking dish 1 Waiter: Serving dish 1... Chef: Cooking dish 2 Waiter: Serving dish 2... Chef: Cooking dish 3 Waiter: Serving dish 3... Chef: Cooking dish 4 Waiter: Serving dish 4... Total time: 3000ms // (5 dishes * (100ms cook + 500ms serve)) The chef is constantly blocked, waiting for the slow waiter. We can do better.\nStrategy 1: buffer() - The Warming Table The buffer() operator runs the producer coroutine concurrently with the consumer, with a buffer in between. The chef can place dishes on a warming table without waiting for the waiter.\n1 2 3 4 5 6 makeDishesQuickly() .buffer() .collect { dish -\u0026gt; println(\u0026#34;Waiter: Serving dish $dish...\u0026#34;) delay(500) } Output:\n1 2 3 4 5 6 7 8 9 10 11 12 Chef: Cooking dish 0 Chef: Cooking dish 1 Chef: Cooking dish 2 Chef: Cooking dish 3 Chef: Cooking dish 4 Waiter: Serving dish 0... Waiter: Serving dish 1... Waiter: Serving dish 2... Waiter: Serving dish 3... Waiter: Serving dish 4... Total time: 2600ms // Chef finishes in ~500ms, Waiter takes ~2500ms. Much faster! The chef finishes cooking almost instantly, and the total time is now dominated only by the slow waiter.\nStrategy 2: conflate() - The \u0026ldquo;Latest Special\u0026rdquo; Board What if we only care about the most recent value? conflate() is a strategy where a slow consumer skips intermediate values. If the chef puts down three new dishes while the waiter is busy, the waiter will ignore the first two and just deliver the latest one.\n1 2 3 4 5 6 7 makeDishesQuickly() // Only deliver the latest dish .conflate() .collect { dish -\u0026gt; println(\u0026#34;Waiter: Serving dish $dish...\u0026#34;) delay(500) } Output:\n1 2 3 4 5 6 7 8 9 Chef: Cooking dish 0 Chef: Cooking dish 1 Chef: Cooking dish 2 Chef: Cooking dish 3 Chef: Cooking dish 4 Waiter: Serving dish 0... // By the time this is done, chef is at dish 4 Waiter: Serving dish 4... // Skips 1, 2, and 3 Total time: 1100ms // Super fast, but with data loss Strategy 3: collectLatest() - The Search Bar This collector processes only the latest value, but it goes a step further. If a new value arrives while the previous one is being processed, it cancels the old processing block and starts over with the new value. This is the perfect pattern for handling rapid-fire UI events like search queries.\n1 2 3 4 5 6 7 makeDishesQuickly() // It\u0026#39;s a collector, not an operator ! .collectLatest { dish -\u0026gt; println(\u0026#34;Waiter: Grabbing dish $dish...\u0026#34;) delay(500) println(\u0026#34;Waiter: FINISHED serving dish $dish.\u0026#34;) } Output:\n1 2 3 4 5 6 7 8 9 10 11 12 13 Chef: Cooking dish 0 Waiter: Grabbing dish 0... Chef: Cooking dish 1 Waiter: Grabbing dish 1... // Cancels serving dish 0 Chef: Cooking dish 2 Waiter: Grabbing dish 2... // Cancels serving dish 1 Chef: Cooking dish 3 Waiter: Grabbing dish 3... // Cancels serving dish 2 Chef: Cooking dish 4 Waiter: Grabbing dish 4... // Cancels serving dish 3 Waiter: FINISHED serving dish 4. // Only the last one completes! Total time: 1000ms flowOn(): Keeping the Kitchen Organized By default, the producer and the collector run in the same coroutine and on the same thread. This can be a problem if the chef is doing heavy work (like butchering on an IO thread) that shouldn\u0026rsquo;t happen at the delicate plating station (Main UI thread).\nThe flowOn() operator changes the execution context for the upstream code (the producer and any operators before it).\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 flow { println(\u0026#34;Chef: Prepping ingredients. [Thread: ${Thread.currentThread().name}]\u0026#34;) emit(\u0026#34;Dish\u0026#34;) } .map { dish -\u0026gt; println(\u0026#34;Sous-chef: Decorating the dish. [Thread: ${Thread.currentThread().name}]\u0026#34;) \u0026#34;$dish with Decoration\u0026#34; } .flowOn(Dispatchers.IO) // Everything ABOVE runs on the IO Dispatcher .collect { dish -\u0026gt; println(\u0026#34;Waiter: Serving \u0026#39;$dish\u0026#39;. [Thread: ${Thread.currentThread().name}]\u0026#34;) } Output:\n1 2 3 Chef: Prepping ingredients. [Thread: DefaultDispatcher-worker-1] Sous-chef: Decorating the dish. [Thread: DefaultDispatcher-worker-1] Waiter: Serving \u0026#39;Dish with Decoration\u0026#39;. [Thread: main] flowOn acts as a boundary. The heavy kitchen work stays off the main thread.\ncatch(): Handling a Burnt Dish What happens if something goes wrong in the stream? By default, an exception will terminate the flow and crash the collector.\n1 2 3 4 5 6 7 8 flow { emit(\u0026#34;Salad\u0026#34;) emit(\u0026#34;Bread\u0026#34;) throw RuntimeException(\u0026#34;Burnt the steak!\u0026#34;) emit(\u0026#34;Dessert\u0026#34;) } // This would crash! .collect { dish -\u0026gt; println(\u0026#34;Enjoying the $dish\u0026#34;) } The catch operator provides a declarative way to handle upstream exceptions.\n1 2 3 4 5 6 7 8 9 10 11 12 13 flow { emit(\u0026#34;Salad\u0026#34;) emit(\u0026#34;Bread\u0026#34;) throw RuntimeException(\u0026#34;Burnt the steak!\u0026#34;) } .catch { e -\u0026gt; println(\u0026#34;Inspector: Caught a problem! ${e.message}\u0026#34;) // Can even emit a replacement value emit(\u0026#34;Compensatory Cookies\u0026#34;) } .collect { dish -\u0026gt; println(\u0026#34;Customer: Enjoying the $dish\u0026#34;) } Output:\n1 2 3 4 Customer: Enjoying the Salad Customer: Enjoying the Bread Inspector: Caught a problem! Burnt the steak! Customer: Enjoying the Compensatory Cookies The stream didn\u0026rsquo;t crash. The error was handled gracefully.\nNote: catch can only handle exceptions from upstream operators. It can\u0026rsquo;t catch an exception in the collect block itself !\nWrap-up The kitchen is now officially resilient and ready for the dinner rush.\nBackpressure Strategies: Manages fast producers and slow consumers using buffer (concurrency), conflate (latest value), and collectLatest (cancellable work). flowOn: Assignes specific parts of stream to the correct context, keeping code organized and UI responsive. catch: Use to handle errors declaratively within stream, preventing crashes and allowing for recovery. What\u0026rsquo;s Next in Part 5? We\u0026rsquo;ve designed an incredible restaurant, trained our staff, and built resilient systems. But how do we prove it all works without opening for business? How can we be sure the chef\u0026rsquo;s timing is right and the waiter\u0026rsquo;s logic is good?\nIn the final part, we will dive into the essential topic of Testing Coroutines and Flows. We\u0026rsquo;ll explore the kotlinx-coroutines-test library, learn how to control virtual time, and write stable, reliable tests for our asynchronous world.\n","permalink":"https://vjukic.com/posts/coroutines/coroutines-deep-dive-part4/","summary":"Learn to handle backpressure, manage execution context with flowOn, and handle errors without crashing stream.","title":"Coroutines Deep Dive - Part 4: Advanced Flow \u0026 Resilience"},{"content":"From a Single Dish to a Full Restaurant In our last lessons, the kitchen became a tuned machine for handling single orders. A customer requests one thing, and a suspend function returns one result.\nBut a real restaurant is far more complex. It\u0026rsquo;s a dynamic environment with multiple streams of information:\nA buffet line where new dishes are being served. A bartender and a chef who need to synchronize a food and drink order. A live status board for customers to track their order. A PA system for making announcements to the staff. suspend functions are not enough for this. To manage multiple values over time, we need the powerful coroutine tool library: Kotlin Flow.\nThe Buffet Line: Flow (Cold Stream) A Flow is an asynchronous stream of values. Think of it as a buffet line. The chef (producer) puts dishes on the line one by one (emits values), and the customer (consumer) takes each dish as it becomes available (collects values).\nCrucially, a standard Flow is cold. This means the chef does not start cooking until a customer shows up to collect. If no one is collecting, no work is done. This makes it incredibly efficient.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun serveDishes(): Flow\u0026lt;String\u0026gt; = flow { println(\u0026#34;Chef: \u0026#39;A customer has arrived! Starting to cook.\u0026#39;\u0026#34;) delay(1000); emit(\u0026#34;Pasta\u0026#34;) delay(1000); emit(\u0026#34;Salad\u0026#34;) delay(1000); emit(\u0026#34;Bread\u0026#34;) } fun main() = runBlocking { println(\u0026#34;Customer: \u0026#39;I\u0026#39;d like to eat from the buffet.\u0026#39;\u0026#34;) serveDishes().collect { dish -\u0026gt; println(\u0026#34;Customer: \u0026#39;Yum, enjoying this $dish!\u0026#39;\u0026#34;) } println(\u0026#34;Customer: \u0026#39;I\u0026#39;m full!\u0026#39;\u0026#34;) } The chef only starts cooking when .collect is called, and the stream naturally completes when the chef has no more dishes to emit.\nOrchestrating the Meal: Advanced Flow Operators A real restaurant needs to combine different streams. Flow provides a rich set of operators for this.\nPairing Dishes and Drinks with zip A customer orders a set meal: a dish and a drink. The chef prepares the food, and a bartender prepares the drinks. They must be served together as a pair. This is the job of zip. It combines two flows by pairing their corresponding elements.\n1 2 3 4 5 6 7 8 9 10 11 12 13 import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { val foodFlow = flowOf(\u0026#34;Steak\u0026#34;, \u0026#34;Salad\u0026#34;, \u0026#34;Soup\u0026#34;) val drinkFlow = flowOf(\u0026#34;Wine\u0026#34;, \u0026#34;Water\u0026#34;, \u0026#34;Juice\u0026#34;) foodFlow.zip(drinkFlow) { food, drink -\u0026gt; \u0026#34;$food with $drink\u0026#34; }.collect { meal -\u0026gt; println(\u0026#34;Waiter: \u0026#39;Serving: $meal\u0026#39;\u0026#34;) } } Output:\n1 2 3 Waiter: \u0026#39;Serving: Steak with Wine\u0026#39; Waiter: \u0026#39;Serving: Salad with Water\u0026#39; Waiter: \u0026#39;Serving: Soup with Juice\u0026#39; zip waits until it has a new item from both flows before emitting the combined result. It stops as soon as one of the flows finishes.\nThe Live Menu Board with combine Imagine a digital menu board. It needs to show the \u0026ldquo;Dish of the Day\u0026rdquo;, which changes every few seconds. It also needs to show the \u0026ldquo;Current Price\u0026rdquo;, which is based on market costs and updates at a different interval.\nThe combine operator is perfect for this. It combines the latest value from each flow whenever one of them emits a new value.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun main() = runBlocking { // Dish of the day (changes every 3s) val dishFlow = flow { delay(1500); emit(\u0026#34;Salmon\u0026#34;) delay(1500); emit(\u0026#34;Chicken\u0026#34;) } // Price (updates every 2s) val priceFlow = flow { delay(1000); emit(\u0026#34;$25\u0026#34;) delay(1000); emit(\u0026#34;$23\u0026#34;) delay(1000); emit(\u0026#34;$24\u0026#34;) } dishFlow.combine(priceFlow) { dish, price -\u0026gt; \u0026#34;Today\u0026#39;s Special: $dish for $price\u0026#34; }.collect { menu -\u0026gt; println(\u0026#34;Menu Board: $menu\u0026#34;) } } Output:\n1 2 3 4 5 6 7 8 // ~1.5s: First dish arrives Menu Board: Today\u0026#39;s Special: Salmon for $25 // ~2s: Price updates Menu Board: Today\u0026#39;s Special: Salmon for $23 // ~3s: Second dish arrives, price is still $23 Menu Board: Today\u0026#39;s Special: Chicken for $23 // ~3s: Price updates again Menu Board: Today\u0026#39;s Special: Chicken for $24 This is incredibly useful for UI where multiple data sources need to be combined to render the screen.\nThe Order Status Board: StateFlow (Hot Stream) Our buffet line (Flow) was cold. What about data that exists whether someone is looking at it or not, like the status of an order? This requires a hot stream.\nA StateFlow is like a big digital order status board.\nIt\u0026rsquo;s hot: It\u0026rsquo;s always active and always has a value (e.g. \u0026ldquo;Order Received\u0026rdquo;). It only holds the most recent value. Old statuses are gone forever. New observers immediately get the current status and then any future updates. StateFlow is the standard tool for managing UI state in a ViewModel.\n1 2 3 4 5 6 7 8 9 10 11 12 // ViewModel private val _orderStatus = MutableStateFlow(\u0026#34;Order Received\u0026#34;) val orderStatus: StateFlow\u0026lt;String\u0026gt; = _orderStatus.asStateFlow() fun updateStatus(newStatus: String) { _orderStatus.value = newStatus } // UI viewModel.orderStatus.collect { status -\u0026gt; println(\u0026#34;Customer App: \u0026#39;Status is: $status\u0026#39;\u0026#34;) } The PA System: SharedFlow (Hot Stream for Events) StateFlow is for state. But what about one-time events, like a \u0026ldquo;Payment successful\u0026rdquo; toast or a navigation command? If we use StateFlow, a screen rotation might cause the event to be shown again.\nFor events, we need a SharedFlow. Think of it as the kitchen\u0026rsquo;s PA announcement system.\nIt\u0026rsquo;s hot: The PA system is always on. It broadcasts events to any and all current listeners. By default, new listeners do not receive old announcements. This is perfect for showing a snackbar or navigating to a new screen exactly once.\n1 2 3 4 5 6 7 8 9 10 11 12 // ViewModel private val _announcements = MutableSharedFlow\u0026lt;String\u0026gt;() val announcements: SharedFlow\u0026lt;String\u0026gt; = _announcements.asSharedFlow() suspend fun makeAnnouncement(message: String) { _announcements.emit(message) } // UI viewModel.announcements.collect { announcement -\u0026gt; println(\u0026#34;(Waiter heard): \u0026#39;$announcement\u0026#39;\u0026#34;) } Wrap-up Now you can orchestrate complex streams of data with confidence.\nFlow: A cold stream for on-demand data, like a buffet line. Use it for repository calls that fetch data from a database or network. Advanced Operators: zip: Pairs items from multiple flows one-to-one. combine: Creates a new value from the latest item of each flow. Essential for reactive UIs. StateFlow: A hot stream for representing UI state, like a status board. Use it to hold screen state in your ViewModel. SharedFlow: A hot stream for broadcasting one-time events, like a PA system. Use it for sending events like toasts or navigation commands from your ViewModel. What\u0026rsquo;s Next in Part 4? Our restaurant is now efficient, handling complex orders and streams of information. But so far, we\u0026rsquo;ve been operating under ideal conditions. What happens when the dinner rush hits and our system is put under real stress?\nWe need to turn our efficient kitchen into a truly resilient and production-ready operation. .\nWhat occurs when the kitchen (producer) produces dishes at a pace too quick for the waiters (consumers) to manage? Do we drop dishes on the floor, or do we have a strategy? This is backpressure. How do we ensure the intensive chopping and cooking (Dispatchers.IO) never interferes with the delicate work of plating and serving (Dispatchers.Main)? We\u0026rsquo;ll see the flowOn operator. If one dish in a continuous stream is prepared incorrectly, how can we handle that error with the catch operator without shutting down the entire buffet line? In the next part, we\u0026rsquo;ll dive into the advanced operators and concepts that make Flow robust enough for any scenario you can throw at it.\n","permalink":"https://vjukic.com/posts/coroutines/coroutines-deep-dive-part3/","summary":"The Flow, advanced operators (combine and zip), and the critical role of StateFlow and SharedFlow in modern UI.","title":"Coroutines Deep Dive - Part 3: Streams \u0026 Kotlin Flow"},{"content":"The Rules of the Kitchen In Part 1, we saw how launch and async allow us to perform multiple tasks concurrently (like a chef delegating orders). The kitchen is busy, and food is getting out faster. Professional kitchen requires more than just concurrent cooks. It needs structure, management, and protocols when things go wrong.\nConsider these scenarios:\nA customer cancels their order halfway through. Do we keep cooking their meal? A critical piece of equipment (like an oven) fails. Does the entire kitchen shut down? Specific tasks need to be done at specific stations (chopping vs plating). These are the exact problems that CoroutineScope, Dispatchers, and the principle of Structured Concurrency solve. They are the management system that turns a chaotic collection of tasks into a professional and resilient operation.\nThe Kitchen Stations: Dispatchers So far, our coroutines have been running on some background thread, but we haven\u0026rsquo;t controlled which one. Dispatchers are like the special stations in a kitchen. They tell a coroutine which thread it should run on.\nKotlin gives us three primary dispatchers:\nDispatchers.Main: The front of the house (where food is plated and served to the customer). This is the UI thread (on Android). Use it for any task that touches the user interface. It\u0026rsquo;s optimized for very short, fast operations. Never block this thread! Dispatchers.IO: The storage. This is for I/O-intensive work, like making network calls, reading from a database, or accessing files. It maintains a large pool of threads designed for tasks that spend most of their time waiting. Dispatchers.Default: The main prep area. This is for CPU-intensive work, like sorting a huge list, doing complex calculations, or parsing large JSON objects. Its thread pool is sized to the number of CPU cores. To move a task between stations, we use the withContext function.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 fun main() = runBlocking { println(\u0026#34;Head Chef: \u0026#39;Get me the user data!\u0026#39; (Starts on Main thread)\u0026#34;) launch(Dispatchers.Main) { // Start on the Main println(\u0026#34;UI Chef: \u0026#39;Showing loading spinner.\u0026#39; \u0026#34; + \u0026#34;[Thread: ${Thread.currentThread().name}]\u0026#34;) // Switch to the IO dispatcher to fetch data without blocking the UI val userData = withContext(Dispatchers.IO) { println(\u0026#34;Data Chef: \u0026#39;Fetching user data...\u0026#39; \u0026#34; + \u0026#34;[Thread: ${Thread.currentThread().name}]\u0026#34;) delay(1000) \u0026#34;User Data Fetched\u0026#34; } // withContext automatically switches back to the Main thread to update the UI println(\u0026#34;UI Chef: \u0026#39;Hiding spinner, showing data: $userData\u0026#39; \u0026#34; + \u0026#34;[Thread: ${Thread.currentThread().name}]\u0026#34;) } } Output:\n1 2 3 4 5 Head Chef: \u0026#39;Get me the user data!\u0026#39; (Starts on Main thread) UI Chef: \u0026#39;Showing loading spinner.\u0026#39; [Thread: main] Data Chef: \u0026#39;Fetching user data...\u0026#39; [Thread: DefaultDispatcher-worker-1] (1 second passes) UI Chef: \u0026#39;Hiding spinner, showing data: User Data Fetched\u0026#39; [Thread: main] withContext is a suspend function that lets us switch to a different context for a specific block of code. After code is finished, it switches back. This is a fundamental pattern for async programming with coroutines.\nThe Head Chef: CoroutineScope and Structured Concurrency If Dispatchers are the stations, who is in charge of all the cooks? This is the job of the CoroutineScope. A scope is like a head chef for a set of coroutines. It manages their overall lifecycle.\nThis brings us to the principle of Structured Concurrency. New coroutines can only be launched within a scope. Scope defines their lifetime. When the scope\u0026rsquo;s lifetime ends, all coroutines within it are automatically cancelled. This prevents coroutines from leaking (e.g. continuing to fetch data for a screen that\u0026rsquo;s no longer visible).\nFramework-Provided Scopes On Android, you get pre-made scopes tied to component lifecycles, which you should almost always use:\nviewModelScope: Tied to a ViewModel. It cancels all coroutines when the ViewModel is cleared. This is the default choice in Android. lifecycleScope: Tied to an Activity or Fragment\u0026rsquo;s Lifecycle. Useful for work that needs to align with specific lifecycle events. Warning: Avoid GlobalScope GlobalScope is like a rogue chef who works independently and never goes home when the kitchen closes. Coroutines launched in it are not tied to any job and can easily lead to memory leaks and wasted resources. There is almost no good reason to use it in application code.\nJob vs. SupervisorJob: How the Chef Handles Failure A CoroutineScope is defined by its CoroutineContext, which must include a Job. The Job represents the scope\u0026rsquo;s own lifecycle and how it handles failures.\nThe default Job has a strict \u0026ldquo;all for one, one for all\u0026rdquo; policy. If any child coroutine fails with an exception, it immediately cancels its parent Job and all of its siblings. It\u0026rsquo;s like a head chef who evacuates the entire section if one cook starts a fire.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 fun main() = runBlocking { val chefScope = CoroutineScope(Job()) chefScope.launch { delay(200) println(\u0026#34;Cook A: \u0026#39;Plating the salad.\u0026#39;\u0026#34;) } chefScope.launch { delay(100) println(\u0026#34;Cook B: \u0026#39;Oh no, I burned the toast!\u0026#39;\u0026#34;) throw Exception(\u0026#34;Toast is on fire!\u0026#34;) } delay(500) println(\u0026#34;Kitchen Manager: \u0026#39;Strict chef\u0026#39;s section is quiet now.\u0026#39;\u0026#34;) } Output:\n1 2 3 Cook B: \u0026#39;Oh no, I burned the toast!\u0026#39; (Exception is thrown, the scope is cancelled) Kitchen Manager: \u0026#39;Strict chef\u0026#39;s section is quiet now.\u0026#39; Notice \u0026ldquo;Cook A\u0026rdquo; never got to finish. Cook B\u0026rsquo;s failure cancelled the entire scope.\nBut what if you want one failure to not affect other tasks? For that, you use a SupervisorJob. It allows child coroutines to fail independently without bringing down the whole scope This is the more lenient head chef who deals with the one cook\u0026rsquo;s mistake while telling everyone else to keep working.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 fun main() = runBlocking { val chefScope = CoroutineScope(SupervisorJob()) chefScope.launch { delay(200) println(\u0026#34;Cook A: \u0026#39;Plating the salad.\u0026#39;\u0026#34;) } chefScope.launch { try { delay(100) println(\u0026#34;Cook B: \u0026#39;Oh no, I burned the toast!\u0026#39;\u0026#34;) throw Exception(\u0026#34;Toast is on fire!\u0026#34;) } catch (e: Exception) { println(\u0026#34;Cook B (to manager): \u0026#39;${e.message}\u0026#39;\u0026#34;) } } delay(500) println(\u0026#34;Kitchen Manager: \u0026#39;Lenient chef\u0026#39;s section is still running.\u0026#39;\u0026#34;) } Output:\n1 2 3 4 Cook B: \u0026#39;Oh no, I burned the toast!\u0026#39; Cook B (to manager): \u0026#39;Toast is on fire!\u0026#39; Cook A: \u0026#39;Plating the salad.\u0026#39; Kitchen Manager: \u0026#39;Lenient chef\u0026#39;s section is still running.\u0026#39; viewModelScope uses a SupervisorJob by default, which is why one failing network call in a ViewModel doesn\u0026rsquo;t necessarily stop another one from completing. This makes it incredibly robust for UI-related tasks.\nHandling Emergencies: Exception Handling What happens when a task fails with an error? In the world of coroutines, exceptions propagate up the hierarchy. An uncaught exception will cancel its parent scope (unless it\u0026rsquo;s a SupervisorJob). This is a \u0026ldquo;fail-fast\u0026rdquo; safety feature.\nTo handle errors gracefully without crashing the scope, use a try-catch block (typically around the .await() call).\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 fun main() = runBlocking { val scope = CoroutineScope(Dispatchers.Default) scope.launch { println(\u0026#34;Chef: \u0026#39;I\u0026#39;ll get that steak from the freezer.\u0026#39;\u0026#34;) val deferredSteak = async\u0026lt;String\u0026gt; { delay(500) throw Exception(\u0026#34;Oh no, the freezer is broken!\u0026#34;) } try { val steak = deferredSteak.await() println(\u0026#34;Chef: \u0026#39;Got the steak: $steak\u0026#39;\u0026#34;) } catch (e: Exception) { println(\u0026#34;Chef: \u0026#39;Error! I\u0026#39;ll tell the customer we\u0026#39;re out of steak.\u0026#39;\u0026#34;) } } delay(1000) } Output:\n1 2 Chef: \u0026#39;I\u0026#39;ll get that steak from the freezer.\u0026#39; Chef: \u0026#39;Error! Oh no, the freezer is broken!. I\u0026#39;ll tell the customer we\u0026#39;re out of steak.\u0026#39; By catching the exception, we handled the problem locally and allowed the operation to finish gracefully.\nWrap-up Our kitchen is now managed. We have:\nDispatchers: The special stations that ensure work happens in the right place (Main, IO, Default). CoroutineScope: The Head Chef who manages the lifecycle of all tasks. It ensures nothing gets leaked. Job vs SupervisorJob: Different management styles for handling failure. Cancellation \u0026amp; Exceptions: Clear protocols for when an order is cancelled or something goes wrong. What\u0026rsquo;s Next in Part 3? So far, our chefs have been preparing one-off orders. The customer asks for one thing, and we await one result. What happens when we need a continuous stream of dishes for a tasting menu, or a buffet that needs constant refilling?\nIn Part 3, we\u0026rsquo;ll introduce a core concept for handling streams of data: Flow. We\u0026rsquo;ll learn how to emit, transform, and collect series of values over time, taking our kitchen\u0026rsquo;s capabilities to the next level.\n","permalink":"https://vjukic.com/posts/coroutines/coroutines-deep-dive-part2/","summary":"Learn how to control where your code runs, manage its lifecycle, and handle errors and cancellations gracefully.","title":"Coroutines Deep Dive - Part 2: Scope, Context, and Cancellation"},{"content":"That Frozen App Feeling. How to Fix It? If you have ever built an app with a user interface, you have been in this situation: you kick off a network call, and suddenly, the whole app freezes. The buttons don\u0026rsquo;t respond, the animations stop and users start getting frustrated. This is the classic problem of long-running tasks on the main thread. It\u0026rsquo;s exactly what Kotlin Coroutines solve.\nThey give us a powerful way to write asynchronous code that looks and feels like simple, straightforward, synchronous code. No more \u0026ldquo;callback hell\u0026rdquo;!\nTo really get it, imagine a chef in a kitchen. Chef can only do one thing at a time, just like our app\u0026rsquo;s main UI thread.\nHere\u0026rsquo;s the recipe:\nChop vegetables (CPU work). Microwave them for 2 minutes (I/O task). Prepare a salad while waiting (CPU work). Serve the meal. The Blocking Way (The Inefficient Kitchen) Without coroutines, chef operates like this:\nChops the vegetables. Puts them in the microwave, hits start, and then\u0026hellip; stands there, staring at the timer for two full minutes. Nothing else gets done. Once the microwave finally beeps, the chef snaps back to life and starts the salad. The result? An incredibly inefficient kitchen. The chef is completely blocked. In the app world, this is a frozen UI, and a frustrated user (and one-star review waiting to happen 🙂).\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 fun main() { // This all runs on the main thread println(\u0026#34;Chef starts cooking.\u0026#34;) println(\u0026#34;1. Chopping vegetables.\u0026#34;) // Starting the microwave (main thread is BLOCKED!) println(\u0026#34;2. Putting food in microwave.\u0026#34;) Thread.sleep(2000) println(\u0026#34;Microwave finished.\u0026#34;) println(\u0026#34;3. Preparing salad.\u0026#34;) println(\u0026#34;4. Serving the meal.\u0026#34;) println(\u0026#34;Chef finished cooking.\u0026#34;) } Output:\n1 2 3 4 5 6 7 8 Chef starts cooking. 1. Chopping vegetables. 2. Putting food in microwave. (...2 second pause where nothing happens...) Microwave finished. 3. Preparing salad. 4. Serving the meal. Chef finished cooking. That 2-second pause is deadly for user experience. It should be fixed.\nThe Suspending Way (The Coroutine Kitchen) So, how do coroutines pull this off? The secret is the suspend keyword.\nA suspend function is special. It tells the Kotlin compiler, \u0026ldquo;Hey, this function might take a while. Feel free to pause it here and let the thread go do something else. I\u0026rsquo;ll let you know when I\u0026rsquo;m ready to resume.\u0026rdquo;\nThe new, efficient chef does this:\nChops the vegetables. Puts them in the microwave, hits start and immediately walks away to do other work. This is a suspension point. The chef is free! Prepares the salad. When the microwave beeps, the chef is notified and comes back to get the food. But you can\u0026rsquo;t just call a suspend function whenever you want. You have to launch it within a coroutine. This is where Coroutine Builders come in. They are our entry point into this new, non-blocking way.\nTask 1: Fire and Forget with launch Let\u0026rsquo;s start with the simplest case: we need to run the microwave in the background. We don\u0026rsquo;t need a result back from it immediately. We just want to \u0026ldquo;fire and forget\u0026rdquo; the task.\nFor this, we use the launch builder. Think of it as telling chef: \u0026ldquo;Go do this thing. I don\u0026rsquo;t need anything back from you right away, just get it done.\u0026rdquo;\nA quick but important warning: To run these in a main function, we use a special builder called runBlocking. It\u0026rsquo;s designed to bridge the blocking world with the suspending world of coroutines. It will block the main thread until every coroutine inside it finishes. This is great for demos and tests, but NEVER use it in production Android code.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import kotlinx.coroutines.* // runBlocking creates a scope and blocks the main thread fun main() = runBlocking { println(\u0026#34;Chef starts cooking.\u0026#34;) println(\u0026#34;1. Chopping vegetables.\u0026#34;) // launch starts a new task in the background launch { // Calling our suspend function. microwaveFood() } // This runs IMMEDIATELY after launch (without waiting!) println(\u0026#34;3. Preparing salad.\u0026#34;) println(\u0026#34;4. Waiting for everything to finish to serve the meal.\u0026#34;) // runBlocking waits here for the launch block to complete } suspend fun microwaveFood() { println(\u0026#34;2. Putting food in microwave.\u0026#34;) // Suspension point. It pauses coroutine, not the thread delay(2000) println(\u0026#34;Microwave finished.\u0026#34;) } Output:\n1 2 3 4 5 6 7 Chef starts cooking. 1. Chopping vegetables. 3. Preparing salad. 4. Waiting for everything to finish to serve the meal. 2. Putting food in microwave. (...2 second pause where the app is NOT frozen...) Microwave finished. The chef starts the salad right away. The microwave task runs concurrently in the background. We have achieved true non-blocking concurrency.\nTask 2: Getting a Result Back with async and await Okay, launch is great for kicking off background work. But let\u0026rsquo;s be real, most of the time we\u0026rsquo;re not just \u0026lsquo;firing and forgetting\u0026rsquo;, we\u0026rsquo;re fetching data. We need a result.\nImagine that the chef needs a special sauce. He tells his assistant to make it. He can keep working, but at some point, he will need to stop and wait for that sauce before he can finish the dish.\nThis is the job for async. It\u0026rsquo;s another builder, but instead of just a Job, it gives us back something called a Deferred\u0026lt;T\u0026gt;. Don\u0026rsquo;t let the fancy name scare you. It\u0026rsquo;s just a promise that will contain our value\u0026hellip; eventually.\nTo get our value, we call .await(). And here\u0026rsquo;s the key: .await() is a suspend function. If the sauce isn\u0026rsquo;t ready, the chef will pause there (without blocking the thread!) until it is.\n1 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 import kotlinx.coroutines.* fun main() = runBlocking { println(\u0026#34;Chef: Starting to prepare a meal and a sauce.\u0026#34;) // Start making the sauce in the background with async val deferredSauce: Deferred\u0026lt;String\u0026gt; = async { prepareSauce() } // While the sauce is preparing, chef gets to work println(\u0026#34;Chef: Preparing the main course...\u0026#34;) delay(1000) println(\u0026#34;Chef: Main course is ready.\u0026#34;) // We need the sauce now // The coroutine suspends here if the sauce isn\u0026#39;t ready yet val sauce = deferredSauce.await() println(\u0026#34;Chef: Got the sauce! It\u0026#39;s \u0026#39;$sauce\u0026#39;.\u0026#34;) println(\u0026#34;Chef: Combining everything and serving the meal.\u0026#34;) } suspend fun prepareSauce(): String { println(\u0026#34;Assistant: Starting to prepare the sauce...\u0026#34;) delay(2000) println(\u0026#34;Assistant: Sauce is ready!\u0026#34;) return \u0026#34;Tomato Sauce\u0026#34; } Output:\n1 2 3 4 5 6 7 8 9 Chef: Starting to prepare a meal and a sauce. Chef: Preparing the main course... Assistant: Starting to prepare the sauce... (1 second passes) Chef: Main course is ready. (another 1 second passes) Assistant: Sauce is ready! Chef: Got the sauce! It\u0026#39;s \u0026#39;Tomato Sauce\u0026#39;. Chef: Combining everything and serving the meal. This is the beautiful part. The code still reads top-to-bottom, like a story. No callbacks, no complicated reactive chains. We just async the work and await the result when we need it.\nWrap-up That\u0026rsquo;s it for the fundamentals! We\u0026rsquo;ve covered the absolute core of coroutines:\nWhy: Blocking threads freezes apps; suspend functions are the answer. Fire-and-Forget: Use launch when you just need to start a background task. Getting a Result: Use async to start a task that returns a value, and .await() to get that value when you\u0026rsquo;re ready for it. What\u0026rsquo;s Next in Part 2? Our kitchen is running, but it\u0026rsquo;s a bit\u0026hellip; magical. Where are background tasks actually running? What happens if the customer cancels their order halfway through? Do chefs just keep cooking forever, wasting resources?\nWe\u0026rsquo;ll dive into the safety net that makes coroutines so robust. We\u0026rsquo;ll talk about CoroutineScope, Job lifecycles, and Dispatchers to see how we can manage our coroutines and tell them exactly which part of the kitchen to work in. See you there!\n","permalink":"https://vjukic.com/posts/coroutines/coroutines-deep-dive-part1/","summary":"The fundamentals of async work in Kotlin. How to launch tasks and get results back without freezing your app.","title":"Coroutines Deep Dive - Part 1: The Fundamentals"},{"content":"It all started with a simple question at the beginning: \u0026ldquo;I wonder what it takes to build a game engine with Java Swing?\u0026rdquo; What began as a small project to test Swing\u0026rsquo;s capabilities, just a single level and an animated player character, has since spiraled into the most ambitious and educational project of my student life. This is the story of \u0026ldquo;Merciless Warrior,\u0026rdquo; my journey from a simple curiosity to building a solid gaming platform.\nA Custom 2D Engine I intentionally chose Java Swing for the game client. I wanted to understand the fundamentals, to build an engine from the ground up without relying on frameworks like Unity or Godot. This meant creating everything: the game loop, state management, rendering pipeline, physics, and asset handling.\nThe project quickly grew from a simple player walking on a platform to a feature-rich 2D platformer with:\nA deep combat system with melee attacks, special abilities, and spells. Challenging enemies and boss fights. An inventory system with crafting, shopping, and looting. Character progression through perks and quests. This path was filled with mistakes. I had many breakdowns dealing with bugs and hard refactoring. My initial code was dirty and many components were so tightly coupled that I couldn\u0026rsquo;t code normally. As features were added, the complexity snowballed, and some refactors took as long as three months. The breakthrough came when I re-architected the core using an Event Bus and a form of Dependency Injection to simplify things. It was a difficult migration, but I somehow did it, and the improvement in code quality and maintainability was massive. It was through this process of trial and error that I truly learned the value of good software architecture.\nThe Solo Developer Grind Working alone meant the challenges went beyond just code. Since I\u0026rsquo;m not a pixel artist, finding suitable assets was a major hurdle. It wasn\u0026rsquo;t just about finding art, but finding art with a consistent style that made the game world feel coherent. Sourcing music and sound effects that fit the atmosphere was equally difficult. And then there was level design\u0026hellip; ah. Crafting levels that are fun, fair, and engaging is an entirely different skill set. These non-coding tasks were a significant part of the grind, but essential for creating a complete experience.\nThe Turning Point - From a Game to a Platform As the game client matured, so did my ambitions. I wanted features like cloud saves, user accounts, and leaderboards. A simple monolithic backend wouldn\u0026rsquo;t do justice to the complexity I envisioned. This was the turning point where \u0026ldquo;Merciless Warrior\u0026rdquo; began its transformation into a full gaming platform, built on a distributed backend.\nThe backend is a powerful, event-driven system designed for scalability and resilience, running entirely in Docker containers.\nIt’s a complete microservices ecosystem:\nAPI Gateway - unified entry point for all requests from the game client. Service Registry - allows services to discover each other dynamically. Auth Service - handles secure authentication, with rate limiting using Redis. Game Service - the core of the backend, managing all game data. Multiplayer Service - the latest addition, currently in development. The Data Pipeline - From Gameplay to Insight One of the most exciting parts of this project was building a complete data pipeline to analyze gameplay events in real time.\nThe architecture is event-driven:\nThe Game Service produces events in Avro format to an Apache Kafka topic. The Analytics Service, written in Scala, consumes these events. A Spark Structured Streaming job processes the data in near real-time, writing it to a partitioned, Parquet-based Data Lake. An on-demand Spark Batch Job can then run complex analytical queries on this data lake This pipeline decouples the game\u0026rsquo;s operational logic from its analytical needs, creating a scalable system capable of handling vast amounts of data.\nThe Journey So Far Now, as a final-year student, looking back at the project, I see more than just code. I see a journey of learning and perseverance. What began as a simple experiment became a piece spanning game development, backend engineering, and data pipelines.\nEvery challenge, from physics bugs in Swing to ensuring transactional consistency in a distributed system, has been an invaluable lesson. This is the key point: I learned so many things here that the faculty could never teach me. I can learn pure theory on courses, at university, or from YouTube, but practice is a totally different beast. The project is a testament to the idea that starting small, driven by curiosity, can lead to something far bigger than you ever imagined. And with multiplayer on the horizon, the journey is far from over.\n","permalink":"https://vjukic.com/posts/mw/merciless-warrior-journey/","summary":"How a simple curiosity about Java Swing evolved into a full-fledged 2D platformer with a distributed backend, a real-time data pipeline, and a lot of lessons learned.","title":"The Accidental Game Engine: My Journey with Merciless Warrior"},{"content":"Every great distributed system has a way to achieve consensus. It’s the digital handshake that ensures every node in a cluster agrees on the state of the world, even when chaos strikes. For over a decade, Apache Kafka\u0026rsquo;s secret weapon wasn\u0026rsquo;t its own. It used Apache ZooKeeper.\nThis combination was essential for Kafka\u0026rsquo;s growth, but it came at a cost. Managing a Kafka cluster meant managing two complex distributed systems. This was a source of operational headaches.\nToday, that era is over with the introduction of the KRaft (Kafka Raft) protocol. Kafka has broken free. It manages its own destiny with built-in consensus mechanism. This isn\u0026rsquo;t just an update, it\u0026rsquo;s a fundamental reimagining of Kafka\u0026rsquo;s core. Let’s break down why this move is such a massive win.\nThe Old Way: ZooKeeper In the past, a Kafka cluster couldn\u0026rsquo;t exist without a ZooKeeper. ZooKeeper was the central nervous system. The single source of truth for all metadata.\nHere’s how it worked:\nThe Controller Election: ZooKeeper would select one Kafka broker as the controller (the designated manager for the entire cluster). The Single Source of Truth: Every metadata change (a new topic, a broker failure, a configuration update) had to be written to ZooKeeper first. A Game of Telephone: The controller would watch ZooKeeper for these changes and then be responsible for propagating them to all the other brokers. The Operational Nightmare For anyone who ran Kafka at scale, this architecture was a double-edged sword.\nTwo Systems (Twice the Trouble): You weren\u0026rsquo;t just a Kafka expert, you had to be a ZooKeeper expert. This meant separate provisioning, separate monitoring, separate security, and a whole separate system to debug. The ZooKeeper Bottleneck: As clusters grew, ZooKeeper often became a chokepoint. All administrative commands were directed through it. It limited how quickly you could create topics or scale your cluster. Slow Failovers: This was the real killer. If the controller broker failed, the recovery process was really slow. Electing a new controller and forcing it to read all the state from ZooKeeper could take dozens of seconds, or even minutes. In a high-availability world, that\u0026rsquo;s an eternity of downtime. A New Dawn: Kafka in KRaft Mode The Kafka community knew there had to be a better way. The answer was KRaft, an implementation of the Raft consensus algorithm that lives inside Kafka itself. No external dependencies, no split-brain architecture.\nThe new world is beautifully simple:\nA few brokers are designated as controllers. They form a self-contained Raft quorum. All cluster metadata is now stored in an internal Kafka topic: __cluster_metadata. The elected leader of the controller quorum writes all changes to this log. Every other node in the cluster simply subscribes to it. The elegance is stunning: Kafka now uses its own battle-tested replication protocol to manage its own state.\nThe Showdown: Why KRaft Wins, Hands Down This architectural shift makes massive benefits.\n1. Radical Simplicity The most obvious win? You get to delete your ZooKeeper deployment. This means less infrastructure to manage, fewer configurations to juggle and a single, unified system to monitor and secure. The operational burden is cut in half.\n2. From Minutes to Milliseconds: Lightning-Fast Recovery This is KRaft\u0026rsquo;s absolute win. Because the cluster state is already replicated across the controllers in a Kafka log, failover is almost instantaneous. A new controller can take leadership in a few seconds, often sub-second.\nThe performance charts tell the whole story:\nLook at that recovery time after an uncontrolled shutdown. The difference isn\u0026rsquo;t an improvement; it\u0026rsquo;s a complete transformation. What used to be a coffee break of downtime is now over in the blink of an eye.\n3. Scaling Without the ZooKeeper bottleneck, Kafka can now support a high number of partitions. We\u0026rsquo;re talking about scaling to millions of partitions in a single cluster, a feat that was simply unthinkable in the ZooKeeper era, which topped out in the hundreds of thousands.\nThe Future is ZooKeeper-less The move to KRaft isn\u0026rsquo;t just a new feature. It\u0026rsquo;s the foundation for the next decade of Kafka. It makes Kafka simpler to operate, dramatically more resilient, and capable of scaling to levels we could only dream of before.\nIf you\u0026rsquo;re building a new data platform, the choice is a no-brainer. KRaft is production-ready and the clear path forward. It’s time to say goodbye to ZooKeeper and embrace a faster, simpler and more powerful Kafka.\n","permalink":"https://vjukic.com/posts/kafka/kafka-zookeeper-vs-kraft/","summary":"Kafka has finally discard its oldest dependency, ZooKeeper. We explore why this move to the internal KRaft protocol is the most significant evolution in Kafka\u0026rsquo;s recent history.","title":"Why Ditching ZooKeeper for KRaft is a Game-Changer"},{"content":"Hello! I\u0026rsquo;m Vasilije Jukic, a dedicated software developer and a student at the Faculty of Computing, Union University, in Belgrade. My passion lies at the intersection of software development and machine learning, where I find great satisfaction in building applications that solve real-world challenges.\nCurrently, my academic journey is focused on mastering Distributed Systems, a field I find fascinating for its complexity and scalability challenges.\nTechnical Skills My technical toolkit is diverse, allowing me to adapt to various project requirements. Here are a summary of the technologies I work with:\nLanguages: Backend: Frontend: Databases: AI/ML: Cloud \u0026amp; DevOps: Beyond the Code Beyond my technical pursuits, I believe in a balanced lifestyle. I enjoy the creative outlet of playing the piano and maintain my physical and mental well-being through calisthenics .\nLet\u0026rsquo;s Connect I\u0026rsquo;m always open to discussing new projects, innovative ideas, or opportunities to collaborate. Feel free to reach out via email at vasilijejukic1@gmail.com.\n","permalink":"https://vjukic.com/about/","summary":"\u003cp\u003eHello! I\u0026rsquo;m Vasilije Jukic, a dedicated software developer and a student at the Faculty of Computing, Union University, in Belgrade. My passion lies at the intersection of software development and machine learning, where I find great satisfaction in building applications that solve real-world challenges.\u003c/p\u003e\n\u003cp\u003eCurrently, my academic journey is focused on mastering \u003cstrong\u003eDistributed Systems\u003c/strong\u003e, a field I find fascinating for its complexity and scalability challenges.\u003c/p\u003e\n\u003ch3 id=\"hahahugoshortcode1s0hbhb-technical-skills\"\u003e\u003ci class=\"fa-solid fa-laptop-code\"\u003e\u003c/i\u003e Technical Skills\u003c/h3\u003e\n\u003cp\u003eMy technical toolkit is diverse, allowing me to adapt to various project requirements. Here are a summary of the technologies I work with:\u003c/p\u003e","title":"About Me"}]