orchid_env / notebook.md
cytoe's picture
Upload folder using huggingface_hub
0473e28 verified
|
Raw
History Blame Contribute Delete
12.4 kB

Module 4: Build a Word Game Environment

Build a letter-guessing (Hangman-style) environment from scratch using the OpenEnv pattern.

Time: ~30 min Β· Difficulty: Intermediate Β· GPU: Not required

!pip install -q openenv-core
!git clone --depth=1 -q https://github.com/meta-pytorch/OpenEnv.git 2>/dev/null || true

import sys, os
repo = os.path.abspath('OpenEnv')
for p in [repo, os.path.join(repo, 'src')]:
    if p not in sys.path:
        sys.path.insert(0, p)
print("Setup complete!")

1. Define the Types

Every OpenEnv environment starts with its data contracts: what actions can you take, what do you observe, what metadata exists?

from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any

# These would normally go in models.py

@dataclass
class WordGameAction:
    """Player guesses a single letter."""
    guess: str
    metadata: Dict[str, Any] = field(default_factory=dict)

@dataclass
class WordGameObservation:
    """What the player sees after each guess."""
    done: bool
    reward: Optional[float]
    masked_word: str            # e.g., "p_th_n"
    guessed_letters: List[str]  # All letters tried
    attempts_remaining: int
    message: str                # Feedback text
    metadata: Dict[str, Any] = field(default_factory=dict)

@dataclass
class WordGameState:
    """Episode metadata."""
    episode_id: Optional[str] = None
    step_count: int = 0
    target_word: str = ""
    max_attempts: int = 6

print("Types defined: WordGameAction, WordGameObservation, WordGameState")

2. Implement the Environment

The environment implements three methods: reset(), step(), and state. This is where the game logic lives.

import random
import uuid

WORDS = [
    "python", "neural", "tensor", "matrix", "vector",
    "kernel", "lambda", "signal", "binary", "cipher",
    "model", "layer", "epoch", "batch", "token",
]

class WordGameEnvironment:
    """A letter-guessing game environment following the OpenEnv pattern."""

    def __init__(self):
        self._state = WordGameState()
        self._target = ""
        self._guessed = set()
        self._remaining = 6

    def reset(self) -> WordGameObservation:
        """Start a new episode with a random word."""
        self._target = random.choice(WORDS)
        self._guessed = set()
        self._remaining = 10
        self._state = WordGameState(
            episode_id=str(uuid.uuid4()),
            step_count=0,
            target_word=self._target,
            max_attempts=10,
        )
        return WordGameObservation(
            done=False,
            reward=None,
            masked_word=self._mask(),
            guessed_letters=[],
            attempts_remaining=self._remaining,
            message=f"Guess letters in a {len(self._target)}-letter word!",
        )

    def step(self, action: WordGameAction) -> WordGameObservation:
        """Process a letter guess."""
        letter = action.guess.lower().strip()
        self._state.step_count += 1

        # Already guessed?
        if letter in self._guessed:
            return WordGameObservation(
                done=False,
                reward=0.0,
                masked_word=self._mask(),
                guessed_letters=sorted(self._guessed),
                attempts_remaining=self._remaining,
                message=f"Already guessed '{letter}'. Try another.",
            )

        self._guessed.add(letter)

        if letter in self._target:
            message = f"'{letter}' is in the word!"
        else:
            self._remaining -= 1
            message = f"'{letter}' is not in the word."

        # Check win/lose
        masked = self._mask()
        won = "_" not in masked
        lost = self._remaining <= 0
        done = won or lost

        if won:
            reward = 1.0
            message = f"You got it! The word was '{self._target}'."
        elif lost:
            reward = 0.0
            message = f"Out of attempts. The word was '{self._target}'."
        else:
            reward = 0.0

        return WordGameObservation(
            done=done,
            reward=reward,
            masked_word=masked,
            guessed_letters=sorted(self._guessed),
            attempts_remaining=self._remaining,
            message=message,
        )

    @property
    def state(self) -> WordGameState:
        return self._state

    def _mask(self) -> str:
        """Show guessed letters, hide the rest."""
        return "".join(c if c in self._guessed else "_" for c in self._target)

print("WordGameEnvironment defined.")

3. Test the Environment Directly

Before wiring up HTTP, test the pure game logic.

env = WordGameEnvironment()
obs = env.reset()
print(f"Word: {obs.masked_word} ({len(obs.masked_word)} letters)")
print(f"Message: {obs.message}")
print(f"Attempts: {obs.attempts_remaining}")
print()

# Play with common letters
for letter in ["e", "a", "t", "n", "o", "r", "s", "i", "l"]:
    if obs.done:
        break
    obs = env.step(WordGameAction(guess=letter))
    print(f"  Guess '{letter}': {obs.masked_word}  ({obs.message})")

print(f"\nFinal: reward={obs.reward}, done={obs.done}")
print(f"State: episode={env.state.episode_id[:8]}..., steps={env.state.step_count}")

4. Write Policies

Let's write two policies and compare them.

import string

class RandomLetterPolicy:
    """Guess random unused letters."""
    name = "Random"

    def select_action(self, obs: WordGameObservation) -> WordGameAction:
        available = [c for c in string.ascii_lowercase if c not in obs.guessed_letters]
        return WordGameAction(guess=random.choice(available))


class FrequencyPolicy:
    """Guess by English letter frequency."""
    name = "Frequency"
    FREQ_ORDER = "etaoinshrdlcumwfgypbvkjxqz"

    def select_action(self, obs: WordGameObservation) -> WordGameAction:
        for letter in self.FREQ_ORDER:
            if letter not in obs.guessed_letters:
                return WordGameAction(guess=letter)
        return WordGameAction(guess="a")  # fallback


def evaluate(env, policy, episodes=100):
    wins = 0
    total_steps = 0
    for _ in range(episodes):
        obs = env.reset()
        while not obs.done:
            action = policy.select_action(obs)
            obs = env.step(action)
        if obs.reward and obs.reward > 0:
            wins += 1
        total_steps += env.state.step_count
    return wins / episodes, total_steps / episodes


env = WordGameEnvironment()

for policy in [RandomLetterPolicy(), FrequencyPolicy()]:
    win_rate, avg_steps = evaluate(env, policy)
    print(f"{policy.name:15s} β€” Win rate: {win_rate*100:.1f}%, Avg steps: {avg_steps:.1f}")

Frequency should significantly outperform random. With technical vocabulary and individual letter guessing, both win rates are modest β€” but Frequency is typically 5–10Γ— better than Random. Increase max_attempts in WordGameEnvironment (e.g. to 15) to see higher absolute win rates.

5. Wire Up FastAPI

In a real deployment, you'd create server/app.py with:

from openenv.core.env_server import create_fastapi_app
from environment import WordGameEnvironment

app = create_fastapi_app(WordGameEnvironment)

That single call creates all endpoints: /ws, /reset, /step, /state, /health, /web, /docs.

Let's simulate the server locally to demonstrate the full stack.

# Write the environment files to disk for deployment
import os

os.makedirs('word_game/server', exist_ok=True)

# models.py β€” uses Pydantic (Action, Observation, State are Pydantic BaseModel subclasses)
models_code = '''
from typing import List, Optional
from openenv.core.env_server import Action, Observation, State


class WordGameAction(Action):
    """Player guesses a single letter."""
    guess: str


class WordGameObservation(Observation):
    """What the player sees after each guess.

    Note: done and reward are inherited from Observation.
    """
    masked_word: str            # e.g. "p_th_n"
    guessed_letters: List[str]  # All letters tried
    attempts_remaining: int
    message: str                # Feedback text


class WordGameState(State):
    """Episode metadata.

    Note: episode_id and step_count are inherited from State.
    """
    target_word: str = ""
    max_attempts: int = 6
'''

with open('word_game/models.py', 'w') as f:
    f.write(models_code)

# client.py β€” uses EnvClient (WebSocket-based)
client_code = '''
from openenv.core.env_client import EnvClient
from openenv.core.client_types import StepResult
from .models import WordGameAction, WordGameObservation, WordGameState


class WordGameEnv(EnvClient[WordGameAction, WordGameObservation, WordGameState]):
    def _step_payload(self, action: WordGameAction) -> dict:
        return {"guess": action.guess}

    def _parse_result(self, payload: dict) -> StepResult:
        obs_data = payload.get("observation", {})
        return StepResult(
            observation=WordGameObservation(
                done=payload.get("done", False),
                reward=payload.get("reward"),
                masked_word=obs_data.get("masked_word", ""),
                guessed_letters=obs_data.get("guessed_letters", []),
                attempts_remaining=obs_data.get("attempts_remaining", 0),
                message=obs_data.get("message", ""),
            ),
            reward=payload.get("reward"),
            done=payload.get("done", False),
        )

    def _parse_state(self, payload: dict) -> WordGameState:
        return WordGameState(
            episode_id=payload.get("episode_id"),
            step_count=payload.get("step_count", 0),
            target_word=payload.get("target_word", ""),
            max_attempts=payload.get("max_attempts", 6),
        )
'''

with open('word_game/client.py', 'w') as f:
    f.write(client_code)

# server/app.py
app_code = '''
from openenv.core.env_server import create_fastapi_app
from ..models import WordGameAction, WordGameObservation
from .environment import WordGameEnvironment

app = create_fastapi_app(WordGameEnvironment, WordGameAction, WordGameObservation)
'''

with open('word_game/server/app.py', 'w') as f:
    f.write(app_code)

print('Created word_game/models.py  (Pydantic models)')
print('Created word_game/client.py  (EnvClient subclass)')
print('Created word_game/server/app.py')
print()
print('Next steps:')
print('  1. Add server/environment.py with WordGameEnvironment class')
print('  2. Test locally: uvicorn word_game.server.app:app --reload')
print('  3. Deploy: openenv push --repo-id username/word-game')

6. The Client

The client translates between your typed models and JSON over the wire. Three methods:

class WordGameEnv(EnvClient[WordGameAction, WordGameObservation, WordGameState]):
    def _step_payload(self, action):
        return {"guess": action.guess}

    def _parse_result(self, payload):
        return StepResult(
            observation=WordGameObservation(**payload),
            reward=payload.get("reward", 0),
            done=payload["done"],
        )

    def _parse_state(self, payload):
        return WordGameState(**payload)

Users of your environment would then write:

from word_game import WordGameEnv, WordGameAction

with WordGameEnv(base_url="https://username-word-game.hf.space").sync() as env:
    result = env.reset()
    result = env.step(WordGameAction(guess="e"))
    print(result.observation.masked_word)

7. Scaffold with openenv init

Instead of writing everything by hand, use the CLI:

openenv init word_game
cd word_game
# Edit models.py, server/environment.py, client.py
uv run server           # Test locally
openenv push             # Deploy to HF Spaces

This creates the full directory structure. You fill in your types and game logic.

Summary

You built a complete OpenEnv environment:

File What it does Lines of code
models.py Action, Observation, State types ~30
server/environment.py Game logic (reset, step, state) ~60
client.py HTTP client (3 parsing methods) ~25
server/app.py FastAPI wiring ~3

The pattern is always the same: types β†’ server logic β†’ client β†’ container.

Next: Module 5 β€” Training a model to play games with GRPO.