Erik KurjakAI Product Engineer
← All work

Splash Sports / Contest Creation Engine

Contest Creation Engine

From a V1 built to help commissioners create more contests to a V4 engine with deeply connected business rules. I owned the backend, frontend, and design implementation end to end.

Role
Principal Engineer · Full ownership
Stack
React · TypeScript · Next.js API routes · Zod

01 / V1 → V4

A conversion problem that grew into an engine.

The first version started with a clear product goal: help more commissioners complete contest creation and encourage them to create more contests. The experience needed to make the next decision understandable, from choosing a game to setting up entries and prizes.

By V4, that flow had grown into a much broader system. Different sports, contest formats, schedules, payout structures, and operating modes introduced rules that depended on one another. The challenge was keeping the experience approachable as the number of possible configurations grew.

02 / Ownership

One owner across the entire experience.

I had full ownership of the backend, frontend, and design implementation. That meant carrying a product decision all the way through: shaping the API response, implementing the business rules, building the interaction, and making sure the final UI behaved as intended.

The boundary between design and engineering mattered here. A change to the schedule could change the available settings or payout breakdown. I needed to account for what the commissioner would see while those rules resolved, which answers should survive a change, and how to recover when something failed.

03 / Architecture

The server describes the form. The UI resolves it.

The server-side form builders combine contest templates, event and schedule data, permissions, feature flags, and the current operation: create, edit, or restart. They return JSON describing sections and fields, including labels, defaults, validation settings, dependencies, and the endpoints used to load more configuration.

On the frontend, the nested response is normalized into section and field records with separate query caches. React renders those records through a shared field registry. Static fields already contain their configuration; DynamicField and DynamicFields descriptors resolve one field or a group of fields from an endpoint. The same approach also supports dynamic sections.

  1. 01 / Product context Templates + rules

    Event data, schedule, permissions, feature flags, and create / edit / restart mode.

    Server
  2. 02 / Form builders JSON contract

    Sections, field types, defaults, validation settings, and dependency descriptors.

    Server → browser
  3. 03 / Client store Normalize + resolve

    Section and field records, dependency values, query caches, and form state.

    Browser
  4. 04 / React renderer The commissioner’s UI

    Inputs, groups, accordions, payout tables, loading states, and recovery.

    Browser

Each relevant answer feeds back into the engine. Evaluate dependencies → reuse cached configuration or fetch updated JSON → preserve, reset, or clear affected answers → render again.

Simplified view of the configuration-driven architecture. Submission follows a separate path: validate values → map the service payload → create or update the contest.

04 / JSON contract

Configuration carries structure and behavior.

A field is more than a label and a value. Its type selects the renderer, its key identifies its place in the form, and its dependencies describe when it becomes relevant. Groups and accordions contain nested fields, so the contract can describe a complete experience rather than a flat list of inputs.

This shortened example shows the same descriptor structure used by the engine. The dynamic field waits for the values it needs before requesting its configuration. The server responds with a concrete field descriptor that the UI can render.

A section with a dynamic fieldjson
{
  "type": "BaseSection",
  "key": "entries",
  "fields": [
    {
      "type": "TextField",
      "key": "contestName",
      "label": "Contest title",
      "isRequired": true
    },
    {
      "type": "DynamicField",
      "key": "payoutPreview",
      "endpointName": "payout-preview",
      "dependsOn": [
        { "fieldKey": "entryFee" },
        { "fieldKey": "maxEntries" },
        { "sectionKey": "gameplay", "fieldKey": "schedule" }
      ]
    }
  ]
}
Illustrative JSON showing the form contract. Names are generalized and supporting fields are omitted for readability.

05 / Dependencies

Changing one answer can change the rest of the form.

The dependency evaluator checks whether required values exist, whether they have validation errors, and whether their conditions match. Descriptors can reference fields in the current section or another section, combine conditions with $and / $or, and compare values using operators such as $eq, $in, or $gte.

Visibility and refetching are separate concerns. A field can remain relevant while only certain changes require new options. For example, changing the event schedule can refresh period-specific options when a contest is configured per period. The same change may not require new options when the setting applies to the whole contest.

The contract also controls what happens to an existing answer. preserveOnChange keeps it for validation against the refreshed options, softReset restores the available default, and the default behavior clears the old answer. Dependency order matters: the first changed dependency determines those reset flags.

Refresh only when the rule requires itts
const dependency = {
  fieldKey: "schedule",
  isOptional: true,
  preserveOnChange: true,
  refetchWhen: {
    fieldKey: "configurationScope",
    conditions: { $eq: "perPeriod" },
  },
};

// Keep the selected answer when the schedule changes.
// Refresh its options only in per-period mode.
// Validate the answer against the refreshed options.
Generalized example of conditional refetching and value preservation, adapted from the dependency system.

06 / UI states

Every transition is part of the product.

The UI keeps form values, touched fields, validation errors, and fetched field configuration separate. That distinction lets it show a loading skeleton without confusing a pending response with a user error, and offer a retry when a request fails.

Dynamic field requests use a cache key built from the endpoint and dependency values. Requests are debounced, previous requests can be cancelled, and cached configurations can be reused. When a field becomes hidden, its values and touched state are cleaned up; fields marked optional when hidden are also excluded from required validation.

How the interface responds
StateBehavior
WaitingRequired dependencies are missing or invalid; dynamic configuration waits to resolve.
LoadingA relevant change requests configuration and the renderer shows a skeleton.
ReadyThe returned descriptor selects a component; groups render their children recursively.
InvalidField validators report errors; unresolved required configuration also blocks validation.
Request failedThe field exposes an error and retry action. Network failures have bounded automatic retries.
SubmittingThe submit flow tracks progress, failure, and success before continuing to the next destination.
One renderer, multiple query statestsx
if (fieldQuery.isLoading) return <SectionSkeleton />;
if (fieldQuery.error) {
  return <FieldQueryError onRetry={retryFn} />;
}
if (!fieldQuery.data) return null;

if (fieldQuery.type === "DynamicFields") {
  if (!isVisible) return null;
  return fieldQuery.data.map((key) => (
    <FieldSwitch key={key} fieldKey={key} />
  ));
}

return (
  <StaticFieldComponent
    field={fieldQuery.data}
    isVisible={isVisible}
  />
);
Simplified rendering logic. The full implementation also handles nested paths, labels, and visibility for static components.

07 / Business rules

The complexity lives between the settings.

Scheduling includes event groups, presets, exclusions, and deadlines. Gameplay settings cover selection limits, automated choices, scoring rules, and eligibility to continue. Those choices must remain compatible with the contest template and selected schedule.

Entries connect contest capacity, per-user limits, entry fees, rewards, and payout formats. Payout previews combine those inputs with the chosen schedule and segment structure. Editability adds another dimension: an existing contest can expose a different set of editable settings from a new contest.

Supporting these combinations requires coordinated form builders, reusable renderers, validation, and dependency handling. A guided form becomes a substantial system when every choice can affect the choices that follow.

08 / Validation & delivery

Turn a valid form into a contest.

The frontend builds section validation from the registered field descriptors and resolved configuration. Unresolved required fields cannot silently pass validation. Server-side handlers also parse incoming data with Zod before translating the nested form values into the backend service payload.

That translation is a meaningful part of the backend work: resolving the selected schedule, applying game exclusions, mapping payout settings, and converting entry fees into the service’s money representation. The JSON that describes the interface and the payload that creates the contest serve different purposes.

Across V1 to V4, I owned the evolution of that entire path. The result is an engine that supports increasingly complex contest rules while giving commissioners a guided way to configure them.