How I Built an Open-Source Stream Production System

aws community community open source templates Apr 01, 2026
Remotion Studio Screenshot

How I Built 4h Live Stream Visuals for 53 AWS User Groups Across 23 Countries With Two GitHub Repos

AWS Community GameDay Europe 2026 had 53+ user groups, 23 countries, 4 timezones, and a 4-hour live stream with 35 visual compositions that needed to switch at exactly the right moment. I organized it from Vienna while players competed all over Europe.

During the 2-hour gameplay phase I had one job: keep the stream alive and interesting for an audience watching teams they can't hear solve AWS challenges they can't see. I solved this with an insert system - 29 full-screen 30-second overlays I could fire at any moment from Remotion Studio. Team spotlights. Quest completion alerts. Leaderboard updates. All pre-rendered, triggered manually, back to gameplay in 30 seconds.

The whole system is now open source. Two repos, a fork, three clicks, and any community event can use it.


Scale and Structure

The numbers: 53+ European user groups, 20+ countries, 4 timezones, one 4-hour broadcast window starting at 17:30 CET. The stream runs in phases - pre-show loop, welcome and instructions, two hours of muted gameplay, then a live closing ceremony with animated winner reveals.

Every composition was built in Remotion - a React framework where you write components that render to video frames. At 30 fps over 4 hours, that's around 450,000 frames total across all compositions. I did not want to build a custom video player. I wanted something I could open in a browser during the stream and click "play."

The web player is a Vite app deployed to GitHub Pages. It knows what composition to show based on the current time in the host timezone. No manual switching needed for the automatic transitions - preshow at 17:30, main event at 18:00, gameplay at 18:30, closing at 20:30. The inserts sit on top, triggered manually when something interesting happens on the leaderboard.


The Two-Repo Architecture

The most important design decision: one repository holds all the code, another holds only the event-specific data. You can fork the event repo, update three config files, push to main, and deploy a fully working stream for your own event - without touching a single line of TypeScript in the template.

 The two repos

community-gameday-europe-stream-templates - all code: 35 Remotion compositions, web player, design system, documentation, build tooling. This is the engine. It never changes between editions unless someone wants to change the visual system itself.

community-gameday-europe-event - event fuel: participant data, schedule, insert prep notes, organizer face photos. This is what changes per edition. Fork this repo to run your own event.

Why split? If both lived in one repo, forking would carry every previous edition's participant data and schedule. You'd have to hunt through the code to figure out what to replace. The split means the event repo is tiny - config/, public/faces/, and a workflow file. Everything else lives in the template and is pulled fresh at build time.

stream-templates / (template repo) 35 compositions, web player, design system, docs - never forks
event / config/participants.ts 8 organizers, 4 AWS supporters, 57 user groups with logos - replace with your event's data
event / config/schedule.ts Segment start times for the web player auto-switcher
event / config/inserts.md Operator prep: which inserts to fire, when, with what data filled in

Running Your Own Event: The Exact Steps

Option A - Use the template as-is, change only event data

You don't need to write a single line of code to run your event on this system. Fork the event repo, update the config files, and get through three one-time setup steps. After that, every push to main deploys automatically.

Step 1: Fork

Fork community-gameday-europe-event on GitHub.

Step 2: Enable GitHub Actions

GitHub disables workflows on all forks by default - this is a security policy. Go to the Actions tab of your fork and click:

"I understand my workflows, go ahead and enable them"

This click is unavoidable. GitHub cannot enable Actions programmatically on forks.

Step 3: Enable GitHub Pages

Go to Settings → Pages and change the source:

Source: Deploy from a branch → GitHub Actions → Save

GitHub does not allow workflows to enable Pages automatically on forked repos. This click is also unavoidable. The workflow checks whether Pages is configured and skips the deploy step with a clear message if it isn't - so if you forget this, it tells you exactly what to do.

Step 4: Trigger your first deploy

If you've already pushed changes, just re-run the workflow - it only skipped the deploy step, it ran the build successfully. Either use the GitHub UI (Actions → Deploy to GitHub Pages → Re-run jobs) or:

gh workflow run deploy.yml --repo your-org/your-repo-name

Your page will be live at https://<your-org>.github.io/<your-repo-name>/ within ~2 minutes.

Option B - Modify the visual system itself

If you want to change compositions, add new insert types, or rework the design, fork both repos. Then point your event repo at your template fork using a repository variable:

In your event repo fork, go to Settings → Secrets and variables → Actions → Variables tab and create:

Name TEMPLATE_REPO
Value your-org/your-template-repo-name

The workflow uses ${{ vars.TEMPLATE_REPO || 'mzzavaa/community-gameday-europe-stream-templates' }} - so it falls back to the upstream template if the variable is not set. No workflow file edits needed.

Note on event.ts: The event name, host timezone, and timing offsets live in config/event.ts inside the template repo - not the event repo. If you only fork the event repo (Option A), these values come from the upstream template. They will say "AWS Community GameDay Europe 2026." If you need different event branding, use Option B and edit config/event.ts in your template fork.


Inside the Config Files

config/participants.ts - the data layer

This is the file that drives most of the visual content. It defines the organizer intros shown during the main event, the AWS supporter logos, and the complete list of user groups that appear on the gameplay HUD and in the closing animation.

// config/participants.ts (event repo)
export const ORGANIZERS: Organizer[] = [
  {
    name: "Linda",
    fullName: "Linda Mohamed",
    role: "Stream Host & Organizer",
    city: "Vienna, Austria",
    bio: "AWS Hero, cloud architect, and builder of overly automated things.",
    face: "linda.jpg",  // must match a file in public/faces/
  },
  // ... 7 more organizers
];

export const USER_GROUPS: UserGroup[] = [
  { name: "AWS UG Vienna",   flag: "๐Ÿ‡ฆ๐Ÿ‡น", country: "Austria" },
  { name: "AWS UG Berlin",   flag: "๐Ÿ‡ฉ๐Ÿ‡ช", country: "Germany" },
  { name: "AWS UG Istanbul", flag: "๐Ÿ‡น๐Ÿ‡ท", country: "Turkey" },
  // ... 54 more groups
];

The face field must match a file in public/faces/ in the event repo - all lowercase, first name only. The build merges these into stream/public/assets/faces/ in the template. The user group logos come from config/logos.ts in the template repo (Notion CDN URLs for the 2026 edition).

config/schedule.ts - what drives the auto-switcher

This file gets copied from the event repo into stream/web-player/src/schedule.ts at build time. The web player imports it directly and uses it to decide which composition to display.

// config/schedule.ts (event repo)
export const EVENT_DATE = "2026-03-26";
export const TIMEZONE = "Europe/Vienna";

export const SCHEDULE = [
  { id: "preshow",   start: "17:30", label: "Pre-Show Loop"  },
  { id: "mainevent", start: "18:00", label: "Live Stream"    },
  { id: "gameplay",  start: "18:30", label: "GameDay"        },
  { id: "closing",   start: "20:30", label: "Closing Ceremony" },
  { id: "end",       start: "21:00", label: "Stream Ended"   },
] as const;

At runtime, the web player calls Intl.DateTimeFormat with the Europe/Vienna timezone to get the current time regardless of where the viewer's browser is located. It walks the schedule array and finds the last entry whose start time is before now. That composition plays. When the clock passes 18:30, the player switches to the gameplay composition automatically - no stream operator action needed.


Remotion: 35 Compositions in One Project

 What is Remotion?

A React framework for creating video programmatically. You write React components. Remotion renders them to video by calling each frame as a pure function of the frame number - useCurrentFrame() gives you the current frame, interpolate() maps frame ranges to values, spring() gives you physics-based animations. Remotion Studio is a browser-based dev environment where you can preview and render any composition. Docs: remotion.dev/docs

The template repo has 35 compositions organized by stream phase:

00-preshow/ Countdown timer + rotating info loop while teams gather (muted)
01-main-event/ Welcome, organizer intros, AWS supporter logos, GameDay instructions (audio)
02-gameplay/ 2-hour ambient overlay - leaderboard-style HUD, subtle motion, no audio
03-closing/ Pre-rendered closing + live winners reveal with animated bar chart and podium (audio)
marketing/ Social media clips for pre-event promotion
inserts/ 29 full-screen 30-second overlays - the live insert system

All compositions are registered in src/Root.tsx. Remotion Studio shows them all in the sidebar. You can preview any frame of any composition, scrub through the timeline, and render to MP4 directly from the UI.

How a Remotion composition actually works

Every composition is a pure function of time. Here is what a simple one looks like:

import { useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";

export const TeamSpotlight: React.FC<Props> = ({ teamName, country, flag, fact }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Slide in from bottom over 20 frames
  const slideY = interpolate(frame, [0, 20], [80, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Fade out at the end (frames 800-900 of a 900-frame insert)
  const opacity = interpolate(frame, [800, 900], [1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // Physics-based scale bounce for the flag
  const flagScale = spring({ frame, fps, config: { stiffness: 120, damping: 14 } });

  return (
    <div style={{ transform: `translateY(${slideY}px)`, opacity }}>
      <div style={{ transform: `scale(${flagScale})` }}>{flag}</div>
      <h2>{teamName}</h2>
      <p>{country}</p>
      <p>{fact}</p>
    </div>
  );
};

interpolate(frame, [0, 20], [80, 0]) reads as: "from frame 0 to frame 20, map the value linearly from 80 to 0." That is the slide-in. At 30 fps, 20 frames is 0.67 seconds. The extrapolateLeft: "clamp" means the value stays at 80 before frame 0 and stays at 0 after frame 20 - it doesn't keep going. spring() gives a physically correct animation that overshoots slightly and settles - configurable via stiffness and damping.

Because each frame is deterministic - same frame number always produces the same output - Remotion can render frames in parallel and seek to any point instantly. There is no "play and record" step.


The Insert System

This is the part that makes a 2-hour muted gameplay stream watchable. The stream operator - in this case me, from Vienna - fires 30-second overlays at manually chosen moments based on what's happening on the leaderboard.

29 total inserts, split into two categories:

Scheduled inserts (prepared before the event)

These fire at predictable moments and don't depend on live data. You fill in the variables at the top of each .tsx file in Remotion Studio the day before.

T+0 (18:30) Insert-QuestsLive Quests are live - no changes needed
T+20 (18:50) Insert-TeamSpotlight Spotlight: fill in team name, user group, country, one interesting fact
T+30 (19:00) Insert-LocationShoutout Waves through 15 cities - edit the LOCATIONS array once
T+60 (19:30) Insert-HalfTime One hour left - no changes needed
T+105 (20:15) Insert-FinalCountdown Set MINUTES_REMAINING = 15
T+118 (20:28) Insert-FinalCountdown Set MINUTES_REMAINING = 2

Reactive inserts (fire when something happens)

These respond to live events and cannot be scheduled. You fill them in during the stream when you see something worth highlighting. Because Remotion Studio hot-reloads, saving the file and rendering to MP4 takes under a minute.

Insert-FirstCompletion First team finishes a quest - fill in team name + quest name live
Insert-CloseRace Two teams within 50-100 points - fill in both names and the gap
Insert-ComebackAlert Team climbs 5+ positions - fill in team name and from/to rank
Insert-QuestBroken / Insert-QuestFixed Used back-to-back when a quest has an issue
Insert-GamemastersUpdate Announcement from the Gamemasters - fill in text live

One insert max every 10 minutes during active gameplay. Reactive inserts (quest breaks, first completions) can happen anytime. Return to the gameplay composition immediately after the 30 seconds - never stay on an insert. The full operator timing guide is in docs/playbook.md in the template repo.

The insert schedule and all prep data for a specific edition lives in config/inserts.md in the event repo. This is a Markdown file - not code. It has the timing table, the spotlight prep with TBD fields to fill in, the city shoutout list, and quest hints to get from the Gamemasters in advance.


The GitHub Actions Workflow

The entire deploy pipeline is 70 lines in .github/workflows/deploy.yml. Here is exactly what it does:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # 1. Check out this event config repo into countdown/
      - uses: actions/checkout@v6
        with:
          path: countdown

      # 2. Check out the template repo into stream/
      - uses: actions/checkout@v6
        with:
          repository: ${{ vars.TEMPLATE_REPO || 'mzzavaa/community-gameday-europe-stream-templates' }}
          path: stream

      # 3. Overwrite template config with event-specific data
      - name: Apply event config
        run: |
          cp countdown/config/schedule.ts stream/web-player/src/schedule.ts
          cp countdown/config/participants.ts stream/config/participants.ts
          if [ -f countdown/config/event.ts ]; then
            cp countdown/config/event.ts stream/config/event.ts
          fi

      # 4. Merge face photos
      - name: Merge assets
        run: |
          if [ -d countdown/public/faces ]; then
            cp -r countdown/public/faces/. stream/public/assets/faces/
          fi

      # 5. Build web player with auto-detected base path
      - name: Build
        working-directory: stream/web-player
        run: |
          REPO_NAME="${GITHUB_REPOSITORY##*/}"
          npm run build -- --base=/${REPO_NAME}/

      # 6. Check Pages config - skip deploy (not fail) if not set up yet
      - name: Check GitHub Pages configuration
        id: check-pages
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          BUILD_TYPE=$(gh api repos/${{ github.repository }}/pages \
            --jq '.build_type' 2>/dev/null || echo "none")
          if [ "$BUILD_TYPE" = "workflow" ]; then
            echo "ready=true" >> $GITHUB_OUTPUT
          else
            echo "ready=false" >> $GITHUB_OUTPUT
            echo "Pages not configured yet - go to Settings → Pages → GitHub Actions"
          fi

  deploy:
    needs: build
    if: needs.build.outputs.pages-ready == 'true'
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/deploy-pages@v4

Two things worth pointing out here:

Base path auto-detection. The line REPO_NAME="${GITHUB_REPOSITORY##*/}" uses bash parameter expansion to strip everything before the last slash from the full repository name (org/repo-name becomes repo-name). Vite then builds with --base=/repo-name/ so all asset paths are correct for GitHub Pages. Forks with a different repo name work automatically - no one needs to edit the workflow file.

Pages check instead of hard fail. Rather than letting the deploy step fail when Pages isn't configured, the workflow detects the situation and surfaces a clear message. The build artifact is still uploaded. Only the deploy step is skipped. This means you can set up Pages after your first push and just re-run the workflow - no second push needed.


The Winners Reveal

The closing ceremony has two compositions. One is pre-rendered and plays automatically at 20:30. The other - the animated winners reveal with a live bar chart and podium - requires manual data entry during the event, because final scores don't exist until the game ends.

The data structure lives in src/utils/closing.ts:

// src/utils/closing.ts (template repo - edit locally, do not commit)
export const PODIUM_TEAMS: TeamData[] = [
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score: 18500 },
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score: 15200 },
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score: 12800 },
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score: 11500 },
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score: 10200 },
  { teamName: "TEAM NAME", ugName: "REPLACE_WITH_UG_NAME", flag: "๐Ÿณ๏ธ", city: "CITY, COUNTRY", score:  8900 },
];

The ugName field is used to look up the user group logo via the LOGO_MAP in config/logos.ts. If it doesn't match exactly, no logo shows. To see valid values: Object.keys(LOGO_MAP) in the console, or just open config/logos.ts and check the keys.

The workflow during the event: scores come in at 20:30, you fill in all 6 entries, save the file, Remotion Studio hot-reloads, and you render the composition locally with:

npx remotion render src/index.ts 03B-ClosingWinnersTemplate out/closing-winners.mp4

The closing animation is ~9000 frames (5 minutes) with four phases: a shuffle of all user groups scrolling across the screen (0-1:00), a progressive bar chart reveal showing 6th place down to 1st (1:00-4:00), a final podium card grid (4:00-4:20), and a thank-you fade to black (4:20-5:00).

Fill in real scores only. The composition ships with placeholder data so it renders without errors. If you forget to update it and play the pre-render live, the audience sees white flags, "TEAM NAME," and zeros. There is a TEMPLATE.md in the root of the template repo with the exact field reference and a step-by-step checklist - use it as your closing ceremony runbook.


Local Preview in Remotion Studio

The event repo deploys the web player to GitHub Pages, but for development and insert prep, you work directly in the template repo using Remotion Studio:

git clone https://github.com/mzzavaa/community-gameday-europe-stream-templates.git
cd community-gameday-europe-stream-templates
npm install
npm run studio
# Open http://localhost:3000

All 35 compositions appear in the sidebar. Click any of them, scrub the timeline, preview at full 1280x720. Hot reload works - edit a file, the Studio updates within a second.

To render a composition to MP4:

# From Remotion Studio UI: click Render → pick composition → Start render
# Or from CLI:
npx remotion render src/index.ts Insert-TeamSpotlight out/insert-spotlight.mp4

All rendered video files go into out/ which is gitignored. You load them into OBS or your streaming tool for playback.


Testing Across Timezones

The web player's auto-switching logic runs on the viewer's local clock converted to the host timezone. This means a bug in the timezone logic would show the wrong composition to viewers in different regions - the pre-show could still be running for someone in Istanbul while the main event is live for someone in Vienna.

The Americas and APAC dry runs tested exactly this. The test procedure: set the system clock to a time that corresponds to each schedule boundary, open the web player, verify the composition switches correctly. Schedule boundaries are at 17:30, 18:00, 18:30, and 20:30 CET. In UTC those are 16:30, 17:00, 17:30, and 19:30.

The web player uses Intl.DateTimeFormat("en-US", { timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit", hour12: false }) to get the current time in CET regardless of the browser's local timezone. This is the same mechanism that makes "display time in Vienna" work correctly on a phone in New York. It does not depend on the user's system clock timezone setting.


What Was Worth It and What Wasn't

The two-repo architecture was worth every minute of refactoring after the event. When the event repo has 3 config files and a faces folder, it is immediately clear to anyone forking it what they need to change. There is no hunting through composition code looking for hardcoded strings.

The TEMPLATE_REPO variable pattern was a late addition but turned out to be important. Without it, anyone who wanted to modify the visual system would need to edit the workflow YAML in their fork. With it, the workflow is a sealed unit - you set one variable and it builds from wherever you point it.

The winners reveal being manual was the right call for the first edition. Automating it would require an API integration with the GameDay scoring system, handling partial scores, edge cases around ties, and failure modes during the most visible 5 minutes of the stream. The manual flow took 4 minutes to execute live and produced a clean result. The API automation is documented in LESSONS_LEARNED.md and TEMPLATE.md as a future contribution idea.

What I underestimated: the user group logos. Getting logos for 53 groups in consistent quality before the event took more coordination than any technical task. The solution that worked: pull logos directly from meetup.com. Every AWS user group has a Meetup page with a group photo - that URL is publicly accessible and stable, so config/logos.ts is just a map of group names to their Meetup CDN URLs. No file collection, no upload coordination. The Notion approach documented in CONTRIBUTING.md was an earlier workflow used for the DACH groups - Meetup URLs replaced it as the primary source. And for any group that has no logo at all, the system falls back automatically to the country flag emoji - so every group has something on screen, even if a Meetup URL isn't available.


What I Learned Preparing This Stream (Almost) Alone

I built the entire visuals system alone (or actually with Kiro & Claude Code). From the first Remotion composition to the GitHub Actions workflow to the insert library - all of it, start to finish, around 80 hours of work. That includes the initial build, all the compositions, and the refactoring pass I did on the insert system afterwards.

99% of the stream was prepared. The compositions, the operator schedule, the spotlight prep - all of it was ready before the event started. What I hadn't anticipated were the announcement inserts needed when things broke during the GameDay itself: quests that went down, environment issues mid-game, situations that needed a "we're aware, stand by" slide on screen while the Gamemasters worked on a fix. Those few reactive inserts I built during the stream while the gameplay overlay was running - not because the preparation was lacking, but because you can't prepare for a specific failure you don't know will happen.

The setup itself added its own constraints: I was running the stream through Zoom, which is built for calls, not broadcasting. And I was doing it from the Raiffeisen Informatik Office in Vienna (thank you for being sponsor of the day and one of our Top Hosts from the past 10 years) - the same building where the GameDay was happening in person, just in a different room. Not my usual location, not my usual tools. Werner Vogels says "everything fails, all the time" - and yes, during a first edition spanning 23 countries and 53 teams, things in the environment will fail. That's not a preparation problem. That's a scale problem, and you only learn what can go wrong by running it once.

All of that went back into the codebase. The reactive insert types in the library - quest broken, quest fixed, technical issue, gamemaster update - exist because real situations happened that needed them. The version that's open source now is the refactored version, built with that knowledge.

I'm genuinely happy with what this turned into. A system that any user group organizer anywhere in the world can fork and use to run a professional stream for their community. That was the goal. The 80 hours were worth it.


Why Open Source

Every AWS user group organizer who runs a live event faces the same problem: the technical infrastructure for a professional stream costs weeks of setup time that volunteer organizers don't have. Most events end up with a static slide deck and a Zoom link. That works, but it doesn't feel like the global community it actually represents.

The two repos are licensed under CC BY-NC-SA 4.0 - free for non-commercial community use, with attribution. A user group in any city can fork, update three config files, and have the same stream system in under an hour.

If you build something on top of this - new insert types, a scoring API integration, support for a different stream format - the CONTRIBUTING.md has the contribution guide. The biggest gap right now is the automated winners reveal. If you build it, it goes in the template and every future event gets it.


Resources

awsgameday.eu
Official AWS Community GameDay Europe website
community-gameday-europe-event
Fork this to run your own event - config, photos, GitHub Actions deploy
community-gameday-europe-stream-templates
Fork this to modify compositions, design, or add new inserts
docs/playbook.md
Stream operator guide - when to trigger each insert, what to fill in live
TEMPLATE.md
Winners reveal runbook - fill in PODIUM_TEAMS before the closing ceremony
CONTRIBUTING.md
How to adapt for your own event, how to source logos, how to contribute back
docs/remotion.md
Developer guide - Remotion Studio, rendering, all 35 compositions
remotion.dev/docs
Remotion documentation - useCurrentFrame, interpolate, spring, Player, rendering

Linda Mohamed is an AWS Hero and cloud architect based in Vienna. She supported AWS Community GameDay Europe and builds open-source tooling for community events.

Connect on LinkedIn · AWS Hero profile · GitHub

โœจ Stay in the Loopย 

Want to know where Iโ€™m speaking next - or catch my behind-the-scenes recaps from re:Invent, Community Days, and other events?

Join me on my journey through Cloud, AI, and community innovation.