# Architect a block

## What a Rafflex block is

A block is one self-contained Twig template: markup, styles, and JavaScript in a single file, rendered inside a strict shared sandbox as a section of a customer's raffle site (a hero, countdown, winner wall, product grid, FAQ, and similar). It is driven by the competitions (pre-sorted as all, ending_soon, latest, and featured), categories, winners, instant_winners, user, and settings context, plus the options the site owner sets for it. Blocks render on arbitrary pages, so the plays array is usually empty and must not be relied on. Theme the block from settings (primary_color, background, button_text_color, currency_icon, site_name) so it matches whichever site it is placed on, and make it fully responsive.

## With a connected AI

When the creator has connected their AI to the marketplace ([Connect your AI](https://marketplace.rafflex.io/docs/how-to-create/connect.md)), skip the copy and paste loop: write the template to the draft through the marketplace tools, read the platform's check result the write returns, and fix everything it reports before going further. The copy and paste prompts still work for creators who do not connect. Either way, nothing is submitted until the creator says so.

## Build locally

An AI that runs commands on the creator's computer (Claude Code, Codex, Cursor, VS Code) can keep every product the creator sells in one local workspace with the dev kit, previewing with the platform's sample data and the creator's own art before anything reaches the marketplace. When you can run commands and Node 20 or newer is installed, work in the workspace, because the check runs on every save and the creator watches the preview as you build. When you cannot, build in drafts through the tools as above. The full reference is [docs/dev-kit.md](https://marketplace.rafflex.io/docs/dev-kit.md), and setting up from nothing is the [setup runbook](https://marketplace.rafflex.io/start.md).

To start, the creator opens their AI in an empty folder and sends the setup prompt:

> Set up my Rafflex workspace by following https://marketplace.rafflex.io/start.md step by step, and ask me before anything that needs my approval. From then on follow the workspace's AGENTS.md. Do not change or submit any product until I ask. If you cannot run commands here, tell me instead of guessing.

Or they run `npx @rafflex/dev init` themselves, then `npx @rafflex/dev new <game|block> "<title>"` for a new product, then `npx @rafflex/dev` to open the preview.

The workspace has a fixed layout:

- `rafflex.json`: marks the folder as a workspace. `AGENTS.md`: how an AI works there, generated by the platform, so the next session needs no setup prompt.
- `games/` and `blocks/`: one folder per product, named after its slug, holding `product.json` (the version in progress and the last known marketplace state, written by the kit), `template.twig`, `options.json`, `listing.md`, `CHANGELOG.md`, and `assets/`.
- `assets/`: art, sound, 3D models, and approved library builds. Each file is `files['tag']` in the template, where the tag is its slugified filename stem (`Win Jingle.mp3` becomes `win-jingle`).
- `.rafflex/`: the platform's rules, cached so the kit works offline.

An existing product comes local with `export_product` and `npx @rafflex/dev import`. The kit never signs in, so pushing goes through the connected AI:

1. The AI runs `npx @rafflex/dev verify <product> --json` and fixes every blocking issue: it formats, lints, checks, and with Playwright plays the game through in a real browser.
2. It runs `npx @rafflex/dev plan <product> --json` and pushes exactly what it lists: `create_product` the first time, `update_draft` for the template and options, `update_product_details` for the listing, and `request_media_upload` for each new or changed asset, or `attach_approved_library` for an approved library build.
3. It reads the product with `get_product` and pipes the result to `npx @rafflex/dev synced <product>`, so the workspace records what the marketplace holds.
4. It reads the marketplace's check result on the push and fixes what it reports. That check, not the kit's, decides.

## The data the template receives

Every render receives exactly this context. The sample below shows real values with lists collapsed to their first entry and long values truncated:

{
    "competitions": {
        "all": [
            {
                "id": 1,
                "title": "Win a MacBook Pro 16\"",
                "subtitle": "Draw closes soon",
                "slug": "macbook-pro-16",
                "url": "#macbook-pro-16",
                "image_url": "https://static.rafflex.io/marketplace/samples/image-url.png",
                "featured_video_url": null,
                "slider_image_url": "https://static.rafflex.io/marketplace/samples/slider-image-url.png",
                "portrait_image_url": null,
                "price": "£2.99",
                "price_raw": 2.99,
                "price_display": "£2.99",
                "original_price": null,
                "original_price_raw": 2.99,
                "is_on_sale": false,
                "is_free": false,
                "has_question": true,
                "question": "What is the capital of France?",
                "cash_alt": "£4,200.00",
                "cash_alt_raw": 4200,
                "draw_date": "1 day from now",
                "end_date": "1 day from now",
                "draw_date_raw": "2026-10-05 16:09:04",
                "end_date_raw": "2026-10-05 16:09:04",
                "start_date_raw": "2026-09-19 16:09:04",
                "draw_date_seconds": 172800,
                "end_date_seconds": 172800,
                "max_tickets": 5000,
                "max_per_user": 100,
                "sold_tickets": 3890,
                "tickets_remaining": 1110,
                "instant_wins": 25,
                "instant_wins_remaining": 12,
                "game_type": null,
                "has_ended": false,
                "is_sold_out": false,
                "is_active": true,
                "is_featured": true,
                "simple_max_ticket": null,
                "winner_qty": 1,
                "auto_draw": true,
                "leaderboard_enabled": false,
                "percent_sold": 77.8,
                "category": "Tech",
                "categories": [
                    "Tech"
                ],
                "description": "A sample competition so your template has data to render.",
                "gallery": []
            }
        ],
        "ending_soon": [
            "Same shape as competitions.all[0]"
        ],
        "latest": [
            "Same shape as competitions.all[0]"
        ],
        "featured": [
            "Same shape as competitions.all[0]"
        ]
    },
    "categories": [
        {
            "id": 1,
            "name": "Tech",
            "slug": "tech",
            "url": "#category-tech",
            "competitions": [
                "Same shape as competitions.all[0]"
            ]
        }
    ],
    "winners": [
        {
            "name": "Sarah T.",
            "prize": "MacBook Pro 16\"",
            "product_title": "Win a MacBook Pro 16\"",
            "product_url": "#",
            "product_image": "https://static.rafflex.io/marketplace/samples/product-image.png",
            "date": "Oct 1, 2026",
            "date_ago": "2 days ago"
        }
    ],
    "instant_winners": [
        {
            "winner_name": "Megan L.",
            "prize": "£250 Cash",
            "prize_value": "250.00",
            "prize_value_raw": 250,
            "is_site_credit": false,
            "product_title": "£1,000 Cash Instant Win",
            "product_url": "#",
            "product_image": "https://static.rafflex.io/marketplace/samples/product-image.png",
            "date": "Oct 2, 2026",
            "date_ago": "1 day ago"
        }
    ],
    "user": {
        "id": 1,
        "name": "Alex",
        "last_name": "Morgan",
        "full_name": "Alex Morgan",
        "display_name": "Alex M.",
        "email": "alex@example.com",
        "is_logged_in": true,
        "balance": "£12.50",
        "balance_raw": 12.5
    },
    "settings": {
        "primary_color": "#4F45E4",
        "background": "#111827",
        "button_text_color": "#ffffff",
        "logo_url": "",
        "email_logo_url": "",
        "favicon_url": "",
        "wallet_icon_url": "",
        "site_name": "Sample Competitions",
        "footer_text": "Sample Competitions Ltd.",
        "currency_icon": "£",
        "facebook_url": "",
        "twitter_url": "",
        "instagram_url": "",
        "tiktok_url": "",
        "youtube_url": "",
        "trustpilot_score": "4.8",
        "trustpilot_url": "",
        "facebook_review_score": "4.9"
    },
    "plays": [
        {
            "ticket_number": 1042,
            "won": true,
            "prize": {
                "name": "£1,000 Cash",
                "value": "£1,000.00",
                "value_raw": 1000,
                "is_site_credit": false,
                "image_url": "https://static.rafflex.io/marketplace/samples/image-url.png"
            }
        }
    ],
    "play_count": 5,
    "win_count": 2,
    "files": {
        "background": "https://static.rafflex.io/marketplace/products/1/background.png",
        "win-jingle": "https://static.rafflex.io/marketplace/products/1/win-jingle.mp3",
        "prize-box": "https://static.rafflex.io/marketplace/products/1/prize-box.glb",
        "three": "https://static.rafflex.io/marketplace/libraries/three/0.186.1/three-0.186.1.m..."
    },
    "options": {
        "heading": "Ending this week",
        "accent_colour": "#4F45E4",
        "show_countdown": false
    },
    "products": [
        "Same shape as competitions.all[0]"
    ]
}

### The plays array

plays contains one entry per ticket in play: { "ticket_number": 1042, "won": true, "prize": { ... } }.

- won is pre-decided by the platform before the template runs.
- prize is null on a loss. On a win it is { "name", "value", "value_raw", "is_site_credit", "image_url" }, where value is pre-formatted (for example "£250.00") and value_raw is the numeric amount.
- play_count and win_count are pre-computed integers. Use them instead of counting in Twig: the sandbox has no functions and caps loops.
- Outcomes are pre-decided by the platform and the game is an animation that reveals them. Never compute or randomise a win in the template.

### The playthrough hooks

Two attributes let a browser play the game through and prove it reveals exactly what the platform decided: data-rafflex-play marks the element that plays the next entry, and data-rafflex-result="win" or "lose" is set on the element that reveals each outcome, when it is revealed (in Alpine, for example x-bind:data-rafflex-result="play.won ? 'win' : 'lose'" on each revealed item). They are recommended, not required: a game without them gets the warning playthrough_hooks_missing and is not played through.

## Getting the data into JavaScript

Game and interaction logic lives in JavaScript over the injected data; Twig is for markup and simple conditionals only. Inject the context with the json filter:

<script>
    const plays = {{ plays|json }};
    const files = {{ files|json }};
    const settings = {{ settings|json }};
</script>

Alpine.js is already available on the page if you prefer it over vanilla JavaScript. The only other library a template may load is an approved library from the product's media library, and only through its files reference (for example `import * as THREE from "{{ files['three'] }}";` inside a `<script type="module">`). Never load any other library or script.

## Sandbox rules (hard constraints)

The Twig sandbox rejects anything outside this whitelist:

- Allowed tags: if, for, set. No other tag exists (no block, include, extends, macro, verbatim, or apply).
- Allowed filters: upper, lower, date, number_format, default, length, first, last, join, slice, round, trim, escape, json. No other filter exists.
- No Twig functions at all (no range(), cycle(), random(), dump(), max(), or min()).
- No range operator (..).
- At most 3 {% for %} loops in the whole template.
- Iteration caps: at most 1000 items per collection and 25000 total loop iterations, within a 2000 ms render budget.

Scripts are checked in the source and again in the rendered output of every scenario:

- Your own JavaScript stays inline. A script src, or a static import in a <script type="module">, must be exactly a files reference such as {{ files['three'] }} whose tag points at an approved library. Any other src fails with "Scripts may load only an approved library through files, for example src="{{ files['three'] }}". Put your own code inline."
- Bare imports (import * as THREE from "three"), relative or absolute URLs, and libraries that are not approved fail with "Import approved libraries by their files URL, for example import * as THREE from "{{ files['three'] }}". Bare imports and other URLs are not allowed."
- Import maps fail with "Import maps are not allowed. Import the approved library by its files URL instead."
- Dynamic import() fails with "Dynamic script imports are not allowed."

## Submission rules (hard constraints)

- Templates are first party only, with no allowlist and no exceptions: no external domain may appear anywhere, in any attribute, style, or script. That means no CDN scripts, no Google Fonts, no hotlinked images, no absolute http(s) URLs, and no protocol-relative //domain URLs. Reference only relative platform paths and uploaded media through the files map by tag ({{ files['background'] }}). Every layer enforces this: the static checks, a scan of the rendered output, the preview's security policy, human review, and periodic fleet re-scans.
- No <form> elements, formaction attributes, or <link> tags. Game entry flows through the platform, inputs bound to Alpine state cover every interactive need, and styles ship inline.
- No network or exfiltration APIs, even aliased: fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, WebRTC, and the entire navigator object are banned as bare identifiers, along with eval, Function, constructor or __proto__ or Reflect access, import.meta, CSS @import, data:text/html, and hex or unicode escape sequences.
- Everything stays inline in the single template: styles in a <style> block, JavaScript in a <script> block. The one exception is an approved library: a script src, or an import in a <script type="module">, may load one, and only through its files reference, such as {{ files['three'] }}. Your own code never moves into a separate file.
- The template must render a graceful, well-designed empty state when plays is empty and when any list it uses has no entries.

## Definition of done

Check the finished template against every point before presenting it:

- Renders correctly with the sample data above.
- Fully responsive from mobile to desktop.
- Themed from settings (colours, currency icon, site name) rather than hardcoded brand values.
- Reads well on a light site and on a dark one, because tenants choose their own background. Colour it with the theme variables every Rafflex page defines, which flip automatically on a light site: var(--gray-color-50) for text, var(--gray-color-400) for muted text, var(--gray-color-900) for cards with var(--gray-color-800) borders, var(--gray-color-950) for the page, var(--primary-color-500) for accents, and var(--primary-button-color) for text on the primary colour. Always set a text colour, because the page sets none. Do not use Tailwind classes; they are not compiled for templates. A dark site also carries the dark class on <html>, for the rare colour the variables cannot express. A template that paints its own scene may keep one look. Check both with the preview's Light and Dark switch.
- Handles empty lists gracefully (no competitions, no winners) and does not rely on plays.
- Reads competitions from the pre-sorted lists (competitions.ending_soon and similar) rather than sorting in Twig, and never uses the legacy products key.
- Every option the site owner can change is read from options with a sensible default, named by the conventions above.
- References media only through the files map; no external URLs anywhere.
- Uses only the allowed tags and filters, stays within the for-loop cap, and uses no Twig functions.
- Styles and JavaScript are inline in the one template.

## Documents in this series

Every document is public, plain markdown, and self contained. Read the one that matches the task instead of guessing a rule:

- [Start here](https://marketplace.rafflex.io/docs/how-to-create.md): What Rafflex is, the two asset types and when to build each, the creator workflow, and the sandbox rules.
- [Connect your AI](https://marketplace.rafflex.io/docs/how-to-create/connect.md): The remote MCP server: connecting each AI client, signing in, starter prompts, watch mode, uploads per client, what a connected AI can and cannot do, and disconnecting.
- [Architect a game](https://marketplace.rafflex.io/docs/how-to-create/games.md): The complete specification for building a marketplace game: data contract, plays array, injection pattern, and definition of done.
- [Use images and sound](https://marketplace.rafflex.io/docs/how-to-create/media.md): Uploading images, sound, and 3D models to the product media library, adding approved libraries, and wiring them into a template through the files map.
- [Test and debug](https://marketplace.rafflex.io/docs/how-to-create/testing.md): The preview scenarios that exercise every template state, testing with a connected AI, and the platform errors mapped to fixes.
- [List and submit](https://marketplace.rafflex.io/docs/how-to-create/submit.md): Creating the product, the listing fields, pricing and creator earnings, the Ready to submit checklist, the reviewer pitfalls, and what review checks.
- [After launch](https://marketplace.rafflex.io/docs/how-to-create/after-launch.md): New versions, release channels, how installs update, install notes, bug reports, questions, what a connected AI reports, and announcements.
- [Template data contract](https://marketplace.rafflex.io/docs/template-context.md): every variable a template receives, with generated samples.