Rate Limiter Visualizer with gstack
Build an interactive rate limiting visualizer using an AI sprint workflow.
Introduction
30 Second Summary
Every time you hit a "too many requests" error from an API or get throttled downloading a large file, a rate limiter is doing its job behind the scenes. Understanding how these algorithms work is a core system design skill that comes up repeatedly in technical interviews.
In this project, you will build an interactive rate limiter visualizer that implements three classic algorithms with real-time Canvas animation. You'll use gstack's AI-assisted sprint workflow to think, plan, build, review, test, and ship the project professionally through OpenCode.
What You'll Build
A single-page web application that visualizes rate limiting algorithms in real-time, with animated particles flowing through a gate that accepts or rejects requests based on the selected algorithm's rules.
By the end of this project, you'll have:
- A working interactive visualizer with Canvas animation showing requests accepted or rejected in real-time.
- Three correctly implemented rate limiting algorithms (Token Bucket, Sliding Window Log, and Fixed Window Counter) with interactive parameter controls.
- Hands-on experience with gstack's full sprint workflow (Think, Plan, Build, Review, QA, Ship) using OpenCode.
- Secret Mission: Add a fourth algorithm (Leaky Bucket) and auto-generate professional documentation with gstack's /document-release skill.
Are there any prerequisites?
You should be comfortable using terminal-based AI coding agents and have basic JavaScript knowledge. You'll also need a GitHub account.
Before We Start
To build and ship this rate limiter visualizer using gstack's sprint workflow, you need two tools that aren't on your machine yet: Bun (gstack's package manager and runtime) and gstack itself.
In this step, you'll install both tools, verify they work alongside your existing Git and Node.js setup, and create a clean project repository ready for the sprint.
In this step, get ready to:
- Install Bun and verify your tools.
- Install gstack for OpenCode.
- Create the project repository.
Install Bun and verify your tools
gstack requires Bun as its runtime. Since you already have npm available, you can install Bun globally through it.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal (macOS) or Git Bash (Windows) and pressing Enter.
- Install Bun globally by running this command:
npm install -g bun
What does this command do?
The -g flag tells npm to install Bun as a global tool, making it available from any directory in your terminal. npm handles downloading the correct binary for your operating system.
- Verify Bun is installed by running:
bun --version
✔️ I see version 1.0 or higher
Bun is installed and ready. You should see a version number like 1.3.14 or similar. Any version 1.0+ works for this project.
ⓧ I see a version lower than 1.0
Pre-release versions of Bun are not compatible with gstack. Upgrade to the latest stable version by re-running the install command:
npm install -g bun
Close and reopen your terminal, then run bun --version again to confirm you see 1.0 or higher.
ⓧ Command not found
Your terminal cannot find the bun binary. This usually means your npm global bin directory is not in your PATH.
- Close your terminal completely and reopen it. PATH changes from the install often require a fresh terminal session.
- If it still fails, check your npm global prefix by running:
npm config get prefix
Make sure the path it prints (plus /bin on macOS/Linux) is in your system PATH. On Windows, this is typically C:\Users\YourName\AppData\Roaming\npm.
- After fixing your PATH, restart the terminal and run npm install -g bun again.
- Confirm Git and Node.js are also working by running:
git --version
node --version
You should see version numbers for both. Any recent version of Git and Node.js will work.
Git or Node not found?
If git --version or node --version returns an error, these tools may not be installed. Install Git from git-scm.com and Node.js from nodejs.org. Both are prerequisites for this project.
help me install Git and Node.js for this project
Install gstack for OpenCode
gstack is a skill library that gives your AI coding agent (OpenCode) a structured sprint workflow. It chains skills together so that each one feeds into the next.
- Clone gstack and run its setup by entering this command in Git Bash (Windows) or your terminal (macOS/Linux):
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack && cd ~/gstack && ./setup --host opencode
What does this command do?
This command does three things in sequence. First, it clones only the latest snapshot of the gstack repository to ~/gstack (keeping the download small). Then it moves into that folder. Finally, it runs the setup script targeting OpenCode as the host agent.
The setup installs gstack skills to ~/.config/opencode/skills/gstack-*/. These skills are what power commands like /office-hours, /review, and /ship inside OpenCode.
You should see output confirming the skills were installed successfully.
Windows without Developer Mode
On Windows without Developer Mode enabled, gstack uses file copies instead of symlinks. If you update gstack later with git pull, re-run the setup command to keep your skills current:
cd ~/gstack && ./setup --host opencode
Setup script failing?
Make sure you're running this in Git Bash on Windows (not PowerShell or CMD). The setup script requires a Bash-compatible shell. Also confirm that Bun is accessible by running bun --version first.
help me troubleshoot gstack setup for OpenCode
Create the project repository
With your tools ready, it's time to create the project folder and initialize it as a Git repository.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir rate-limiter-visualizer
cd rate-limiter-visualizer
- Initialize a Git repository and set the branch to main by running:
git init
git branch -M main
What does git branch -M main do?
The -M flag renames the default branch to main. This ensures your local branch name matches the convention GitHub uses when you push.
- Create the three project files by running:
touch index.html styles.css app.js
- Confirm the files exist by running:
ls
You should see index.html, styles.css, and app.js listed in the output.
- Stage all files and make your initial commit by running:
git add .
git commit -m "Initial commit: empty project scaffolding"
- Go to github.com/new in your browser to create a new repository.
- Enter rate-limiter-visualizer as the repository name.
- Leave all other settings as defaults (public, no README, no .gitignore).
- Click Create repository.
GitHub will show a page with setup instructions. Copy the commands under the heading that says "push an existing repository from the command line."
- Back in your terminal, connect your local repo to GitHub and push by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/rate-limiter-visualizer.git"]]
git push -u origin main
Where do I find my repo URL?
Your repository URL is shown on the GitHub page immediately after creating the repo. It looks like https://github.com/your-username/rate-limiter-visualizer.git. Copy the exact URL GitHub provides and paste it in place of the placeholder above.
Push rejected or authentication error?
If Git asks for credentials, you may need to set up a personal access token or SSH key with GitHub. Check that your GitHub account is properly authenticated in your terminal.
help me authenticate Git with GitHub so I can push my repository
Your environment is ready and your code is on GitHub. Next up, you'll use gstack's thinking and planning skills to design the rate limiter visualizer before writing any code.
Think and Plan with gstack
Your project repository is set up and pushed to GitHub. Now it's time to define what you're building before writing a single line of code.
gstack's sprint workflow starts with thinking and planning. You'll use three skills that challenge your assumptions, sharpen the product scope, and lock the technical architecture before the build phase begins.
In this step, get ready to:
- Run /office-hours to define and sharpen the product idea.
- Run /plan-ceo-review and /plan-eng-review to lock scope and architecture.
Run /office-hours to define the product
The /office-hours skill is gstack's product interrogation tool. It challenges your framing with six forcing questions, pushes back on assumptions, and produces a design doc that feeds into every downstream skill.
- Open OpenCode inside your rate-limiter-visualizer directory by running this command in your terminal:
cd ~/Desktop/rate-limiter-visualizer
opencode
- Once OpenCode is running, type /office-hours and press Enter.
- When gstack asks what you're building, describe the project by typing this:
I want to build an interactive rate limiter visualizer that shows how Token Bucket, Sliding Window Log, and Fixed Window Counter algorithms work in real-time. Users control request rate and algorithm parameters, and watch animated requests get accepted or rejected.
gstack will now challenge your framing with six forcing questions. It will push back on assumptions, extract capabilities you hadn't considered, and help you think through the product before writing code.
- Answer each of the six forcing questions thoughtfully. There are no wrong answers. The point is to sharpen your thinking.
Why think before building?
Most developers jump straight to code and discover scope problems halfway through. gstack's sprint workflow flips this by forcing the hardest thinking upfront.
The design doc that /office-hours produces isn't just for you. It feeds directly into the planning and review skills you'll run next.
- Once the conversation completes, verify that gstack has saved a design doc in your project directory. You should see a new file appear in rate-limiter-visualizer containing your product framing and answers to the six forcing questions.
gstack didn't save a design doc?
Make sure you fully completed the conversation. gstack writes the design doc after it finishes all six forcing questions.
Check that OpenCode has write access to your project folder. If you launched OpenCode from a different directory, the file may have been saved elsewhere.
Why didn't /office-hours produce a design doc in my project?
Run /plan-ceo-review and /plan-eng-review
With the design doc in place, you'll now run two planning skills that challenge scope and lock architecture. These skills read the design doc that /office-hours just wrote. Each one builds on the last.
- In OpenCode, run /plan-ceo-review to have gstack challenge your product scope.
What does /plan-ceo-review do?
This skill asks whether your project is too narrow or too broad. It suggests what a "10-star" version looks like and recommends a focused starting point.
It has four modes: Expansion, Selective Expansion, Hold Scope, and Reduction. Engage with its questions and confirm a focused starting point for your three-algorithm visualizer.
- Once the CEO review completes, run /plan-eng-review to lock in your technical architecture.
What does /plan-eng-review do?
This skill produces ASCII diagrams for data flow, identifies edge cases, defines a test matrix, and locks the technical approach. It forces hidden assumptions into the open.
For this project, it will help you think through algorithm accuracy at edge cases, animation frame budget, and Canvas performance. The output becomes the blueprint for the build step.
- Engage with gstack's architecture questions. Let it challenge your assumptions about how the three algorithms (Token Bucket, Sliding Window Log, Fixed Window Counter) will be structured and how the visualization will render.
- Once complete, verify that the engineering plan has been saved in your project directory. It should contain ASCII data flow diagrams, an edge case list, a test matrix, and a locked technical approach.
Skills chain together
Each gstack skill reads the output of the previous one. The /plan-eng-review reads the design doc from /office-hours and the scope decisions from /plan-ceo-review. This chaining is what makes the sprint workflow coherent from idea to shipped code.
gstack skill not recognized?
If OpenCode doesn't recognize a gstack skill, verify that the skills were installed correctly. Check that the ~/.config/opencode/skills/gstack-*/ directory exists and contains files.
If the directory is empty or missing, re-run the setup from the ~/gstack folder:
cd ~/gstack && ./setup --host opencode
Why isn't OpenCode recognizing my gstack skills?
Your blueprint is locked in. You have a design doc, a confirmed scope, and an engineering plan with data flow diagrams and edge cases mapped out. Next up, you'll use OpenCode to build the actual rate limiting algorithms and Canvas visualization.
Build with OpenCode
You've sharpened your product idea and locked your architecture with gstack's planning skills. Now it's time to turn that blueprint into working code.
In this step, you'll direct OpenCode to implement the three rate limiting algorithms as pure logic classes, then build the Canvas-based visualization and HTML interface that brings them to life.
In this step, get ready to:
- Implement three rate limiting algorithm classes (Token Bucket, Sliding Window Log, Fixed Window Counter).
- Build the Canvas visualization, HTML controls, and CSS styling.
- Verify everything works locally with a dev server.
Implement the rate limiting algorithms
The engineering plan from /plan-eng-review defined three algorithm classes with clean interfaces. Each class is pure logic with no DOM dependencies, which keeps the architecture testable and makes the /review step cleaner later.
- In OpenCode, prompt the agent to implement the three rate limiting algorithms in app.js. Describe the requirements:
Implement three rate limiting algorithm classes in app.js based on the engineering review:
1. TokenBucket - constructor takes capacity and refillRate. Tokens start full, refill over time. allowRequest() consumes one token and returns true/false. getState() returns current token count. reset() refills to capacity.
2. SlidingWindowLog - constructor takes windowSize (seconds) and maxRequests. Tracks timestamps in an array. allowRequest() filters expired entries, adds new if under limit. getState() returns remaining slots. reset() clears the log.
3. FixedWindowCounter - constructor takes windowSize (seconds) and maxRequests. Counts requests per fixed window. allowRequest() resets counter when window expires, increments if under limit. getState() returns remaining slots. reset() zeroes counter.
All classes should use Date.now() for timing. No DOM dependencies - pure algorithm logic only.
What are these algorithms doing?
Each algorithm solves the same problem differently: how many requests should a system allow through per time period?
- Token Bucket adds tokens at a fixed rate and allows bursts up to bucket capacity. Great for APIs that tolerate short traffic spikes.
- Sliding Window Log tracks every request timestamp in a rolling window. The most precise, but uses more memory.
- Fixed Window Counter counts requests in fixed time blocks. The simplest, but can allow double the limit at window boundaries.
- After OpenCode generates the code, open app.js and verify that each class has the correct methods: allowRequest(), getState(), and reset().
- Confirm that none of the algorithm classes reference document or any DOM APIs.
Why separate algorithms from the DOM?
Keeping the algorithm classes as pure logic (no DOM, no Canvas, no UI) means they can be tested independently and reused elsewhere. This separation is exactly what gstack's /review skill looks for when evaluating code quality.
Build the visualization and UI
With the algorithm logic in place, the next piece is the visual layer. The visualization uses a "gate" metaphor where requests appear as animated particles that either pass through (green) or bounce back (red).
- Prompt OpenCode to build the Canvas visualization and wire up the HTML controls:
Now build the visualization layer and UI for the rate limiter:
1. In app.js, add a Particle class (x, y, accepted, velocity, alpha, radius, born timestamp) with update() and isDead() methods. Accepted particles fly right (green), rejected fly left (red).
2. Add a Visualizer class that manages the Canvas. It needs: resize() for responsive canvas, addRequest(accepted) to spawn particles, draw(algorithmState, algorithmType) to render each frame, drawGate() to show a fill indicator based on algorithm capacity, drawParticles() for the animated dots, drawLabels() for INCOMING/ACCEPTED text, and clear().
3. Add an App class that orchestrates everything: binds the HTML controls (algorithm selector, request-rate slider, capacity, refill-rate, window-size, max-requests), creates the algorithm instance based on selection, runs requestAnimationFrame loop, and processes requests at the user-defined rate using setInterval.
4. For index.html: add a controls section with algorithm dropdown (token-bucket, sliding-window, fixed-window), request rate slider (1-20), capacity slider (1-30), refill rate slider (1-15), window size slider (1-10), max requests slider (1-30), and Start/Stop/Reset buttons. Add a canvas element, stats cards (accepted, rejected, acceptance rate, algorithm state), and an algorithm-info section.
5. For styles.css: dark theme (#0f0f13 background), responsive grid controls panel, styled sliders with purple (#6366f1) thumbs, stat cards with color-coded values (green accepted, red rejected, purple rate, amber state), and a gradient heading.
What does the visualization show?
The Canvas draws a vertical dashed line in the center representing the rate limiter "gate." A small indicator box fills and drains based on how much capacity the algorithm has left.
When a request arrives, a particle spawns at the gate. If allowed, it flies right as a green dot. If rejected, it bounces left as a red dot. Both fade out over time using the alpha channel.
- After OpenCode finishes, verify that index.html has a <canvas id="visualizer"> element and a <select id="algorithm"> dropdown with three options.
- Check that styles.css sets the body background to #0f0f13 and the primary accent to #6366f1.
- Confirm that app.js ends with a DOMContentLoaded listener that creates a new App().
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all three files. Compare yours against these and fix any differences.
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Limiter Visualizer</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Rate Limiter Visualizer</h1>
<p class="subtitle">Interactive visualization of common rate limiting algorithms</p>
</header>
<div class="controls">
<div class="control-group">
<label for="algorithm">Algorithm</label>
<select id="algorithm">
<option value="token-bucket">Token Bucket</option>
<option value="sliding-window">Sliding Window Log</option>
<option value="fixed-window">Fixed Window Counter</option>
</select>
</div>
<div class="control-group">
<label for="request-rate">Request Rate: <span id="rate-value">5</span>/sec</label>
<input type="range" id="request-rate" min="1" max="20" value="5">
</div>
<div class="control-group" id="capacity-group">
<label for="capacity">Bucket Capacity: <span id="capacity-value">10</span></label>
<input type="range" id="capacity" min="1" max="30" value="10">
</div>
<div class="control-group" id="refill-group">
<label for="refill-rate">Refill Rate: <span id="refill-value">3</span>/sec</label>
<input type="range" id="refill-rate" min="1" max="15" value="3">
</div>
<div class="control-group" id="window-group" style="display:none;">
<label for="window-size">Window Size: <span id="window-value">5</span>s</label>
<input type="range" id="window-size" min="1" max="10" value="5">
</div>
<div class="control-group" id="limit-group" style="display:none;">
<label for="max-requests">Max Requests: <span id="limit-value">10</span>/window</label>
<input type="range" id="max-requests" min="1" max="30" value="10">
</div>
<div class="control-actions">
<button id="start-btn" class="btn btn-primary">Start</button>
<button id="stop-btn" class="btn btn-secondary" disabled>Stop</button>
<button id="reset-btn" class="btn btn-outline">Reset</button>
</div>
</div>
<canvas id="visualizer"></canvas>
<div class="stats">
<div class="stat-card stat-accepted">
<span class="stat-value" id="accepted-count">0</span>
<span class="stat-label">Accepted</span>
</div>
<div class="stat-card stat-rejected">
<span class="stat-value" id="rejected-count">0</span>
<span class="stat-label">Rejected</span>
</div>
<div class="stat-card stat-rate">
<span class="stat-value" id="acceptance-rate">0%</span>
<span class="stat-label">Acceptance Rate</span>
</div>
<div class="stat-card stat-state">
<span class="stat-value" id="algorithm-state">-</span>
<span class="stat-label" id="state-label">Tokens Available</span>
</div>
</div>
<div class="algorithm-info">
<h2 id="info-title">Token Bucket</h2>
<p id="info-description">Tokens are added to a bucket at a fixed rate. Each request consumes one token. If the bucket is empty, the request is rejected. Allows bursts up to the bucket capacity.</p>
<div class="info-traits">
<span class="trait">Allows Bursts</span>
<span class="trait">Smooth Refill</span>
<span class="trait">Memory Efficient</span>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
styles.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f0f13;
color: #e4e4e7;
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 960px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: #71717a;
margin-top: 0.5rem;
}
.controls {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
font-size: 0.85rem;
color: #a1a1aa;
font-weight: 500;
}
.control-group select,
.control-group input[type="range"] {
width: 100%;
}
select {
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 6px;
padding: 0.5rem;
color: #e4e4e7;
font-size: 0.9rem;
cursor: pointer;
}
select:focus {
outline: none;
border-color: #6366f1;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 6px;
background: #27272a;
border-radius: 3px;
outline: none;
margin-top: 0.25rem;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
border: none;
}
.control-actions {
display: flex;
align-items: end;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
border: none;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #6366f1;
color: white;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-primary:disabled {
background: #3f3f46;
cursor: not-allowed;
}
.btn-secondary {
background: #27272a;
color: #e4e4e7;
border: 1px solid #3f3f46;
}
.btn-secondary:hover {
background: #3f3f46;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-outline {
background: transparent;
color: #a1a1aa;
border: 1px solid #3f3f46;
}
.btn-outline:hover {
background: #27272a;
color: #e4e4e7;
}
canvas {
width: 100%;
height: 400px;
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
display: block;
margin-bottom: 1.5rem;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 10px;
padding: 1.25rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
font-size: 0.75rem;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-accepted .stat-value {
color: #22c55e;
}
.stat-rejected .stat-value {
color: #ef4444;
}
.stat-rate .stat-value {
color: #6366f1;
}
.stat-state .stat-value {
color: #f59e0b;
}
.algorithm-info {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.algorithm-info h2 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #e4e4e7;
}
.algorithm-info p {
color: #a1a1aa;
font-size: 0.9rem;
line-height: 1.6;
margin-bottom: 1rem;
}
.info-traits {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.trait {
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 20px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: #a1a1aa;
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.controls {
grid-template-columns: 1fr;
}
}
app.js:
// --- Rate Limiting Algorithms ---
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity;
this.lastRefill = Date.now();
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
allowRequest() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
getState() {
this.refill();
return Math.floor(this.tokens);
}
reset() {
this.tokens = this.capacity;
this.lastRefill = Date.now();
}
}
class SlidingWindowLog {
constructor(windowSize, maxRequests) {
this.windowSize = windowSize * 1000;
this.maxRequests = maxRequests;
this.log = [];
}
allowRequest() {
const now = Date.now();
this.log = this.log.filter(timestamp => now - timestamp < this.windowSize);
if (this.log.length < this.maxRequests) {
this.log.push(now);
return true;
}
return false;
}
getState() {
const now = Date.now();
this.log = this.log.filter(timestamp => now - timestamp < this.windowSize);
return this.maxRequests - this.log.length;
}
reset() {
this.log = [];
}
}
class FixedWindowCounter {
constructor(windowSize, maxRequests) {
this.windowSize = windowSize * 1000;
this.maxRequests = maxRequests;
this.counter = 0;
this.windowStart = Date.now();
}
allowRequest() {
const now = Date.now();
if (now - this.windowStart >= this.windowSize) {
this.counter = 0;
this.windowStart = now;
}
if (this.counter < this.maxRequests) {
this.counter++;
return true;
}
return false;
}
getState() {
const now = Date.now();
if (now - this.windowStart >= this.windowSize) {
return this.maxRequests;
}
return this.maxRequests - this.counter;
}
reset() {
this.counter = 0;
this.windowStart = Date.now();
}
}
// --- Visualization ---
class Particle {
constructor(x, y, accepted) {
this.x = x;
this.y = y;
this.accepted = accepted;
this.vx = accepted ? 3 : -2;
this.vy = (Math.random() - 0.5) * 2;
this.alpha = 1;
this.radius = 5;
this.born = Date.now();
}
update() {
this.x += this.vx;
this.vy *= 0.98;
this.y += this.vy;
const age = Date.now() - this.born;
if (age > 1500) {
this.alpha -= 0.02;
}
}
isDead() {
return this.alpha <= 0 || this.x > 950 || this.x < -50;
}
}
class Visualizer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.particles = [];
this.gateX = 450;
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * window.devicePixelRatio;
this.canvas.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
this.width = rect.width;
this.height = rect.height;
this.gateX = this.width / 2;
}
addRequest(accepted) {
const y = this.height / 2 + (Math.random() - 0.5) * 100;
const particle = new Particle(this.gateX, y, accepted);
this.particles.push(particle);
}
draw(algorithmState, algorithmType) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawGate(algorithmState, algorithmType);
this.drawParticles();
this.drawLabels();
}
drawGate(state, type) {
const x = this.gateX;
const h = this.height;
this.ctx.strokeStyle = '#3f3f46';
this.ctx.lineWidth = 2;
this.ctx.setLineDash([5, 5]);
this.ctx.beginPath();
this.ctx.moveTo(x, 0);
this.ctx.lineTo(x, h);
this.ctx.stroke();
this.ctx.setLineDash([]);
const indicatorHeight = 60;
const indicatorY = h / 2 - indicatorHeight / 2;
this.ctx.fillStyle = '#27272a';
this.ctx.strokeStyle = '#3f3f46';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.roundRect(x - 30, indicatorY, 60, indicatorHeight, 8);
this.ctx.fill();
this.ctx.stroke();
let fillRatio = 0;
if (type === 'token-bucket') {
fillRatio = state.tokens / state.capacity;
} else {
fillRatio = state.remaining / state.max;
}
const fillHeight = indicatorHeight * fillRatio;
const fillColor = fillRatio > 0.5 ? '#22c55e' : fillRatio > 0.2 ? '#f59e0b' : '#ef4444';
this.ctx.save();
this.ctx.beginPath();
this.roundRect(x - 30, indicatorY, 60, indicatorHeight, 8);
this.ctx.clip();
this.ctx.fillStyle = fillColor + '33';
this.ctx.fillRect(x - 30, indicatorY + indicatorHeight - fillHeight, 60, fillHeight);
this.ctx.restore();
}
drawParticles() {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.update();
if (p.isDead()) {
this.particles.splice(i, 1);
continue;
}
this.ctx.globalAlpha = p.alpha;
this.ctx.fillStyle = p.accepted ? '#22c55e' : '#ef4444';
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.fillStyle = p.accepted ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)';
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.radius * 2, 0, Math.PI * 2);
this.ctx.fill();
this.ctx.globalAlpha = 1;
}
}
drawLabels() {
this.ctx.fillStyle = '#71717a';
this.ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText('INCOMING', this.gateX - 100, 30);
this.ctx.fillText('ACCEPTED', this.gateX + 100, 30);
}
roundRect(x, y, w, h, r) {
this.ctx.moveTo(x + r, y);
this.ctx.lineTo(x + w - r, y);
this.ctx.quadraticCurveTo(x + w, y, x + w, y + r);
this.ctx.lineTo(x + w, y + h - r);
this.ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
this.ctx.lineTo(x + r, y + h);
this.ctx.quadraticCurveTo(x, y + h, x, y + h - r);
this.ctx.lineTo(x, y + r);
this.ctx.quadraticCurveTo(x, y, x + r, y);
this.ctx.closePath();
}
clear() {
this.particles = [];
this.ctx.clearRect(0, 0, this.width, this.height);
}
}
// --- Application ---
class App {
constructor() {
this.canvas = document.getElementById('visualizer');
this.visualizer = new Visualizer(this.canvas);
this.algorithm = null;
this.algorithmType = 'token-bucket';
this.running = false;
this.requestInterval = null;
this.animationFrame = null;
this.accepted = 0;
this.rejected = 0;
this.algorithmInfo = {
'token-bucket': {
title: 'Token Bucket',
description: 'Tokens are added to a bucket at a fixed rate. Each request consumes one token. If the bucket is empty, the request is rejected. Allows bursts up to the bucket capacity.',
traits: ['Allows Bursts', 'Smooth Refill', 'Memory Efficient'],
stateLabel: 'Tokens Available'
},
'sliding-window': {
title: 'Sliding Window Log',
description: 'Tracks the timestamp of each request within a rolling time window. If the number of requests in the current window exceeds the limit, new requests are rejected. Most precise but uses more memory.',
traits: ['Precise', 'No Boundary Issues', 'Higher Memory'],
stateLabel: 'Slots Available'
},
'fixed-window': {
title: 'Fixed Window Counter',
description: 'Divides time into fixed windows and counts requests per window. When the window expires, the counter resets. Simple but can allow 2x burst at window boundaries.',
traits: ['Simple', 'Low Memory', 'Boundary Burst Risk'],
stateLabel: 'Slots Available'
}
};
this.bindControls();
this.initAlgorithm();
this.updateAlgorithmInfo();
this.animate();
}
bindControls() {
document.getElementById('algorithm').addEventListener('change', (e) => {
this.algorithmType = e.target.value;
this.updateControlVisibility();
this.updateAlgorithmInfo();
this.initAlgorithm();
if (this.running) {
this.stop();
this.start();
}
});
document.getElementById('request-rate').addEventListener('input', (e) => {
document.getElementById('rate-value').textContent = e.target.value;
if (this.running) {
this.stop();
this.start();
}
});
document.getElementById('capacity').addEventListener('input', (e) => {
document.getElementById('capacity-value').textContent = e.target.value;
this.initAlgorithm();
});
document.getElementById('refill-rate').addEventListener('input', (e) => {
document.getElementById('refill-value').textContent = e.target.value;
this.initAlgorithm();
});
document.getElementById('window-size').addEventListener('input', (e) => {
document.getElementById('window-value').textContent = e.target.value;
this.initAlgorithm();
});
document.getElementById('max-requests').addEventListener('input', (e) => {
document.getElementById('limit-value').textContent = e.target.value;
this.initAlgorithm();
});
document.getElementById('start-btn').addEventListener('click', () => this.start());
document.getElementById('stop-btn').addEventListener('click', () => this.stop());
document.getElementById('reset-btn').addEventListener('click', () => this.reset());
}
updateControlVisibility() {
const type = this.algorithmType;
document.getElementById('capacity-group').style.display = type === 'token-bucket' ? '' : 'none';
document.getElementById('refill-group').style.display = type === 'token-bucket' ? '' : 'none';
document.getElementById('window-group').style.display = type !== 'token-bucket' ? '' : 'none';
document.getElementById('limit-group').style.display = type !== 'token-bucket' ? '' : 'none';
}
updateAlgorithmInfo() {
const info = this.algorithmInfo[this.algorithmType];
document.getElementById('info-title').textContent = info.title;
document.getElementById('info-description').textContent = info.description;
document.getElementById('state-label').textContent = info.stateLabel;
const traitsEl = document.querySelector('.info-traits');
traitsEl.innerHTML = info.traits.map(t => `<span class="trait">${t}</span>`).join('');
}
initAlgorithm() {
const type = this.algorithmType;
if (type === 'token-bucket') {
const capacity = parseInt(document.getElementById('capacity').value);
const refillRate = parseInt(document.getElementById('refill-rate').value);
this.algorithm = new TokenBucket(capacity, refillRate);
} else if (type === 'sliding-window') {
const windowSize = parseInt(document.getElementById('window-size').value);
const maxRequests = parseInt(document.getElementById('max-requests').value);
this.algorithm = new SlidingWindowLog(windowSize, maxRequests);
} else {
const windowSize = parseInt(document.getElementById('window-size').value);
const maxRequests = parseInt(document.getElementById('max-requests').value);
this.algorithm = new FixedWindowCounter(windowSize, maxRequests);
}
}
start() {
this.running = true;
document.getElementById('start-btn').disabled = true;
document.getElementById('stop-btn').disabled = false;
const rate = parseInt(document.getElementById('request-rate').value);
const interval = 1000 / rate;
this.requestInterval = setInterval(() => {
this.processRequest();
}, interval);
}
stop() {
this.running = false;
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
if (this.requestInterval) {
clearInterval(this.requestInterval);
this.requestInterval = null;
}
}
reset() {
this.stop();
this.accepted = 0;
this.rejected = 0;
this.initAlgorithm();
this.visualizer.clear();
this.updateStats();
}
processRequest() {
const allowed = this.algorithm.allowRequest();
if (allowed) {
this.accepted++;
} else {
this.rejected++;
}
this.visualizer.addRequest(allowed);
this.updateStats();
}
updateStats() {
document.getElementById('accepted-count').textContent = this.accepted;
document.getElementById('rejected-count').textContent = this.rejected;
const total = this.accepted + this.rejected;
const rate = total > 0 ? Math.round((this.accepted / total) * 100) : 0;
document.getElementById('acceptance-rate').textContent = rate + '%';
const state = this.algorithm.getState();
document.getElementById('algorithm-state').textContent = state;
}
getAlgorithmState() {
if (this.algorithmType === 'token-bucket') {
return {
tokens: this.algorithm.getState(),
capacity: this.algorithm.capacity
};
} else {
return {
remaining: this.algorithm.getState(),
max: this.algorithm.maxRequests
};
}
}
animate() {
const state = this.getAlgorithmState();
this.visualizer.draw(state, this.algorithmType);
this.animationFrame = requestAnimationFrame(() => this.animate());
}
}
document.addEventListener('DOMContentLoaded', () => {
new App();
});
Verify locally
The project is pure client-side HTML, CSS, and JavaScript. You need a local server because opening index.html directly from the filesystem can cause issues with some browser security policies.
- Start a local development server by running this command:
npx serve .
What does this command do?
npx serve . downloads and runs the serve package, which hosts your current directory as a static web server. It defaults to port 3000, so your app will be available at http://localhost:3000.
- Open http://localhost:3000 in your browser.
- Select each algorithm from the dropdown and confirm that the controls change (capacity/refill for Token Bucket, window/limit for the other two).
- Click Start and watch the animated particles flow through the gate.
- Verify that the stats cards (Accepted, Rejected, Acceptance Rate) update in real-time.
- Click Stop and confirm that requests stop flowing.
- Click Reset and confirm that all counters return to zero and particles clear.
- Try switching algorithms mid-run and confirm it resets correctly.
Page is blank or shows errors?
- Open your browser's DevTools console (F12 or Cmd+Option+I) and look for red error messages. A typo in an element ID (like visualiser instead of visualizer) is the most common cause.
- Confirm that index.html loads app.js with a <script src="app.js"></script> tag at the bottom of the body.
- Make sure all three files (index.html, styles.css, app.js) are saved in the same rate-limiter-visualizer folder.
Still stuck? Help me debug why my rate limiter visualizer is showing a blank page or console errors.
- Once everything works, stop the server with Ctrl+C in your terminal.
- Commit your build progress by running these commands:
git add .
git commit -m "Implement rate limiter algorithms and visualization"
git push
Your visualizer is alive. Next up, you'll use gstack's /review and /qa skills to catch bugs and polish the app before shipping.
Review and QA
Your rate limiter visualizer is built and running locally. The algorithms work, the Canvas renders particles, and the controls respond to input.
Before shipping, you need to catch the bugs that slip past manual testing. In this step, you'll use gstack's /review skill for a staff-level code review and /qa skill to test your app with a real browser.
In this step, get ready to:
- Run /review to catch production-level bugs and accept or reject its suggestions.
- Run /qa to test your app with a real Chromium browser and auto-generate regression tests.
Run /review for code review
The /review skill acts as a staff engineer reviewing your branch. It reads your committed code, auto-fixes obvious issues, flags completeness gaps, and asks you about architectural decisions that need human judgment.
- Switch back to OpenCode in your rate-limiter-visualizer project directory.
- Run the review skill by typing this command:
/review
What does /review do?
The /review skill scans your committed code and operates in three passes:
- Auto-fixes obvious issues like formatting inconsistencies and unused variables.
- Flags completeness gaps such as missing error handling or edge cases your algorithms don't cover.
- Asks you about architectural decisions that require human judgment. For example, it might question whether the animation frame budget is realistic at high request rates.
- Read through the review output carefully.
- Accept the auto-fixes it proposes (formatting, unused variables).
- For architectural questions, respond with your reasoning. This is where gstack catches bugs that pass linting but fail in production.
Engage with the review
Don't just accept everything blindly. The /review skill is designed to surface tradeoffs you might have missed. If it flags something you intentionally chose, explain your reasoning back to it. This builds the habit of defending architectural decisions during real code reviews.
- Once you've addressed all suggestions, verify that your changes are committed by checking your git log.
Review not finding your code?
Make sure your work is committed before running /review. The skill reviews committed code on your branch, not unstaged changes.
Help me troubleshoot why /review isn't picking up my code changes.
Run /qa for browser testing
Manual testing only catches what you think to test. The /qa skill opens a real Chromium browser, clicks through your UI, and finds bugs you missed.
- Make sure your local server is still running. If you stopped it, restart it by running this in a separate terminal:
npx serve .
- Back in OpenCode, run the QA skill by typing this command:
/qa http://localhost:3000
What does /qa test?
The /qa skill launches a real Chromium browser and interacts with your app the way a user would. It tests:
- Does each algorithm switch correctly when the dropdown changes?
- Do sliders update their displayed values in real-time?
- Does the Stop button actually stop sending requests?
- Does Reset clear all state (stats, particles, algorithm)?
When /qa finds a bug, it fixes it with an atomic commit and generates a regression test to prevent that bug from returning.
Why this matters for parallel sprints
You can trust the QA is real because it uses a real browser, not a mocked DOM. This is what makes gstack's sprint workflow reliable. When /qa passes, you know the app works for real users.
- Wait for /qa to finish its browser testing. Review the bugs it found and the fixes it applied.
- Check your git log to see the atomic commits /qa made for each fix and the regression tests it generated.
- Push all changes to GitHub by running this command:
git push
QA can't connect to your app?
Make sure your local server is running before you invoke /qa. If the port number in the terminal output doesn't say 3000, use the actual port shown (for example, /qa http://localhost:5000).
Help me get /qa connected to my local server.
Your code has been reviewed by a staff-level AI reviewer and tested by a real browser. The bugs are squashed and the regression tests are in place. Next up, you'll ship your work as a clean Pull Request on GitHub.
Ship Your Project
Your code has been reviewed by a staff-level AI reviewer and tested with a real browser. Bugs are fixed, regression tests are generated, and everything is committed.
Now it's time to ship. The /ship skill handles the entire release process: syncing branches, running tests, auditing coverage, pushing code, and opening a Pull Request on GitHub. This is the final step of a full gstack sprint.
In this step, get ready to:
- Run /ship to open a Pull Request on GitHub.
- Verify and merge the PR to complete a full gstack sprint.
Run /ship to open a Pull Request
The /ship skill automates everything between "code is ready" and "PR is live on GitHub." It syncs your branch with main, runs tests, audits test coverage, pushes your branch, and opens a Pull Request.
- Switch back to OpenCode in your rate-limiter-visualizer project.
- Type /ship and press Enter.
What does /ship do?
The /ship skill runs through a full release checklist automatically:
- Syncs your feature branch with main to catch any conflicts.
- Bootstraps a test framework if your project doesn't have one yet.
- Runs tests and audits test coverage.
- Pushes your branch to GitHub and opens a Pull Request with a clean description of what changed.
Wait for /ship to complete. It will report its progress as it runs through each stage of the release checklist.
Tests failing?
If /ship reports test failures, it will tell you exactly what broke. Fix the failing tests (or ask OpenCode to fix them), commit the fix, and run /ship again.
Help me fix my failing tests so /ship can complete.
Verify your shipped PR
Once /ship completes, it will confirm that a Pull Request has been opened on your GitHub repository. Time to review and merge it.
- Open your browser and navigate to your rate-limiter-visualizer repository on GitHub.
- Click the Pull requests tab.
- Click on the open PR that /ship created.
- Review the PR description, the diff (under the Files changed tab), and any test results.
- Click Merge pull request, then click Confirm merge.
What just happened?
You just completed a full gstack sprint: Think (/office-hours), Plan (/plan-ceo-review and /plan-eng-review), Build (OpenCode implementation), Review (/review), QA (/qa), Ship (/ship).
Each skill fed into the next. The design doc from /office-hours informed the architecture. The architecture informed the build. The review caught bugs. QA verified in a real browser. And /ship turned it all into a clean PR with tests.
You should see the PR status change to Merged with a purple merge icon on the PR page.
Don't see the Pull Request?
Make sure you're looking at the correct repository on GitHub. The repository name should match rate-limiter-visualizer.
If the Pull requests tab shows no open PRs, check if /ship reported any errors in OpenCode. It may have failed to push if your GitHub remote isn't configured correctly.
Help me figure out why /ship didn't create a Pull Request.
Secret mission
Leaky Bucket + Auto-Documentation
Your visualizer covers three algorithms, but there's a fourth classic that comes up in every system design interview: the Leaky Bucket. Add it to your visualizer, then use gstack's /document-release skill to auto-generate professional documentation for your entire project.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally with a free GitHub repository, so there are no ongoing costs.
Resources you used:
- Local project folder (rate-limiter-visualizer on your Desktop).
- GitHub repository (rate-limiter-visualizer).
- gstack installation (~/gstack and skills in ~/.config/opencode/skills/gstack-*/).
- Bun (installed globally via npm).
Keep everything running
No action needed. Choose this if you're still actively building or want to extend the project further.
- Your GitHub repo and local files stay exactly as they are.
- Consider deploying to GitHub Pages for a live demo link in your portfolio.
- gstack remains installed and ready for your next project.
- Bun remains available globally for future projects.
Pause - I'll come back to this later
There are no running processes or paid resources to pause. Your local development server stopped when you closed the terminal.
- If you still have a terminal running npx serve ., press Ctrl+C to stop it.
- Everything else is already paused by default. No ongoing costs or processes.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete your local project folder by running:
cd ~/Desktop
rm -rf rate-limiter-visualizer
On Windows (PowerShell), use:
cd ~/Desktop
Remove-Item -Recurse -Force rate-limiter-visualizer
- Uninstall gstack by running this in Git Bash:
~/gstack/bin/gstack-uninstall
Keep your gstack config
Add --keep-state to preserve your configuration and analytics data while removing gstack itself.
- Uninstall Bun by running:
npm uninstall -g bun
- Delete your GitHub repository by going to your repository page on GitHub.
- Click Settings in the top navigation bar.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a portfolio-ready rate limiter visualizer that demonstrates FAANG-level system design knowledge, shipped entirely through a professional AI-assisted sprint workflow.
You've learned how to:
- Build three rate limiting algorithms (Token Bucket, Sliding Window Log, Fixed Window Counter) with interactive Canvas-based visualization showing real-time request acceptance and rejection.
- Use gstack's Think and Plan skills (/office-hours, /plan-ceo-review, /plan-eng-review) to sharpen product ideas and lock architecture before writing code.
- Use /review, /qa, and /ship to catch production-level bugs, test with a real browser, and open a clean Pull Request on GitHub.
- Secret Mission: Added a fourth algorithm (Leaky Bucket) and used /document-release to auto-generate professional documentation with a Diataxis coverage map.
Ready to quiz yourself?