Loading session...

AI Roundtable Open Debate · 4 models

What’s the best way to implement a trigger + criteria system in Kotlin + PaperMC efficiently? eg I want a unified trigger system (event listener type) & criteria system (requirements for the events to update), that can be dynamically registered to by achievement and quest systems. I don’t want exact code, moreso an algorithm and class model overview

Answer: Implement a centralized, tiered event bus using multi-level indexing and immutable event contexts for high-performance dynamic registration. (All 4 models agreed)

Model Responses

Grok 4.3

Answer: A centralized TriggerRegistry with indexed subscriptions by trigger type and player UUID, using composable Criteria predicates on immutable contexts, remains the most efficient model for dynamic achievement and quest registration.

All three models converge on the same architecture: a single listener per event type feeding a dispatcher that routes to indexed subscriptions. This confirms my original stance that a TriggerManager (or TriggerBus/Registry) wrapping PaperMC events and evaluating pluggable Criteria is optimal. The refinements around primary-key indexing, separating indexable vs non-indexable criteria, and returning SubscriptionHandles for clean unregistration strengthen the design without requiring a fundamental change. Therefore the core algorithm—register once, dispatch by trigger key then player bucket, evaluate criteria, mutate progress—stays the best practical approach.

Claude Opus 4.7 (changed mind)

Answer: Use a typed, generic Trigger<T> registry backed by Bukkit event listeners that dispatches normalized 'TriggerContext' objects to subscribers whose Criteria predicates match, with progress state stored per-player and indexed by trigger type — and additionally by a per-trigger 'primary key' for hot dispatch paths.

My original position holds: the three-layer split (Trigger / Criteria / Subscriber) on top of a single centralized bus is what every other model converged on too. That's strong signal it's the right shape. However, Gemini 3.1 Pro and GPT-5.5 both raised a refinement I underweighted: a flat `Map<TriggerId, List<Subscription>>` is fine, but it still degenerates to O(N) over all subscribers of that trigger type on every event. On a busy server, `BlockBreakEvent` fires constantly, and if you have hundreds of active quests watching block breaks across all online players, scanning them linearly per event is wasteful. The fix is a *secondary index* via a per-Trigger 'primary key extractor' (Gemini's term) or 'indexable criteria' (GPT-5.5's term). Each `Trigger<C>` declares which field of its context is the natural discriminator — `Material` for block break, `EntityType` for mob kill, `Material` for craft. The registry then stores `Map<TriggerId, Map<PrimaryKey, List<Subscription>>>` plus a fallback bucket for subscriptions that don't constrain that key. Dispatch becomes two hash lookups followed by predicate evaluation on a tiny candidate set. I'm adopting this refinement. GPT-5.5's other point I'm folding in: also index by `PlayerId` for *quest* subscriptions (which are inherently per-player and ephemeral), while keeping *achievement* subscriptions global per trigger (since every online player is potentially eligible). This asymmetry matters — treating them identically wastes memory for achievements or wastes CPU for quests. I'm also accepting GPT-5.5's clean separation of `Criterion` (does it count?) from `ProgressRule` (what happens if it counts?), which I had implicitly bundled into the subscriber's handler. Splitting them makes counters, booleans, streaks, and multi-stage objectives pluggable without touching the dispatch path. What I'm *not* changing: Grok's suggestion of weak references for subscriptions is the wrong tool here — quest/achievement lifetimes are deterministic (quest completed, abandoned, player logged out, plugin reloaded), so explicit `SubscriptionHandle.unregister()` is safer and avoids GC-timing bugs. Weak refs invite stale-state heisenbugs in a tick-driven environment. So: same architecture, but with (a) primary-key secondary indexing per trigger, (b) asymmetric indexing for quests vs achievements, and (c) Criterion/ProgressRule split. Threading stays main-thread for dispatch and in-memory progress; only persistence goes async.

Gemini 3.1 Pro (changed mind)

Answer: Implement a centralized, typed event bus that normalizes Bukkit events into immutable contexts, routing them through a registry indexed by player UUID and indexable criteria to efficiently evaluate composable predicates.

To achieve maximum efficiency, the system must avoid duplicate Bukkit listeners and prevent scanning unrelated quests. The core class model should separate event capture, condition evaluation, and progress mutation. A `Trigger<C>` class listens to a specific Bukkit event (e.g., BlockBreakEvent) and translates it into an immutable `TriggerContext`. Instead of a rigid primary key, I've adopted the concept of separating criteria into 'indexable' and 'non-indexable' types. An indexable criterion (like `BlockTypeCriterion(Material.STONE)`) provides a hashing key, while non-indexable criteria (like `PlayerHealthCriterion(>10)`) are evaluated as standard boolean predicates. The registration algorithm must handle quests and achievements differently. Quests are highly transient and specific to individuals, so their subscriptions should be stored in a `Map<UUID, Map<TriggerKey, Map<IndexKey, List<Subscription>>>>`. Achievements are persistent and apply to everyone, so they can be stored in a global `Map<TriggerKey, Map<IndexKey, List<Subscription>>>`. The execution algorithm begins when a player triggers an event. The single PaperMC listener generates a `TriggerContext` and passes it to the `TriggerBus`. The bus retrieves the global achievements and the specific player's active quests for that `TriggerKey`. It uses the context to extract the `IndexKey` (e.g., the block broken), instantly narrowing the pool of subscriptions to an absolute minimum via O(1) lookups. Finally, it iterates through this tiny subset, evaluates the remaining composable, non-indexable criteria, and offloads successful matches to a decoupled `ProgressService`.

GPT-5.5 (changed mind)

Answer: Use a centralized typed trigger bus with one Paper listener per event type, immutable trigger contexts, composable criteria predicates, and subscriber indexes by trigger, player, and high-selectivity event keys before mutating quest or achievement progress.

I would keep my original architecture, but Gemini 3.1 Pro’s point about a primary-key index is a useful efficiency refinement: for high-frequency events like block breaks, mob kills, item pickups, and crafting, indexing only by trigger type may still leave too many subscriptions to scan. So the best model is not just `TriggerId -> subscriptions`, but often `TriggerId -> player UUID/global -> primary key -> subscriptions`, with a fallback bucket for criteria that cannot be indexed cleanly. The class model should separate four concepts. A `Trigger<C>` represents the kind of event, such as block break, mob kill, craft item, region enter, or fish catch. It owns or is connected to exactly one Paper/Bukkit listener path and converts raw Paper events into your own immutable `TriggerContext`, such as `BlockBreakContext(player, material, world, location, tool)`. This keeps Bukkit-specific details at the edge of the system. A `Criterion<C>` is a typed predicate over that context and optionally player progress state. Examples are material equals diamond ore, entity type is zombie, world is nether, item has enchantment, permission exists, region matches, or count is at least N. Criteria should be composable with `allOf`, `anyOf`, and `not`, and should be parsed or compiled at quest/achievement load time so the hot event path is only doing simple predicate calls. A `Subscription<C>` binds together a trigger, criteria, owner metadata, progress behavior, and callback. Achievements and quests should both register subscriptions into the same `TriggerBus`; the bus should not care whether the owner is an achievement, quest, daily task, battle pass challenge, or anything else. It only knows: when this trigger fires and the criteria pass, call this handler or progress rule. The efficient dispatch algorithm is: Paper event fires, the trigger adapter builds a normalized context, the bus extracts the player UUID if present, extracts any configured primary key such as `Material`, `EntityType`, `ItemStack.type`, `World`, or region id, then looks up a small candidate list. It should evaluate secondary criteria only for that candidate list, mutate progress for matches, mark state dirty, and complete/unsubscribe objectives when needed. For example, a block-break trigger can index subscriptions by material. A quest requiring 100 stone breaks is filed under `STONE`; an achievement requiring diamond ore is filed under `DIAMOND_ORE`; a generic achievement like “break any 10,000 blocks” goes into an `ANY` bucket. On a block break, you check the player-specific `STONE` bucket plus the generic `ANY` bucket, not every block-break-related quest on the server. For dynamic registration, definitions should produce subscription templates. Achievements may register globally and check per-player progress state when fired, while active quests are often better materialized per player and stored under that player’s UUID. The `subscribe` call should return a `SubscriptionHandle` so quest steps can be removed when completed, abandoned, expired, or replaced by the next step. Progress state should live outside the trigger system, keyed by player UUID and objective/subscription id. Event processing and criteria evaluation should stay on the main thread because Paper events and world/player access are main-thread concerns. Persistence should be debounced and asynchronous: mark player progress dirty during dispatch, then batch-save later or on logout/plugin disable. So the core algorithm is: register listeners once, normalize events into typed contexts, route through indexed subscriptions, evaluate small composable criteria, update external progress state, and dynamically add/remove subscriptions as achievement and quest definitions change. This gives you a unified system without turning your Paper listeners into a giant quest-specific switch statement.

Final Summary

The AI Roundtable reached a strong consensus on a three-tier architecture comprising Triggers, Criteria, and Subscribers. Claude Opus 4.7 and GPT-5.5 highlighted the importance of normalizing raw PaperMC events into domain-specific 'Context' objects, while Gemini 3.1 Pro and Grok 4.3 focused on minimizing overhead by using a single Bukkit listener per event type rather than per quest.

All 4 models agreed