DEV Community

Kārlis Rozenbergs
Kārlis Rozenbergs

Posted on AI-assisted

I built a 16-bit RPG inside Jira, and Forge took away my server

I could not make myself log time in Jira.

Not because it is hard. Because nothing happens afterwards. You type a number into a box, the box says nothing back, and by Thursday the habit is gone again. Every tool I tried fixed this by adding another box.

So I built the missing half instead. Feed The Troll gives everyone on a team a pixel-art troll that gains XP from the work they already do in Jira, and turns sprint results into a village the whole project shares. It is on the Atlassian Marketplace now.

The hero capture: the troll reveal, a teammate's kudos claimed.

This post skips the game itself. It is about five problems that turned out to be hard in ways I did not expect, each one a consequence of building the thing on Atlassian Forge, alone.

What Forge gives you, and what it takes back

Forge runs your code on Atlassian's infrastructure. There is no server of mine anywhere in the picture. That is the line on the listing page, and it was the single fact that shaped every decision underneath it.

You get a Node 22 runtime, Forge SQL (TiDB under the hood) for storage, and Custom UI modules that reach the backend through @forge/bridge. You give up a backend you control, a cache you can reach, and outbound HTTP to anything you did not declare. The one that keeps mattering: any way to open the database at three in the morning and fix a single row by hand.

The whole app declares six scopes. None of them are write scopes:

read:board-scope:jira-software
read:issue-details:jira
read:jira-work
read:jira-user
read:sprint:jira-software
storage:app
Enter fullscreen mode Exit fullscreen mode

That last line is the entire persistence layer. Twenty-one tables live behind it now, but only ten shipped with v1.0: trolls, XP events, daily activity, kudos, quests, inventory, team quests, villages, raids, project settings. Every table added since arrived the only way the platform makes comfortable, as a new migration appended to the list, never an edit to one already deployed.

migrationRunner
  .enqueue('v001_create_trolls', CREATE_TROLLS_TABLE)
  // ...
  .enqueue('v012_create_product_metric_events', CREATE_PRODUCT_METRIC_EVENTS_TABLE);
Enter fullscreen mode Exit fullscreen mode

Those v001, v012 names are load-bearing. Forge SQL tracks which operations it has already run by that string, across every installation, forever. Rename one and you have described a migration that never happened. So the rule enforces itself: new schema is a new line at the bottom, and the old lines are frozen. There is no maintenance window in which to do anything else.

The same event can arrive twice

Jira sends you events. Events are not promises. The same worklog can trigger the handler more than once, and a game that pays XP per event will happily pay twice for one piece of work.

You cannot reach for a lock, because there is nothing to lock. What saved me is that the mechanism I already needed for a different reason does this job too.

Every XP-earning event carries a cooldown (more on why in a second). Logging time can only pay once an hour. So a redelivered worklog inside that hour awards nothing, and it does not matter whether the second copy came from a person trying to farm or from Jira delivering the same event twice.

The XP ledger itself is deliberately append-only. Every award gets a fresh UUID and its own row, because I want the full history to compute the caps and to render a feed. I never dedupe it. The window does the deduping.

That covers everything with a cooldown. It does not cover the events that are supposed to happen exactly once, ever, where "once an hour" is meaningless.

The app emits eight installation-level activation counters through Forge custom metrics: "this install opened onboarding", "this install hatched its first troll", and so on. A counter that fires twice is worse than one that never fires, because it quietly corrupts a number you will later make decisions from. Here there is no window to hide behind, so there is a whole table whose only job is to remember which counters an installation has already sent:

const affectedRows = await executeAffectedRows(
  `INSERT IGNORE INTO product_metric_events (installation_id, metric_name)
   VALUES (?, ?)`,
  installationId,
  metricName,
);

if (affectedRows === 0) {
  return { status: 'duplicate' };
}
Enter fullscreen mode Exit fullscreen mode

The composite primary key (installation_id, metric_name) is the guarantee. INSERT IGNORE turns the second attempt into zero affected rows instead of an error, and the counter increments only on the branch where a row was genuinely new. The database decides; the metric follows.

A dedicated table to stop one counter double-counting felt like overkill the day I wrote it. It was the thing that made the activation funnel trustworthy.

An economy nobody has a reason to farm

The XP formula is small:

totalXP = floor(baseXP * qualityMultiplier * (1 + typeBonus))
Enter fullscreen mode Exit fullscreen mode

Base values are per event type. Logging time is 15, a status transition 10, flagging a blocker 20, a comment 8, a completed sprint 50.

The formula is boring. What matters is the two numbers sitting next to every event type: a daily cap and a cooldown.

Event Base XP Daily cap Cooldown
Log time 15 4 60 min
Transition an issue 10 8 30 min
Flag a blocker 20 3 2 hr
Comment 8 6 15 min
Sprint completed 50 1 none

Without these, the optimal strategy is obvious and awful: split one afternoon of work into six worklogs. With them, splitting earns you nothing. The fourth worklog of the day pays, the fifth does not, and the app says nothing about it. No error, no warning, no red text. The troll just stops eating until tomorrow.

A troll with its level and XP bar

I am deliberate about that silence. A visible "you have hit your limit" turns the cap into a scoreboard, and a scoreboard is a thing people play against. Silence turns it into a ceiling nobody thinks about, which is the only version that actually changes behaviour.

The multipliers then lean on the scale in the direction you want. A worklog filed the same day the work happened is worth 1.5x. A blocker flagged and explained in a comment is worth 2x. And closing something that has rotted in the backlog for a month:

// Dusty Ticket bonus: closing a backlog item that has sat for 30+ days.
if (isDone && context.isAgingIssue) return 2.5;
if (isDone) return 2.0;
if (newStatusLower === 'in progress') return 1.3;
return 1.0; // backward transitions are normal Agile, never penalised
Enter fullscreen mode Exit fullscreen mode

The fastest path to XP is the behaviour a decent team lead would ask for anyway. That is the only reason any of this is defensible inside a company instead of being one more thing to resent.

The streak that pauses

Almost every app ships a streak that resets at midnight. I think that one decision is what kills gamification at work. The moment a number can be lost, it stops being a reward and becomes a thing you defend. And people defend a worklog streak by logging work that did not happen.

So mine pauses. It never resets.

export function updateStreak(lastActiveDate, today, currentStreak) {
  if (!lastActiveDate) return 1;                 // first ever activity
  if (lastActiveDate === today) return currentStreak; // already counted today

  const diffDays = daysBetween(lastActiveDate, today);
  if (diffDays === 1) return currentStreak + 1;  // consecutive day
  return currentStreak;                          // missed a day, or ten: hold
}
Enter fullscreen mode Exit fullscreen mode

There is a quiet engineering payoff hiding in that product decision. A resetting streak needs a job that runs at local midnight for every user in every installation. Forge does give you a scheduled trigger, and I run one to apply schema migrations every hour, but it fires globally on a fixed interval, not per person in their own timezone. That is exactly the shape a midnight reset would need, and exactly the shape Forge does not offer.

A streak that pauses needs no job at all. You store the last active date and the count, and you compute the rest the moment someone looks. The behaviour I chose for human reasons deleted a scheduler I would not have enjoyed writing.

Troll mood comes out of the same two fields. Three days idle and the troll falls asleep, the lowest state there is. There is no sad state and no dying state. Falling asleep is the only backward move in the whole app, and any activity at all wakes it straight back up. A streak you can lose over one day off is a resignation letter with a progress bar.

Privacy has to be enforced on the read

Everyone controls five toggles: whether their troll shows up in the village, whether their level is visible, their streak, their XP total, and whether teammates can send them kudos. Troll and level default on. Streak and XP total default off, because those are the two numbers colleagues would quietly rank each other by.

The five privacy toggles, with streak and XP total off by default.

The mistake I nearly shipped was treating this as a display problem: hide the number in the component and move on. It runs deeper than that. Nothing is hidden at rest, the booleans sit in the clear next to the data. Every read that assembles a village has to filter twice, once against Jira's own project permissions and once against each person's choices, and if either filter is wrong you have built a surveillance tool by accident.

const isViewer = row.user_id === options.viewerUserId;
if (!isViewer && !flagIsEnabled(row.privacy_show_village)) continue; // gone entirely
// ...
const canShowLevel  = accumulator.isViewer || accumulator.rows.every(r => flagIsEnabled(r.privacy_show_level));
const canShowStreak = accumulator.isViewer || accumulator.rows.every(r => flagIsEnabled(r.privacy_show_streak));
const canShowXp     = accumulator.isViewer || accumulator.rows.every(r => flagIsEnabled(r.privacy_show_xp));
Enter fullscreen mode Exit fullscreen mode

Two things in there took me a second pass to get right. isViewer short-circuits everything, so you always see your own numbers even with every toggle off, or the village would feel broken.

And because a troll's progress can be combined across several projects, visibility is .every, not .some: one project where you opted out hides the number everywhere. The strictest choice wins. Linking more projects can never widen what other people see.

Storing everything in the clear and filtering on read has one property I have come to depend on. A privacy toggle takes effect instantly and retroactively, with no migration and no backfill. That matters most in the exact moment someone flips a toggle off, because usually they have just realised their manager can see something they did not mean to share.

How one person tests a team game

This is the part I was least ready for.

Feed The Troll is multiplayer. The village ages issues across a whole team, raids fire when someone closes a sprint, the defence score is built from work-in-progress limits and blocker flags and how active the team has been, and the raid outcome depends on all of it at the instant the sprint closes.

I am one person. I do not have a team, a sprint, or forty trolls.

So most of the testing is a lie I tell the code carefully. It runs in three layers, and the interesting story is in the middle one.

The XP engine is pure functions. calculateXP, updateStreak, and nextTrollState take values and return values, no I/O, so ordinary unit tests cover the rules. That part was easy and boring, which is the point.

The middle layer is where a team gets fabricated. The entire backend runs in Jest against a hand-written in-memory stand-in for @forge/sql, plus two simulators. One fires a whole team's worth of events at once:

const results = await SprintStormSimulator.fire({
  userIds: SprintStormSimulator.buildUserIds(50), // u-001 .. u-050
  eventType: 'mixed',                             // round-robin worklog/status/comment
});
// 50 handler invocations, one Promise.allSettled, all racing the same troll rows
Enter fullscreen mode Exit fullscreen mode

The other controls time, because streaks and cooldowns and seasons all read the wall clock, and a test that passes on a Tuesday should not fail on New Year's Eve:

TimeMachine.setDate('2025-06-15');
TimeMachine.advanceSeconds(1799); // still inside the 30-min cooldown -> blocked
TimeMachine.advanceSeconds(2);    // 1801s -> the next award goes through
Enter fullscreen mode Exit fullscreen mode

Between them I can seed fifty trolls, run a power user for thirty consecutive days, and slam the same account past its daily cap, all deterministically, in milliseconds. On top of that sits a small chaos suite (a mega-village, an anti-cheat fuzzer, a data-corruption run) throwing adversarial input at the same handlers.

Here is what none of it fakes, and where it bit me. The mock is a regex-based SQL parser. If you have heard the old line about solving a problem with regular expressions and ending up with two problems, you already know how this part goes. It only knows the rules I teach it, and anything I forget to teach, it fakes successfully. That is the worst kind of failure, a green suite sitting on top of a real bug.

The original mock built its rows from whatever columns an INSERT named. So a query that inserted a created_at into a table whose real schema has no such column passed every single test. Real TiDB is less forgiving. It threw Unknown column 'created_at' in 'field list' in production, on the sprint-close raid path, the one place I was not looking.

The fix was to make the test double lie less. It now parses the real CREATE TABLE DDL out of the schema file, builds a column allowlist per table, and throws on an unknown column exactly the way TiDB does:

const knownCols = schemaColumns[tableName];
if (knownCols) {
  for (const col of columns) {
    if (!knownCols.has(col.toLowerCase())) {
      throw new Error(`Unknown column '${col}' in 'field list'`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That closed one class of the problem. It did not close the category, and it cannot: every SQL feature the app uses has to be taught to the mock by hand, and the mock's confidence is always higher than its coverage.

Which is why the third layer exists at all. There is a webtrigger, shipped disabled, that an external runner calls during forge tunnel to fire genuine Jira events at the genuine backend and read the resulting rows back out of real TiDB. A header bypasses cooldowns, so a test can discover a daily cap in seconds instead of a day. It is slow, it is manual, and it is the only place the mock's lies get caught by the real thing. I run it rarely and I never ship it on.

The frontend gets its own fake reality. Both the Issue Panel and the Team Village have a dev-only "God Mode" that injects state straight into React: a village full of teammates, an incoming kudos from a colleague, a level-up caught mid-animation. None of it real, so I get to see the multiplayer moments a solo developer never sees otherwise.

It is gated twice, by a build flag that tree-shakes the whole panel out of the production bundle, and by a backend that refuses to confirm a non-production environment to the frontend at all. Even if someone flipped the flag, the server would not play along.

What I would do differently

Two things.

I would write the idempotency and anti-gaming layer first, not retrofit it. Every event handler ended up needing the same "have we already paid for this, is this inside a cooldown" context, and adding it late meant opening all of them again.

And I would settle the privacy defaults before building a single view. Deciding that troll and level are visible while streak and XP are not is a product call, but every read path in the app leans on it, and changing your mind after the fact means changing it in a lot of files at once.

The line-up of all 15 troll types

If you want to poke at the thing itself, it is free for two Jira projects and installs in about a minute: Feed The Troll on the Atlassian Marketplace.There is a bit more about how it works at dev.drinkits.lv/feedthetroll.

Happy to get into any of the Forge details in the comments. The platform is genuinely good once you stop mourning the server you do not get.

Top comments (0)