Spaces:
Sleeping
Sleeping
| # 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 | |
| ```python | |
| !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? | |
| ```python | |
| 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. | |
| ```python | |
| 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. | |
| ```python | |
| 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. | |
| ```python | |
| 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: | |
| ```python | |
| 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. | |
| ```python | |
| # 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: | |
| ```python | |
| 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: | |
| ```python | |
| 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: | |
| ```bash | |
| 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](../module-5/README.md) — Training a model to play games with GRPO. | |