OneAbove.Arena.GameContract
4.4.0
dotnet add package OneAbove.Arena.GameContract --version 4.4.0
NuGet\Install-Package OneAbove.Arena.GameContract -Version 4.4.0
<PackageReference Include="OneAbove.Arena.GameContract" Version="4.4.0" />
<PackageVersion Include="OneAbove.Arena.GameContract" Version="4.4.0" />
<PackageReference Include="OneAbove.Arena.GameContract" />
paket add OneAbove.Arena.GameContract --version 4.4.0
#r "nuget: OneAbove.Arena.GameContract, 4.4.0"
#:package OneAbove.Arena.GameContract@4.4.0
#addin nuget:?package=OneAbove.Arena.GameContract&version=4.4.0
#tool nuget:?package=OneAbove.Arena.GameContract&version=4.4.0
OneAbove.Arena.GameContract
The game contract of the One-Above Arena. Whoever builds a game for the arena needs this package — and nothing else from the platform.
The three sentences everything follows from
- The server decides. Your rule module says what is allowed. The renderer in the browser never does.
- Your rule module is pure. Same input, same output — today and in two
years. No
DateTime.Now, noRandom.Shared, no file, no network. Randomness solely from the seed in the state. - Every seat sees only its own view. What
GetView(state, seat)does not output does not exist for that player. That is a security boundary, not a display question.
The smallest thing a game is
public sealed class MyGame : IGameEngine<MyState, MyMove, MyView>
{
public GameDescriptor Descriptor { get; } = new()
{
Id = GameId.Parse("my-game"),
Version = new GameVersion(1, 0, 0),
DisplayName = LocalizedText.De("Mein Spiel"),
Spectators = true,
OpenInformation = true,
Chrome = GameChrome.Platform,
Offerings =
[
new GameOffering
{
Key = "online",
Name = LocalizedText.De("Online"),
Reach = OfferingReach.Remote,
SeatCounts = [2],
AllowedSeatKinds = SeatKinds.Human,
Clocks = [ArenaClock.PerTurn.Minutes5],
OnTimeout = TimeoutPolicy.Resign,
Result = new ResultShape { Kind = ResultKind.Ranking },
},
],
};
public MyState CreateInitialState(MatchSetup setup) => …;
public IReadOnlyList<Seat> GetSeatsToAct(MyState s) => …;
public IReadOnlyList<LegalMove<MyMove>> GetLegalMoves(MyState s, Seat seat) => …;
public MoveResult<MyState> ApplyMove(MyState s, Seat seat, MyMove move) => …;
public GameView<MyView> GetView(MyState s, Seat? seat) => …; // seat == null ⇒ spectator
public MatchOutcome? GetOutcome(MyState s) => …;
public LegalMove<MyMove>? GetForcedMove(MyState s, Seat seat) => …;
}
What a game declares is per offering, not per game: seat counts, clocks, options, what happens on a timeout. „2 or 4 seats, not 3" is a set, and two numbers could not express it.
What your rule module does NOT do
- Check whether the caller may have the seat or is to act. The platform does that before it calls in. Your module believes the seat it is told.
- Throw because a client sent nonsense. Every conceivable move gets an
answer — accepted or
MoveResult.Rejected. An exception is a system error, not a rejected move. - Reference anything other than this package. No access to a database, clock, network or another game.
Pitfalls that cost time
Computed properties do not belong in the state. A property like
IsOver => … is serialised along and devalues every recorded match as soon as
somebody changes its derivation. [JsonIgnore] on every derived property.
Dictionary order, double arithmetic, LINQ without a stable sort. The
three places at which a replay falls apart on you in a year's time. For money
and points use decimal, not double.
GetSeatsToAct returns a list. In turn-based games it is exactly one seat
long. Several seats mean simultaneous moves — hidden bids, programming phases.
Seat colours, title image and manual (contract 4.4)
Colours
An offering may say which colour belongs to which seat — SeatColours[i]
belongs to seat i. It is optional: Minesweeper has one seat, and a game
that colours by piece rather than by seat should leave it empty. Empty means
„not my concept", not „incomplete".
SeatColours =
[
new SeatColour { Key = "gelb", Name = LocalizedText.De("Gelb"), Hex = "#e8c547" },
new SeatColour { Key = "rot", Name = LocalizedText.De("Rot"), Hex = "#c2452d" },
]
⚠️ The colour also reaches your renderer (ArenaSeat.colour,
arena-client 3.2.0), and that is the reason the field exists at all rather
than living in your CSS: without it there would be two copies of the same
hex value — one in the rule module, one in the renderer — and the arena would
claim a colour your board could contradict. A wrong statement is worse than
none. Whoever draws a seat colour reads it from there.
What is refused, and every line of it fail-closed, because the declaration comes out of a foreign repository and the surface writes the value as an inline style:
| Rule | Why |
|---|---|
Hex exactly #rrggbb, lower case |
Upper case would make #E8C547 and #e8c547 two colours for the duplicate check and one for the eye |
| Contrast ≥ 3:1 against both of the arena's reference grounds | Measured: real palettes sit at 4.7–7.8. #017E97 — out of the arena's own colour list — reaches 2.02 and would be an invisible dot |
| Pairwise distance ≥ 0.08 (Oklab) | Measured: real pairs 0.138 and 0.255; two near-identical yellows 0.008. Twelve colours of which two look alike are eleven |
Key unique, Hex unique |
Two seats in the same colour is the reported bug, word for word, back again |
Exactly SeatCounts.Max() of them, or none |
⚠️ SeatCounts is a set. Three colours leave three seats colourless at six, and „the order is the mapping" then fails silently |
💡 The colour's name never travels in a match view. It is resolved once per
lobby load in the asker's language; the seat DTOs carry key and hex only. A
LocalizedText in a push has no recipient to be resolved against.
Media
Optional, next to your renderer and your golden matches:
media/
cover/<locale>.webp title image, square (1024² recommended) ≤ 400 KB
icon/<locale>.webp icon, square (256² recommended) ≤ 64 KB
manual/<locale>.json a ManualDocument ≤ 64 KB
manual/img/<name>.webp the manual's pictures ≤ 200 KB
4 MB in total, at most 20 manual pictures. .webp, .png, .jpg, .jpeg —
no SVG: an <img src="…svg"> runs no script today, but that exception
holds only for as long as nobody turns it into an inline SVG, and that is a
promise about future code.
⚠️ The languages are derived from the file names, not declared. The truth is which files you shipped. A declared language set would be a second truth about your own directory — and a declared language tag would be a foreign string the platform builds a path out of.
⚠️ The measurements are a recommendation, the byte caps are caps. Checking the dimensions would need an image decoder over foreign bytes, and that is an attack surface of its own for a statement that protects nobody.
A manual is a ManualDocument with a Format version and exactly two
chapter levels — as a type, not as a check: ManualSubchapter has no
Chapters, so your compiler refuses a third level. Blocks: text (with
strong/em runs), list, table, image, callout. An image's Src is
checked against what you actually shipped.
⚠️ Refused, not filtered. An unknown block kind, a control character, a
bidi override, a Src outside your inventory: the whole bundle is refused with
a sentence. A manual that silently swallows a block looks complete and is not —
and you would pay a version number to find out.
GameEngineContract runs the very same check your admission will run, so a
refused manual costs you a test run and not a v tag.
Fullscreen (contract 4.2)
Chrome = GameChrome.Either lets the player take the whole screen (Game
always does). Then FullscreenSidebar says what the arena keeps drawing
beside your board: Open — its side bar with players, log, chat and rules,
you draw only the board; Collapsed (the default) — only the stub in the
corner, you draw your own surface and ask for the rest over the bridge
(you, log, chat; undo(), resign(), say(), open()). Keep the
top-right corner free (chrome.reserved), and use no <form> — the iframe
is sandboxed without allow-forms.
Taking back (contract 4.1)
Undo is a move, not a deletion: the platform appends the rolled-back state
as a further move and keeps the one that was taken back. A game opts in per
offering (Undo = UndoAvailability.OptionalOff, an optional UndoBudgetPerTurn)
and implements IUndoableGame<TState>. Whether the table chose it arrives in
MatchSetup.UndoEnabled — and that is the point: a game may shape its turns
differently then. Connect Four, with undo on, makes a drop tentative
(MoveResult.AcceptedWithinTurn) and adds a confirming move; only between the
two is there something to take back. A refusal from Undo is the normal
answer, not a fault.
What belongs with it
| Package | What for |
|---|---|
OneAbove.Arena.GameContract.Testing |
The check your module has to pass |
OneAbove.Arena.GameHost.Dev |
The test table: dotnet run, two browser windows |
@one-above/arena-client (npm) |
The bridge between renderer and platform |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on OneAbove.Arena.GameContract:
| Package | Downloads |
|---|---|
|
OneAbove.Arena.GameContract.Testing
The One-Above Arena's contract suite: purity, determinism, totality and leak checks for every rule module — plus the mechanics for golden matches. |
|
|
OneAbove.Arena.GameHost.Dev
The One-Above Arena's test table: a rule module and its renderer, playable with `dotnet run` — without a database, without a sign-in, without the platform. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.4.0 | 0 | 9/18/2026 |
| 4.4.0-rc.1 | 0 | 9/18/2026 |
| 4.3.0 | 53 | 9/17/2026 |
| 4.3.0-rc.2 | 27 | 9/17/2026 |
| 4.3.0-rc.1 | 33 | 9/17/2026 |
| 4.2.0 | 62 | 9/17/2026 |
| 4.2.0-rc.1 | 37 | 9/17/2026 |
| 4.1.1 | 64 | 9/17/2026 |
| 4.1.1-rc.1 | 40 | 9/17/2026 |
| 4.1.0 | 59 | 9/17/2026 |
| 4.1.0-rc.3 | 38 | 9/17/2026 |
| 4.1.0-rc.2 | 37 | 9/17/2026 |
| 4.1.0-rc.1 | 35 | 9/17/2026 |
| 4.0.0 | 81 | 9/16/2026 |
| 4.0.0-rc.5 | 80 | 9/16/2026 |
| 4.0.0-rc.4 | 76 | 9/15/2026 |
| 4.0.0-rc.3 | 47 | 9/15/2026 |
| 4.0.0-rc.2 | 50 | 9/15/2026 |
| 4.0.0-rc.1 | 51 | 9/15/2026 |
| 3.0.1 | 99 | 9/14/2026 |