OpenLitterMap
June 7, 2026 · View on GitHub
XP Values
| Action | XP | Notes |
|---|---|---|
| Upload a photo | 5 | Base XP, always awarded |
| Each litter object tagged | 1 | Multiplied by quantity |
| Each material tagged | 2 | Multiplied by parent tag's quantity |
| Each brand tagged | 3 | Brands have their own independent quantity |
| Each custom tag | 1 | Multiplied by parent tag's quantity |
| Picked up | 5 | Per object (×quantity) when photo_tags.picked_up = true |
Special Object Overrides
Some objects award more than the default 1 XP per item:
| Object Key | XP per item |
|---|---|
dumping_small | 10 |
dumping_medium | 25 |
dumping_large | 50 |
bags_litter | 10 |
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:
quantityis 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 = 12material XP. - Brands: Independent quantities — each brand has its own quantity. If you tag Coca-Cola (qty 2) on a can, that's
2×3 = 6brand 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 XP — XpCalculator::calculateFromFlatSummary() only awards object XP when object_id > 0. They still earn their extra-tag XP:
- Brand-only tag:
brand_quantity × 3XP - Material-only tag:
parent_quantity × 2XP - Custom-only tag:
parent_quantity × 1XP
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).
| Level | XP Required | Title |
|---|---|---|
| 1 | 0 | Noob |
| 2 | 100 | Litter Picker |
| 3 | 1,000 | Litter Wizard |
| 4 | 5,000 | Trash Warrior |
| 5 | 10,000 | Early Guardian |
| 6 | 15,000 | Trashmonster |
| 7 | 50,000 | Force of Nature |
| 8 | 100,000 | Planet Protector |
| 9 | 200,000 | Galactic Garbagething |
| 10 | 500,000 | Interplanetary |
| 11 | 1,000,000 | SuperIntelligent 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
- User uploads a photo → 5 upload XP awarded immediately to
users.xp+ metrics (public photos only; school photos deferred) - User adds tags →
GeneratePhotoSummaryServicecalculates tag XP (including per-tag picked_up bonus), stores inphoto.xp TagsVerifiedByAdminevent fires (immediate for all non-school users)ProcessPhotoMetricslistener →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 XPXpScore::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
| File | Purpose |
|---|---|
app/Enums/XpScore.php | XP multiplier values (single source of truth) |
app/Services/Tags/XpCalculator.php | XP calculation from tags or summary |
app/Services/Tags/GeneratePhotoSummaryService.php | Builds summary + calculates XP + applies per-tag picked-up bonus |
app/Listeners/Metrics/ProcessPhotoMetrics.php | Listener that triggers MetricsService from TagsVerifiedByAdmin |
resources/js/views/General/Tagging/v2/useXpCalculator.js | Frontend XP preview (mirrors backend logic with literal integers) |
config/levels.php | Level thresholds |
app/Services/LevelService.php | Maps XP to level info |
app/Helpers/helpers.php | rewardXpToAdmin() |
tests/Feature/Tags/v2/CalculatePhotoXpTest.php | XP 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.