Oddinary Farm

OVERVIEW

// METRICS

Engine
Unity
My Roles
Associate Director,Gameplay Programmer,Sprite Artist
Platforms
PC

Oddinary Farm is an arcade-style co-op farming game built in Unity. The game combines classic farming mechanics with chaotic gameplay by juggling casual building with a tower defense aspect. The game is made by Spring Rolls Studio, a small team made up of college graduates.

The lead programmer is Khoa Ngyuen and the associate lead programmer is me, Ryan Carpenter. The game hosts peer-to-peer multiplayer utilizing Steamworks Facepunch and Unity's Netcode for GameObjects. The open playtest is out now on Steam!

TECHNICAL FEATURES

Peer-to-peer Multiplayer

Unity Netcode for GameObjects, transported over Facepunch (Steamworks). Networking is deeply woven through the codebase, not confined to one layer. Allows player clients to connect to a host over the internet through Steam invites. One player hosts from their own PC and friends join through Steam. Game traffic travels over Steam Datagram Relay, Valve's relay network, so nobody forwards ports and players never see each other's IP addresses.

Authority Model

Every machine runs the same simulation, so each piece of state needs exactly one machine that is allowed to change it. The game splits that responsibility three ways. The host owns the world. Each player owns their own character. Shared objects like mounts, turrets and dropped items are owned by whoever is using them, and go back to the host afterwards.

Listen-Server Architecture

Anything all players share lives on the host. The game phase, the clock, quotas and the player roster are NetworkVariables that only the server can write. Clients read them and react to their change callbacks. Server write is Netcode's default, so most declarations don't need to say it.

// PlayerManager & GameManager
// PlayerManager
private NetworkList<ulong> PlayerClientIds = new NetworkList<ulong>();
private NetworkVariable<int> ParticipantCountValue = new NetworkVariable<int>(
    0,
    NetworkVariableReadPermission.Everyone,
    NetworkVariableWritePermission.Server);

...

// GameManager
private NetworkVariable<GameState> CurrentGameState = new NetworkVariable<GameState>(GameState.Uninitialized);

World changes travel as requests. When a player hoes the ground, their machine sends the target position to the host. The host spawns the farm plot and Netcode replicates it to everyone. Removing a plot works the same way, and the host runs its own physics lookup to find the plot, so the client only ever sends a position.

// ItemSystem
public void SpawnFarmPlot(Vector2 position)
{
    SpawnFarmPlotRpc(position);
    // ... tutorial bookkeeping
}

[Rpc(SendTo.Server)]
private void SpawnFarmPlotRpc(Vector2 position)
{
    AssetManager.Main.SpawnPrefabOnServer(AssetManager.Main.FarmPlotPrefab, position);
    // ... editor-only spawn recording
}

[Rpc(SendTo.Server)]
private void RemoveFarmPlotRpc(Vector2 position)
{
    var farmPlotCollider = Physics2D.OverlapPoint(position, LayerManager.Main.FarmPlotLayer);
    if (farmPlotCollider)
    {
        // ... editor-only spawn recording
        farmPlotCollider.GetComponent<NetworkObject>().Despawn();
    }
}

Small pieces of player-only state are owner-write NetworkVariables. These include facing direction, melee mode, the held item's modifier and the character's outfit. The held Item object only exists on the owner's machine, so the owner publishes its modifier for everyone else to render.

// PlayerController
private NetworkVariable<bool> IsFacingRight = new NetworkVariable<bool>(false, default, NetworkVariableWritePermission.Owner);
private NetworkVariable<bool> ForceMeleeMode = new NetworkVariable<bool>(false, default, NetworkVariableWritePermission.Owner);
// The held Item only exists on the owner, so every other machine reads its modifier from here.
private NetworkVariable<ItemModifier> HeldModifier = new NetworkVariable<ItemModifier>(ItemModifier.NoModifier, default, NetworkVariableWritePermission.Owner);

private void HandleCurrentItemChanged(Item item)
{
    currentItem = item;

    // Only the owner holds the Item. A changed modifier refreshes the particles through
    // HandleHeldModifierChanged, here and on every other machine.
    if (IsSpawned && IsOwner) HeldModifier.Value = item ? item.Modifier : ItemModifier.NoModifier;
}

Co-Op Over Steam Relay

The transport sits on the Network Manager prefab next to Unity's NetworkManager, and that object persists across scenes. In Awake it starts the Steam client with the game's App ID. A coroutine waits for Steam to come online and then initializes relay access early, so the first connection doesn't have to wait for it.

Async callbacks are turned off, and the transport calls SteamClient.RunCallbacks() from Update. Every Steam callback, from lobby invites to connection status changes, runs inside that call on Unity's main thread, where it can safely touch game objects.

// FacepunchTransport
private void Awake()
{
    try
    {
        SteamClient.Init(steamAppId, false);
    }
    catch (Exception e)
    {
        // ... log the error
    }
    finally
    {
        StartCoroutine(InitSteamworks());
    }
}

private void Update()
{
    SteamClient.RunCallbacks();
}

private IEnumerator InitSteamworks()
{
    yield return new WaitUntil(() => SteamClient.IsValid);

    SteamNetworkingUtils.InitRelayNetworkAccess();

    userSteamId = SteamClient.SteamId;
}

The Lobby Is The Address Book

Clients connect to a SteamID instead of an IP address, and the Steam lobby is where they find it. Pressing Host creates a lobby sized to the 16-player cap, makes it friends-only by default and names it after the host. The menu waits for the lobby to exist before it starts Netcode. Whenever the game enters a lobby, as host or as guest, it points the transport at the lobby owner's SteamID.

From the in-game lobby panel the host can switch between friends-only, public and private, copy the lobby ID to share, or open Steam's invite overlay.

// ConnectionManager
public async void CreateLobby()
{
    try
    {
        CurrentLobby = await SteamMatchmaking.CreateLobbyAsync(maxPlayer);

        if (!CurrentLobby.HasValue) return;

        var lobby = CurrentLobby.Value;

        lobby.SetFriendsOnly();   // default visibility
        lobby.SetJoinable(true);
        lobby.SetData(LOBBY_NAME_KEY, lobby.Owner.Name + "'s Lobby");
        lobby.SetData(LOBBY_VISIBILITY_KEY, ((int)LobbyVisibility.FriendsOnly).ToString());
    }
    catch (Exception e)
    {
        // ... log and return
    }
}

private void OnLobbyEntered(Lobby lobby)
{
    if (lobby.MemberCount <= 0)
    {
        lobby.Leave();
        return;
    }

    SetLobbyAndTransportTarget(lobby);
}

private void SetLobbyAndTransportTarget(Lobby lobby)
{
    CurrentLobby = lobby;
    facepunchTransport.targetSteamId = lobby.Owner.Id;
}

Joining From Steam Invite

Players can accept an invite, or click Join Game in their Steam friends list, at any moment, including in the middle of another match. An invite that arrives while the game is open shows a Join or Decline prompt first. The handler then leaves the current session and waits for the NetworkManager to finish shutting down. After that it joins the new lobby and sets the transport target straight from the join result instead of waiting on a second lobby callback.

// ConnectionManager
private IEnumerator JoinLobbyFromInviteCoroutine(Lobby lobby)
{
    // If the player is already in a game, leave it first
    if (networkManager.IsServer || networkManager.IsClient)
    {
        yield return TransitionUI.Main.ShowCoroutine();

        isDisconnecting = true;
        LeaveLobby();

        if (networkManager)
        {
            if (networkManager.IsListening && !networkManager.ShutdownInProgress)
                networkManager.Shutdown();

            yield return new WaitUntil(() => !networkManager.IsListening && !networkManager.ShutdownInProgress);
        }

        // ... load the main menu
        isDisconnecting = false;
    }

    var joinTask = SteamMatchmaking.JoinLobbyAsync(lobby.Id);

    yield return new WaitUntil(() => joinTask.IsCompleted);

    // ... show an error dialog if the join faulted or was canceled

    var joinedLobby = joinTask.Result;

    // ... show an error dialog if the lobby is invalid

    // JoinLobbyAsync has already confirmed membership. Set the target directly
    // instead of depending on a second callback that might be delayed or missed.
    SetLobbyAndTransportTarget(joinedLobby.Value);

    StartGameOnlineClient();
}

THOUGHTS & REFLECTION

Oddinary Farm reflection

What was my role in this project?

I hopped on this project back in August of 2025. A very good friend of mine was the director of the game and decided to make me an associate director due to my previous experience working with games. As associate director I handled reviewing design documentation, making tasks for developers and artists, and overseeing codebase changes through Git. In addition to this responsibility I also programmed some systems including the mounting system, procedural generation, UI, and some aspects of the lobby system. I also helped pitch the game at Keadim Indie Showcase, an event at 2026 GDC, which ended up winning an award for best pitch.

What did I learn

This was my first large-scale project where real stakes were involved. I took my role seriously and organized design meetings and tasks for developers alike. My biggest takaway was learning to do what was best for the game not just what looked good to me. This was also my first introduction to multiplayer programming which became a core personal interest.

What would I change.

With more time and experience, I would have decoupled more of the multiplayer code from the engine code. Since our game was peer-to-peer only aspects of the game that are essential for other client simulations should be sent over the network.