OpenLitterMap

June 7, 2026 · View on GitHub

XP Values

ActionXPNotes
Upload a photo5Base XP, always awarded
Each litter object tagged1Multiplied by quantity
Each material tagged2Multiplied by parent tag's quantity
Each brand tagged3Brands have their own independent quantity
Each custom tag1Multiplied by parent tag's quantity
Picked up5Per object (×quantity) when photo_tags.picked_up = true

Special Object Overrides

Some objects award more than the default 1 XP per item:

Object KeyXP per item
dumping_small10
dumping_medium25
dumping_large50
bags_litter10

These are litter size categories — tagging a large item rewards more because it takes more effort to document and pick up.


Formula

``$ \text{Total} \text{XP} = \text{Upload} + \text{Objects} + \text{Materials} + \text{Brands} + \text{Custom} \text{Tags} + \text{Picked} \text{Up} \text{Bonus}

\text{Upload} = 5 (\text{always}) \text{Objects} = Σ(\text{quantity} \times \text{object_xp}) \text{object_xp} = 1 (\text{or} \text{special} \text{override}) \text{Materials} = Σ(\text{parent_quantity} \times 2) \text{per} \text{material} \text{on} \text{each} \text{tag} \text{Brands} = Σ(\text{brand_quantity} \times 3) \text{brands} \text{use} \text{their} \text{OWN} \text{quantity} \text{Custom} \text{Tags} = Σ(\text{parent_quantity} \times 1) \text{per} \text{custom} \text{tag} \text{on} \text{each} \text{tag} \text{Picked} \text{Up} = Σ(\text{quantity} \times 5) \text{per} \text{tag} \text{where} \text{picked_up}=\text{true} \text{AND} \text{has} \text{object} $``

Quantity Rules

  • Objects: quantity is set by the user (e.g., "3 cigarette butts"). XP = quantity × xp_per_object.
  • Materials: Set membership — each material on a tag uses the parent tag's quantity as the multiplier. If you tag 3 bottles with plastic and glass, that's 3×2 + 3×2 = 12 material XP.
  • Brands: Independent quantities — each brand has its own quantity. If you tag Coca-Cola (qty 2) on a can, that's 2×3 = 6 brand XP regardless of the parent tag's quantity.
  • Custom tags: Same as materials — use the parent tag's quantity as the multiplier.

Extra-Tag-Only (Loose) Tags

PhotoTags with null CLO (brand-only, material-only, custom-only) receive no object XPXpCalculator::calculateFromFlatSummary() only awards object XP when object_id > 0. They still earn their extra-tag XP:

  • Brand-only tag: brand_quantity × 3 XP
  • Material-only tag: parent_quantity × 2 XP
  • Custom-only tag: parent_quantity × 1 XP

These tags also do not count toward totalLitter (GeneratePhotoSummaryService only counts litter when objectId > 0).


Example

A user photographs litter on the ground, tags it, and picks some up:

``$ \text{Tag} 1: \text{Cigarette} \text{Butt} (\text{qty} 5, \text{picked_up} = \text{true}) → \text{Object}: 5 \times 1 = 5 \text{XP} → \text{Picked} \text{Up}: 5 \times 5 = 25 \text{XP}

\text{Tag} 2: \text{Plastic} \text{Bottle} (\text{qty} 2, \text{picked_up} = \text{true}), \text{materials}: [\text{plastic}], \text{brands}: [\text{Coca}-\text{Cola} \text{qty} 1] → \text{Object}: 2 \times 1 = 2 \text{XP} → \text{Material}: 2 \times 2 = 4 \text{XP} (\text{parent} \text{qty} \times \text{material} \text{XP}) → \text{Brand}: 1 \times 3 = 3 \text{XP} (\text{brand}'\text{s} \text{own} \text{qty} \times \text{brand} \text{XP}) → \text{Picked} \text{Up}: 2 \times 5 = 10 \text{XP}

\text{Tag} 3: \text{Large} \text{item} (\text{qty} 1, \text{picked_up} = \text{false}) → \text{Object}: 1 \times 50 = 50 \text{XP} (\text{special} \text{override}) → \text{Picked} \text{Up}: 0 \text{XP} (\text{not} \text{picked} \text{up})

\text{Calculation}: \text{Upload}: 5 \text{Objects}: 5 + 2 + 50 = 57 \text{Materials}: 4 \text{Brands}: 3 \text{Custom} \text{Tags}: 0 \text{Picked} \text{Up}: 25 + 10 = 35 (\text{per}-\text{tag}: \text{qty} \times 5 \text{where} \text{picked_up}=\text{true}) ───────────── \text{Total}: 104 \text{XP} $``


Levels

XP accumulates into levels. Thresholds are flat (not exponential).

LevelXP RequiredTitle
10Noob
2100Litter Picker
31,000Litter Wizard
45,000Trash Warrior
510,000Early Guardian
615,000Trashmonster
750,000Force of Nature
8100,000Planet Protector
9200,000Galactic Garbagething
10500,000Interplanetary
111,000,000SuperIntelligent LitterMaster

LevelService::getUserLevel($xp) returns: level, title, xp_into_level, xp_for_next, xp_remaining, progress_percent.

Frontend reads user.next_level.title and user.next_level.progress_percent for the XP bar.


Admin XP

Admins earn 1 XP per verification action (approve, delete, re-tag, reset). Awarded via rewardXpToAdmin() which increments the user's DB xp column and updates their Redis leaderboard score.


When XP Is Processed

  1. User uploads a photo → 5 upload XP awarded immediately to users.xp + metrics (public photos only; school photos deferred)
  2. User adds tags → GeneratePhotoSummaryService calculates tag XP (including per-tag picked_up bonus), stores in photo.xp
  3. TagsVerifiedByAdmin event fires (immediate for all non-school users)
  4. ProcessPhotoMetrics listener → MetricsService::processPhoto() adds full XP (upload + tag) to leaderboards and metrics

School students: Upload XP and tag XP are both deferred until teacher approval. processPhoto()doCreate() handles everything in one pass.


Critical Rule: Never Hardcode XP Values

Always use XpScore enum — never hardcode XP integers anywhere in PHP or return them as literals in API responses.

// WRONG — hardcoded value will drift out of sync
'xp_awarded' => 5,

// RIGHT — single source of truth
'xp_awarded' => XpScore::Upload->xp(),

The XpScore enum (app/Enums/XpScore.php) is the single source of truth for all XP values. Use:

  • XpScore::Upload->xp() — upload XP (5)
  • XpScore::Object->xp() — default object XP (1)
  • XpScore::getObjectXp($key, $typeKey = null) — object XP with special overrides. For dumping objects with a type (small/medium/large), the type determines XP (10/25/50)
  • XpScore::getTagXp($type) — map string type to XP
  • XpScore::Brand->xp(), XpScore::Material->xp(), XpScore::CustomTag->xp(), XpScore::PickedUp->xp()

Test assertions may use literal integers (they verify the enum works), but all production code must reference the enum.


Key Files

FilePurpose
app/Enums/XpScore.phpXP multiplier values (single source of truth)
app/Services/Tags/XpCalculator.phpXP calculation from tags or summary
app/Services/Tags/GeneratePhotoSummaryService.phpBuilds summary + calculates XP + applies per-tag picked-up bonus
app/Listeners/Metrics/ProcessPhotoMetrics.phpListener that triggers MetricsService from TagsVerifiedByAdmin
resources/js/views/General/Tagging/v2/useXpCalculator.jsFrontend XP preview (mirrors backend logic with literal integers)
config/levels.phpLevel thresholds
app/Services/LevelService.phpMaps XP to level info
app/Helpers/helpers.phprewardXpToAdmin()
tests/Feature/Tags/v2/CalculatePhotoXpTest.phpXP calculation tests

Note: The frontend useXpCalculator.js uses literal integers (e.g., 3 for brands, 5 for picked_up) because it has no access to the PHP enum. These must be kept in sync manually with XpScore.