triggair

Blog / ugc

Let players build and share levels (UGC)

3 min read

Let players build and share levels (UGC) cover art

The best content in a game is often the stuff you didn't build. A level editor turns players into a content pipeline that never runs dry. But shipping user-generated content means a draft state, a publish step, moderation so you don't wake up to slurs in level titles, a browse feed, and rating. Getting rating right matters too: only people who actually played should score a level. That's a lot of surface area before a single custom level exists.

Draft, then publish through moderation

Creating an item makes a private draft. Your level data lives in payload: any JSON up to 256KB, shaped however your editor likes. submit is the publish step: it runs moderation on the title and description and either goes public or gets held for review.

ts
const { item } = await tg.ugc.create('level', {
  title: 'Sky Bridge Sprint',
  description: 'Three jumps, one moving platform, no checkpoints.',
  payload: { tiles, spawn, exit, theme: 'dusk' }, // your level JSON, <=256KB
  tags: ['platformer', 'hard'],
  allow_remix: true,
});

const { state, moderation_state } = await tg.ugc.submit(item.id);
// state: 'public' when clean, otherwise held with moderation_state for review

Moderation is on by default and runs on the text a stranger will read, the title and description, not your payload. Check moderation_state after submit so your UI can say "live" or "under review" honestly instead of pretending everything published.

Browse, play, then rate

A browse feed is what makes the content loop spin. Sort by new to surface fresh levels, top for the highest rated, popular for what people are actually playing.

ts
const feed = await tg.ugc.browse({ type: 'level', sort: 'popular' });

const chosen = await tg.ugc.get(feed[0].id);
loadLevel(chosen.item.payload); // hand the JSON back to your game

Rating is gated on play, and that's the point: it kills drive-by one-star reviews from people who never opened the level. Call play when the player actually starts, which unlocks rate for them. like is the lightweight signal for everything else.

ts
await tg.ugc.play(chosen.item.id);   // unlocks rating for this player
await tg.ugc.rate(chosen.item.id, 5); // 1..5, only after play
await tg.ugc.like(chosen.item.id);

const authored = await tg.ugc.mine(); // the creator's own items, any state

Wire mine into a "My Levels" tab so creators can see their drafts, what's public, and what's under review in one place.

Gotchas

A few things that save debugging later. payload is capped at 256KB: for a big level, store geometry compactly (indices, run-length rows) rather than a fat object per tile. Rating before play will error, so drive rating off play state in your UI, not just a visible star widget. And a held item isn't rejected: it's awaiting review, so keep it visible to its author with its real status rather than making it vanish. Every error is typed with a code and an agent_hint, so surface the reason instead of a generic failure toast.

Takeaway

Draft, submit through moderation, browse, play-to-unlock-rate: that's a complete community-content loop with safety already wired in. Point your editor's output at payload and let your players fill the game with levels you'd never have made.

Build this into your game.

Everything above is one import and a publishable key away. Start with the quickstart, or read the guide for the exact feature.

Keep reading